diff --git a/agent/methods.py b/agent/methods.py deleted file mode 100644 index 14419ea22..000000000 --- a/agent/methods.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Any, Final - -from chipcompiler.runtime.methods import RuntimeMethodSpec -from chipcompiler.runtime.requests import WorkspaceIdRequest - -from .requests import ( - CandidateBindInputRequest, - CandidateMaterializeRequest, - CandidateRerunRequest, - WorkspaceExtractFoundationRequest, -) - -AGENT_RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( - RuntimeMethodSpec( - method_name="workspace.extract_foundation", - request_model=WorkspaceExtractFoundationRequest, - handler_name="extract_foundation", - ), - RuntimeMethodSpec( - method_name="candidate.export_capabilities", - request_model=WorkspaceIdRequest, - handler_name="export_candidate_capabilities", - ), - RuntimeMethodSpec( - method_name="candidate.bind_input", - request_model=CandidateBindInputRequest, - handler_name="bind_candidate_input", - ), - RuntimeMethodSpec( - method_name="candidate.materialize", - request_model=CandidateMaterializeRequest, - handler_name="materialize_candidate", - ), - RuntimeMethodSpec( - method_name="candidate.rerun", - request_model=CandidateRerunRequest, - handler_name="candidate_rerun", - ), -) - - -def agent_method_names() -> tuple[str, ...]: - return tuple(spec.method_name for spec in AGENT_RUNTIME_METHODS) diff --git a/agent/requests.py b/agent/requests.py deleted file mode 100644 index 30be9d4a2..000000000 --- a/agent/requests.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from chipcompiler.runtime.requests import RequestValidationError, parse_request_model - - -@dataclass(frozen=True) -class WorkspaceExtractFoundationRequest: - workspace_id: str - - -@dataclass(frozen=True) -class CandidateBindInputRequest: - workspace_id: str - target_step: str - source_step: str - candidate_id: str - - -@dataclass(frozen=True) -class CandidateMaterializeRequest: - workspace_id: str - target_step: str - candidate_id: str - patch: list[dict[str, Any]] - - -@dataclass(frozen=True) -class CandidateRerunRequest: - workspace_id: str - target_step: str - end_step: str - candidate_id: str - patch: list[dict[str, Any]] - execution_scope: str - - -_FIELD_ALIASES = { - "workspaceId": "workspace_id", - "targetStep": "target_step", - "endStep": "end_step", - "sourceStep": "source_step", - "candidateId": "candidate_id", - "executionScope": "execution_scope", -} - - -def parse_agent_request_model(model: type, params: object): - if not isinstance(params, dict): - raise RequestValidationError("params must be an object") - normalized = {} - for key, value in params.items(): - name = _FIELD_ALIASES.get(str(key), str(key)) - if name in normalized: - raise RequestValidationError(f"duplicate field: {name}") - normalized[name] = value - return parse_request_model(model, normalized) diff --git a/agent/server.py b/agent/server.py deleted file mode 100644 index d5a1b0ef2..000000000 --- a/agent/server.py +++ /dev/null @@ -1,50 +0,0 @@ -from jsonrpcserver import Error - -from chipcompiler.runtime.server import ERROR_CODES, RuntimeServer -from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi - -from .methods import AGENT_RUNTIME_METHODS, agent_method_names -from .requests import parse_agent_request_model -from .workspace_api import FlowAgentRuntimeApi - - -class AgentRuntimeServer(RuntimeServer): - def __init__( - self, - api: WorkspaceRuntimeApi | None = None, - *, - persistent_db_enabled: bool = False, - ): - super().__init__(api=api, persistent_db_enabled=persistent_db_enabled) - self.agent_api = FlowAgentRuntimeApi(self.api) - self._register_agent_methods() - - @property - def capabilities(self) -> tuple[str, ...]: - return (*super().capabilities, *agent_method_names()) - - def _register_agent_methods(self) -> None: - for spec in AGENT_RUNTIME_METHODS: - handler = getattr(self.agent_api, spec.handler_name) - self.dispatcher.add_method(spec.method_name, self._agent_method_handler(spec, handler)) - - @staticmethod - def _agent_method_handler(spec, handler): - def dispatch(**params): - try: - request = parse_agent_request_model(spec.request_model, params) - return handler(request) - except RuntimeApiError as exc: - return Error( - ERROR_CODES.get(exc.code, -32000), - exc.code, - {"message": exc.message, **exc.data}, - ) - except Exception as exc: - return Error( - ERROR_CODES["command_failed"], - "command_failed", - {"message": str(exc)}, - ) - - return dispatch diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py deleted file mode 100644 index a27f6ff5e..000000000 --- a/agent/test/test_requests.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest - -from agent.methods import agent_method_names -from agent.requests import CandidateRerunRequest, parse_agent_request_model -from agent.server import AgentRuntimeServer -from chipcompiler.runtime.requests import RequestValidationError - - -def test_agent_methods_keep_the_original_rpc_names(): - assert agent_method_names() == ( - "workspace.extract_foundation", - "candidate.export_capabilities", - "candidate.bind_input", - "candidate.materialize", - "candidate.rerun", - ) - - -def test_agent_runtime_server_registers_isolated_methods(): - server = AgentRuntimeServer() - - assert set(agent_method_names()).issubset(server.capabilities) - - -def test_agent_request_normalizes_camel_case_fields(): - request = parse_agent_request_model( - CandidateRerunRequest, - { - "workspaceId": "workspace-1", - "targetStep": "place", - "endStep": "CTS", - "candidateId": "candidate-1", - "patch": [], - "executionScope": "full_flow", - }, - ) - - assert request == CandidateRerunRequest( - workspace_id="workspace-1", - target_step="place", - end_step="CTS", - candidate_id="candidate-1", - patch=[], - execution_scope="full_flow", - ) - - -def test_agent_request_rejects_duplicate_aliases(): - with pytest.raises(RequestValidationError, match="duplicate field: workspace_id"): - parse_agent_request_model( - CandidateRerunRequest, - { - "workspaceId": "workspace-1", - "workspace_id": "workspace-1", - "targetStep": "place", - "endStep": "place", - "candidateId": "candidate-1", - "patch": [], - "executionScope": "single_step", - }, - ) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py deleted file mode 100644 index f1106b931..000000000 --- a/agent/test/test_workspace_api.py +++ /dev/null @@ -1,151 +0,0 @@ -from pathlib import Path -from types import SimpleNamespace - -from agent.requests import CandidateRerunRequest -from agent.workspace_api import FlowAgentRuntimeApi, _candidate_step_artifact_dirs -from chipcompiler.data import StateEnum -from chipcompiler.data.workspace.layout import EccOutput - - -def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): - output_dir = tmp_path / "place_dreamplace" / "output" - analysis_dir = tmp_path / "place_dreamplace" / "analysis" - step = SimpleNamespace( - output=EccOutput(dir=output_dir), - analysis={"dir": analysis_dir}, - ) - - assert _candidate_step_artifact_dirs(step) == (Path(output_dir), Path(analysis_dir)) - - -def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatch, tmp_path): - workspace = SimpleNamespace( - directory=tmp_path, - flow=SimpleNamespace( - data={ - "steps": [ - {"name": "Floorplan", "tool": "ecc", "state": "Success"}, - {"name": "place", "tool": "dreamplace", "state": "Success"}, - {"name": "CTS", "tool": "ecc", "state": "Success"}, - ] - } - ), - ) - place_output = tmp_path / "place_dreamplace" / "output" - place_analysis = tmp_path / "place_dreamplace" / "analysis" - cts_output = tmp_path / "CTS_ecc" / "output" - for directory in (place_output, place_analysis, cts_output): - directory.mkdir(parents=True) - (directory / "stale").write_text("stale", encoding="utf-8") - flow = _Flow( - workspace, - ( - SimpleNamespace(name="Floorplan", tool="ecc", output={}), - SimpleNamespace( - name="place", - tool="dreamplace", - output=EccOutput(dir=place_output), - analysis={"dir": place_analysis}, - ), - SimpleNamespace(name="CTS", tool="ecc", output={"dir": cts_output}), - ), - ) - api = FlowAgentRuntimeApi(_EccApi(workspace)) - calls = [] - monkeypatch.setattr("agent.workspace_api.build_agent_flow_for_workspace", lambda _ws: flow) - monkeypatch.setattr( - "agent.workspace_api.bind_candidate_input", - lambda _ws, _flow, target, source, candidate: calls.append( - ("bind", target, source, candidate) - ), - ) - monkeypatch.setattr( - "agent.workspace_api.materialize_candidate_config", - lambda _ws, target, patch, candidate: calls.append( - ("materialize", target, patch, candidate) - ), - ) - monkeypatch.setattr( - "agent.workspace_api.validate_candidate_step_contract", - lambda _ws, _target: "candidate-1", - ) - monkeypatch.setattr( - "agent.workspace_api.reapply_candidate_input_binding", - lambda _ws, _flow, target: calls.append(("reapply", target)), - ) - monkeypatch.setattr( - "agent.workspace_api._init_db_engine_for_workspace_step", - lambda _flow, step: calls.append(("init", step.name)), - ) - - result = api.candidate_rerun( - CandidateRerunRequest( - workspace_id="workspace-1", - target_step="place", - end_step="CTS", - candidate_id="candidate-1", - patch=[{"knob_id": "place.target_density", "value": 0.6}], - execution_scope="full_flow", - ) - ) - - assert result == { - "target_step": "place", - "end_step": "CTS", - "execution_scope": "full_flow", - } - assert calls == [ - ("bind", "place", "Floorplan", "candidate-1"), - ( - "materialize", - "place", - [{"knob_id": "place.target_density", "value": 0.6}], - "candidate-1", - ), - ("reapply", "place"), - ("init", "place"), - ("init", "CTS"), - ] - assert flow.run_calls == [("place", True), ("CTS", True)] - assert not list(place_output.iterdir()) - assert not list(place_analysis.iterdir()) - assert not list(cts_output.iterdir()) - - -class _EccApi: - def __init__(self, workspace): - self.session = SimpleNamespace(workspace=workspace, db_handle=None) - - def _with_session_mutation_lock(self, workspace_id, operation): - assert workspace_id == "workspace-1" - return operation(self.session) - - def _should_capture_session_db(self, _session): - return False - - def _close_transient_flow_db(self, _flow): - return None - - -class _Flow: - def __init__(self, workspace, workspace_steps): - self.workspace = workspace - self.workspace_steps = workspace_steps - self.run_calls = [] - - def get_step(self, name, tool): - return next( - ( - step - for step in self.workspace.flow.data["steps"] - if step["name"] == name and step["tool"] == tool - ), - None, - ) - - def save(self): - return True - - def run_step(self, step, *, rerun): - self.run_calls.append((step.name, rerun)) - return StateEnum.Success diff --git a/agent/workspace_api.py b/agent/workspace_api.py deleted file mode 100644 index 9c310a17d..000000000 --- a/agent/workspace_api.py +++ /dev/null @@ -1,271 +0,0 @@ -import json -import shutil -from hashlib import sha256 -from pathlib import Path - -from chipcompiler.runtime.requests import WorkspaceIdRequest -from chipcompiler.runtime.workspace_api import ( - RuntimeApiError, - WorkspaceRuntimeApi, - _init_db_engine_for_workspace_step, - _state_value, -) -from chipcompiler.utility.path import path_is_within - -from .data import ( - FoundationExtractor, - bind_candidate_input, - export_candidate_capabilities, - materialize_candidate_config, - reapply_candidate_input_binding, - validate_candidate_step_contract, -) -from .engine import AgentEngineFlow -from .requests import ( - CandidateBindInputRequest, - CandidateMaterializeRequest, - CandidateRerunRequest, - WorkspaceExtractFoundationRequest, -) - - -def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): - import chipcompiler.rtl2gds as rtl2gds_api - - flow = AgentEngineFlow(workspace=workspace) - if not flow.has_init(): - for step, tool, state in rtl2gds_api.build_rtl2gds_flow(): - flow.add_step(step=step, tool=tool, state=state) - if create_step_workspaces: - flow.create_step_workspaces() - return flow - - -class FlowAgentRuntimeApi: - """Optional Flow Agent RPC handlers over one ECC workspace runtime.""" - - def __init__(self, ecc_api: WorkspaceRuntimeApi): - self.ecc_api = ecc_api - - def extract_foundation(self, request: WorkspaceExtractFoundationRequest) -> dict: - def extract(session): - workspace_dir = Path(session.workspace.directory).resolve() - FoundationExtractor(str(workspace_dir), profile="iccd_full_v1").extract( - include_raw_refs=False, - materialize_audit_tables=True, - route_detail_level="full", - ) - return _foundation_receipt(workspace_dir) - - return self._with_workspace_lock(request.workspace_id, extract) - - def export_candidate_capabilities(self, request: WorkspaceIdRequest) -> dict: - return self._with_workspace_lock( - request.workspace_id, - lambda session: export_candidate_capabilities(session.workspace), - ) - - def bind_candidate_input(self, request: CandidateBindInputRequest) -> dict: - def bind(session): - flow = build_agent_flow_for_workspace(session.workspace) - return bind_candidate_input( - session.workspace, - flow, - request.target_step, - request.source_step, - request.candidate_id, - ) - - return self._with_workspace_lock(request.workspace_id, bind) - - def materialize_candidate(self, request: CandidateMaterializeRequest) -> dict: - return self._with_workspace_lock( - request.workspace_id, - lambda session: materialize_candidate_config( - session.workspace, - request.target_step, - request.patch, - request.candidate_id, - ), - ) - - def candidate_rerun(self, request: CandidateRerunRequest) -> dict: - return self._with_workspace_lock( - request.workspace_id, - lambda session: self._candidate_rerun(session, request), - ) - - def _candidate_rerun(self, session, request: CandidateRerunRequest) -> dict: - should_capture = self.ecc_api._should_capture_session_db(session) - previous_db = session.db_handle if should_capture else None - if should_capture: - self.ecc_api._release_session_db(session) - previous_db = None - flow = self._build_flow(session) - try: - steps = _candidate_rerun_steps( - flow, - request.target_step, - request.end_step, - request.execution_scope, - ) - if request.patch: - _materialize_candidate_rerun(session.workspace, flow, request) - _prepare_candidate_rerun(session.workspace, flow, steps) - if request.patch: - _reapply_candidate_input(session.workspace, flow, request.target_step) - for step in steps: - _run_candidate_step(flow, step) - return { - "end_step": request.end_step, - "execution_scope": request.execution_scope, - "target_step": request.target_step, - } - finally: - self._finish_flow( - session, - flow, - should_capture=should_capture, - previous_db=previous_db, - ) - - def _build_flow(self, session): - flow = build_agent_flow_for_workspace(session.workspace) - return flow - - def _finish_flow(self, session, flow, *, should_capture: bool, previous_db) -> None: - if should_capture: - self.ecc_api._capture_flow_db(session, flow, previous_handle=previous_db) - else: - self.ecc_api._close_transient_flow_db(flow) - - def _with_workspace_lock(self, workspace_id: str, operation): - return self.ecc_api._with_session_mutation_lock(workspace_id, operation) - - -def _foundation_receipt(workspace_dir: Path) -> dict: - manifest = workspace_dir / "foundation_data/ecc/manifest.json" - try: - payload = json.loads(manifest.read_text(encoding="utf-8")) - except (OSError, ValueError) as exc: - raise RuntimeApiError("command_failed", f"foundation extraction failed: {exc}") from exc - if payload.get("contract_name") != "foundation_data/ecc": - raise RuntimeApiError( - "command_failed", "foundation extractor produced an unsupported contract" - ) - return { - "manifestRef": "foundation_data/ecc/manifest.json", - "manifestSha256": sha256(manifest.read_bytes()).hexdigest(), - "contractName": payload["contract_name"], - "schemaVersion": payload.get("schema_version"), - } - - -def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scope: str) -> list: - if execution_scope not in {"single_step", "full_flow"}: - raise RuntimeApiError("invalid_request", "candidate rerun execution scope is invalid") - steps = list(getattr(flow, "workspace_steps", ())) - target_index = next( - (index for index, step in enumerate(steps) if step.name == target_step), None - ) - end_index = next((index for index, step in enumerate(steps) if step.name == end_step), None) - if target_index is None or end_index is None: - raise RuntimeApiError( - "command_failed", f"rerun step not found: {target_step} or {end_step}" - ) - if execution_scope == "single_step": - if target_step != end_step: - raise RuntimeApiError( - "invalid_request", "single-step rerun end step must match the target step" - ) - return [steps[target_index]] - if end_index < target_index: - raise RuntimeApiError("invalid_request", "rerun end step precedes the target step") - return steps[target_index : end_index + 1] - - -def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None: - source_step = _candidate_source_step(flow, request.target_step) - bind_candidate_input( - workspace, - flow, - request.target_step, - source_step, - request.candidate_id, - ) - materialize_candidate_config( - workspace, - request.target_step, - request.patch, - request.candidate_id, - ) - - -def _candidate_source_step(flow, target_step: str) -> str: - steps = list(getattr(flow, "workspace_steps", ())) - for index, step in enumerate(steps): - if step.name == target_step and index: - return steps[index - 1].name - raise RuntimeApiError("invalid_request", f"candidate target has no predecessor: {target_step}") - - -def _reapply_candidate_input(workspace, flow, target_step: str) -> None: - try: - candidate_id = validate_candidate_step_contract(workspace, target_step) - if candidate_id is not None: - reapply_candidate_input_binding(workspace, flow, target_step) - except ValueError as error: - raise RuntimeApiError( - "command_failed", f"candidate contract is invalid for {target_step}: {error}" - ) from error - - -def _prepare_candidate_rerun(workspace, flow, steps: list) -> None: - workspace_root = Path(workspace.directory).resolve() - for step in steps: - for directory in _candidate_step_artifact_dirs(step): - _clear_candidate_artifact_dir(workspace_root, directory, step.name) - record = flow.get_step(step.name, step.tool) - if record is None: - raise RuntimeApiError("command_failed", f"candidate flow state is missing: {step.name}") - record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) - flow.save() - - -def _candidate_step_artifact_dirs(step) -> tuple[Path, ...]: - directories = [] - for field in ("output", "data", "feature", "analysis", "report", "log"): - value = getattr(step, field, {}) - directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None) - if directory: - directories.append(Path(directory)) - return tuple(dict.fromkeys(directories)) - - -def _clear_candidate_artifact_dir(workspace_root: Path, directory: Path, step_name: str) -> None: - resolved = directory.resolve() - if ( - resolved == workspace_root - or not path_is_within(resolved, workspace_root) - or directory.is_symlink() - ): - raise RuntimeApiError( - "command_failed", f"candidate artifact escapes workspace: {step_name}" - ) - if directory.exists(): - if not directory.is_dir(): - raise RuntimeApiError( - "command_failed", f"candidate artifact is not a directory: {step_name}" - ) - shutil.rmtree(directory) - directory.mkdir(parents=True, exist_ok=True) - - -def _run_candidate_step(flow, step) -> None: - _init_db_engine_for_workspace_step(flow, step) - state = flow.run_step(step, rerun=True) - if _state_value(state) != "Success": - raise RuntimeApiError( - "command_failed", - f"candidate rerun step {step.name} failed with state {_state_value(state)}", - ) diff --git a/chipcompiler/cli/app.py b/chipcompiler/cli/app.py index 57b606568..52dda2d6d 100644 --- a/chipcompiler/cli/app.py +++ b/chipcompiler/cli/app.py @@ -12,7 +12,6 @@ from chipcompiler.cli.commands.project import register_project_commands from chipcompiler.cli.commands.project_config import project_app from chipcompiler.cli.commands.report import report_app -from chipcompiler.cli.commands.rpc import rpc_app from chipcompiler.cli.commands.signoff import signoff_app from chipcompiler.cli.commands.workspace import workspace_app from chipcompiler.cli.core.apps import create_app @@ -82,7 +81,6 @@ def layout_image_cmd( app.add_typer(workspace_app, name="workspace") app.add_typer(signoff_app, name="signoff") app.add_typer(report_app, name="report") -app.add_typer(rpc_app, name="rpc") def invoke_typer_app(argv: Sequence[str]) -> int: diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py index 8a56b08fd..0e795c64e 100644 --- a/chipcompiler/cli/command_handlers/param.py +++ b/chipcompiler/cli/command_handlers/param.py @@ -11,6 +11,7 @@ validate_pdk_target, validate_value, ) +from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step from chipcompiler.utility.file import write_text_atomic @@ -29,6 +30,20 @@ def _manifest_mode_error(ctx: CommandContext) -> CommandResult | None: return None +def _parameter_flow_error(schema, ctx: CommandContext) -> CommandResult | None: + preset = getattr(ctx.config, "flow_preset", "") + builder = get_flow_builders().get(preset) + if builder is None or schema.applies == "all" or schema.pdk_target is not None: + return None + flow_steps = {normalize_flow_step(step).casefold() for step, _tool, _state in builder()} + if normalize_flow_step(schema.applies).casefold() in flow_steps: + return None + return CommandResult.err( + [error_record("parameter_not_in_flow", param=schema.param, preset=preset)], + exit_code=1, + ) + + def param_list(args, ctx: CommandContext) -> CommandResult: if getattr(args, "workspace", None) is not None: from chipcompiler.cli.command_handlers import workspace_params @@ -45,12 +60,31 @@ def param_list(args, ctx: CommandContext) -> CommandResult: resolved, _ = resolve_parameters(toml_overrides=toml_overrides) project = ctx.project - selected_step = (getattr(args, "step", None) or "").casefold() + selected_step = normalize_flow_step(getattr(args, "step", None) or "").casefold() show_all = bool(getattr(args, "all", False)) + preset = getattr(ctx.config, "flow_preset", "") + builder = get_flow_builders().get(preset) + flow_step_names = ( + [normalize_flow_step(step).casefold() for step, _tool, _state in builder()] + if builder is not None + else [] + ) + flow_steps = set(flow_step_names) + first_flow_step = flow_step_names[0] if flow_step_names else "" + if selected_step and flow_steps and selected_step not in flow_steps: + return CommandResult.ok([]) records = [] for rp in resolved: s = rp.schema - if selected_step and selected_step not in {s.group.casefold(), s.applies.casefold()}: + applies = normalize_flow_step(s.applies).casefold() + if flow_steps and s.applies != "all" and applies not in flow_steps: + continue + schema_steps = { + normalize_flow_step(s.group).casefold(), + normalize_flow_step(s.applies).casefold(), + } + global_at_first_step = s.applies == "all" and selected_step == first_flow_step + if selected_step and selected_step not in schema_steps and not global_at_first_step: continue if not selected_step and not show_all and s.has_direct_target and not rp.is_explicit: continue @@ -160,6 +194,9 @@ def param_set(args, ctx: CommandContext) -> CommandResult: ], exit_code=1, ) + flow_error = _parameter_flow_error(schema, ctx) + if flow_error is not None: + return flow_error try: value = parse_value(raw_value, schema) @@ -248,6 +285,9 @@ def param_unset(args, ctx: CommandContext) -> CommandResult: ], exit_code=1, ) + flow_error = _parameter_flow_error(schema, ctx) + if flow_error is not None: + return flow_error config_path = _find_config_path(ctx.project_dir) if config_path is None: diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 6ea8a5973..8df05f937 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -422,7 +422,6 @@ def error(kind: str, **fields) -> CommandResult: layer_warnings.append(set_warning) # TODO: Move non-interactive project run preparation/execution into - # chipcompiler.runtime.project_runner.run_project or # chipcompiler.engine.project_run.prepare_and_run. Keep CLI ownership limited # to input parsing, progress renderer selection, and CommandResult mapping. project_state = ctx.project_state @@ -444,6 +443,20 @@ def error(kind: str, **fields) -> CommandResult: return error("flow_range_requires_pair") if fresh_target and (command_input.resume or command_input.only is not None): return error("selector_requires_workspace") + if not fresh_target and ctx.project_state == "manifest" and cfg.manifest_driven: + return run_dispatch.dispatch_project_run( + command_input, + ctx, + cfg, + run_dir, + run_name, + cli_overrides, + flow_config, + project_state, + warning_records, + workspace_registered=workspace_registered, + execute_flow=execute_flow, + ) errors = effective_config.validate_effective( ctx, cfg, diff --git a/chipcompiler/cli/command_handlers/signoff.py b/chipcompiler/cli/command_handlers/signoff.py index aa55c376e..80f53e73e 100644 --- a/chipcompiler/cli/command_handlers/signoff.py +++ b/chipcompiler/cli/command_handlers/signoff.py @@ -9,7 +9,7 @@ def inspect(command_input, ctx: CommandContext) -> CommandResult: if failure is not None: return failure - from chipcompiler.runtime.signoff_export import inspect_signoff_package + from chipcompiler.engine.signoff_export import inspect_signoff_package review = inspect_signoff_package(workspace) project = ctx.project @@ -56,17 +56,18 @@ def export(command_input, ctx: CommandContext) -> CommandResult: if failure is not None: return failure - from chipcompiler.runtime.workspace_api import RuntimeApiError - try: - from chipcompiler.runtime.signoff_export import export_signoff_package_archive + from chipcompiler.engine.signoff_export import ( + SignoffExportError, + export_signoff_package_archive, + ) output_path = export_signoff_package_archive( workspace, command_input.output_path, include_debug=command_input.include_debug, ) - except RuntimeApiError as exc: + except SignoffExportError as exc: return CommandResult.err( [ error_record( diff --git a/chipcompiler/cli/command_handlers/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py index 8c576c77e..673de6501 100644 --- a/chipcompiler/cli/command_handlers/workspace_params.py +++ b/chipcompiler/cli/command_handlers/workspace_params.py @@ -12,6 +12,7 @@ workspace_param_step, workspace_param_value, ) +from chipcompiler.rtl2gds import normalize_flow_step def param_set(args, ctx: CommandContext) -> CommandResult: @@ -62,15 +63,19 @@ def param_list(args, ctx: CommandContext) -> CommandResult: if workspace_error is not None: return workspace_error overrides = {record["key"] for record in workspace_param_diff(workspace)} - selected_step = (args.step or "").casefold() + selected_step = normalize_flow_step(args.step or "").casefold() + flow_steps = workspace.flow.data.get("steps", []) + first_step = normalize_flow_step(flow_steps[0]["name"]).casefold() if flow_steps else "" records = [] for schema in list_schemas(): if schema.pdk_target is not None or (not args.all and schema.param not in overrides): continue - if selected_step and selected_step not in { - schema.group.casefold(), - schema.applies.casefold(), - }: + schema_steps = { + normalize_flow_step(schema.group).casefold(), + normalize_flow_step(schema.applies).casefold(), + } + global_at_first_step = schema.applies == "all" and selected_step == first_step + if selected_step and selected_step not in schema_steps and not global_at_first_step: continue try: value = workspace_param_value(workspace, schema) diff --git a/chipcompiler/cli/commands/report.py b/chipcompiler/cli/commands/report.py index 2aa6f3bfd..015ad1e82 100644 --- a/chipcompiler/cli/commands/report.py +++ b/chipcompiler/cli/commands/report.py @@ -31,7 +31,7 @@ def _finish(subcommand: str, command_input, handler) -> None: execute_command("report", command_input, handler, render_key=f"report:{subcommand}") -@report_app.command("qor", help="Show the overall QoR score report (GUI scoring rules)") +@report_app.command("qor", help="Show the overall QoR score report") def qor_cmd( *, output_path: OutputPathOption = None, diff --git a/chipcompiler/cli/commands/rpc.py b/chipcompiler/cli/commands/rpc.py deleted file mode 100644 index 184b96291..000000000 --- a/chipcompiler/cli/commands/rpc.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Annotated - -import typer - -from chipcompiler.cli.core.apps import create_app - -rpc_app = create_app(help="Run the private ECC JSON-RPC runtime") - - -@rpc_app.command("serve", help="Serve the private ECC JSON-RPC runtime") -def serve_cmd( - *, - stdio: Annotated[ - bool, - typer.Option("--stdio", help="Use Content-Length framed stdio transport."), - ] = False, - persistent_db: Annotated[ - bool, - typer.Option( - "--persistent-db", - help="Enable explicit persistent DB lifecycle RPC methods.", - ), - ] = False, -) -> None: - if not stdio: - raise typer.BadParameter("--stdio is required", param_hint="--stdio") - - from chipcompiler.runtime.stdio_server import main - - raise typer.Exit(code=main(persistent_db_enabled=persistent_db)) diff --git a/chipcompiler/cli/project/manifest.py b/chipcompiler/cli/project/manifest.py index aaf329c85..57aac98b5 100644 --- a/chipcompiler/cli/project/manifest.py +++ b/chipcompiler/cli/project/manifest.py @@ -1,423 +1,7 @@ -#!/usr/bin/env python +"""CLI compatibility alias for the Project Manifest domain module.""" -"""``project.json`` manifest support for the CLI. +import sys -The manifest is the GUI's project descriptor (schema v1). The CLI reads it -for configuration layering and run discovery, and projects it into -configuration payloads. Write operations (generation, status write-back, -registration) live in chipcompiler.cli.project.manifest_write, which -routes every write through one read-modify-write helper. +from chipcompiler.project import manifest as _domain_manifest -This module sits on the CLI startup path (imported by -cli/core/invocation.py): keep module-level imports cheap — no -chipcompiler.data imports here. -""" - -import json -import os -import re -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -MANIFEST_FILENAME = "project.json" - -# GUI display names for the canonical rtl2gds chain. -MANIFEST_FLOW_STEPS = ( - "Synth", - "LEC", - "Floor", - "Place", - "CTS", - "Legal", - "TimingOpt", - "Route", - "Filler", - "RCX", - "STA", - "LVS", - "PostRouteLEC", - "DRC", - "Harden", -) - -PRESET_MANIFEST_RANGE = { - "syn_sta": ("Synth", "Synth"), - "rtl2gds": ("Synth", "Harden"), - "synthesis_lec": ("Synth", "LEC"), - # Legacy presets removed from the builder; keep resolving them so - # persisted projects still load. - "rcx": ("Synth", "STA"), - "harden": ("Synth", "Harden"), -} - -_CANONICAL_TO_MANIFEST_STEP = { - "Synthesis": "Synth", - "lec": "LEC", - "Floorplan": "Floor", - "place": "Place", - "CTS": "CTS", - "legalization": "Legal", - "Timing optimization": "TimingOpt", - "route": "Route", - "filler": "Filler", - "RCX": "RCX", - "sta": "STA", - "lvs": "LVS", - "postRouteLec": "PostRouteLEC", - "drc": "DRC", - "Harden": "Harden", -} - -_WORKSPACE_STATUSES = frozenset( - {"success", "failed", "running", "in_progress", "not_started", "archived"} -) - - -class ManifestError(ValueError): - """Raised when a project.json manifest cannot be used (manifest_invalid).""" - - -@dataclass(frozen=True) -class ManifestWorkspace: - workspace_id: str - workspace_path: str - start_step: str - end_step: str - status: str - parameter_patch: dict = field(default_factory=dict) - raw: dict = field(default_factory=dict) - - -@dataclass(frozen=True) -class ProjectManifest: - project_dir: str - path: str - project_id: str - name: str - design_name: str - base_design: dict - objectives: dict - workspaces: tuple[ManifestWorkspace, ...] - qor_baseline: dict | None - raw: dict - - def active_workspaces(self) -> list[ManifestWorkspace]: - return [w for w in self.workspaces if w.status != "archived"] - - def find_workspace(self, workspace_id: str) -> ManifestWorkspace | None: - """Match a managed workspace by its declared identifier only.""" - for workspace in self.workspaces: - if workspace.workspace_id == workspace_id: - return workspace - return None - - -def _optional_str(value: Any) -> str: - return value.strip() if isinstance(value, str) else "" - - -def _record(value: Any) -> dict: - return value if isinstance(value, dict) else {} - - -def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> ManifestWorkspace: - source = _record(value) - workspace_id = _optional_str(source.get("workspace_id")) - workspace_path = _optional_str(source.get("workspace_path")) - if not workspace_id or not workspace_path: - raise ManifestError(f"workspaces[{index}] requires workspace_id and workspace_path") - resolved = Path(workspace_path) - if not resolved.is_absolute(): - resolved = Path(project_dir) / resolved - try: - canonical = resolved.resolve() - canonical.relative_to(Path(project_dir).resolve()) - except ValueError: - raise ManifestError( - f"workspaces[{index}] workspace_path escapes the project root: {workspace_path}" - ) from None - except RuntimeError as exc: - # e.g. a symlink loop inside the spelled path: invalid manifest - # input, never a traceback. - raise ManifestError( - f"workspaces[{index}] workspace_path cannot be resolved: {workspace_path}" - ) from exc - status = source.get("status") - if not isinstance(status, str) or status not in _WORKSPACE_STATUSES: - status = "not_started" - start_step = _optional_str(source.get("start_step")) or "Synth" - end_step = _optional_str(source.get("end_step")) or "Harden" - for step_name, field_name in ((start_step, "start_step"), (end_step, "end_step")): - if step_name not in MANIFEST_FLOW_STEPS: - raise ManifestError( - f"workspaces[{index}] {field_name} is not on the canonical flow chain: {step_name}" - ) - if MANIFEST_FLOW_STEPS.index(start_step) > MANIFEST_FLOW_STEPS.index(end_step): - raise ManifestError( - f"workspaces[{index}] flow range is reversed: {start_step} -> {end_step}" - ) - return ManifestWorkspace( - workspace_id=workspace_id, - workspace_path=str(canonical), - start_step=start_step, - end_step=end_step, - status=status, - parameter_patch=_record(source.get("parameter_patch")), - raw=dict(source), - ) - - -def _validate_mpc(value: Any) -> None: - """Mirror the GUI parser's mpc rules: null or a well-formed MPC record.""" - if value is None: - return - source = _record(value) - if not source: - raise ManifestError("invalid project manifest: mpc must be an object or null") - resource_id = _optional_str(source.get("resource_id")) - if not resource_id.startswith("mpc:") or len(resource_id) == 4: - raise ManifestError("invalid project manifest: mpc.resource_id must be an MPC id") - for field_name in ("display_name", "installed_version", "path", "spec_path"): - if not _optional_str(source.get(field_name)): - raise ManifestError(f"invalid project manifest: mpc.{field_name} is required") - mpc_path = source["path"].rstrip("/") - if source["spec_path"] != f"{mpc_path}/spec/spec.json.in": - raise ManifestError( - "invalid project manifest: mpc.spec_path must reference spec/spec.json.in" - ) - design = _record(source.get("design")) - index = design.get("index") - if ( - not design - or isinstance(index, bool) # a JSON boolean is not an index (True == 1 in Python) - or not isinstance(index, (int, float)) - # The GUI accepts integral numbers (0.0). is_integer() applies to - # floats only: float(huge_int) raises OverflowError, and ints are - # integral by construction. - or (isinstance(index, float) and not index.is_integer()) - or index < 0 - or not _optional_str(design.get("design_name")) - ): - raise ManifestError( - "invalid project manifest: mpc.design requires a non-negative index and design_name" - ) - if not isinstance(source.get("core_template"), dict): - raise ManifestError("invalid project manifest: mpc.core_template must be an object") - - -def load_manifest(project_dir: str) -> ProjectManifest: - """Load and tolerantly normalize ``/project.json``. - - Mirrors the GUI parser's contract: schema_version 1 and a workspaces - array are required, everything else is default-filled. Raises - ManifestError on parse failure, root_path mismatch, or a workspace - path outside the project root. - """ - path = os.path.join(project_dir, MANIFEST_FILENAME) - try: - with open(path, encoding="utf-8") as f: - source = json.load(f) - except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: - raise ManifestError(f"invalid project manifest: {path}: {exc}") from exc - - if not isinstance(source, dict): - raise ManifestError(f"invalid project manifest: {path}: top level must be an object") - schema_version = source.get("schema_version") - # A JSON boolean is not the schema version (True == 1 in Python); the - # GUI parser rejects it, and the CLI must not open what the GUI cannot. - if isinstance(schema_version, bool) or schema_version != 1: - raise ManifestError("invalid project manifest: schema_version 1 is required") - raw_workspaces = source.get("workspaces") - if not isinstance(raw_workspaces, list): - raise ManifestError("invalid project manifest: workspaces must be an array") - - root_path = _optional_str(source.get("root_path")) - if not root_path: - raise ManifestError("invalid project manifest: root_path is required") - if os.path.realpath(root_path) != os.path.realpath(project_dir): - raise ManifestError( - f"invalid project manifest: root_path {root_path} does not match {project_dir}" - ) - design_name = _optional_str(source.get("design_name")) - if not design_name: - raise ManifestError("invalid project manifest: design_name is required") - - name = _optional_str(source.get("name")) or os.path.basename(project_dir) or "project" - base_design = _record(source.get("base_design")) - base_design = {**base_design, "parameters": _record(base_design.get("parameters"))} - # Mirror the GUI parser: primary defaults to "timing", directions keep - # only maximize/minimize entries from the source (no default fill). - objectives_raw = _record(source.get("objectives")) - objectives = dict(objectives_raw) - objectives["primary"] = _optional_str(objectives_raw.get("primary")) or "timing" - objectives["directions"] = { - key: value - for key, value in _record(objectives_raw.get("directions")).items() - if value in ("maximize", "minimize") - } - qor_baseline_raw = _record(source.get("qor_baseline")) - qor_baseline = None - if _optional_str(qor_baseline_raw.get("workspace_id")): - qor_baseline = { - "workspace_id": qor_baseline_raw["workspace_id"], - "reason": _optional_str(qor_baseline_raw.get("reason")) or "Project QoR baseline", - } - - _validate_mpc(source.get("mpc")) - - return ProjectManifest( - project_dir=project_dir, - path=path, - project_id=_optional_str(source.get("project_id")) or f"proj_{_slugify(name)}", - name=name, - design_name=design_name, - base_design=base_design, - objectives=objectives, - workspaces=tuple( - _normalize_workspace_entry(entry, index, project_dir) - for index, entry in enumerate(raw_workspaces) - ), - qor_baseline=qor_baseline, - raw=source, - ) - - -def find_manifest(project_dir: str) -> str | None: - """The manifest path when the entry lexically exists. - - Lexical presence, not readability: a directory, symlink loop, or - dangling symlink still counts as PRESENT so ``load_manifest`` fails - loud (manifest_invalid) instead of silently demoting the project to - the virgin or legacy layout. - """ - path = os.path.join(project_dir, MANIFEST_FILENAME) - return path if os.path.lexists(path) else None - - -def has_legacy_runs_layout(project_dir: str) -> bool: - runs_dir = os.path.join(project_dir, "runs") - if not os.path.isdir(runs_dir): - return False - try: - return any(os.path.isdir(os.path.join(runs_dir, entry)) for entry in os.listdir(runs_dir)) - except OSError: - return False - - -def classify_project(project_dir: str) -> str: - """Classify a project directory: manifest | legacy | virgin.""" - if find_manifest(project_dir) is not None: - return "manifest" - if has_legacy_runs_layout(project_dir): - return "legacy" - return "virgin" - - -def assemble_config(manifest: ProjectManifest, workspace: ManifestWorkspace | None) -> dict: - """Flatten the manifest into a parameter payload (lowest precedence layer). - - ``base_design.parameters`` plus the workspace's ``parameter_patch`` form - the base layer beneath project ecc.toml and --set overrides. - """ - parameters = dict(manifest.base_design.get("parameters") or {}) - if workspace is not None: - for key, change in workspace.parameter_patch.items(): - parameters[key] = ( - change["to"] if isinstance(change, dict) and "to" in change else change - ) - if manifest.design_name and not _optional_str(parameters.get("design")): - parameters["design"] = manifest.design_name - rtl_list = manifest.base_design.get("rtl_list") - if not isinstance(rtl_list, list): - rtl_list = [] - return { - "pdk": _optional_str(manifest.base_design.get("pdk")), - "pdk_root": _optional_str(manifest.base_design.get("pdk_root")), - "design_name": manifest.design_name, - "top_module": _optional_str(manifest.base_design.get("top_module")), - "clock": _optional_str(manifest.base_design.get("clock")), - "rtl_list": [item for item in rtl_list if isinstance(item, str)], - "origin_verilog": _optional_str(manifest.base_design.get("origin_verilog")), - "origin_def": _optional_str(manifest.base_design.get("origin_def")), - "netlist": _optional_str(manifest.base_design.get("netlist")), - "golden_netlist": _optional_str(manifest.base_design.get("golden_netlist")), - "sdc": _optional_str(manifest.base_design.get("sdc")), - "spef": _optional_str(manifest.base_design.get("spef")), - "parameters": parameters, - } - - -def resolved_base_parameters(cfg) -> dict: - """The ecc.toml-resolved base_design.parameters for a generated manifest. - - GUI-flat vocabulary: identity fields plus the [params] overrides, - projected through the geometry converter so positional values surface - as the wizard's aliases (die_width, utilitization, margin, ...). - --set values are run-scoped and never included. - """ - canonical: dict = { - "design": cfg.design_name, - "top_module": cfg.design_top, - "clock": cfg.design_clock_port, - "frequency_max": cfg.design_frequency_mhz, - } - if cfg.params_overrides: - from chipcompiler.cli.project.params import ( - build_backend_overrides, - resolve_parameters, - ) - - resolved, _ = resolve_parameters(toml_overrides=cfg.params_overrides) - canonical.update(build_backend_overrides(resolved)) - - from chipcompiler.data.parameter_keys import parameters_to_geometry - - flat = parameters_to_geometry(canonical) - # Exclusive GUI-flat shape: geometry lives only in the aliases — - # the canonical die/core subtrees are consumed, not duplicated. - # Non-positional members (e.g. aspect_ratio) hoist to flat top-level - # keys; positional members are covered by the aliases. - for subtree_name in ("die", "core"): - subtree = flat.pop(subtree_name, None) - if not isinstance(subtree, dict): - continue - for member, value in subtree.items(): - if member in ("size", "utilitization", "margin"): - continue - flat.setdefault(member, value) - return flat - - -def base_design_from_config(cfg, pdk_root: str) -> dict: - """The base_design document for a generated manifest. - - Identity and sources come from the ecc.toml-resolved config with the - DECLARED project source spellings preserved: ``rtl_list`` verbatim, - and ``origin_verilog`` when the single source is plain RTL (empty for - a filelist source; the document builder drops empty keys). Parameters - are the GUI-flat projection. Shared by virgin generation and first - migration so the two writers cannot drift. - """ - from chipcompiler.cli.project.config import resolve_rtl - - _, origin_verilog, _ = resolve_rtl(cfg) - return { - "pdk": cfg.pdk_name, - "pdk_root": pdk_root, - "top_module": cfg.design_top, - "clock": cfg.design_clock_port, - "rtl_list": cfg.design_rtl, - "origin_verilog": cfg.design_rtl[0] if origin_verilog else "", - "origin_def": cfg.design_def, - "netlist": cfg.design_netlist, - "golden_netlist": cfg.design_golden_netlist, - "sdc": cfg.design_sdc, - "spef": cfg.design_spef, - "parameters": resolved_base_parameters(cfg), - } - - -def _slugify(value: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_") - return slug or "project" +sys.modules[__name__] = _domain_manifest diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py index c1d422dbf..61eb0ca32 100644 --- a/chipcompiler/cli/project/manifest_write.py +++ b/chipcompiler/cli/project/manifest_write.py @@ -1,372 +1,7 @@ -#!/usr/bin/env python +"""CLI compatibility alias for Project Manifest mutations.""" -"""Manifest write, mutation, and registration operations for the CLI. +import sys -All ``project.json`` writes go through one read-modify-write helper so -status write-back and migration entry-append share the same atomicity -story. Loading and normalization live in -chipcompiler.cli.project.manifest; this module imports from it, never -the reverse. +from chipcompiler.project import manifest_write as _domain_manifest_write -Like manifest.py, this module sits on the CLI startup path (imported by -run dispatch and migration flows): keep module-level imports cheap — no -chipcompiler.data imports here. -""" - -import json -import logging -import os -import tempfile -from copy import deepcopy -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from chipcompiler.cli.project.manifest import ( - _CANONICAL_TO_MANIFEST_STEP, - MANIFEST_FILENAME, - PRESET_MANIFEST_RANGE, - ManifestError, - _record, - _slugify, - base_design_from_config, -) - -logger = logging.getLogger(__name__) - -DEFAULT_OBJECTIVES = { - "primary": "timing", - "directions": { - "wns": "maximize", - "tns": "maximize", - "area": "minimize", - "drc_count": "minimize", - "lvs_count": "minimize", - "power": "minimize", - }, -} - - -def _now_iso() -> str: - return datetime.now(UTC).isoformat() - - -def manifest_workspace_entry( - workspace_id: str, - *, - name: str, - workspace_path: str, - start_step: str, - end_step: str, - status: str, - now: str, -) -> dict: - """One complete schema-v1 workspaces[] entry, every field materialized. - - The single builder for generated manifests and migration previews, so - the previewed entry and the applied entry are the same object shape. - """ - return { - "workspace_id": workspace_id, - "name": name, - "workspace_path": workspace_path, - "source_workspace_id": None, - "branch_from": None, - "start_step": start_step, - "end_step": end_step, - "status": status, - "created_at": now, - "updated_at": now, - "parameter_patch": {}, - "metrics_summary": {}, - "step_metrics": {}, - } - - -def build_manifest_document( - project_dir: str, - *, - design_name: str, - base_design: dict, - workspace_id: str, - workspace_path: str, - start_step: str, - end_step: str, - status: str = "running", -) -> dict: - """Assemble a schema-v1 manifest for a virgin project's first run.""" - now = _now_iso() - name = os.path.basename(os.path.normpath(project_dir)) or "project" - document: dict[str, Any] = { - "schema_version": 1, - "project_id": f"proj_{_slugify(name)}", - "name": name, - "design_name": design_name, - "description": "", - "root_path": project_dir, - "created_at": now, - "updated_at": now, - "base_design": { - **{key: value for key, value in base_design.items() if key != "parameters" and value}, - "parameters": _record(base_design.get("parameters")), - "rtl_list": [ - item for item in base_design.get("rtl_list") or [] if isinstance(item, str) - ], - }, - "objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)), - "workspaces": [ - manifest_workspace_entry( - workspace_id, - name=design_name, - workspace_path=workspace_path, - start_step=start_step, - end_step=end_step, - status=status, - now=now, - ) - ], - "mpc": None, - "best_workspace": None, - "qor_baseline": {"workspace_id": workspace_id, "reason": "Default project QoR baseline"}, - } - return document - - -def write_manifest_if_absent(project_dir: str, document: dict) -> bool: - """Write the manifest only when it does not exist (virgin generation race). - - Fully written and fsynced at a temp path, then linked into place: - readers never see a partial file, and a concurrent creator wins the - link — ours is discarded and the caller continues read-only. - """ - path = os.path.join(project_dir, MANIFEST_FILENAME) - content = json.dumps(document, indent=2) + "\n" - tmp_path = None - try: - with tempfile.NamedTemporaryFile( - "w", - dir=project_dir, - delete=False, - prefix=f".{MANIFEST_FILENAME}.", - suffix=".tmp", - encoding="utf-8", - ) as f: - tmp_path = f.name - f.write(content) - f.flush() - os.fsync(f.fileno()) - # Mode stays the tempfile default (0600), matching json_write's - # convention for newly created state files. - os.link(tmp_path, path) - return True - except FileExistsError: - return False - except OSError as exc: - logger.warning("manifest write failed: %s: %s", path, exc) - return False - finally: - if tmp_path is not None: - Path(tmp_path).unlink(missing_ok=True) - - -def _read_manifest_document(path: str): - try: - with open(path, encoding="utf-8") as f: - document = json.load(f) - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return None - return document if isinstance(document, dict) else None - - -def update_manifest(project_dir: str, mutator) -> bool: - """Read-modify-write the manifest atomically (locked re-read + patch + replace). - - The whole read-modify-replace runs under ``.manifest.lock`` (flock): - two cooperating writers can no longer both complete the fresh read - before either replaces, so neither loses the other's update. The - mutator receives the parsed document and edits it in place. When an - unrelated change lands between the read and the write, the mutator is - re-applied to the freshest document instead of overwriting the change. - Project-level fields (including updated_at) are owned by the mutator. - Returns False (with a warning) when the manifest is missing, unreadable, - the lock cannot be taken, or the write fails — callers degrade to a - warning, never a run failure. - """ - from chipcompiler.cli.project.migrate_fs import flock_file - - path = os.path.join(project_dir, MANIFEST_FILENAME) - try: - with flock_file(os.path.join(project_dir, ".manifest.lock"), exclusive=True): - return _update_manifest_locked(path, mutator) - except OSError as exc: - # An untakeable lock (e.g. a directory at the lock path) degrades - # like any write failure: a warning, never an uncaught exception — - # the migration registration path relies on False to roll back. - logger.warning("manifest update failed: %s: %s", path, exc) - return False - - -def _update_manifest_locked(path: str, mutator) -> bool: - base = _read_manifest_document(path) - if base is None: - logger.warning("manifest update skipped (unreadable): %s", path) - return False - - document = deepcopy(base) - mutator(document) - - fresh = _read_manifest_document(path) - if fresh is not None and fresh != base: - # An unrelated edit landed after our read: re-apply the mutator to - # the freshest document so the interleaved change survives. - document = fresh - mutator(document) - - target = Path(path) - tmp_path = None - try: - with tempfile.NamedTemporaryFile( - "w", - dir=target.parent, - delete=False, - prefix=f".{target.name}.", - suffix=".tmp", - encoding="utf-8", - ) as f: - tmp_path = Path(f.name) - json.dump(document, f, indent=2) - f.write("\n") - f.flush() - os.fsync(f.fileno()) - # Preserve the existing manifest's permissions: mkstemp's 0600 must - # not silently narrow a shared project.json. - if target.exists(): - os.chmod(tmp_path, target.stat().st_mode & 0o7777) - os.replace(tmp_path, target) - return True - except OSError as exc: - logger.warning("manifest update failed: %s: %s", path, exc) - if tmp_path is not None: - tmp_path.unlink(missing_ok=True) - return False - - -def write_back_workspace_status(project_dir: str, workspace_id: str, status: str) -> bool: - """Update one workspace entry's status (and updated_at) after a run.""" - - def mutate(document: dict) -> None: - for entry in document.get("workspaces", []): - if isinstance(entry, dict) and entry.get("workspace_id") == workspace_id: - entry["status"] = status - entry["updated_at"] = _now_iso() - - return update_manifest(project_dir, mutate) - - -def remove_workspace_registration(project_dir: str, workspace_id: str) -> bool: - """Roll back a pre-registration: drop the freshly added entry. - - Used when an overwrite run against an undeclared workspace fails before - the replacement is constructed: the restored previous workspace must not - be shadowed by a stale ``not_started`` entry this invocation created. - """ - - def mutate(document: dict) -> None: - workspaces = document.get("workspaces") - if isinstance(workspaces, list): - document["workspaces"] = [ - entry - for entry in workspaces - if not (isinstance(entry, dict) and entry.get("workspace_id") == workspace_id) - ] - document["updated_at"] = _now_iso() - - return update_manifest(project_dir, mutate) - - -def manifest_range_for_flow(cfg, flow_config: dict | None) -> tuple[str, str]: - """Return the GUI manifest range for a workspace's effective target.""" - if isinstance(flow_config, dict) and flow_config.get("start_step"): - from chipcompiler.rtl2gds import normalize_flow_step - - start = normalize_flow_step(flow_config["start_step"]) - end = normalize_flow_step(flow_config.get("end_step") or start) - try: - return (_CANONICAL_TO_MANIFEST_STEP[start], _CANONICAL_TO_MANIFEST_STEP[end]) - except KeyError as exc: - raise ManifestError(f"unknown workspace flow step: {exc.args[0]}") from None - return PRESET_MANIFEST_RANGE.get(cfg.flow_preset, ("Synth", "Harden")) - - -def pre_register_workspace( - project_dir: str, - *, - cfg, - pdk_root: str, - workspace_id: str, - workspace_path: str, - flow_config: dict | None, -) -> str: - """Atomically register a fresh managed workspace before filesystem creation. - - Returns ``registered``, ``existing``, ``conflict``, or ``failed``. A - workspace entry intentionally contains no input snapshot: copied files and - the workspace config are the reproducibility boundary. - """ - try: - start_step, end_step = manifest_range_for_flow(cfg, flow_config) - except ManifestError: - return "failed" - now = _now_iso() - manifest_path = os.path.join(project_dir, MANIFEST_FILENAME) - if not os.path.lexists(manifest_path): - document = build_manifest_document( - project_dir, - design_name=cfg.design_name, - base_design=base_design_from_config(cfg, pdk_root), - workspace_id=workspace_id, - workspace_path=workspace_path, - start_step=start_step, - end_step=end_step, - status="not_started", - ) - if write_manifest_if_absent(project_dir, document): - return "registered" - # A concurrent creator won the link race: fall through and apply the - # same registration mutation under the manifest lock instead of - # aborting this run. A genuine I/O failure fails again below. - - outcome = "registered" - - def mutate(document: dict) -> None: - nonlocal outcome - workspaces = document.get("workspaces") - if not isinstance(workspaces, list): - outcome = "failed" - return - for entry in workspaces: - if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id: - continue - if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath( - workspace_path - ): - outcome = "existing" - else: - outcome = "conflict" - return - workspaces.append( - manifest_workspace_entry( - workspace_id, - name=cfg.design_name, - workspace_path=workspace_path, - start_step=start_step, - end_step=end_step, - status="not_started", - now=now, - ) - ) - document["updated_at"] = now - - if not update_manifest(project_dir, mutate): - return "failed" - return outcome +sys.modules[__name__] = _domain_manifest_write diff --git a/chipcompiler/cli/project/params.py b/chipcompiler/cli/project/params.py index b4db5c0b1..6241f7d70 100644 --- a/chipcompiler/cli/project/params.py +++ b/chipcompiler/cli/project/params.py @@ -1,682 +1,27 @@ -import copy -import json -from dataclasses import dataclass +"""CLI compatibility facade for the canonical data parameter catalog.""" -from chipcompiler.data.config_params import CONFIG_PARAM_SCHEMAS -from chipcompiler.data.config_params.common import ParamSchema +from dataclasses import replace -_LEGACY_PARAM_REGISTRY: tuple[ParamSchema, ...] = ( - ParamSchema( - param="design.frequency_mhz", - group="design", - name="frequency_mhz", - type="float", - default=100.0, - applies="synthesis", - maps_to="frequency_max", - description="Target clock frequency in MHz", - range=(1e-6, 10000.0), - unit="MHz", - example="200.0", - ), - ParamSchema( - param="floorplan.core_util", - group="floorplan", - name="core_util", - type="float", - default=0.4, - applies="floorplan", - maps_to={"core": "utilitization"}, - description="Core utilization ratio", - range=(0.01, 1.0), - example="0.45", - ), - ParamSchema( - param="floorplan.core_margin", - group="floorplan", - name="core_margin", - type="list[int]", - default=[2, 2], - applies="floorplan", - maps_to={"core": "margin"}, - description="Core margin in micrometers [horizontal, vertical]", - example="[2, 2]", - ), - ParamSchema( - param="floorplan.aspect_ratio", - group="floorplan", - name="aspect_ratio", - type="float", - default=1.0, - applies="floorplan", - maps_to={"core": "aspect_ratio"}, - description="Core aspect ratio (width/height)", - range=(0.1, 10.0), - example="1.0", - ), - ParamSchema( - param="cts.max_fanout", - group="cts", - name="max_fanout", - type="int", - default=20, - applies="cts", - maps_to="max_fanout", - description="Maximum fanout for clock tree synthesis", - range=(1, 200), - example="16", - ), - ParamSchema( - param="place.target_density", - group="place", - name="target_density", - type="float", - default=0.2, - applies="placement", - maps_to={"dreamplace": "target_density"}, - description="Target placement density", - range=(0.1, 0.95), - example="0.65", - ), - ParamSchema( - param="place.target_overflow", - group="place", - name="target_overflow", - type="float", - default=0.1, - applies="placement", - maps_to={"dreamplace": "stop_overflow"}, - description="Target overflow for global placement", - range=(0.0, 1.0), - example="0.08", - ), - ParamSchema( - param="place.global_right_padding", - group="place", - name="global_right_padding", - type="int", - default=0, - applies="placement", - maps_to="global_right_padding", - description="Global right padding for placement sites", - range=(0, 100), - example="8", - ), - ParamSchema( - param="place.cell_padding_x", - group="place", - name="cell_padding_x", - type="int", - default=300, - applies="placement", - maps_to={"dreamplace": "cell_padding_x"}, - description="Cell padding in x-direction in database units", - range=(0, 10000), - example="400", - ), - ParamSchema( - param="place.routability_opt", - group="place", - name="routability_opt", - type="int", - default=1, - applies="placement", - maps_to={"dreamplace": "routability_opt_flag"}, - description="Enable routability-driven placement optimization", - choices=("0", "1"), - example="1", - ), - ParamSchema( - param="route.bottom_layer", - group="route", - name="bottom_layer", - type="str", - default="MET2", - applies="routing", - maps_to="bottom_layer", - description="Bottom routing layer", - choices=("MET1", "MET2", "MET3", "MET4", "MET5"), - example="MET2", - ), - ParamSchema( - param="route.top_layer", - group="route", - name="top_layer", - type="str", - default="MET5", - applies="routing", - maps_to="top_layer", - description="Top routing layer", - choices=("MET2", "MET3", "MET4", "MET5", "MET6"), - example="MET5", - ), - ParamSchema( - param="sta.max_paths", - group="sta", - name="max_paths", - type="int", - default=1000, - applies="sta", - maps_to="sta_max_paths", - description="Maximum number of paths in each STA timing report", - range=(1, 100000), - example="1000", - ), - ParamSchema( - param="flow.run_analysis", - group="flow", - name="run_analysis", - type="bool", - default=True, - applies="all", - maps_to="run_analysis", - description="Run per-step analysis (metrics, plots, checklist) after each step", - example="false", - ), -) +from chipcompiler.data.parameter_schema import * # noqa: F401,F403 +from chipcompiler.data.parameter_schema import validate_schema_type -PARAM_REGISTRY = _LEGACY_PARAM_REGISTRY + CONFIG_PARAM_SCHEMAS +_validate_schema_type = validate_schema_type -_REGISTRY_INDEX: dict[str, ParamSchema] = {s.param: s for s in PARAM_REGISTRY} -_REQUIRED_FIELDS = ( - "param", - "group", - "name", - "type", - "default", - "applies", - "description", -) - -def lookup_schema(key: str) -> ParamSchema | None: - return _REGISTRY_INDEX.get(key) - - -def list_schemas() -> tuple[ParamSchema, ...]: - return PARAM_REGISTRY - - -def list_groups() -> list[str]: - seen: list[str] = [] - for s in PARAM_REGISTRY: - if s.group not in seen: - seen.append(s.group) - return seen - - -def is_known_key(key: str) -> bool: - return key in _REGISTRY_INDEX - - -def validate_schema_record(schema: ParamSchema) -> list[str]: - return [ - f"missing required field: {f}" - for f in _REQUIRED_FIELDS - if getattr(schema, f, None) is None or (f != "default" and getattr(schema, f) == "") - ] - - -# --------------------------------------------------------------------------- -# Value parsing -# --------------------------------------------------------------------------- - - -def parse_value(raw: str, schema: ParamSchema) -> object: - ptype = schema.type - - if ptype == "int": - try: - return int(raw) - except ValueError as exc: - raise ValueError(f"expected int for {schema.param}, got '{raw}'") from exc - - if ptype == "float": - try: - return float(raw) - except ValueError as exc: - raise ValueError(f"expected float for {schema.param}, got '{raw}'") from exc - - if ptype == "bool": - low = raw.lower() - if low in ("true", "1", "yes"): - return True - if low in ("false", "0", "no"): - return False - raise ValueError(f"expected bool for {schema.param}, got '{raw}'") - - if ptype == "str": - return raw - - if ptype in ("list[int]", "list[float]", "list[str]"): - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - parsed = None - if parsed is not None: - value, error = _validate_schema_type(parsed, schema) - if error is None: - return value - raise ValueError(error) - - stripped = raw.strip("[]() ") - if not stripped: - return [] - parts = [p.strip() for p in stripped.split(",")] - if ptype == "list[int]": - try: - return [int(p) for p in parts if p] - except ValueError as exc: - raise ValueError(f"expected list[int] for {schema.param}, got '{raw}'") from exc - if ptype == "list[float]": - try: - return [float(p) for p in parts if p] - except ValueError as exc: - raise ValueError(f"expected list[float] for {schema.param}, got '{raw}'") from exc - return [p for p in parts if p] - - if ptype == "json": - try: - return json.loads(raw) - except json.JSONDecodeError as exc: - raise ValueError(f"expected JSON for {schema.param}, got '{raw}'") from exc - - raise ValueError(f"unsupported type '{ptype}' for {schema.param}") - - -def validate_value(value: object, schema: ParamSchema) -> list[str]: - errors: list[str] = [] - - if schema.range is not None: - lo, hi = schema.range - if not isinstance(value, (int, float)): - # Raw layers (e.g. project.json parameters) reach this without - # the typed parsing --set/[params] get: a string must fail loud - # instead of silently skipping the range check. - errors.append(f"value {value!r} is not numeric for {schema.param}") - elif value < lo or value > hi: - errors.append(f"value {value} out of range [{lo}, {hi}] for {schema.param}") - - if schema.choices is not None: - str_val = str(value) - if str_val not in schema.choices: - errors.append( - f"value '{str_val}' not in allowed choices {schema.choices} for {schema.param}" - ) - - return errors - - -# --------------------------------------------------------------------------- -# Source-aware resolution -# --------------------------------------------------------------------------- - - -@dataclass -class ResolvedParam: - param: str - value: object - default: object - source: str - schema: ParamSchema - - @property - def is_explicit(self) -> bool: - return self.source != "default" - - -def _validate_schema_type(value: object, schema: ParamSchema) -> tuple[object, str | None]: - ptype = schema.type - key = schema.param - - if ptype == "int": - if isinstance(value, bool) or not isinstance(value, int): - return value, f"expected int for {key}, got {type(value).__name__}" - return value, None - - if ptype == "float": - if isinstance(value, bool): - return value, f"expected float for {key}, got bool" - if isinstance(value, (int, float)): - try: - return float(value), None - except OverflowError: - # A huge JSON integer cannot become a float; that is invalid - # input, not a crash. - return value, f"expected float for {key}, value too large to represent" - return value, f"expected float for {key}, got {type(value).__name__}" - - if ptype == "bool": - if isinstance(value, bool): - return value, None - if isinstance(value, str): - low = value.lower() - if low in ("true", "1", "yes"): - return True, None - if low in ("false", "0", "no"): - return False, None - return value, f"expected bool for {key}, got {type(value).__name__}" - - if ptype == "str": - if isinstance(value, str): - return value, None - return value, f"expected str for {key}, got {type(value).__name__}" - - if ptype == "list[int]": - if not isinstance(value, list): - return value, f"expected list for {key}, got {type(value).__name__}" - for i, v in enumerate(value): - if isinstance(v, bool) or not isinstance(v, int): - return value, f"expected list[int] for {key}, element {i} is {type(v).__name__}" - return value, None - - if ptype == "list[float]": - if not isinstance(value, list): - return value, f"expected list for {key}, got {type(value).__name__}" - for i, v in enumerate(value): - if isinstance(v, bool): - return value, f"expected list[float] for {key}, element {i} is bool" - if not isinstance(v, (int, float)): - return value, f"expected list[float] for {key}, element {i} is {type(v).__name__}" - try: - return [float(v) for v in value], None - except OverflowError: - return value, f"expected list[float] for {key}, value too large to represent" - - if ptype == "list[str]": - if not isinstance(value, list): - return value, f"expected list for {key}, got {type(value).__name__}" - for i, v in enumerate(value): - if not isinstance(v, str): - return value, f"expected list[str] for {key}, element {i} is {type(v).__name__}" - return value, None - - if ptype == "json": - if isinstance(value, (dict, list)): - return value, None - return value, f"expected JSON object or array for {key}, got {type(value).__name__}" - - return value, None - - -def resolve_parameters( - toml_overrides: dict[str, object] | None = None, - cli_overrides: dict[str, object] | None = None, - manifest_overrides: dict[str, object] | None = None, -) -> tuple[list[ResolvedParam], list[str]]: - toml_overrides = toml_overrides or {} - cli_overrides = cli_overrides or {} - manifest_overrides = manifest_overrides or {} - resolved: list[ResolvedParam] = [] - errors: list[str] = [] - - for schema in PARAM_REGISTRY: - key = schema.param - if key in cli_overrides: - value = cli_overrides[key] - val_errors = validate_value(value, schema) - if val_errors: - errors.extend(val_errors) - resolved.append( - ResolvedParam( - param=key, - value=value, - default=schema.default, - source="cli", - schema=schema, - ) - ) - elif key in toml_overrides: - value = toml_overrides[key] - value, coerce_err = _validate_schema_type(value, schema) - if coerce_err: - errors.append(coerce_err) - val_errors = validate_value(value, schema) - if val_errors: - errors.extend(val_errors) - resolved.append( - ResolvedParam( - param=key, - value=value, - default=schema.default, - source="ecc.toml", - schema=schema, - ) - ) - elif key in manifest_overrides: - value = manifest_overrides[key] - value, coerce_err = _validate_schema_type(value, schema) - if coerce_err: - errors.append(coerce_err) - val_errors = validate_value(value, schema) - if val_errors: - errors.extend(val_errors) - resolved.append( - ResolvedParam( - param=key, - value=value, - default=schema.default, - source="project.json", - schema=schema, - ) - ) - else: - resolved.append( - ResolvedParam( - param=key, - value=schema.default, - default=schema.default, - source="default", - schema=schema, - ) - ) - - return resolved, errors - - -# --------------------------------------------------------------------------- -# Semantic-to-backend mapping -# --------------------------------------------------------------------------- - - -def manifest_value_for(canonical: dict, maps_to: str | dict | None) -> tuple[object, bool]: - """Resolve a manifest-layer value via its schema target (presence-keyed). - - A string ``maps_to`` is a top-level canonical key; a dict ``maps_to`` is a - single-level (subtree, leaf) path. Returns (value, present): an explicit - JSON ``null`` counts as present, an absent key does not. - """ - if isinstance(maps_to, str): - if maps_to in canonical: - return canonical[maps_to], True - return None, False - if maps_to is None: - return None, False - for subtree, leaf in maps_to.items(): - node = canonical.get(subtree) - if isinstance(node, dict) and leaf in node: - return node[leaf], True - return None, False - - -def coerce_manifest_parameters( - canonical: dict, - registry: tuple[ParamSchema, ...] = PARAM_REGISTRY, - skip_params: frozenset | set = frozenset(), -) -> tuple[dict, list[str]]: - """Coerce manifest-layer parameter values to their schema types. - - Type check/coercion only — range/choice rules stay check-path concerns. - Works on a deep copy: coerced values are written back, uncoercible values - are left as-is (the caller decides what an error means), and keys outside - the registry pass through untouched (forward-compatible). - """ - coerced = copy.deepcopy(canonical) - errors: list[str] = [] - for schema in registry: - if schema.param in skip_params: - continue - value, present = manifest_value_for(coerced, schema.maps_to) - if not present: - continue - new_value, err = _validate_schema_type(value, schema) - if err: - errors.append(err) - continue - maps_to = schema.maps_to - if isinstance(maps_to, str): - coerced[maps_to] = new_value - elif isinstance(maps_to, dict): - for subtree, leaf in maps_to.items(): - node = coerced.get(subtree) - if isinstance(node, dict) and leaf in node: - node[leaf] = new_value - break - return coerced, errors - - -def build_backend_overrides(resolved: list[ResolvedParam]) -> dict: - overrides: dict = {} - for rp in resolved: - if rp.value == rp.default and rp.source == "default": - continue - maps_to = rp.schema.maps_to - value = rp.value - if isinstance(maps_to, str): - overrides[maps_to] = value - elif isinstance(maps_to, dict): - for parent_key, child_key in maps_to.items(): - if parent_key not in overrides: - overrides[parent_key] = {} - overrides[parent_key][child_key] = value - return overrides - - -def build_config_overrides(resolved: list[ResolvedParam]) -> dict[str, object]: - overrides: dict[str, object] = {} - for rp in resolved: - target = rp.schema.config_target - if target is None or not rp.is_explicit: - continue - config_overrides = overrides.setdefault(target.config_key, {}) - _set_nested_value(config_overrides, target.json_path, rp.value) - return overrides - - -def build_pdk_overrides(resolved: list[ResolvedParam]) -> dict[str, object]: - return { - rp.schema.pdk_target: rp.value - for rp in resolved - if rp.schema.pdk_target is not None and rp.is_explicit - } - - -def _set_nested_value(data: dict, path: tuple[str, ...], value: object) -> None: - current = data - for key in path[:-1]: - child = current.get(key) - if not isinstance(child, dict): - child = {} - current[key] = child - current = child - current[path[-1]] = value - - -def parse_cli_overrides(pairs: list[str]) -> tuple[dict[str, object], list[str]]: - result: dict[str, object] = {} - errors: list[str] = [] - - for pair in pairs: - if "=" not in pair: - errors.append(f"malformed override: '{pair}' (expected key=value)") - continue - - key, _, raw_value = pair.partition("=") - key = key.strip() - raw_value = raw_value.strip() - - schema = lookup_schema(key) - if schema is None: - errors.append(f"unknown parameter: '{key}'") - continue - - try: - value = parse_value(raw_value, schema) - except ValueError as exc: - errors.append(str(exc)) - continue - - val_errors = validate_value(value, schema) - if val_errors: - errors.extend(val_errors) - continue - - result[key] = value - - return result, errors - - -def validate_pdk_target(schema: ParamSchema, value: object, cfg) -> str | None: - """Validate a PDK-target value using the existing PDK resolver and checks.""" +def validate_pdk_target(schema, value, cfg) -> str | None: if schema.pdk_target is None: return None - - from dataclasses import replace - from chipcompiler.cli.project.config import ( _validate_pdk_contents, resolve_pdk_overrides, resolve_pdk_root, ) - raw_overrides = dict(cfg.pdk_overrides) - raw_overrides[schema.pdk_target] = value - candidate = replace(cfg, pdk_overrides=raw_overrides) + overrides = dict(cfg.pdk_overrides) + overrides[schema.pdk_target] = value + candidate = replace(cfg, pdk_overrides=overrides) return _validate_pdk_contents( candidate.pdk_name, resolve_pdk_root(candidate), resolve_pdk_overrides(candidate), ) - - -def parse_toml_params(params_table: dict) -> tuple[dict[str, object], list[str]]: - flat: dict[str, object] = {} - errors: list[str] = [] - - def visit(path: tuple[str, ...], value: object) -> None: - param_key = ".".join(path) - schema = lookup_schema(param_key) - if schema is not None: - try: - if isinstance(value, str): - parsed = parse_value(value, schema) - else: - parsed, type_err = _validate_schema_type(value, schema) - if type_err: - errors.append(type_err) - return - except ValueError as exc: - errors.append(str(exc)) - return - - val_errors = validate_value(parsed, schema) - if val_errors: - errors.extend(val_errors) - return - flat[param_key] = parsed - return - - if isinstance(value, dict): - for key, child in value.items(): - visit((*path, key), child) - return - - errors.append(f"unknown parameter in ecc.toml: '{param_key}'") - - for group_key, group_val in params_table.items(): - if not isinstance(group_val, dict): - errors.append(f"[params.{group_key}] must be a table, got {type(group_val).__name__}") - continue - visit((group_key,), group_val) - - return flat, errors diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index bed0caf21..00fdb580f 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -353,6 +353,9 @@ def failed_workspace(reason: str | None) -> CommandResult: input_filelist = generated_filelist origin_verilog = "" parameters = to_parameters(cfg) + if uses_netlist_input: + # Preserve the creator's input role for Studio/CLI reopen symmetry. + parameters["_input_mode"] = "postSynthesis" pdk_root = resolve_pdk_root(cfg) if base is not None: diff --git a/chipcompiler/cli/project/workspace_params.py b/chipcompiler/cli/project/workspace_params.py index 07203d06f..da1e397fe 100644 --- a/chipcompiler/cli/project/workspace_params.py +++ b/chipcompiler/cli/project/workspace_params.py @@ -1,136 +1,3 @@ -"""Workspace-local parameter persistence and replay helpers.""" +"""CLI compatibility facade for workspace parameter persistence.""" -from copy import deepcopy - -from chipcompiler.cli.project.params import ( - ResolvedParam, - build_backend_overrides, - build_config_overrides, -) -from chipcompiler.data.parameter import update_parameters -from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY -from chipcompiler.utility import json_read - -WORKSPACE_PARAM_OVERRIDES_KEY = "workspace_param_overrides" - -_APPLIES_TO_STEP = { - "synthesis": "Synthesis", - "floorplan": "Floorplan", - "placement": "place", - "cts": "CTS", - "routing": "route", - "filler": "filler", - "rcx": "RCX", - "sta": "sta", -} -_MISSING = object() - - -def workspace_param_step(schema) -> str: - step = _APPLIES_TO_STEP.get(schema.applies) - if step is None: - raise ValueError(f"{schema.param} requires a full workspace refresh") - return step - - -def workspace_param_value(workspace, schema) -> object: - if schema.pdk_target is not None: - raise ValueError(f"{schema.param} requires a full workspace refresh") - if schema.maps_to is not None: - return _parameter_target_value(workspace.parameters.data, schema.maps_to, schema.default) - if schema.config_target is not None: - config_path = workspace.config.get(schema.config_target.config_key) - if config_path is None: - raise ValueError(f"workspace config missing target: {schema.config_target.config_key}") - value = _nested_value(json_read(config_path), schema.config_target.json_path) - return schema.default if value is _MISSING else value - raise ValueError(f"{schema.param} has no workspace configuration target") - - -def set_workspace_param(workspace, schema, value: object) -> tuple[object, str]: - records = _override_records(workspace.parameters.data) - record = next((item for item in records if item["key"] == schema.param), None) - if record is None: - record = { - "key": schema.param, - "baseline": deepcopy(workspace_param_value(workspace, schema)), - } - records.append(record) - record["value"] = deepcopy(value) - _set_override_records(workspace.parameters.data, records) - _apply_workspace_value(workspace, schema, value) - return record["baseline"], workspace_param_step(schema) - - -def unset_workspace_param(workspace, schema) -> tuple[object, str] | None: - records = _override_records(workspace.parameters.data) - record = next((item for item in records if item["key"] == schema.param), None) - if record is None: - return None - records.remove(record) - _set_override_records(workspace.parameters.data, records) - _apply_workspace_value(workspace, schema, record["baseline"]) - return record["baseline"], workspace_param_step(schema) - - -def workspace_param_diff(workspace) -> list[dict]: - return _override_records(workspace.parameters.data) - - -def _apply_workspace_value(workspace, schema, value: object) -> None: - resolved = ResolvedParam( - param=schema.param, - value=value, - default=schema.default, - source="workspace", - schema=schema, - ) - update_parameters(build_backend_overrides([resolved]), workspace.parameters.data) - config_overrides = build_config_overrides([resolved]) - if config_overrides: - update_parameters({CONFIG_OVERRIDES_KEY: config_overrides}, workspace.parameters.data) - - -def _override_records(parameters: dict) -> list[dict]: - raw = parameters.get(WORKSPACE_PARAM_OVERRIDES_KEY, []) - if not isinstance(raw, list): - return [] - records = [] - for item in raw: - if not isinstance(item, dict): - continue - key = item.get("key") - if not isinstance(key, str) or "baseline" not in item: - continue - records.append(deepcopy(item)) - return records - - -def _set_override_records(parameters: dict, records: list[dict]) -> None: - if records: - parameters[WORKSPACE_PARAM_OVERRIDES_KEY] = records - else: - parameters.pop(WORKSPACE_PARAM_OVERRIDES_KEY, None) - - -def _parameter_target_value(parameters: dict, target, default: object) -> object: - if isinstance(target, str): - return deepcopy(parameters.get(target, default)) - if isinstance(target, dict) and len(target) == 1: - parent, child = next(iter(target.items())) - nested = parameters.get(parent) - if isinstance(nested, dict): - return deepcopy(nested.get(child, default)) - return deepcopy(default) - - -def _nested_value(data: object, path: tuple[str, ...]) -> object: - current = data - for key in path: - if not isinstance(current, dict): - return _MISSING - payload: dict = dict(current) - if key not in payload: - return _MISSING - current = payload[key] - return deepcopy(current) +from chipcompiler.data.workspace_parameters import * # noqa: F401,F403 diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py index 11b3e9b10..638b2e296 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -18,6 +18,7 @@ is_finished_step_state, load_metrics, save_metrics, + step_storage_name, ) from .workspace import ( OriginDesign, @@ -70,6 +71,10 @@ YosysReport, YosysStep, ) +from .workspace_transaction import ( + WorkspaceFileTransaction, + recover_workspace_file_transaction, +) __all__ = [ "create_workspace", @@ -109,6 +114,8 @@ "build_workspace_config_paths", "workspace_config_paths", "workspace_config_path", + "WorkspaceFileTransaction", + "recover_workspace_file_transaction", "step_config_keys", "step_config_paths", "init_workspace_config", @@ -132,6 +139,7 @@ "get_design_parameters", "get_pdk", "StepEnum", + "step_storage_name", "StateEnum", "CheckState", "StepMetrics", diff --git a/chipcompiler/data/checklist.py b/chipcompiler/data/checklist.py index 2356a4479..0f7b80d28 100644 --- a/chipcompiler/data/checklist.py +++ b/chipcompiler/data/checklist.py @@ -46,6 +46,18 @@ def __init__(self, path: Path | str): self.path = Path(path) self.data = self._load_current_data() + @classmethod + def from_items(cls, path: Path | str, items) -> dict: + """Build normalized checklist data without reading or writing a file.""" + checklist = cls.__new__(cls) + checklist.path = Path(path) + checklist.data = checklist._default_data() + checklist.data["checklist"] = [ + cls._normalize_item(item) for item in items if isinstance(item, dict) + ] + cls._refresh_summary(checklist.data) + return checklist.data + @staticmethod def _timestamp() -> str: return datetime.now(UTC).isoformat().replace("+00:00", "Z") diff --git a/chipcompiler/data/parameter_keys.py b/chipcompiler/data/parameter_keys.py index 8478d4bb7..1cc2d9a37 100644 --- a/chipcompiler/data/parameter_keys.py +++ b/chipcompiler/data/parameter_keys.py @@ -6,7 +6,7 @@ ``top_module``, ``die``/``core`` subtrees, ...). Older workspaces persisted display-oriented keys (``"Frequency max [MHz]"``, ``"Top module"``, ...) and the GUI sends flat keys plus a handful of positional geometry aliases at the -project/RPC boundary. This module is the only place that knows the legacy +project/adapter boundary. This module is the only place that knows the legacy vocabulary; everything else consumes the canonical form. """ diff --git a/chipcompiler/data/parameter_schema.py b/chipcompiler/data/parameter_schema.py new file mode 100644 index 000000000..3dc108765 --- /dev/null +++ b/chipcompiler/data/parameter_schema.py @@ -0,0 +1,491 @@ +"""Canonical public parameter catalog and schema operations. + +The catalog is shared by the headless Engine, CLI, and Studio adapter. CLI +specific PDK path validation remains in ``chipcompiler.cli.project.params``. +""" + +import copy +import json +from dataclasses import dataclass + +from chipcompiler.data.config_params import CONFIG_PARAM_SCHEMAS +from chipcompiler.data.config_params.common import ParamSchema + +_LEGACY_PARAM_REGISTRY: tuple[ParamSchema, ...] = ( + ParamSchema( + "design.frequency_mhz", + "design", + "frequency_mhz", + "float", + 100.0, + "synthesis", + "Target clock frequency in MHz", + "frequency_max", + range=(1e-6, 10000.0), + unit="MHz", + example="200.0", + ), + ParamSchema( + "floorplan.core_util", + "floorplan", + "core_util", + "float", + 0.4, + "floorplan", + "Core utilization ratio", + {"core": "utilitization"}, + range=(0.01, 1.0), + example="0.45", + ), + ParamSchema( + "floorplan.core_margin", + "floorplan", + "core_margin", + "list[int]", + [2, 2], + "floorplan", + "Core margin in micrometers [horizontal, vertical]", + {"core": "margin"}, + example="[2, 2]", + ), + ParamSchema( + "floorplan.aspect_ratio", + "floorplan", + "aspect_ratio", + "float", + 1.0, + "floorplan", + "Core aspect ratio (width/height)", + {"core": "aspect_ratio"}, + range=(0.1, 10.0), + example="1.0", + ), + ParamSchema( + "cts.max_fanout", + "cts", + "max_fanout", + "int", + 20, + "cts", + "Maximum fanout for clock tree synthesis", + "max_fanout", + range=(1, 200), + example="16", + ), + ParamSchema( + "place.target_density", + "place", + "target_density", + "float", + 0.2, + "placement", + "Target placement density", + {"dreamplace": "target_density"}, + range=(0.1, 0.95), + example="0.65", + ), + ParamSchema( + "place.target_overflow", + "place", + "target_overflow", + "float", + 0.1, + "placement", + "Target overflow for global placement", + {"dreamplace": "stop_overflow"}, + range=(0.0, 1.0), + example="0.08", + ), + ParamSchema( + "place.global_right_padding", + "place", + "global_right_padding", + "int", + 0, + "placement", + "Global right padding for placement sites", + "global_right_padding", + range=(0, 100), + example="8", + ), + ParamSchema( + "place.cell_padding_x", + "place", + "cell_padding_x", + "int", + 300, + "placement", + "Cell padding in x-direction in database units", + {"dreamplace": "cell_padding_x"}, + range=(0, 10000), + example="400", + ), + ParamSchema( + "place.routability_opt", + "place", + "routability_opt", + "int", + 1, + "placement", + "Enable routability-driven placement optimization", + {"dreamplace": "routability_opt_flag"}, + choices=("0", "1"), + example="1", + ), + ParamSchema( + "route.bottom_layer", + "route", + "bottom_layer", + "str", + "MET2", + "routing", + "Bottom routing layer", + "bottom_layer", + choices=("MET1", "MET2", "MET3", "MET4", "MET5"), + example="MET2", + ), + ParamSchema( + "route.top_layer", + "route", + "top_layer", + "str", + "MET5", + "routing", + "Top routing layer", + "top_layer", + choices=("MET2", "MET3", "MET4", "MET5", "MET6"), + example="MET5", + ), + ParamSchema( + "sta.max_paths", + "sta", + "max_paths", + "int", + 1000, + "sta", + "Maximum number of paths in each STA timing report", + "sta_max_paths", + range=(1, 100000), + example="1000", + ), + ParamSchema( + param="flow.run_analysis", + group="flow", + name="run_analysis", + type="bool", + default=True, + applies="all", + description="Run per-step analysis (metrics, plots, checklist) after each step", + maps_to="run_analysis", + example="false", + ), +) + +PARAM_REGISTRY = _LEGACY_PARAM_REGISTRY + CONFIG_PARAM_SCHEMAS +_REGISTRY_INDEX = {schema.param: schema for schema in PARAM_REGISTRY} +_REQUIRED_FIELDS = ("param", "group", "name", "type", "default", "applies", "description") + + +def lookup_schema(key: str) -> ParamSchema | None: + return _REGISTRY_INDEX.get(key) + + +def list_schemas() -> tuple[ParamSchema, ...]: + return PARAM_REGISTRY + + +def list_groups() -> list[str]: + return list(dict.fromkeys(schema.group for schema in PARAM_REGISTRY)) + + +def is_known_key(key: str) -> bool: + return key in _REGISTRY_INDEX + + +def validate_schema_record(schema: ParamSchema) -> list[str]: + return [ + f"missing required field: {field}" + for field in _REQUIRED_FIELDS + if getattr(schema, field, None) is None + or (field != "default" and getattr(schema, field) == "") + ] + + +def validate_schema_type(value: object, schema: ParamSchema) -> tuple[object, str | None]: + key, ptype = schema.param, schema.type + if ptype == "int": + return ( + (value, None) + if isinstance(value, int) and not isinstance(value, bool) + else (value, f"expected int for {key}, got {type(value).__name__}") + ) + if ptype == "float": + if isinstance(value, bool) or not isinstance(value, (int, float)): + return value, f"expected float for {key}, got {type(value).__name__}" + try: + return float(value), None + except OverflowError: + return value, f"expected float for {key}, value too large to represent" + if ptype == "bool": + if isinstance(value, bool): + return value, None + if isinstance(value, str) and value.lower() in {"true", "1", "yes", "false", "0", "no"}: + return value.lower() in {"true", "1", "yes"}, None + return value, f"expected bool for {key}, got {type(value).__name__}" + if ptype == "str": + return ( + (value, None) + if isinstance(value, str) + else (value, f"expected str for {key}, got {type(value).__name__}") + ) + if ptype.startswith("list["): + if not isinstance(value, list): + return value, f"expected list for {key}, got {type(value).__name__}" + element_type = ptype[5:-1] + for index, item in enumerate(value): + if element_type == "str": + valid = isinstance(item, str) + elif element_type == "int": + valid = isinstance(item, int) and not isinstance(item, bool) + else: + valid = isinstance(item, (int, float)) and not isinstance(item, bool) + if not valid: + return ( + value, + f"expected {ptype} for {key}, element {index} is {type(item).__name__}", + ) + if element_type == "float": + try: + return [float(item) for item in value], None + except OverflowError: + return value, f"expected list[float] for {key}, value too large to represent" + return value, None + if ptype == "json": + return ( + (value, None) + if isinstance(value, (dict, list)) + else (value, f"expected JSON object or array for {key}, got {type(value).__name__}") + ) + return value, None + + +def parse_value(raw: str, schema: ParamSchema) -> object: + if schema.type in {"int", "float"}: + try: + return int(raw) if schema.type == "int" else float(raw) + except ValueError as exc: + raise ValueError(f"expected {schema.type} for {schema.param}, got '{raw}'") from exc + if schema.type == "bool": + low = raw.lower() + if low in {"true", "1", "yes"}: + return True + if low in {"false", "0", "no"}: + return False + raise ValueError(f"expected bool for {schema.param}, got '{raw}'") + if schema.type == "str": + return raw + if schema.type.startswith("list["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parts = [part.strip() for part in raw.strip("[]() ").split(",") if part.strip()] + element_type = schema.type[5:-1] + try: + parsed = ( + [int(part) for part in parts] + if element_type == "int" + else [float(part) for part in parts] + if element_type == "float" + else parts + ) + except ValueError as exc: + raise ValueError(f"expected {schema.type} for {schema.param}, got '{raw}'") from exc + value, error = validate_schema_type(parsed, schema) + if error: + raise ValueError(error) + return value + if schema.type == "json": + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"expected JSON for {schema.param}, got '{raw}'") from exc + raise ValueError(f"unsupported type '{schema.type}' for {schema.param}") + + +def validate_value(value: object, schema: ParamSchema) -> list[str]: + errors = [] + if schema.range is not None: + if not isinstance(value, (int, float)) or isinstance(value, bool): + errors.append(f"value {value!r} is not numeric for {schema.param}") + elif value < schema.range[0] or value > schema.range[1]: + errors.append( + f"value {value} out of range [{schema.range[0]}, {schema.range[1]}] " + f"for {schema.param}" + ) + if schema.choices is not None and str(value) not in schema.choices: + errors.append(f"value '{value}' not in allowed choices {schema.choices} for {schema.param}") + return errors + + +@dataclass +class ResolvedParam: + param: str + value: object + default: object + source: str + schema: ParamSchema + + @property + def is_explicit(self) -> bool: + return self.source != "default" + + +def resolve_parameters(toml_overrides=None, cli_overrides=None, manifest_overrides=None): + layers = ( + (cli_overrides or {}, "cli"), + (toml_overrides or {}, "ecc.toml"), + (manifest_overrides or {}, "project.json"), + ) + resolved, errors = [], [] + for schema in PARAM_REGISTRY: + value, source = schema.default, "default" + for layer, layer_source in layers: + if schema.param not in layer: + continue + value, source = layer[schema.param], layer_source + if source != "cli": + value, type_error = validate_schema_type(value, schema) + if type_error: + errors.append(type_error) + errors.extend(validate_value(value, schema)) + break + resolved.append(ResolvedParam(schema.param, value, schema.default, source, schema)) + return resolved, errors + + +def manifest_value_for(canonical: dict, maps_to) -> tuple[object, bool]: + if isinstance(maps_to, str): + return (canonical[maps_to], True) if maps_to in canonical else (None, False) + if isinstance(maps_to, dict): + for parent, child in maps_to.items(): + node = canonical.get(parent) + if isinstance(node, dict) and child in node: + return node[child], True + return None, False + + +def coerce_manifest_parameters(canonical: dict, registry=PARAM_REGISTRY, skip_params=frozenset()): + coerced, errors = copy.deepcopy(canonical), [] + for schema in registry: + if schema.param in skip_params: + continue + value, present = manifest_value_for(coerced, schema.maps_to) + if not present: + continue + value, error = validate_schema_type(value, schema) + if error: + errors.append(error) + continue + if isinstance(schema.maps_to, str): + coerced[schema.maps_to] = value + else: + for parent, child in schema.maps_to.items(): + if isinstance(coerced.get(parent), dict) and child in coerced[parent]: + coerced[parent][child] = value + break + return coerced, errors + + +def build_backend_overrides(resolved, *, include_defaults: bool = False): + overrides = {} + for item in resolved: + if not include_defaults and item.source == "default" and item.value == item.default: + continue + target, value = item.schema.maps_to, item.value + if isinstance(target, str): + overrides[target] = value + elif isinstance(target, dict): + for parent, child in target.items(): + overrides.setdefault(parent, {})[child] = value + return overrides + + +def build_config_overrides(resolved): + overrides = {} + for item in resolved: + target = item.schema.config_target + if target is None or not item.is_explicit: + continue + current = overrides.setdefault(target.config_key, {}) + for key in target.json_path[:-1]: + current = current.setdefault(key, {}) + current[target.json_path[-1]] = item.value + return overrides + + +def build_pdk_overrides(resolved): + return { + item.schema.pdk_target: item.value + for item in resolved + if item.schema.pdk_target and item.is_explicit + } + + +def parse_cli_overrides(pairs): + result, errors = {}, [] + for pair in pairs: + if "=" not in pair: + errors.append(f"malformed override: '{pair}' (expected key=value)") + continue + key, raw = (part.strip() for part in pair.split("=", 1)) + schema = lookup_schema(key) + if schema is None: + errors.append(f"unknown parameter: '{key}'") + continue + try: + value = parse_value(raw, schema) + except ValueError as exc: + errors.append(str(exc)) + continue + value_errors = validate_value(value, schema) + if value_errors: + errors.extend(value_errors) + continue + result[key] = value + return result, errors + + +def parse_toml_params(params_table): + flat, errors = {}, [] + + def visit(path, value): + key = ".".join(path) + schema = lookup_schema(key) + if schema: + try: + parsed = parse_value(value, schema) if isinstance(value, str) else value + parsed, type_error = validate_schema_type(parsed, schema) + if type_error: + errors.append(type_error) + return + except ValueError as exc: + errors.append(str(exc)) + return + value_errors = validate_value(parsed, schema) + if value_errors: + errors.extend(value_errors) + return + flat[key] = parsed + return + if isinstance(value, dict): + for child, child_value in value.items(): + visit((*path, child), child_value) + else: + errors.append(f"unknown parameter in ecc.toml: '{key}'") + + for group, value in params_table.items(): + if isinstance(value, dict): + visit((group,), value) + else: + errors.append(f"[params.{group}] must be a table, got {type(value).__name__}") + return flat, errors diff --git a/chipcompiler/data/pdk.py b/chipcompiler/data/pdk.py index febc744fb..9d8278b19 100644 --- a/chipcompiler/data/pdk.py +++ b/chipcompiler/data/pdk.py @@ -249,6 +249,8 @@ def get_pdk( pdk_root: str | Path = "", pdk_config: str | Path = "", overrides: dict | None = None, + *, + validate: bool = True, ) -> PDK: """ Return the PDK instance based on the given pdk name. @@ -273,15 +275,16 @@ def get_pdk( pdk = _builtin_pdk(pdk_name_normalized, pdk_root=pdk_root) or PDK(name=pdk_name_normalized) overrides = overrides or {} pdk = apply_pdk_overrides(pdk, overrides) - pdk.validate() - errors = [] - for key, label in _OPTIONAL_PATH_LABELS.items(): - if key not in overrides: - continue - path = getattr(pdk, key) - if path and not path.is_file(): - errors.append(f"{label}: {path}") - _raise_pdk_validation_error(errors) + if validate: + pdk.validate() + errors = [] + for key, label in _OPTIONAL_PATH_LABELS.items(): + if key not in overrides: + continue + path = getattr(pdk, key) + if path and not path.is_file(): + errors.append(f"{label}: {path}") + _raise_pdk_validation_error(errors) return pdk diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py index e4bcbd4b5..16986413a 100644 --- a/chipcompiler/data/step.py +++ b/chipcompiler/data/step.py @@ -45,6 +45,17 @@ class StateEnum(Enum): FINISHED_STEP_STATES = frozenset({StateEnum.Success.value}) +def step_storage_name(step_name: str, tool_name: str) -> str: + """Directory stem for ``{stem}_{tool}`` workspace step folders. + + Sizer stores Timing Opt as ``timing_optimization_sizer`` instead of + ``Timing optimization_sizer``. Other tools keep the flow step name. + """ + if tool_name.lower() == "sizer": + return "_".join(step_name.split()).lower() + return step_name + + def is_finished_step_state(state: object) -> bool: """Whether a persisted step state counts as done for selection and skipping. diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index d75f5d3d9..f3a96b455 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -14,16 +14,20 @@ from ..parameter import ( Parameters, get_parameters, - load_parameter, reload_parameter, save_parameter, update_parameters, ) +from ..parameter import ( + load_parameter as load_parameter, +) from ..pdk import PDK, get_pdk from ..step import StateEnum, StepEnum from ..workspace_config import ( - legacy_parameters_fallback, - migrate_legacy_parameters, + legacy_parameters_fallback as legacy_parameters_fallback, +) +from ..workspace_config import ( + migrate_legacy_parameters as migrate_legacy_parameters, ) from ..workspace_config import ( workspace_config_path as workspace_config_toml_path, @@ -1189,119 +1193,10 @@ def _persisted_golden_verilog(workspace_dir: Path) -> tuple[Path | None, bool]: return None, True -def load_workspace(directory: str | Path) -> Workspace: - workspace_dir = Path(directory).expanduser().resolve() - origin_dir = workspace_dir / "origin" - home_dir = workspace_dir / "home" - if not workspace_dir.exists(): - return None - - migrate_legacy_parameters(workspace_dir) - - # create workspace instance - workspace = Workspace() - workspace.directory = workspace_dir - migrate_workspace_config_filenames(workspace_dir) - workspace.config = build_workspace_config_paths(workspace) - - config_path = workspace_config_toml_path(workspace_dir) - legacy_path = home_dir / "parameters.json" - if config_path.is_symlink(): - # A symlinked canonical config would make the workspace execute - # with external parameters it does not own: reject it the same way - # the save path refuses to write through a symlink. - from chipcompiler.data.workspace_config import WorkspaceConfigError - - raise WorkspaceConfigError(f"workspace config is a symlink: {config_path}") - parameters = load_parameter(workspace_config_toml_path(workspace_dir)) - if len(parameters.data) <= 0 and not config_path.exists() and legacy_path.exists(): - # Migration was deferred (e.g. read-only dir): fall back to the - # normalized in-memory copy so the workspace still opens. When the - # TOML exists it wins unconditionally — a malformed config never - # silently falls back to stale JSON. - fallback = legacy_parameters_fallback(workspace_dir) - if fallback: - parameters.data = fallback - if len(parameters.data) <= 0: - return None - - workspace.parameters = parameters - - pdk = get_pdk( - pdk_name=parameters.data.get("pdk", ""), - pdk_root=parameters.data.get("pdk_root", ""), - pdk_config=parameters.data.get("pdk_config", ""), - ) - sdc_path = list(origin_dir.rglob("*.sdc")) - if len(sdc_path) > 0: - pdk.sdc = sdc_path[0] - spef_path = list(origin_dir.rglob("*.spef")) - if len(spef_path) > 0: - pdk.spef = spef_path[0] - - # update lef and lib paths based on config - from chipcompiler.utility import json_read - - db_json = json_read(workspace.config.get("db", "")) - if db_json.get("INPUT", {}).get("tech_lef_path", "") != "": - pdk.tech = Path(db_json.get("INPUT", {}).get("tech_lef_path", "")) - if db_json.get("INPUT", {}).get("lef_paths", []) != []: - pdk.lefs = [Path(path) for path in db_json.get("INPUT", {}).get("lef_paths", [])] - if db_json.get("INPUT", {}).get("lib_path", []) != []: - pdk.libs = [Path(path) for path in db_json.get("INPUT", {}).get("lib_path", [])] - workspace.pdk = pdk +def load_workspace(directory: str | Path, *, read_only: bool = False) -> Workspace: + from .loader import load_workspace as hydrate_workspace - # update config - workspace.design.name = parameters.data.get("design", "") - workspace.design.top_module = parameters.data.get("top_module", "") - def_path = list(origin_dir.rglob("*.def")) - def_gz_path = list(origin_dir.rglob("*.def.gz")) - if len(def_path) > 0: - workspace.design.origin_def = def_path[0] - if len(def_gz_path) > 0: - workspace.design.origin_def = def_gz_path[0] - - # The golden netlist path is persisted in the first flow step's info at - # creation; trust it over the golden_* filename convention so a primary - # netlist whose name merely starts with "golden_" keeps its role. Only - # legacy ledgers without step info fall back to the filename convention. - golden, golden_declared = _persisted_golden_verilog(workspace_dir) - if golden is None and not golden_declared: - golden_paths = list(origin_dir.rglob("golden_*.v")) + list( - origin_dir.rglob("golden_*.v.gz") - ) - golden = golden_paths[0] if golden_paths else None - - verilog_path = [path for path in origin_dir.rglob("*.v") if path != golden] - verilog_gz_path = [path for path in origin_dir.rglob("*.v.gz") if path != golden] - if len(verilog_path) > 0: - workspace.design.origin_verilog = verilog_path[0] - if len(verilog_gz_path) > 0: - workspace.design.origin_verilog = verilog_gz_path[0] - - if golden is not None: - workspace.design.golden_verilog = golden - - filelist_path = origin_dir / "filelist" - if filelist_path.exists(): - workspace.design.input_filelist = filelist_path - - # set home data - home_dir.mkdir(parents=True, exist_ok=True) - workspace.config["dir"].mkdir(parents=True, exist_ok=True) - workspace.flow.path = home_dir / "flow.json" - workspace.home.init(path=home_dir / "home.json") - workspace.home.set_flow(workspace.flow.path) - workspace.home.set_checklist(home_dir / "checklist.json") - workspace.home.set_parameters(workspace.parameters.path) - - # create logger first (needed for copy operations) - workspace.logger = create_logger(name=parameters.data["design"], log_dir=workspace_dir / "log") - - log_workspace(workspace) - log_parameters(workspace) - - return workspace + return hydrate_workspace(directory, read_only=read_only) def log_workspace(workspace: Workspace): diff --git a/chipcompiler/data/workspace/loader.py b/chipcompiler/data/workspace/loader.py new file mode 100644 index 000000000..e08b06e76 --- /dev/null +++ b/chipcompiler/data/workspace/loader.py @@ -0,0 +1,132 @@ +"""Hydrate a Workspace from committed files without mixing migration policy.""" + +from pathlib import Path +from typing import Any + +from chipcompiler.utility import Logger, create_logger, json_read + +from ..parameter import load_parameter +from ..pdk import get_pdk +from ..workspace_config import ( + legacy_parameters_fallback, + migrate_legacy_parameters, +) +from ..workspace_config import ( + workspace_config_path as workspace_config_toml_path, +) + + +def load_workspace(directory: str | Path, *, read_only: bool = False) -> Any: + from . import ( + Workspace, + _persisted_golden_verilog, + build_workspace_config_paths, + log_parameters, + log_workspace, + migrate_workspace_config_filenames, + ) + + workspace_dir = Path(directory).expanduser().resolve() + origin_dir = workspace_dir / "origin" + home_dir = workspace_dir / "home" + if not workspace_dir.exists(): + return None + + if not read_only: + from ..workspace_transaction import recover_workspace_file_transaction + + recover_workspace_file_transaction(workspace_dir) + migrate_legacy_parameters(workspace_dir) + + workspace = Workspace() + workspace.directory = workspace_dir + if not read_only: + migrate_workspace_config_filenames(workspace_dir) + workspace.config = build_workspace_config_paths(workspace) + + config_path = workspace_config_toml_path(workspace_dir) + legacy_path = home_dir / "parameters.json" + if config_path.is_symlink(): + from chipcompiler.data.workspace_config import WorkspaceConfigError + + raise WorkspaceConfigError(f"workspace config is a symlink: {config_path}") + parameters = load_parameter(config_path) + if len(parameters.data) <= 0 and not config_path.exists() and legacy_path.exists(): + fallback = legacy_parameters_fallback(workspace_dir) + if fallback: + parameters.data = fallback + if len(parameters.data) <= 0: + return None + + workspace.parameters = parameters + pdk = get_pdk( + pdk_name=parameters.data.get("pdk", ""), + pdk_root=parameters.data.get("pdk_root", ""), + pdk_config=parameters.data.get("pdk_config", ""), + validate=not read_only, + ) + sdc_path = list(origin_dir.rglob("*.sdc")) + if sdc_path: + pdk.sdc = sdc_path[0] + spef_path = list(origin_dir.rglob("*.spef")) + if spef_path: + pdk.spef = spef_path[0] + + db_json = json_read(workspace.config.get("db", "")) + if db_json.get("INPUT", {}).get("tech_lef_path", "") != "": + pdk.tech = Path(db_json["INPUT"]["tech_lef_path"]) + if db_json.get("INPUT", {}).get("lef_paths", []) != []: + pdk.lefs = [Path(path) for path in db_json["INPUT"]["lef_paths"]] + if db_json.get("INPUT", {}).get("lib_path", []) != []: + pdk.libs = [Path(path) for path in db_json["INPUT"]["lib_path"]] + workspace.pdk = pdk + + workspace.design.name = parameters.data.get("design", "") + workspace.design.top_module = parameters.data.get("top_module", "") + def_path = list(origin_dir.rglob("*.def")) + def_gz_path = list(origin_dir.rglob("*.def.gz")) + if def_path: + workspace.design.origin_def = def_path[0] + if def_gz_path: + workspace.design.origin_def = def_gz_path[0] + + golden, golden_declared = _persisted_golden_verilog(workspace_dir) + if golden is None and not golden_declared: + golden_paths = list(origin_dir.rglob("golden_*.v")) + list( + origin_dir.rglob("golden_*.v.gz") + ) + golden = golden_paths[0] if golden_paths else None + + verilog_path = [path for path in origin_dir.rglob("*.v") if path != golden] + verilog_gz_path = [path for path in origin_dir.rglob("*.v.gz") if path != golden] + if verilog_path: + workspace.design.origin_verilog = verilog_path[0] + if verilog_gz_path: + workspace.design.origin_verilog = verilog_gz_path[0] + if golden is not None: + workspace.design.golden_verilog = golden + + filelist_path = origin_dir / "filelist" + if filelist_path.exists(): + workspace.design.input_filelist = filelist_path + + workspace.flow.path = home_dir / "flow.json" + if read_only: + workspace.home.path = home_dir / "home.json" + home_data = json_read(workspace.home.path) + workspace.home.data = home_data if isinstance(home_data, dict) else {} + workspace.logger = Logger(name=parameters.data["design"]) + else: + home_dir.mkdir(parents=True, exist_ok=True) + workspace.config["dir"].mkdir(parents=True, exist_ok=True) + workspace.home.init(path=home_dir / "home.json") + workspace.home.set_flow(workspace.flow.path) + workspace.home.set_checklist(home_dir / "checklist.json") + workspace.home.set_parameters(workspace.parameters.path) + workspace.logger = create_logger( + name=parameters.data["design"], log_dir=workspace_dir / "log" + ) + log_workspace(workspace) + log_parameters(workspace) + + return workspace diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index 97df94111..941b8b38b 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -140,7 +140,7 @@ def validate_flow_config(flow: object) -> dict[str, str]: if not isinstance(value, str): raise WorkspaceFlowTargetError(f"[flow] {key} must be a string: {value!r}") # Workspace files carry canonical step names only; display-name - # aliases are translated at the manifest/RPC boundary, never here. + # aliases are translated at the manifest/adapter boundary, never here. if value not in canonical_names: raise WorkspaceFlowTargetError(f"[flow] unknown step name: {value!r}") normalized[key] = value diff --git a/chipcompiler/data/workspace_parameters.py b/chipcompiler/data/workspace_parameters.py new file mode 100644 index 000000000..8beee995c --- /dev/null +++ b/chipcompiler/data/workspace_parameters.py @@ -0,0 +1,127 @@ +"""Workspace-local parameter persistence shared by Engine and CLI.""" + +from copy import deepcopy + +from chipcompiler.data.parameter import update_parameters +from chipcompiler.data.parameter_schema import ( + ResolvedParam, + build_backend_overrides, + build_config_overrides, +) +from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY +from chipcompiler.utility import json_read + +WORKSPACE_PARAM_OVERRIDES_KEY = "workspace_param_overrides" +_APPLIES_TO_STEP = { + "synthesis": "Synthesis", + "floorplan": "Floorplan", + "placement": "place", + "cts": "CTS", + "routing": "route", + "filler": "filler", + "rcx": "RCX", + "sta": "sta", +} +_MISSING = object() + + +def workspace_param_step(schema) -> str: + step = _APPLIES_TO_STEP.get(schema.applies) + if step is None: + raise ValueError(f"{schema.param} requires a full workspace refresh") + return step + + +def workspace_param_value(workspace, schema) -> object: + if schema.pdk_target is not None: + raise ValueError(f"{schema.param} requires a full workspace refresh") + if schema.maps_to is not None: + return _parameter_target_value(workspace.parameters.data, schema.maps_to, schema.default) + if schema.config_target is not None: + config_path = workspace.config.get(schema.config_target.config_key) + if config_path is None: + raise ValueError(f"workspace config missing target: {schema.config_target.config_key}") + value = _nested_value(json_read(config_path), schema.config_target.json_path) + return schema.default if value is _MISSING else value + raise ValueError(f"{schema.param} has no workspace configuration target") + + +def set_workspace_param(workspace, schema, value: object) -> tuple[object, str]: + baseline = update_workspace_param_value(workspace, schema, value) + return baseline, workspace_param_step(schema) + + +def update_workspace_param_value(workspace, schema, value: object) -> object: + records = _override_records(workspace.parameters.data) + record = next((item for item in records if item["key"] == schema.param), None) + if record is None: + record = { + "key": schema.param, + "baseline": deepcopy(workspace_param_value(workspace, schema)), + } + records.append(record) + record["value"] = deepcopy(value) + _set_override_records(workspace.parameters.data, records) + _apply_workspace_value(workspace, schema, value) + return record["baseline"] + + +def unset_workspace_param(workspace, schema) -> tuple[object, str] | None: + records = _override_records(workspace.parameters.data) + record = next((item for item in records if item["key"] == schema.param), None) + if record is None: + return None + records.remove(record) + _set_override_records(workspace.parameters.data, records) + _apply_workspace_value(workspace, schema, record["baseline"]) + return record["baseline"], workspace_param_step(schema) + + +def workspace_param_diff(workspace) -> list[dict]: + return _override_records(workspace.parameters.data) + + +def _apply_workspace_value(workspace, schema, value: object) -> None: + resolved = ResolvedParam(schema.param, value, schema.default, "workspace", schema) + update_parameters(build_backend_overrides([resolved]), workspace.parameters.data) + config_overrides = build_config_overrides([resolved]) + if config_overrides: + update_parameters({CONFIG_OVERRIDES_KEY: config_overrides}, workspace.parameters.data) + + +def _override_records(parameters: dict) -> list[dict]: + raw = parameters.get(WORKSPACE_PARAM_OVERRIDES_KEY, []) + if not isinstance(raw, list): + return [] + return [ + deepcopy(item) + for item in raw + if isinstance(item, dict) and isinstance(item.get("key"), str) and "baseline" in item + ] + + +def _set_override_records(parameters: dict, records: list[dict]) -> None: + if records: + parameters[WORKSPACE_PARAM_OVERRIDES_KEY] = records + else: + parameters.pop(WORKSPACE_PARAM_OVERRIDES_KEY, None) + + +def _parameter_target_value(parameters: dict, target, default: object) -> object: + if isinstance(target, str): + return deepcopy(parameters.get(target, default)) + if isinstance(target, dict) and len(target) == 1: + parent, child = next(iter(target.items())) + nested = parameters.get(parent) + if isinstance(nested, dict): + return deepcopy(nested.get(child, default)) + return deepcopy(default) + + +def _nested_value(data: object, path: tuple[str, ...]) -> object: + current = data + for key in path: + if not isinstance(current, dict) or key not in current: + return _MISSING + current = current[key] + return deepcopy(current) diff --git a/chipcompiler/data/workspace_transaction.py b/chipcompiler/data/workspace_transaction.py new file mode 100644 index 000000000..96d23b14c --- /dev/null +++ b/chipcompiler/data/workspace_transaction.py @@ -0,0 +1,122 @@ +import fcntl +import os +import shutil +from collections.abc import Iterable +from pathlib import Path +from typing import BinaryIO + +_BACKUP_NAME = ".workspace-configuration-backup" +_DISCARD_NAME = f"{_BACKUP_NAME}.discard" +_ACTIVE: set[Path] = set() + + +class WorkspaceFileTransaction: + def __init__(self, workspace: Path, lock: BinaryIO): + self.workspace = workspace + self.lock = lock + self.backup = workspace / "home" / _BACKUP_NAME + self.finished = False + + @classmethod + def begin(cls, workspace: str | Path, paths: Iterable[Path]) -> "WorkspaceFileTransaction": + root = Path(workspace).expanduser().resolve() + lock = _open_lock(_lock_path(root)) + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + _recover_locked(root) + transaction = cls(root, lock) + transaction._prepare(paths) + _ACTIVE.add(root) + return transaction + except BaseException: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + lock.close() + raise + + def commit(self) -> None: + discard = self.workspace / "home" / _DISCARD_NAME + if discard.exists(): + shutil.rmtree(discard) + if self.backup.exists(): + self.backup.replace(discard) + shutil.rmtree(discard) + self._release() + + def rollback(self) -> None: + try: + _restore_backup(self.workspace, self.backup) + shutil.rmtree(self.backup, ignore_errors=True) + finally: + self._release() + + def _prepare(self, paths: Iterable[Path]) -> None: + home = self.workspace / "home" + if home.is_symlink(): + raise OSError(f"Workspace home directory is a symlink: {home}") + home.mkdir(parents=True, exist_ok=True) + shutil.rmtree(self.backup, ignore_errors=True) + self.backup.mkdir(parents=True) + for path in sorted(set(paths)): + target = Path(path).resolve() + relative = target.relative_to(self.workspace) + if not target.is_file(): + continue + destination = self.backup / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, destination) + + def _release(self) -> None: + if self.finished: + return + _ACTIVE.discard(self.workspace) + fcntl.flock(self.lock.fileno(), fcntl.LOCK_UN) + self.lock.close() + self.finished = True + + +def recover_workspace_file_transaction(workspace: str | Path) -> None: + root = Path(workspace).expanduser().resolve() + if root in _ACTIVE: + return + home = root / "home" + if not (home / _BACKUP_NAME).exists() and not (home / _DISCARD_NAME).exists(): + return + with _open_lock(_lock_path(root)) as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + _recover_locked(root) + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +def _recover_locked(workspace: Path) -> None: + home = workspace / "home" + if home.is_symlink(): + raise OSError(f"Workspace home directory is a symlink: {home}") + discard = home / _DISCARD_NAME + if discard.exists(): + shutil.rmtree(discard) + backup = home / _BACKUP_NAME + if backup.exists(): + _restore_backup(workspace, backup) + shutil.rmtree(backup) + + +def _restore_backup(workspace: Path, backup: Path) -> None: + if not backup.is_dir(): + return + for source in backup.rglob("*"): + if not source.is_file(): + continue + target = workspace / source.relative_to(backup) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _lock_path(workspace: Path) -> Path: + return workspace.parent / f".{workspace.name}.configuration.lock" + + +def _open_lock(path: Path) -> BinaryIO: + descriptor = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + return os.fdopen(descriptor, "a+b") diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index dc843b356..91f33a998 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -517,7 +517,7 @@ $ ecc report summary ### 5.4 QoR 总分:ecc report qor -按 GUI 项目看板同一套规则打分:每条指标折算 0–100 分,按维度加权(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1),60 分为通过线;缺项维度不重归一化(缺项会拉低总分): +用 ECC 共用的 `qor_scoring` 规则打分(Studio Snapshot 也用这一套):每条指标折算 0–100 分,按维度加权(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1),60 分为通过线;缺项维度不重归一化(缺项会拉低总分): ```console $ ecc report qor diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md index 89f2d70e3..f14138fba 100644 --- a/chipcompiler/docs/ecc-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -518,7 +518,7 @@ Excerpts from this gcd run (full report: `cat` the file above): ### 5.4 QoR score: ecc report qor -Scores the workspace with the same rules as the GUI project dashboard: each metric maps to 0–100, dimensions are weighted (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1), 60 is the pass line; absent dimensions are not renormalized (absence drags the overall score down): +Scores the workspace with ECC's shared `qor_scoring` rules (the same table Studio Snapshot uses): each metric maps to 0–100, dimensions are weighted (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1), 60 is the pass line; absent dimensions are not renormalized (absence drags the overall score down): ```console $ ecc report qor diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 4cc5c1a1a..aa48f7d21 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -4,7 +4,6 @@ - 源码位置:[chipcompiler/cli/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/cli/) - 命令扩展开发方式见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli) -- RPC sidecar 协议详见 [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md) ## 0. 调用方式 @@ -81,7 +80,7 @@ uv run ecc --help - 全局:`ecc --version`(单行版本号)、`ecc --help`。 - 项目定位:项目级命令接受 `--project `(缺省为当前目录)。`--workspace <名称>` 是项目内受管的、非空单路径段名称,不能传文件系统路径。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。 -- 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`rpc serve` 和 `layout-image` 使用各自的协议。 +- 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`layout-image` 使用自己的协议。 - 退出码:成功 0;业务失败 1(错误记录形如 `[error] error=<机器可读错误码>`)。 - 步骤名(step token)有三套写法,按场景区分: - **展示名**(`ecc status` / `ecc log` / `ecc report step` 的输出与入参,统一小写/下划线):`synthesis / lec / floorplan / placement / cts / legalization / timing_optimization / routing / filler / rcx / sta / lvs / postroutelec / drc / harden`; @@ -111,7 +110,6 @@ Commands: workspace Refresh managed workspaces from project configuration signoff Inspect and export signoff packages report Generate design-summary, QoR score, checklist, and step reports - rpc Run the private ECC JSON-RPC runtime ``` ## 1.5. doc — 在终端阅读内置指南 @@ -890,7 +888,7 @@ PDK / Node : ics55 ### 12.2 qor — QoR 总体计分报告 -按 GUI 项目看板的计分规则给当前 workspace 打分:每条 v3 `qor_metrics.json` 指标按固定失败阈值折算 0-100 分(slack 类线性、core_utilization 目标区间 [0.45,0.70]、lower/higher_is_better 比例),维度内取平均,再按权重(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1)加权出总分——**缺项维度不重归一化**(与 GUI 一致,缺项会拉低总分);60 分为通过线。默认写 `/signoff/_qor_report.txt`: +用 ECC 共用的 `qor_scoring` 规则给当前 workspace 打分(Studio Snapshot 也用这一套):每条 v3 `qor_metrics.json` 指标按固定失败阈值折算 0-100 分(slack 类线性、core_utilization 目标区间 [0.45,0.70]、lower/higher_is_better 比例),维度内取平均,再按权重(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1)加权出总分——**缺项维度不重归一化**(缺项会拉低总分);60 分为通过线。默认写 `/signoff/_qor_report.txt`: ```console $ ecc report qor --project gcd --plain @@ -958,23 +956,7 @@ $ ecc report step drc --section analysis [BLOCK] qor.drc.clean — drc_count=336 == 0 ``` -## 13. rpc — JSON-RPC runtime sidecar(私有) - -```bash -ecc rpc serve --stdio [--persistent-db] -``` - -供 GUI 等前端使用的 JSON-RPC 2.0 服务,`Content-Length` 帧封装于 stdio。`--persistent-db` 额外开放 `db.ensure` / `db.release` 与 `layout.edit.*` / `floorplan.edit.*` 系列方法。握手与调用示例(完整方法列表和参数见 [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)): - -```console -→ {"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello-1"} -← {"jsonrpc":"2.0","result":{"version":1,"eccVersion":"0.1.0-alpha.11","capabilities":["rpc.hello","rpc.ping","rpc.shutdown","runtime.v2","operation.events","workspace.create","workspace.open","workspace.close","workspace.home","workspace.info","workspace.refresh_config","workspace.sync_config","workspace.reset_flow","workspace.export_signoff","workspace.inspect_signoff","flow.run","flow.run_step","operation.start_flow","operation.start_step","operation.status","operation.cancel","operation.ack_step_rendered","workspace.snapshot","workspace.recover_interrupted"]},"id":"hello-1"} - -→ {"jsonrpc":"2.0","method":"rpc.ping","params":{},"id":"ping-1"} -← {"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"} -``` - -## 14. layout-image — GDS 渲染为图片 +## 13. layout-image — GDS 渲染为图片 ```bash ecc layout-image --gds --image [--width N] [--height N] @@ -986,7 +968,7 @@ ecc layout-image --gds --image [--width N] [--height N] ecc layout-image --gds default/Harden_ecc/output/gcd_Harden.gds --image layout.png --width 2560 --height 1600 ``` -## 15. 端到端典型工作流 +## 14. 端到端典型工作流 ```bash ecc init gcd && cd gcd diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 16abb4c33..78ee47201 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -4,7 +4,6 @@ - Source code: [chipcompiler/cli/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/cli/) - For how to extend the CLI with new commands, see [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli) -- RPC sidecar protocol: [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md) ## 0. Invocation @@ -81,7 +80,7 @@ uv run ecc --help - Global: `ecc --version` (single version line), `ecc --help`. - Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` is a managed, non-empty single path segment in that project, never a filesystem path. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time. -- Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `rpc serve` and `layout-image` use their own protocols instead. +- Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `layout-image` uses its own protocol instead. - Exit codes: 0 on success; 1 on business failure (error records look like `[error] error=`). - Step tokens come in three vocabularies, distinguished by context: - **display names** (output and input of `ecc status` / `ecc log` / `ecc report step`, uniformly lowercase/underscore): `synthesis / lec / floorplan / placement / cts / legalization / timing_optimization / routing / filler / rcx / sta / lvs / postroutelec / drc / harden`; @@ -111,7 +110,6 @@ Commands: workspace Refresh managed workspaces from project configuration signoff Inspect and export signoff packages report Generate design-summary, QoR score, checklist, and step reports - rpc Run the private ECC JSON-RPC runtime ``` ## 1.5. doc — read the bundled guides in the terminal @@ -938,7 +936,7 @@ the report extracts the current state by default (the engine API ### 12.2 qor — overall QoR score report -Scores the current workspace by the GUI project-dashboard rules: every v3 `qor_metrics.json` metric is converted to 0-100 against fixed fail thresholds (slack metrics linearly, core_utilization against the [0.45, 0.70] target window, lower/higher_is_better proportionally), averaged per dimension, then combined with the dimension weights (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1) into the overall score — **absent dimensions are not renormalized** (matching the GUI; missing dimensions lower the score); 60 is the pass line. By default written to `/signoff/_qor_report.txt`: +Scores the current workspace with ECC's shared `qor_scoring` rules (the same table Studio Snapshot uses): every v3 `qor_metrics.json` metric is converted to 0-100 against fixed fail thresholds (slack metrics linearly, core_utilization against the [0.45, 0.70] target window, lower/higher_is_better proportionally), averaged per dimension, then combined with the dimension weights (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1) into the overall score — **absent dimensions are not renormalized** (missing dimensions lower the score); 60 is the pass line. By default written to `/signoff/_qor_report.txt`: ```console $ ecc report qor --project gcd --plain @@ -1006,23 +1004,7 @@ $ ecc report step drc --section analysis [BLOCK] qor.drc.clean — drc_count=336 == 0 ``` -## 13. rpc — JSON-RPC runtime sidecar (private) - -```bash -ecc rpc serve --stdio [--persistent-db] -``` - -A JSON-RPC 2.0 service for front ends such as the GUI, framed with `Content-Length` over stdio. `--persistent-db` additionally exposes `db.ensure` / `db.release` plus the `layout.edit.*` / `floorplan.edit.*` method families. Handshake and call examples (full method list and parameters in [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)): - -```console -→ {"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello-1"} -← {"jsonrpc":"2.0","result":{"version":1,"eccVersion":"0.1.0-alpha.11","capabilities":["rpc.hello","rpc.ping","rpc.shutdown","runtime.v2","operation.events","workspace.create","workspace.open","workspace.close","workspace.home","workspace.info","workspace.refresh_config","workspace.sync_config","workspace.reset_flow","workspace.export_signoff","workspace.inspect_signoff","flow.run","flow.run_step","operation.start_flow","operation.start_step","operation.status","operation.cancel","operation.ack_step_rendered","workspace.snapshot","workspace.recover_interrupted"]},"id":"hello-1"} - -→ {"jsonrpc":"2.0","method":"rpc.ping","params":{},"id":"ping-1"} -← {"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"} -``` - -## 14. layout-image — render a GDS to an image +## 13. layout-image — render a GDS to an image ```bash ecc layout-image --gds --image [--width N] [--height N] @@ -1034,7 +1016,7 @@ Renders a GDS layout snapshot via KLayout (default 1920×1920; KLayout must be a ecc layout-image --gds default/Harden_ecc/output/gcd_Harden.gds --image layout.png --width 2560 --height 1600 ``` -## 15. Typical end-to-end workflow +## 14. Typical end-to-end workflow ```bash ecc init gcd && cd gcd diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py index f75ec0efe..c523bb8e2 100644 --- a/chipcompiler/engine/__init__.py +++ b/chipcompiler/engine/__init__.py @@ -1,12 +1,47 @@ from .db import EngineDB +from .execution import ExecutionPlan, ExecutionResult, execute from .flow import EngineFlow from .rerun import StepRunResult from .signoff import SignoffPackageCollector, SignoffPackageOptions +from .workspace_configuration import ( + read_step_configuration, + read_step_configuration_from_directory, + read_workspace_configuration, + read_workspace_configuration_from_directory, + update_workspace_configuration, + update_workspace_step_configuration, +) +from .workspace_lifecycle import ( + WorkspaceLifecycleError, + apply_workspace_bindings, + assess_execution_readiness, + create_workspace_from_spec, + describe_workspace_binding_requirement, + update_workspace_from_spec, +) +from .workspace_spec import describe_workspace_spec, validate_workspace_spec __all__ = [ "EngineDB", "EngineFlow", + "ExecutionPlan", + "ExecutionResult", "StepRunResult", "SignoffPackageCollector", "SignoffPackageOptions", + "WorkspaceLifecycleError", + "apply_workspace_bindings", + "assess_execution_readiness", + "create_workspace_from_spec", + "describe_workspace_spec", + "describe_workspace_binding_requirement", + "execute", + "read_step_configuration", + "read_step_configuration_from_directory", + "read_workspace_configuration", + "read_workspace_configuration_from_directory", + "update_workspace_from_spec", + "update_workspace_step_configuration", + "update_workspace_configuration", + "validate_workspace_spec", ] diff --git a/chipcompiler/engine/analysis.py b/chipcompiler/engine/analysis.py new file mode 100644 index 000000000..06ddc03e0 --- /dev/null +++ b/chipcompiler/engine/analysis.py @@ -0,0 +1,462 @@ +import hashlib +import math +from pathlib import Path +from typing import Any, TypeGuard + +from chipcompiler.data.step import step_storage_name +from chipcompiler.data.step_dirs import STEP_DIRECTORIES +from chipcompiler.engine.qor_scoring import DIMENSION_WEIGHTS +from chipcompiler.tools.ecc.sta_qor import STA_POWER_REPORT_FILENAME, STA_REPORT_FILENAMES +from chipcompiler.utility import JsonReadError, file_digest, json_read_strict + +_LEGACY_METRIC_CATEGORIES = {"power": "power_integrity"} + +_ANALYSIS_FILES = ( + ("metrics", "qor_metrics", "qor_metrics.json", 3), + ("summary", "qor_summary", "qor_summary.json", 4), + ("hotspots", "qor_hotspots", "qor_hotspots.json", 3), +) +_TIMING_FILE = ("timingIssues", "sta_timing_issues", "sta_timing_issues.json", 1) +_SUBFLOW_MAX_BYTES = 1024 * 1024 +_SUBFLOW_MAX_STEPS = 256 +_ARTIFACT_HASH_MAX_BYTES = 16 * 1024 * 1024 +_CONGESTION_IMAGES = ( + ("egr_congestion_map", "{step}_egr_horizontal_overflow.png"), + ("egr_congestion_map", "{step}_egr_vertical_overflow.png"), + ("egr_congestion_map", "{step}_egr_union_overflow.png"), + ("RUDY_map", "{step}_rudy_horizontal.png"), + ("RUDY_map", "{step}_rudy_vertical.png"), + ("RUDY_map", "{step}_rudy_union.png"), + ("RUDY_map", "{step}_lut_rudy_horizontal.png"), + ("RUDY_map", "{step}_lut_rudy_vertical.png"), + ("RUDY_map", "{step}_lut_rudy_union.png"), + ("density_map", "{step}_allcell_density.png"), +) + + +def build_workspace_analysis( + workspace: Any, workspace_id: str +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + root = Path(workspace.directory).resolve() + flow = getattr(getattr(workspace, "flow", None), "data", {}) + raw_steps = flow.get("steps", []) if isinstance(flow, dict) else [] + design = str(getattr(getattr(workspace, "design", None), "name", "")).strip() + steps: list[dict[str, Any]] = [] + artifacts: list[dict[str, Any]] = [] + for order, raw_step in enumerate(raw_steps): + if not isinstance(raw_step, dict): + continue + step_id = raw_step.get("name") + tool_id = raw_step.get("tool") + if not _safe_segment(step_id) or not _safe_segment(tool_id): + continue + identity = "".join(character for character in step_id.casefold() if character.isalnum()) + if identity == "fixfanout": + continue + step_dir = root / STEP_DIRECTORIES.get( + step_id, f"{step_storage_name(step_id, tool_id)}_{tool_id}" + ) + step: dict[str, Any] = { + "stepId": step_id, + "toolId": tool_id, + "order": order, + "flowState": str(raw_step.get("state", "")), + } + declared = list(_ANALYSIS_FILES) + if step_id.lower() == "sta": + declared.append(_TIMING_FILE) + for field, kind, filename, schema_version in declared: + path = step_dir / "analysis" / filename + reference = path.relative_to(root).as_posix() + artifact = _artifact_ref( + path, + workspace_id=workspace_id, + reference=reference, + step_id=step_id, + kind=kind, + root=root, + ) + artifacts.append(artifact) + step[field] = _analysis_file(path, artifact["artifactId"], schema_version, root) + if "timingIssues" not in step: + step["timingIssues"] = None + if tool_id.lower() == "yosys_lec" and design: + result = step_dir / "output" / f"{design}_{step_id}_result.json" + artifact = _artifact_ref( + result, + workspace_id=workspace_id, + reference=result.relative_to(root).as_posix(), + step_id=step_id, + kind="lec_result", + root=root, + ) + artifacts.append(artifact) + step["lecResult"] = _lec_result_file(result, artifact["artifactId"], root) + step["subflow"] = _subflow_summary(step_dir / "subflow.json", root) + if design: + layout = step_dir / "output" / f"{design}_{step_id}.png" + artifacts.append( + _artifact_ref( + layout, + workspace_id=workspace_id, + reference=layout.relative_to(root).as_posix(), + step_id=step_id, + kind="layout_image", + root=root, + ) + ) + if step_id.lower() == "harden": + for suffix in ("gds", "lef", "lib"): + output = step_dir / "output" / f"{design}_{step_id}.{suffix}" + artifacts.append( + _artifact_ref( + output, + workspace_id=workspace_id, + reference=output.relative_to(root).as_posix(), + step_id=step_id, + kind="harden_output", + root=root, + ) + ) + geometry = step_dir / "output" / "geometry" / "geometry.manifest" + artifacts.append( + _artifact_ref( + geometry, + workspace_id=workspace_id, + reference=geometry.relative_to(root).as_posix(), + step_id=step_id, + kind="layout_geometry", + root=root, + ) + ) + report_names = ( + (f"{step_id}_stat.json", f"{step_id}_check.rpt") + if tool_id.lower() == "yosys" + else (f"{step_id}.db.rpt", f"{step_id}.rpt") + ) + for report_name in report_names: + report = step_dir / "report" / report_name + artifacts.append( + _artifact_ref( + report, + workspace_id=workspace_id, + reference=report.relative_to(root).as_posix(), + step_id=step_id, + kind="report_text", + root=root, + ) + ) + timing_files: list[tuple[str, Path]] = [] + if step_id.lower() == "synthesis": + timing_root = step_dir / "feature" / "post_synthesis" + timing_files.extend( + ( + ("timing_summary", timing_root / "qor_summary.json"), + ("timing_paths", timing_root / "timing_paths.json"), + ) + ) + timing_data = step.get("timingIssues") + timing_payload = timing_data.get("data") if isinstance(timing_data, dict) else None + timing_paths = ( + timing_payload.get("artifact_paths") if isinstance(timing_payload, dict) else None + ) + if step_id.lower() == "sta": + for item in timing_paths[:32] if isinstance(timing_paths, list) else []: + if not isinstance(item, dict): + continue + report_dir = _safe_step_artifact_path(step_dir, item.get("report_dir"), root) + if report_dir is None: + continue + try: + relative_report_dir = report_dir.relative_to(step_dir / "report") + except ValueError: + continue + report_names = list(STA_REPORT_FILENAMES) + if (report_dir / STA_POWER_REPORT_FILENAME).is_file(): + report_names.append(STA_POWER_REPORT_FILENAME) + for report_name in report_names: + report = report_dir / report_name + artifact = _artifact_ref( + report, + workspace_id=workspace_id, + reference=report.relative_to(root).as_posix(), + step_id=step_id, + kind="report_text", + root=root, + ) + artifact["name"] = (relative_report_dir / report_name).as_posix() + artifacts.append(artifact) + for item in timing_paths[:32] if isinstance(timing_paths, list) else []: + if not isinstance(item, dict): + continue + for kind, field in ( + ("timing_summary", "qor_summary_file"), + ("timing_paths", "timing_paths_file"), + ): + timing_path = _safe_step_artifact_path(step_dir, item.get(field), root) + if timing_path is not None: + timing_files.append((kind, timing_path)) + for kind, path in timing_files: + artifacts.append( + _artifact_ref( + path, + workspace_id=workspace_id, + reference=path.relative_to(root).as_posix(), + step_id=step_id, + kind=kind, + root=root, + ) + ) + if step_id.lower() in {"place", "cts"}: + for directory, filename in _CONGESTION_IMAGES: + image = step_dir / "feature" / directory / filename.format(step=step_id) + artifacts.append( + _artifact_ref( + image, + workspace_id=workspace_id, + reference=image.relative_to(root).as_posix(), + step_id=step_id, + kind="congestion_image", + root=root, + ) + ) + steps.append(step) + return {"steps": steps}, artifacts + + +def _lec_result_file(path: Path, artifact_id: str, root: Path) -> dict[str, Any]: + if _has_symlink(path, root): + return { + "artifactId": artifact_id, + "status": "unsafe", + "reasonCode": "LEC_RESULT_UNSAFE", + "data": None, + } + if not path.is_file(): + return { + "artifactId": artifact_id, + "status": "missing", + "reasonCode": "LEC_RESULT_MISSING", + "data": None, + } + try: + data = json_read_strict(path) + except (OSError, JsonReadError): + return { + "artifactId": artifact_id, + "status": "invalid", + "reasonCode": "LEC_RESULT_INVALID", + "data": None, + } + if not isinstance(data, dict): + return { + "artifactId": artifact_id, + "status": "invalid", + "reasonCode": "LEC_RESULT_INVALID", + "data": None, + } + result = dict(data) + result["freshness_status"] = _lec_freshness_status(data, root) + return {"artifactId": artifact_id, "status": "available", "data": result} + + +def _lec_freshness_status(data: dict[str, Any], root: Path) -> str: + if data.get("status") != "proven": + return "incomplete" + for role in ("golden", "gate"): + path = data.get(f"{role}_verilog") + digest = data.get(f"{role}_sha256") + size = data.get(f"{role}_size_bytes") + if not isinstance(path, str) or not isinstance(digest, str) or type(size) is not int: + return "stale" + candidate = Path(path).resolve() + if not candidate.is_relative_to(root) or not candidate.is_file(): + return "stale" + actual = file_digest(candidate) + if actual is None or actual != (digest, size): + return "stale" + return "proven" + + +def _analysis_file(path: Path, artifact_id: str, schema_version: int, root: Path) -> dict[str, Any]: + if _has_symlink(path, root): + return { + "artifactId": artifact_id, + "status": "unsafe", + "reasonCode": "ANALYSIS_REFERENCE_UNSAFE", + "data": None, + } + if not path.is_file(): + return { + "artifactId": artifact_id, + "status": "missing", + "reasonCode": "ANALYSIS_FILE_MISSING", + "data": None, + } + try: + data = json_read_strict(path) + except (OSError, JsonReadError): + return { + "artifactId": artifact_id, + "status": "invalid", + "reasonCode": "ANALYSIS_FILE_INVALID", + "data": None, + } + if not isinstance(data, dict): + return { + "artifactId": artifact_id, + "status": "invalid", + "reasonCode": "ANALYSIS_FILE_INVALID", + "data": None, + } + if data.get("schema_version") != schema_version: + return { + "artifactId": artifact_id, + "status": "unsupported", + "reasonCode": "ANALYSIS_SCHEMA_UNSUPPORTED", + "data": None, + } + if schema_version == 3 and isinstance(data.get("metrics"), list): + data = _canonical_metrics_payload(data) + return {"artifactId": artifact_id, "status": "available", "data": data} + + +def _canonical_metrics_payload(data: dict[str, Any]) -> dict[str, Any]: + records = data.get("metrics") + if not isinstance(records, list): + return data + updated = [] + changed = False + for record in records: + if not isinstance(record, dict): + updated.append(record) + continue + category = record.get("category") + mapped = _LEGACY_METRIC_CATEGORIES.get(category, category) + if mapped != category and mapped in DIMENSION_WEIGHTS: + record = {**record, "category": mapped} + changed = True + updated.append(record) + if not changed: + return data + payload = dict(data) + payload["metrics"] = updated + return payload + + +def _subflow_summary(path: Path, root: Path) -> dict[str, Any]: + if _has_symlink(path, root): + return {"status": "unsafe", "steps": []} + if not path.is_file(): + return {"status": "missing", "steps": []} + try: + if path.stat().st_size > _SUBFLOW_MAX_BYTES: + return {"status": "oversized", "steps": []} + data = json_read_strict(path) + except (OSError, JsonReadError): + return {"status": "invalid", "steps": []} + raw_steps = data.get("steps") if isinstance(data, dict) else None + if not isinstance(raw_steps, list) or len(raw_steps) > _SUBFLOW_MAX_STEPS: + return {"status": "invalid", "steps": []} + steps: list[dict[str, Any]] = [] + for item in raw_steps: + if not isinstance(item, dict): + return {"status": "invalid", "steps": []} + name = item.get("name") + state = item.get("state") + if not isinstance(name, str) or not name or not isinstance(state, str): + return {"status": "invalid", "steps": []} + runtime = item.get("runtime") + peak_memory = item.get("peak memory (mb)") + step: dict[str, Any] = {"name": name, "state": state} + if isinstance(runtime, str): + step["runtime"] = runtime + if ( + isinstance(peak_memory, (int, float)) + and not isinstance(peak_memory, bool) + and math.isfinite(peak_memory) + ): + step["peakMemoryMb"] = peak_memory + steps.append(step) + return {"status": "available", "steps": steps} + + +def _artifact_ref( + path: Path, + *, + workspace_id: str, + reference: str, + step_id: str, + kind: str, + root: Path, +) -> dict[str, Any]: + artifact: dict[str, Any] = { + "artifactId": _artifact_id(workspace_id, reference), + "kind": kind, + "name": path.name, + "stepId": step_id, + "availability": "missing", + "reference": reference, + } + try: + if path.is_file() and not _has_symlink(path, root): + size = path.stat().st_size + if size <= _ARTIFACT_HASH_MAX_BYTES: + artifact.update( + availability="available", + sizeBytes=size, + sha256=_sha256(path), + ) + else: + artifact.update(availability="stale", sizeBytes=size) + except OSError: + pass + return artifact + + +def _safe_step_artifact_path(step_dir: Path, value: object, root: Path) -> Path | None: + if not isinstance(value, str) or not value: + return None + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + return None + resolved = step_dir / candidate + try: + resolved.relative_to(root) + except ValueError: + return None + return resolved + + +def _safe_segment(value: object) -> TypeGuard[str]: + return ( + isinstance(value, str) + and bool(value) + and value not in {".", ".."} + and "/" not in value + and "\\" not in value + ) + + +def _has_symlink(path: Path, root: Path) -> bool: + current = path + while current != root: + if current.is_symlink(): + return True + if current.parent == current: + return True + current = current.parent + return False + + +def _artifact_id(workspace_id: str, reference: str) -> str: + digest = hashlib.sha256(f"{workspace_id}\0{reference}".encode()).hexdigest() + return f"artifact-{digest[:32]}" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/chipcompiler/engine/execution.py b/chipcompiler/engine/execution.py new file mode 100644 index 000000000..24faa3ddd --- /dev/null +++ b/chipcompiler/engine/execution.py @@ -0,0 +1,85 @@ +import inspect +from dataclasses import dataclass +from typing import Any, Literal + +from chipcompiler.data import StateEnum + + +@dataclass(frozen=True) +class ExecutionPlan: + intent: Literal["run", "rerun"] + step_id: str | None = None + + +@dataclass(frozen=True) +class ExecutionResult: + succeeded: bool + state: str + step_id: str | None = None + + +def execute(flow: Any, plan: ExecutionPlan, *, event_sink: Any = None) -> ExecutionResult: + if plan.intent not in {"run", "rerun"}: + raise ValueError(f"unsupported execution intent: {plan.intent}") + if event_sink is None and getattr(flow.workspace, "directory", None): + event_sink = _EngineeringCommitSink(flow.workspace) + rerun = plan.intent == "rerun" + if plan.step_id is None: + succeeded = bool(_invoke(flow.run_steps, rerun=rerun, observer=event_sink)) + return ExecutionResult( + succeeded=succeeded, + state=StateEnum.Success.value if succeeded else StateEnum.Imcomplete.value, + ) + + get_workspace_step = getattr(flow, "get_workspace_step", None) + step = ( + get_workspace_step(plan.step_id) + if callable(get_workspace_step) + else next( + ( + candidate + for candidate in getattr(flow, "workspace_steps", []) + if getattr(candidate, "name", None) == plan.step_id + ), + None, + ) + ) + if step is None: + raise ValueError(f"step not found: {plan.step_id}") + state = _invoke(flow.run_step, step, rerun=rerun, observer=event_sink) + return ExecutionResult( + succeeded=state is StateEnum.Success, + state=getattr(state, "value", str(state)), + step_id=plan.step_id, + ) + + +class _EngineeringCommitSink: + def __init__(self, workspace: Any): + from chipcompiler.engine.snapshot import ensure_engineering_snapshot + + self.workspace = workspace + self.snapshot = ensure_engineering_snapshot(workspace) + + def on_step_completed(self, _step: Any, state: Any, _error: str | None = None) -> None: + from chipcompiler.engine.snapshot import commit_engineering_snapshot + + self.snapshot = commit_engineering_snapshot( + self.workspace, + workspace_id=self.snapshot["workspaceId"], + cause=f"flow_step.{getattr(state, 'value', str(state)).lower()}", + ) + + +def _invoke(callback, *args, rerun: bool, observer: Any): + parameters = inspect.signature(callback).parameters.values() + accepts_kwargs = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters + ) + kwargs = {} + names = {parameter.name for parameter in parameters} + if accepts_kwargs or "rerun" in names: + kwargs["rerun"] = rerun + if observer is not None and (accepts_kwargs or "observer" in names): + kwargs["observer"] = observer + return callback(*args, **kwargs) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 63ed79919..af8981c06 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -805,7 +805,7 @@ def _finalize_interrupted_subflow( peak_memory_mb: float, ) -> None: try: - from chipcompiler.runtime.subflow_events import finalize_interrupted_subflow + from chipcompiler.engine.subflow_events import finalize_interrupted_subflow for subflow_step in finalize_interrupted_subflow( workspace_step, diff --git a/chipcompiler/engine/pdk_binding.py b/chipcompiler/engine/pdk_binding.py new file mode 100644 index 000000000..f26134ac5 --- /dev/null +++ b/chipcompiler/engine/pdk_binding.py @@ -0,0 +1,64 @@ +import hashlib +import json +from pathlib import Path +from typing import Any + + +def pdk_binding_content_hash( + spec: dict[str, Any], + bindings: dict[str, Any], +) -> str: + digest = hashlib.sha256() + digest.update(str(spec.get("familyId", "")).encode()) + digest.update(str(spec.get("version") or bindings.get("version") or "").encode()) + digest.update(json.dumps(spec.get("overrides", {}), sort_keys=True).encode()) + for path in _selected_paths(spec, bindings): + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _selected_paths(spec: dict[str, Any], bindings: dict[str, Any]) -> list[Path]: + if spec.get("mode") == "manual": + files = bindings.get("files") + if not isinstance(files, dict): + return [] + return [ + Path(files[ref["fileId"]]) + for ref in spec.get("files", []) + if isinstance(ref, dict) + and isinstance(ref.get("fileId"), str) + and ref["fileId"] in files + and Path(files[ref["fileId"]]).is_file() + ] + + try: + from chipcompiler.data import get_pdk + + pdk = get_pdk( + spec.get("familyId", ""), + pdk_root=bindings.get("root", ""), + overrides=pdk_overrides_from_binding(spec, bindings), + ) + except (OSError, ValueError): + return [] + candidates = [pdk.tech, *pdk.lefs, *pdk.libs, pdk.mapping_file] + return [Path(path) for path in candidates if path is not None and Path(path).is_file()] + + +def pdk_overrides_from_binding(spec: dict[str, Any], bindings: dict[str, Any]) -> dict[str, Any]: + overrides = dict(spec.get("overrides", {})) + files = bindings.get("files", {}) + by_role: dict[str, list[str]] = {} + for ref in spec.get("files", []): + path = files.get(ref.get("fileId")) if isinstance(files, dict) else None + if isinstance(path, str): + by_role.setdefault(ref["role"], []).append(path) + if by_role.get("tech"): + overrides["tech"] = by_role["tech"][0] + if by_role.get("lef"): + overrides["lefs"] = by_role["lef"] + if by_role.get("liberty"): + overrides["libs"] = by_role["liberty"] + if by_role.get("mapping"): + overrides["mapping_file"] = by_role["mapping"][0] + return overrides diff --git a/chipcompiler/engine/qor.py b/chipcompiler/engine/qor.py new file mode 100644 index 000000000..c54788cb2 --- /dev/null +++ b/chipcompiler/engine/qor.py @@ -0,0 +1,129 @@ +"""Assemble Snapshot `qorAssessment` from committed analysis. + +Scoring rules live in `qor_scoring`; this module only validates metric +records, calls `score_qor`, and attaches gate status from step summaries. +""" + +import math +from typing import Any + +from .qor_scoring import ( + DIMENSION_WEIGHTS, + QOR_SCORE_THRESHOLD, + QorScoringMetric, + score_qor, +) + + +def build_workspace_qor_assessment(analysis: dict[str, Any]) -> dict[str, Any]: + metrics = [] + step_summaries = [] + metric_steps = [] + for step in analysis["steps"]: + if step["flowState"] != "Success": + continue + step_id = step["stepId"] + metrics_file = step["metrics"] + payload = metrics_file["data"] if metrics_file["status"] == "available" else {} + records = payload.get("metrics") + valid_records = [record for record in records or [] if _valid_metric(record)] + metrics.extend(valid_records) + metric_steps.extend((step_id, record) for record in valid_records) + summary_file = step["summary"] + summary = summary_file["data"] if summary_file["status"] == "available" else {} + summary_status = ( + str(summary.get("quality_status", "incomplete")) + if summary.get("schema_version") == 4 + else "unavailable" + ) + step_summaries.append( + { + "stepId": step_id, + "order": step["order"], + "name": step_id, + "status": summary_status, + "summaryMetricCount": len(valid_records), + } + ) + + if not metrics: + return { + "status": "unavailable", + "score": {"value": None, "threshold": QOR_SCORE_THRESHOLD, "gate": "unavailable"}, + "areaScoringStep": None, + "dimensionScores": {}, + "metrics": [], + "steps": step_summaries, + } + + gate = _gate_status(step_summaries) + scoring = score_qor( + [ + QorScoringMetric( + step=step_id, + metric_id=record["id"], + value=float(record["value"]), + dimension=record["category"], + direction=record["direction"], + scope=record["scope"], + corner=record.get("corner"), + project_role=record["project_role"], + rating_score=record["rating"]["score"], + ) + for step_id, record in metric_steps + ] + ) + return { + "status": "ready", + "score": { + "value": scoring.overall_score, + "threshold": QOR_SCORE_THRESHOLD, + "gate": gate, + }, + "areaScoringStep": scoring.area_scoring_step, + "dimensionScores": { + dimension: score for dimension, (score, _count) in scoring.dimensions.items() + }, + "metrics": metrics, + "steps": step_summaries, + } + + +def _valid_metric(record: Any) -> bool: + if not isinstance(record, dict): + return False + value = record.get("value") + rating = record.get("rating") + corner = record.get("corner") + corner_context = record.get("corner_context") + return ( + isinstance(record.get("id"), str) + and isinstance(record.get("display_name"), str) + and isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and record.get("category") in DIMENSION_WEIGHTS + and record.get("direction") + in {"higher_is_better", "lower_is_better", "target_range", "trend_only"} + and isinstance(record.get("scope"), str) + and (corner is None or isinstance(corner, str)) + and (corner_context is None or isinstance(corner_context, dict)) + and isinstance(record.get("analysis_group"), str) + and isinstance(rating, dict) + and isinstance(rating.get("gate"), bool) + and isinstance(rating.get("score"), bool) + and isinstance(rating.get("trend"), bool) + and record.get("project_role") in {"final", "trend", "gate", "none"} + and record.get("step_role") in {"primary", "secondary", "detail", "hidden"} + and record.get("confidence") in {"high", "medium", "low"} + and isinstance(record.get("source"), dict) + ) + + +def _gate_status(steps: list[dict[str, Any]]) -> str: + statuses = {step["status"] for step in steps} + if "blocked" in statuses: + return "blocked" + if statuses & {"incomplete", "unavailable"}: + return "incomplete" + return "pass" diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index 9e73ef92e..331b8aeaa 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -1,18 +1,18 @@ """Overall QoR score report for one workspace. -Python port of the ECOS Studio GUI scoring pipeline -(ecos/gui/apps/renderer/src/utils/projectQorTrend.ts), restricted to the -single-workspace view the CLI needs: normalize the per-step schema-v3 -``analysis/qor_metrics.json`` records, select project-level records, score -each metric against the GUI fail thresholds, average per dimension, and -combine with the GUI dimension weights (deliberately NOT renormalized over -missing dimensions, matching GUI behavior). Trimmed relative to the GUI: -cross-workspace trend/regression analysis, summary blocking-issue gates, and -signoff-readiness score eligibility are project-dashboard concerns. - -Report source of truth: metrics files already carry dimension (category), -polarity (direction), and the rating gate, written by -tools/ecc/metrics.py::build_qor_metrics_payload. +Reads current per-step ``analysis/qor_metrics.json`` files and scores them +with ``chipcompiler.engine.qor_scoring``. That module is the only scoring +rule table (fail thresholds, dimension weights, pass line, metric +selection). Studio uses the same scorer via the Engineering Snapshot +``qorAssessment``; this report does not keep a second copy of the rules. + +This file owns CLI collection and presentation only: metric-file +normalization, DRC/LVS/RCX/STA flow-state gates, and the text report. +Cross-workspace trend, summary blocking-issue gates, and signoff-readiness +score eligibility stay on the Studio project dashboard. + +Metrics already carry dimension (category), polarity (direction), and the +rating gate from tools/ecc/metrics.py::build_qor_metrics_payload. """ import dataclasses @@ -21,6 +21,13 @@ from chipcompiler.data import StateEnum, StepEnum from chipcompiler.data.step_dirs import STEP_DIRECTORIES +from chipcompiler.engine.qor_scoring import ( + DIMENSION_WEIGHTS, + QOR_SCORE_THRESHOLD, + QorScoringMetric, + score_metric, + score_qor, +) from chipcompiler.utility.json import json_read # GUI FlowStep label for each canonical step that owns a scored directory. @@ -50,15 +57,6 @@ FLOW_STEPS = tuple(FLOW_STEP_DIRS) -DIMENSION_WEIGHTS = { - "timing": 0.35, - "power_integrity": 0.25, - "routability_physical": 0.2, - "area_cost": 0.1, - "clock_robustness_dfm": 0.1, - "runtime": 0.0, -} - DIMENSION_LABELS = { "timing": "Timing", "power_integrity": "Power / IR / EM", @@ -68,54 +66,9 @@ "runtime": "Runtime", } -METRIC_FAIL_VALUES = { - "drc_count": 10, - "lvs_count": 10, - "route_wirelength": 6000, - "route_via_count": 2000, - "cts_buffer_count": 20, - "cts_buffer_area": 40, - "clock_wirelength": 400000, - "cts_clock_wirelength_max": 100000, - "cts_clock_tree_max_level": 20, - "die_area": 3000, - "core_area": 2500, - "core_utilization": 0.85, - "synthesis_cell_area": 3000, - "fanout_max": 100, - "place_hpwl": 10000, - "place_grwl": 12000, - "place_flute_wirelength": 10000, - "place_congestion_egr_overflow_total": 100, - "place_congestion_egr_overflow_max": 20, - "place_rudy_utilization_max": 1, - "place_lutrudy_utilization_max": 1, - "route_dr_total_violation_count": 50, - "route_dr_total_patch_count": 100, - "route_dr_total_wirelength": 6000, - "route_dr_total_via_count": 2000, - "route_la_total_overflow": 100, - "rcx_missing_corner_count": 9, - "sta_setup_wns": -0.2, - "sta_setup_tns": -1, - "sta_hold_wns": -0.2, - "sta_hold_tns": -1, - "sta_frequency_mhz": 100, - "sta_setup_violation_count": 1, - "sta_hold_violation_count": 1, - "sta_missing_corner_count": 1, - "harden_artifact_missing_count": 6, -} - -SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"} -CORE_UTILIZATION_TARGET = (0.45, 0.70) - -#: The 0-100 line separating the GUI pass/fail presentation. -QOR_SCORE_THRESHOLD = 60 - GATE_STEPS = ("DRC", "LVS", "RCX", "STA") -_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3} +_ROLE_PRIORITY = {"final", "gate", "trend", "none"} @dataclasses.dataclass(frozen=True) @@ -242,84 +195,34 @@ def _normalize_metrics(step: str, payload: dict) -> list[QorMetricRecord]: return records -# --------------------------------------------------------------------------- -# Scoring (port of scoreRecord / buildDimensionScores / weightedOverallScore) -# --------------------------------------------------------------------------- - - -def _clamp_score(score: float) -> float: - return max(0.0, min(100.0, score)) - - -def _round_score(score: float) -> float: - return round(score, 1) - - -def _score_target_range(value: float, min_target: float, max_target: float, fail: float) -> float: - if min_target <= value <= max_target: - return 100.0 - if value < min_target: - return _clamp_score(100 * value / min_target) - return _clamp_score(100 * (fail - value) / (fail - max_target)) - - def score_record(record: QorMetricRecord) -> float | None: - if record.polarity == "trend_only": - return None - if record.metric_name not in METRIC_FAIL_VALUES: - return None - - if record.metric_name in SLACK_METRICS: - fail = METRIC_FAIL_VALUES[record.metric_name] - if fail >= 0: - return None - if record.value >= 0: - return 100.0 - return _clamp_score(100 * (record.value - fail) / -fail) - - if record.polarity == "target_range": - if record.metric_name == "core_utilization": - return _score_target_range( - record.value, *CORE_UTILIZATION_TARGET, METRIC_FAIL_VALUES["core_utilization"] - ) - return None - - fail = METRIC_FAIL_VALUES[record.metric_name] - if fail <= 0: - return None - if record.polarity == "lower_is_better": - return _clamp_score(100 * (fail - record.value) / fail) - return _clamp_score(100 * record.value / fail) - - -def _record_key(record: QorMetricRecord) -> tuple: - return (record.metric_name, record.scope, record.corner or "") - - -def _select_project_records(records, area_scoring_step) -> list[QorMetricRecord]: - selected: dict[tuple, QorMetricRecord] = {} - for record in records: - if record.project_role == "none": - continue - if record.dimension == "area_cost" and record.step != area_scoring_step: - continue - current = selected.get(_record_key(record)) - if current is None or _selection_rank(record) < _selection_rank(current): - selected[_record_key(record)] = record - return sorted(selected.values(), key=lambda r: r.metric_name) - - -def _selection_rank(record: QorMetricRecord) -> tuple: - return (_ROLE_PRIORITY[record.project_role], -FLOW_STEPS.index(record.step)) + return score_metric(_scoring_metric(record)) + + +def _scoring_metric(record: QorMetricRecord) -> QorScoringMetric: + return QorScoringMetric( + step=record.step, + metric_id=record.metric_name, + value=record.value, + dimension=record.dimension, + direction=record.polarity, + scope=record.scope, + corner=record.corner, + project_role=record.project_role, + rating_score=record.rating_score, + payload=record, + ) def _resolve_area_scoring_step(records, flow_steps_by_label) -> str | None: - for step in reversed(FLOW_STEPS): - if flow_steps_by_label.get(step) != StateEnum.Success.value: - continue - if any(r.step == step and r.dimension == "area_cost" and r.rating_score for r in records): - return step - return None + scored = [ + _scoring_metric(record) + for record in records + if record.rating_score + and record.dimension == "area_cost" + and flow_steps_by_label.get(record.step) == StateEnum.Success.value + ] + return score_qor(scored, flow_order=FLOW_STEPS).area_scoring_step def _gate_status(flow_steps_by_label) -> str: @@ -380,21 +283,6 @@ def _workspace_status(flow_state: str, score: float | None, gate: str) -> str: return "Red" -def _weighted_overall(dimension_scores: dict) -> float | None: - weighted_total = 0.0 - used_weight = 0.0 - for dimension, score in dimension_scores.items(): - weight = DIMENSION_WEIGHTS[dimension] - if weight <= 0: - continue - weighted_total += score * weight - used_weight += weight - if used_weight == 0: - return None - # GUI behavior: no renormalization over missing dimensions. - return weighted_total - - # --------------------------------------------------------------------------- # Workspace collection and rendering # --------------------------------------------------------------------------- @@ -446,25 +334,10 @@ def build_qor_report(workspace) -> QorScoreReport: analyzed_steps.append(step) records.extend(_normalize_metrics(step, payload)) - area_scoring_step = _resolve_area_scoring_step(records, flow_steps_by_label) - project_records = _select_project_records(records, area_scoring_step) - - scored: list[QorMetricRecord] = [] - by_dimension: dict[str, list[float]] = {} - for record in project_records: - # GUI gate: only rating.score records feed dimension averages; the - # rest stay in the table marked as trend-only. - score = score_record(record) if record.rating_score else None - scored.append(dataclasses.replace(record, score=score)) - if score is not None: - by_dimension.setdefault(record.dimension, []).append(score) - - dimension_averages = { - dimension: _round_score(sum(scores) / len(scores)) - for dimension, scores in by_dimension.items() - } - overall = _weighted_overall(dimension_averages) - overall_score = _round_score(overall) if overall is not None else None + scoring = score_qor([_scoring_metric(record) for record in records], flow_order=FLOW_STEPS) + scored = [ + dataclasses.replace(item.metric.payload, score=item.score) for item in scoring.metrics + ] gate = _gate_status(flow_steps_by_label) flow_state = _flow_completion_state(flow_steps_by_label.values()) @@ -484,25 +357,25 @@ def build_qor_report(workspace) -> QorScoreReport: dimension=dimension, label=DIMENSION_LABELS[dimension], weight=DIMENSION_WEIGHTS[dimension], - score=dimension_averages[dimension], - metric_count=len(by_dimension[dimension]), + score=scoring.dimensions[dimension][0], + metric_count=scoring.dimensions[dimension][1], ) for dimension in DIMENSION_WEIGHTS - if dimension in dimension_averages + if dimension in scoring.dimensions ] absent = [ DIMENSION_LABELS[dimension] for dimension in DIMENSION_WEIGHTS - if dimension not in dimension_averages and DIMENSION_WEIGHTS[dimension] > 0 + if dimension not in scoring.dimensions and DIMENSION_WEIGHTS[dimension] > 0 ] return QorScoreReport( workspace=str(workspace_root), design=design, - overall_score=overall_score, - status=_workspace_status(flow_state, overall_score, gate), + overall_score=scoring.overall_score, + status=_workspace_status(flow_state, scoring.overall_score, gate), gate_status=gate, - area_scoring_step=area_scoring_step, + area_scoring_step=scoring.area_scoring_step, dimension_scores=dimension_scores, metrics=scored, absent_dimensions=absent, diff --git a/chipcompiler/engine/qor_scoring.py b/chipcompiler/engine/qor_scoring.py new file mode 100644 index 000000000..d3de78d53 --- /dev/null +++ b/chipcompiler/engine/qor_scoring.py @@ -0,0 +1,200 @@ +"""Shared QoR scoring rules. + +Owns fail thresholds, dimension weights, metric selection, and overall +score. Callers: Snapshot assessment (`qor.py`) and CLI report +(`qor_report.py`). Studio GUI consumes the Snapshot result; it does not +keep a second rule table. +""" + +from dataclasses import dataclass +from typing import Any + +QOR_SCORE_THRESHOLD = 60 + +DIMENSION_WEIGHTS = { + "timing": 0.35, + "power_integrity": 0.25, + "routability_physical": 0.2, + "area_cost": 0.1, + "clock_robustness_dfm": 0.1, + "runtime": 0.0, +} + +METRIC_FAIL_VALUES = { + "drc_count": 10, + "lvs_count": 10, + "route_wirelength": 6000, + "route_via_count": 2000, + "cts_buffer_count": 20, + "cts_buffer_area": 40, + "clock_wirelength": 400000, + "cts_clock_wirelength_max": 100000, + "cts_clock_tree_max_level": 20, + "die_area": 3000, + "core_area": 2500, + "core_utilization": 0.85, + "synthesis_cell_area": 3000, + "fanout_max": 100, + "place_hpwl": 10000, + "place_grwl": 12000, + "place_flute_wirelength": 10000, + "place_congestion_egr_overflow_total": 100, + "place_congestion_egr_overflow_max": 20, + "place_rudy_utilization_max": 1, + "place_lutrudy_utilization_max": 1, + "route_dr_total_violation_count": 50, + "route_dr_total_patch_count": 100, + "route_dr_total_wirelength": 6000, + "route_dr_total_via_count": 2000, + "route_la_total_overflow": 100, + "rcx_missing_corner_count": 9, + "sta_setup_wns": -0.2, + "sta_setup_tns": -1, + "sta_hold_wns": -0.2, + "sta_hold_tns": -1, + "sta_frequency_mhz": 100, + "sta_setup_violation_count": 1, + "sta_hold_violation_count": 1, + "sta_missing_corner_count": 1, + "harden_artifact_missing_count": 6, +} + +_SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"} +_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3} +CORE_UTILIZATION_TARGET = (0.45, 0.70) + + +@dataclass(frozen=True) +class QorScoringMetric: + step: str + metric_id: str + value: float + dimension: str + direction: str + scope: str + corner: str | None + project_role: str + rating_score: bool + payload: Any = None + + +@dataclass(frozen=True) +class ScoredQorMetric: + metric: QorScoringMetric + score: float | None + + +@dataclass(frozen=True) +class QorScoringResult: + area_scoring_step: str | None + metrics: tuple[ScoredQorMetric, ...] + dimensions: dict[str, tuple[float, int]] + overall_score: float | None + + +def score_qor( + records: list[QorScoringMetric], *, flow_order: tuple[str, ...] = () +) -> QorScoringResult: + area_step = _area_scoring_step(records, flow_order) + selected: dict[tuple[str, str, str], tuple[int, int, QorScoringMetric]] = {} + for record in records: + if record.project_role == "none": + continue + if record.dimension == "area_cost" and record.step != area_step: + continue + key = (record.metric_id, record.scope, record.corner or "") + candidate = ( + _ROLE_PRIORITY.get(record.project_role, 3), + -_step_rank(record.step, flow_order), + record, + ) + if key not in selected or candidate[:2] < selected[key][:2]: + selected[key] = candidate + + scored = tuple( + ScoredQorMetric(record, score_metric(record) if record.rating_score else None) + for _role, _rank, record in sorted(selected.values(), key=lambda item: item[2].metric_id) + ) + by_dimension: dict[str, list[float]] = {} + for item in scored: + if item.score is not None: + by_dimension.setdefault(item.metric.dimension, []).append(item.score) + dimensions = { + dimension: ( + round(sum(by_dimension[dimension]) / len(by_dimension[dimension]), 1), + len(by_dimension[dimension]), + ) + for dimension in DIMENSION_WEIGHTS + if dimension in by_dimension + } + weighted = sum( + score * DIMENSION_WEIGHTS[dimension] + for dimension, (score, _count) in dimensions.items() + if DIMENSION_WEIGHTS[dimension] > 0 + ) + overall = ( + round(weighted, 1) + if any(DIMENSION_WEIGHTS[dimension] > 0 for dimension in dimensions) + else None + ) + return QorScoringResult(area_step, scored, dimensions, overall) + + +def score_metric(record: QorScoringMetric) -> float | None: + if record.direction == "trend_only": + return None + fail = METRIC_FAIL_VALUES.get(record.metric_id) + if fail is None: + return None + if record.metric_id in _SLACK_METRICS: + if fail >= 0: + return None + return 100.0 if record.value >= 0 else _clamp(100 * (record.value - fail) / -fail) + if record.direction == "target_range": + if record.metric_id != "core_utilization": + return None + low, high = CORE_UTILIZATION_TARGET + if low <= record.value <= high: + return 100.0 + if record.value < low: + return _clamp(100 * record.value / low) + return _clamp(100 * (fail - record.value) / (fail - high)) + if fail <= 0: + return None + if record.direction == "lower_is_better": + return _clamp(100 * (fail - record.value) / fail) + if record.direction == "higher_is_better": + return _clamp(100 * record.value / fail) + return None + + +def _area_scoring_step(records: list[QorScoringMetric], flow_order: tuple[str, ...]) -> str | None: + if flow_order: + by_step = { + record.step + for record in records + if record.dimension == "area_cost" and record.rating_score + } + for step in reversed(flow_order): + if step in by_step: + return step + return None + return next( + ( + record.step + for record in reversed(records) + if record.dimension == "area_cost" and record.rating_score + ), + None, + ) + + +def _step_rank(step: str, flow_order: tuple[str, ...]) -> int: + try: + return flow_order.index(step) + except ValueError: + return -1 + + +def _clamp(value: float) -> float: + return max(0.0, min(100.0, value)) diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py index ba4fac133..a34c7cd6f 100644 --- a/chipcompiler/engine/signoff/collector.py +++ b/chipcompiler/engine/signoff/collector.py @@ -578,6 +578,7 @@ def add_file( checklist_data = rebuild_home_checklist( self.workspace, resource_issues=[*issues, *analysis_issues], + persist=options.materialize, ) add_file( "status.checklist", diff --git a/chipcompiler/engine/signoff_assessment.py b/chipcompiler/engine/signoff_assessment.py new file mode 100644 index 000000000..35c71bf03 --- /dev/null +++ b/chipcompiler/engine/signoff_assessment.py @@ -0,0 +1,188 @@ +from pathlib import Path +from typing import Any, TypedDict + +from chipcompiler.utility import json_read + +_REVIEW_GROUPS: tuple[tuple[str, str], ...] = ( + ("initial", "Initial"), + ("config", "Config"), + ("harden", "Harden"), + ("final_design", "Final Design"), + ("sta", "STA"), + ("spef", "SPEF"), + ("reports", "Reports"), +) + + +class _ReviewGroup(TypedDict): + id: str + label: str + available: int + expected: int + blocked: list[dict] + attention: list[dict] + + +def build_signoff_assessment(workspace: Any) -> dict[str, Any]: + flow = getattr(workspace, "flow", None) + steps_fn = getattr(flow, "steps", None) + if callable(steps_fn): + steps = steps_fn() + else: + flow_data = getattr(flow, "data", None) + steps = flow_data.get("steps", []) if isinstance(flow_data, dict) else [] + if steps and any(str(step.get("state", "")) not in {"Success", "Skipped"} for step in steps): + return _unavailable_assessment() + checklist = json_read(Path(workspace.directory) / "home" / "checklist.json") + if ( + not isinstance(checklist, dict) + or checklist.get("schema_version") != 3 + or checklist.get("kind") != "signoff_checklist" + or not isinstance(checklist.get("checklist"), list) + ): + return _unavailable_assessment() + + groups: dict[str, _ReviewGroup] = {} + for group_id, label in _REVIEW_GROUPS: + groups[group_id] = { + "id": group_id, + "label": label, + "available": 0, + "expected": 0, + "blocked": [], + "attention": [], + } + for item in checklist.get("checklist", []): + if not isinstance(item, dict): + continue + group = groups[_group_for(item)] + group["expected"] += 1 + if item.get("state") == "pass": + group["available"] += 1 + elif item.get("blocked") is True: + group["blocked"].append(_detail(item)) + else: + group["attention"].append(_detail(item)) + + review_groups = [] + risks = [] + for group_id, _label in _REVIEW_GROUPS: + group = groups[group_id] + blocked = group["blocked"] + attention = group["attention"] + if blocked: + status = "blocked" + summary = f"{len(blocked)} blocking checklist requirements" + risks.append(_risk(group, "blocked", blocked, summary)) + if attention: + risks.append( + _risk( + group, + "warning", + attention, + f"{len(attention)} attention-only checklist requirements", + ) + ) + elif attention: + status = "attention" + summary = f"{len(attention)} attention-only checklist requirements" + risks.append(_risk(group, "warning", attention, summary)) + else: + status = "ready" + summary = ( + f"{group['available']} of {group['expected']} requirements ready" + if group["expected"] + else "No requirements" + ) + review_groups.append( + { + "id": group_id, + "label": group["label"], + "status": status, + "available": group["available"], + "expected": group["expected"], + "summary": summary, + } + ) + + status = checklist.get("status") + return { + "status": status if status in {"ready", "attention", "blocked"} else "blocked", + "groups": review_groups, + "risks": sorted(risks, key=lambda risk: risk["severity"] != "blocked"), + } + + +def _risk(group: _ReviewGroup, severity: str, details: list[dict], summary: str) -> dict: + result = "requirements block export" if severity == "blocked" else "attention" + return { + "severity": severity, + "title": f"{group['label']} signoff {result}", + "summary": summary, + "details": details, + } + + +def _detail(item: dict) -> dict: + source = item.get("source", {}) + source = source if isinstance(source, dict) else {} + evidence = item.get("evidence", []) + return { + "kind": item.get("category", "checklist"), + "label": item.get("title", "Checklist item"), + "location": source.get("path", "home/checklist.json"), + "reason": item.get("summary", ""), + "owner": item.get("owner", "checklist"), + "policy": item.get("policy", "warn"), + "state": item.get("state", "unavailable"), + "evidence": evidence if isinstance(evidence, list) else [], + } + + +def _group_for(item: dict) -> str: + step = str(item.get("step", "")) + category = str(item.get("category", "")) + source = item.get("source", {}) + path = source.get("path", "") if isinstance(source, dict) else "" + if category == "configuration" or path.startswith("config/"): + return "config" + if category == "provenance" or path.startswith(("origin/", "initial/")): + return "initial" + if step == "Harden" or path.startswith(("Harden_ecc/", "harden/")): + return "harden" + if step == "sta" or path.startswith(("sta_ecc/", "final/timing/sta/")): + return "sta" + if step == "RCX" or path.startswith(("RCX_ecc/", "final/timing/spef/")): + return "spef" + if step in {"Route", "drc", "lvs", "filler"} or path.startswith( + ("route_ecc/", "drc_ecc/", "lvs_ecc/", "filler_ecc/", "final/design/") + ): + return "final_design" + return "reports" + + +def _unavailable_assessment() -> dict[str, Any]: + return { + "status": "blocked", + "groups": [ + { + "id": group_id, + "label": label, + "status": "blocked" if group_id == "reports" else "ready", + "available": 0, + "expected": 0, + "summary": ( + "Checklist unavailable" if group_id == "reports" else "No requirements" + ), + } + for group_id, label in _REVIEW_GROUPS + ], + "risks": [ + { + "severity": "blocked", + "title": "Signoff checklist unavailable", + "summary": "Re-run signoff inspection after current-output analysis completes.", + "details": [], + } + ], + } diff --git a/chipcompiler/engine/signoff_export.py b/chipcompiler/engine/signoff_export.py new file mode 100644 index 000000000..19afb6b1b --- /dev/null +++ b/chipcompiler/engine/signoff_export.py @@ -0,0 +1,116 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from chipcompiler.engine import EngineFlow, SignoffPackageOptions +from chipcompiler.engine.signoff_assessment import build_signoff_assessment + + +class SignoffExportError(RuntimeError): + pass + + +def inspect_signoff_package(workspace) -> dict: + """Return the current Signoff Assessment without mutating the Workspace.""" + return build_signoff_assessment(workspace) + + +def export_signoff_package_archive( + workspace, + output_path: str, + additional_files: list[dict[str, str]] | None = None, + *, + include_debug: bool = False, +) -> str: + raw_destination = Path(output_path).expanduser() + destination = raw_destination.parent.resolve() / raw_destination.name + + with tempfile.TemporaryDirectory(prefix="ecc-signoff-") as temporary_root: + try: + result = EngineFlow(workspace).collect_signoff_package( + SignoffPackageOptions( + output_dir=temporary_root, + archive=False, + include_debug=include_debug, + materialize=False, + refresh_analysis=False, + ) + ) + except (OSError, ValueError, TypeError, KeyError) as exc: + raise SignoffExportError(str(exc)) from exc + if not result.ok: + missing = ", ".join(result.missing_required) or "unknown required resources" + raise SignoffExportError(f"signoff package is incomplete: {missing}") + if not result.package_dir: + raise SignoffExportError("signoff package directory was not created") + + package_dir = Path(result.package_dir) + + if additional_files is not None and not isinstance(additional_files, list): + raise SignoffExportError("additional_files must be a list") + if additional_files: + for file_info in additional_files: + if not isinstance(file_info, dict): + raise SignoffExportError("additional file entries must be objects") + archive_path = file_info.get("archivePath") + content = file_info.get("content") + if not isinstance(archive_path, str) or not archive_path: + raise SignoffExportError("additional file entries require archivePath") + if not isinstance(content, str): + raise SignoffExportError("additional file entries require string content") + try: + path = _additional_file_path(package_dir, archive_path) + path.parent.mkdir(parents=True, exist_ok=True) + if _has_symlink_parent(package_dir, path): + raise SignoffExportError("additional file path contains a symlink") + path.write_text(content, encoding="utf-8") + except SignoffExportError: + raise + except (OSError, ValueError, TypeError) as exc: + raise SignoffExportError(str(exc)) from exc + + archive = package_dir.with_suffix(".tar.gz") + import tarfile + + with tarfile.open(archive, "w:gz") as tar: + tar.add(package_dir, arcname=package_dir.name) + + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, staged_name = tempfile.mkstemp( + dir=destination.parent, + prefix=f".{destination.name}.", + ) + os.close(descriptor) + staged_path = Path(staged_name) + try: + shutil.copy2(archive, staged_path) + os.replace(staged_path, destination) + finally: + staged_path.unlink(missing_ok=True) + + return str(destination) + + +def _additional_file_path(package_dir: Path, archive_path: str) -> Path: + if not archive_path or "\x00" in archive_path: + raise SignoffExportError("additional file path must name a relative file") + relative = Path(archive_path) + if relative == Path(".") or relative.is_absolute() or ".." in relative.parts: + raise SignoffExportError("additional file path must stay inside the signoff package") + try: + destination = (package_dir / relative).resolve() + except (OSError, ValueError) as exc: + raise SignoffExportError("additional file path must name a relative file") from exc + if not destination.is_relative_to(package_dir.resolve()): + raise SignoffExportError("additional file path must stay inside the signoff package") + return destination + + +def _has_symlink_parent(package_dir: Path, path: Path) -> bool: + current = package_dir + for component in path.relative_to(package_dir).parts: + current /= component + if current.is_symlink(): + return True + return False diff --git a/chipcompiler/engine/snapshot.py b/chipcompiler/engine/snapshot.py new file mode 100644 index 000000000..8ccd332ae --- /dev/null +++ b/chipcompiler/engine/snapshot.py @@ -0,0 +1,213 @@ +from copy import deepcopy +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from chipcompiler.utility import JsonReadError, json_read, json_read_strict, json_write + +SNAPSHOT_SCHEMA_VERSION = 2 +SNAPSHOT_FILENAME = "engineering-snapshot.json" +STALE_SNAPSHOT_FILENAME = "engineering-snapshot.stale.json" + + +class EngineeringSnapshotError(RuntimeError): + pass + + +def create_engineering_snapshot( + workspace: Any, + *, + workspace_id: str | None = None, + workspace_revision: int = 1, + cause: str = "workspace.created", +) -> dict[str, Any]: + snapshot = _build_snapshot( + workspace, + workspace_id=workspace_id or f"workspace-{uuid4().hex}", + workspace_revision=workspace_revision, + cause=cause, + ) + _write_snapshot(_snapshot_path(workspace), snapshot) + return snapshot + + +def ensure_engineering_snapshot(workspace: Any) -> dict[str, Any]: + path = _snapshot_path(workspace) + if path.is_file(): + return _read_snapshot(path) + return create_engineering_snapshot(workspace, cause="workspace.migrated") + + +def read_engineering_snapshot(workspace: Any) -> dict[str, Any]: + return _read_snapshot(_snapshot_path(workspace)) + + +def read_engineering_snapshot_from_directory(directory: str | Path) -> dict[str, Any]: + return _read_snapshot(Path(directory).expanduser().resolve() / "home" / SNAPSHOT_FILENAME) + + +def read_stale_engineering_snapshot(workspace: Any) -> dict[str, Any] | None: + path = _stale_snapshot_path(workspace) + return _read_snapshot(path) if path.is_file() else None + + +def commit_engineering_snapshot( + workspace: Any, + *, + workspace_id: str, + cause: str, +) -> dict[str, Any]: + current = read_engineering_snapshot(workspace) + if current["workspaceId"] != workspace_id: + raise EngineeringSnapshotError("Workspace identity changed before commit") + snapshot = _build_snapshot( + workspace, + workspace_id=workspace_id, + workspace_revision=current["workspaceRevision"] + 1, + cause=cause, + ) + stale = current.get("stalePredecessor") + if isinstance(stale, dict): + states = { + str(step.get("name")): step.get("state") + for step in snapshot.get("flow", {}).get("steps", []) + if isinstance(step, dict) and step.get("name") + } + remaining = [ + step_id + for step_id in stale.get("invalidatedStepIds", []) + if states.get(step_id) not in {"Success", "Skipped"} + ] + if remaining: + snapshot["stalePredecessor"] = {**deepcopy(stale), "invalidatedStepIds": remaining} + _write_snapshot(_snapshot_path(workspace), snapshot) + if isinstance(stale, dict) and "stalePredecessor" not in snapshot: + _stale_snapshot_path(workspace).unlink(missing_ok=True) + return snapshot + + +def invalidate_engineering_snapshot( + workspace: Any, + *, + workspace_id: str, + cause: str, + first_invalidated_step: str | None = None, +) -> dict[str, Any]: + current = read_engineering_snapshot(workspace) + if current["workspaceId"] != workspace_id: + raise EngineeringSnapshotError("Workspace identity changed before invalidation") + flow = deepcopy(current.get("flow", {})) + steps = flow.get("steps", []) if isinstance(flow, dict) else [] + invalidated_index = 0 + if first_invalidated_step is not None: + invalidated_index = next( + ( + index + for index, step in enumerate(steps) + if isinstance(step, dict) + and str(step.get("name", "")).casefold() == first_invalidated_step.casefold() + ), + -1, + ) + if invalidated_index < 0: + raise EngineeringSnapshotError(f"Flow Step not found: {first_invalidated_step}") + for step in steps[invalidated_index:]: + if isinstance(step, dict): + step["state"] = "Unstart" + step.pop("runtime", None) + step.pop("peak memory (mb)", None) + stale_path = _stale_snapshot_path(workspace) + if not stale_path.is_file(): + _write_snapshot(stale_path, current) + invalidated = [ + str(step["name"]) + for step in steps[invalidated_index:] + if isinstance(step, dict) and step.get("name") + ] + snapshot = _build_snapshot( + workspace, + workspace_id=workspace_id, + workspace_revision=current["workspaceRevision"] + 1, + cause=cause, + ) + snapshot["flow"] = flow + snapshot["stalePredecessor"] = { + "workspaceRevision": current["workspaceRevision"], + "invalidatedStepIds": invalidated, + } + _write_snapshot(_snapshot_path(workspace), snapshot) + return snapshot + + +def _build_snapshot( + workspace: Any, + *, + workspace_id: str, + workspace_revision: int, + cause: str, +) -> dict[str, Any]: + flow_owner = getattr(workspace, "flow", None) + flow = _data_mapping(flow_owner) + if not flow and flow_owner is not None: + steps = flow_owner.steps() + flow = {"steps": deepcopy(steps)} if steps else {} + home = _data_mapping(getattr(workspace, "home", None)) + checklist_path = home.get("checklist") + checklist = json_read(checklist_path) if isinstance(checklist_path, (str, Path)) else {} + from chipcompiler.engine.analysis import build_workspace_analysis + from chipcompiler.engine.qor import build_workspace_qor_assessment + from chipcompiler.engine.signoff_assessment import build_signoff_assessment + + analysis, artifacts = build_workspace_analysis(workspace, workspace_id) + qor_assessment = build_workspace_qor_assessment(analysis) + return { + "schemaVersion": SNAPSHOT_SCHEMA_VERSION, + "workspaceId": workspace_id, + "workspaceRevision": workspace_revision, + "cause": cause, + "flow": flow, + "parameters": _data_mapping(getattr(workspace, "parameters", None)), + "checklist": checklist if isinstance(checklist, dict) else {}, + "analysis": analysis, + "metrics": deepcopy(qor_assessment["metrics"]), + "qorAssessment": qor_assessment, + "signoffAssessment": build_signoff_assessment(workspace), + "artifacts": artifacts, + } + + +def _snapshot_path(workspace: Any) -> Path: + return Path(workspace.directory) / "home" / SNAPSHOT_FILENAME + + +def _stale_snapshot_path(workspace: Any) -> Path: + return Path(workspace.directory) / "home" / STALE_SNAPSHOT_FILENAME + + +def _data_mapping(owner: Any) -> dict[str, Any]: + data = getattr(owner, "data", {}) + return deepcopy(data) if isinstance(data, dict) else {} + + +def _write_snapshot(path: Path, snapshot: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not json_write(path, snapshot): + raise EngineeringSnapshotError(f"failed to persist Engineering Snapshot: {path}") + + +def _read_snapshot(path: Path) -> dict[str, Any]: + try: + snapshot = json_read_strict(path) + except (OSError, JsonReadError) as exc: + raise EngineeringSnapshotError(f"invalid Engineering Snapshot: {path}") from exc + if ( + not isinstance(snapshot, dict) + or snapshot.get("schemaVersion") != SNAPSHOT_SCHEMA_VERSION + or not isinstance(snapshot.get("workspaceId"), str) + or not snapshot["workspaceId"] + or isinstance(snapshot.get("workspaceRevision"), bool) + or not isinstance(snapshot.get("workspaceRevision"), int) + or snapshot["workspaceRevision"] < 1 + ): + raise EngineeringSnapshotError(f"invalid Engineering Snapshot: {path}") + return snapshot diff --git a/chipcompiler/runtime/subflow_events.py b/chipcompiler/engine/subflow_events.py similarity index 98% rename from chipcompiler/runtime/subflow_events.py rename to chipcompiler/engine/subflow_events.py index d72adb74b..c47917f7b 100644 --- a/chipcompiler/runtime/subflow_events.py +++ b/chipcompiler/engine/subflow_events.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from copy import deepcopy from typing import Any diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py new file mode 100644 index 000000000..d4e1eb8f4 --- /dev/null +++ b/chipcompiler/engine/workspace_configuration.py @@ -0,0 +1,473 @@ +from copy import deepcopy +from pathlib import Path +from typing import Any + +from chipcompiler.data import load_workspace, save_parameter +from chipcompiler.data.parameter_schema import ( + list_schemas, + lookup_schema, + validate_schema_type, + validate_value, +) +from chipcompiler.data.workspace import workspace_config_paths +from chipcompiler.data.workspace_parameters import ( + update_workspace_param_value, + workspace_param_value, +) +from chipcompiler.data.workspace_transaction import WorkspaceFileTransaction +from chipcompiler.engine.flow import EngineFlow +from chipcompiler.engine.rerun import invalidate_from +from chipcompiler.engine.snapshot import ( + SNAPSHOT_FILENAME, + STALE_SNAPSHOT_FILENAME, + EngineeringSnapshotError, + ensure_engineering_snapshot, + invalidate_engineering_snapshot, + read_engineering_snapshot, +) +from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step + +from .workspace_lifecycle import ( + WorkspaceLifecycleError, + _command_retry_matches, + _load_committed_workspace, + _workspace_command_fingerprint, + _write_workspace_command, +) + + +def update_workspace_configuration( + target_directory: str | Path, + expected_workspace_revision: int, + configuration: object, + workspace_bindings: object, + command_id: str = "", +): + target = Path(target_directory).expanduser().resolve() + if not target.is_dir(): + raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}") + return _run_file_transaction( + target, + lambda: _update_workspace_configuration( + target, + expected_workspace_revision, + configuration, + workspace_bindings, + command_id, + ), + ) + + +def _update_workspace_configuration( + target: Path, + expected_workspace_revision: int, + configuration: object, + workspace_bindings: object, + command_id: str, +): + if not isinstance(configuration, dict) or not isinstance(workspace_bindings, dict): + raise WorkspaceLifecycleError( + "workspace_spec_invalid", "Workspace configuration and bindings must be objects" + ) + config = {str(key): value for key, value in configuration.items()} + unknown_sections = config.keys() - {"design", "pdk", "parameters"} + if unknown_sections: + raise WorkspaceLifecycleError( + "workspace_spec_invalid", f"Unknown configuration section: {min(unknown_sections)}" + ) + if config.get("design") or config.get("pdk"): + raise WorkspaceLifecycleError( + "workspace_structure_change_requires_update", + "Design and PDK changes require a structural Workspace Update", + ) + requested = config.get("parameters", {}) + if not isinstance(requested, dict): + raise WorkspaceLifecycleError("workspace_spec_invalid", "parameters must be an object") + parameters = {str(key): value for key, value in requested.items()} + fingerprint = _workspace_command_fingerprint( + "configuration", config, workspace_bindings, expected_workspace_revision + ) + if command_id and _command_retry_matches(target, command_id, fingerprint): + return _load_committed_workspace(target) + + workspace = _load_committed_workspace(target) + current = ensure_engineering_snapshot(workspace) + if current["workspaceRevision"] != expected_workspace_revision: + raise WorkspaceLifecycleError( + "revision_conflict", + "Workspace Revision does not match", + { + "expectedWorkspaceRevision": expected_workspace_revision, + "actualWorkspaceRevision": current["workspaceRevision"], + }, + ) + + changed = False + for parameter, value in parameters.items(): + schema = lookup_schema(parameter) + if schema is None or schema.pdk_target is not None: + raise WorkspaceLifecycleError("unknown_parameter", f"Unknown parameter: {parameter}") + normalized, type_error = validate_schema_type(value, schema) + errors = [type_error] if type_error else validate_value(normalized, schema) + if errors: + raise WorkspaceLifecycleError("invalid_parameter", str(errors[0])) + if workspace_param_value(workspace, schema) != normalized: + update_workspace_param_value(workspace, schema, normalized) + changed = True + + if not changed: + _write_workspace_command( + target, + command_id, + fingerprint, + current["workspaceId"], + current["workspaceRevision"], + ) + return workspace + if not save_parameter(workspace.parameters): + raise OSError("Failed to save Workspace parameters") + from chipcompiler.data import refresh_workspace_config + + refresh_workspace_config(workspace) + flow = EngineFlow(workspace) + steps = workspace.flow.steps() + if steps: + invalidate_from(flow, str(steps[0]["name"])) + updated = invalidate_engineering_snapshot( + workspace, + workspace_id=current["workspaceId"], + cause="workspace.configuration_updated", + ) + _write_workspace_command( + target, + command_id, + fingerprint, + updated["workspaceId"], + updated["workspaceRevision"], + ) + return _load_committed_workspace(target) + + +def read_workspace_configuration(workspace: Any) -> dict[str, Any]: + parameters = {} + for schema in list_schemas(): + if schema.pdk_target is not None: + continue + try: + parameters[schema.param] = workspace_param_value(workspace, schema) + except (OSError, ValueError): + continue + steps = workspace.flow.steps() + flow_names = [str(step.get("name", "")) for step in steps if step.get("name")] + flow = {"flowId": _flow_id(flow_names)} + if flow_names: + flow.update({"fromStepId": flow_names[0], "throughStepId": flow_names[-1]}) + inputs, input_bindings = _workspace_inputs(workspace) + pdk_mode = "manual" if workspace.parameters.data.get("pdk_config") else "default" + pdk_files, pdk_file_bindings = ( + _workspace_pdk_files(workspace) if pdk_mode == "manual" else ([], {}) + ) + snapshot = _read_snapshot_metadata(workspace) + return { + "workspaceId": snapshot["workspaceId"], + "workspaceRevision": snapshot["workspaceRevision"], + "workspaceSpec": { + "schemaVersion": 1, + "design": { + "name": workspace.design.name, + "topModule": workspace.design.top_module, + "clockPort": str(workspace.parameters.data.get("clock", "")), + }, + "inputMode": workspace.parameters.data.get("_input_mode") + if workspace.parameters.data.get("_input_mode") in {"rtl", "postSynthesis"} + else ("postSynthesis" if workspace.design.origin_def is not None else "rtl"), + "inputs": inputs, + "pdk": { + "familyId": workspace.pdk.name, + "version": workspace.pdk.version, + "mode": pdk_mode, + **({"files": pdk_files} if pdk_files else {}), + }, + "flow": flow, + "parameters": parameters, + }, + "workspaceBindings": { + "inputs": input_bindings, + "pdk": { + "root": str(workspace.pdk.root or ""), + "version": workspace.pdk.version, + **({"files": pdk_file_bindings} if pdk_file_bindings else {}), + }, + }, + } + + +def read_step_configuration(workspace: Any, step_id: str) -> dict[str, Any]: + step, schemas = _step_catalog(workspace, step_id) + snapshot = _read_snapshot_metadata(workspace) + if not schemas: + raise WorkspaceLifecycleError( + "step_configuration_unavailable", + f"Flow Step has no configurable parameters: {step}", + { + "workspaceId": snapshot["workspaceId"], + "workspaceRevision": snapshot["workspaceRevision"], + }, + ) + return { + "step": step, + "stepId": step, + "parameters": [_public_parameter_record(workspace, schema) for schema in schemas], + "workspaceId": snapshot["workspaceId"], + "workspaceRevision": snapshot["workspaceRevision"], + } + + +def read_step_configuration_from_directory( + target_directory: str | Path, + step_id: str, +) -> dict[str, Any]: + workspace = _load_committed_workspace(Path(target_directory).expanduser().resolve()) + return read_step_configuration(workspace, step_id) + + +def update_workspace_step_configuration( + target_directory: str | Path, + expected_workspace_revision: int, + step_id: str, + parameters: object, + command_id: str = "", +): + target = Path(target_directory).expanduser().resolve() + if not target.is_dir(): + raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}") + return _run_file_transaction( + target, + lambda: _update_workspace_step_configuration( + target, + expected_workspace_revision, + step_id, + parameters, + command_id, + ), + ) + + +def _update_workspace_step_configuration( + target: Path, + expected_workspace_revision: int, + step_id: str, + parameters: object, + command_id: str, +): + if not isinstance(step_id, str) or not step_id.strip() or not isinstance(parameters, dict): + raise WorkspaceLifecycleError( + "workspace_spec_invalid", "Step identity and parameters object are required" + ) + patch = {str(key): value for key, value in parameters.items()} + fingerprint = _workspace_command_fingerprint( + "step_configuration", + {"stepId": step_id, "parameters": patch}, + {}, + expected_workspace_revision, + ) + if command_id and _command_retry_matches(target, command_id, fingerprint): + return _load_committed_workspace(target) + + workspace = _load_committed_workspace(target) + snapshot = ensure_engineering_snapshot(workspace) + if snapshot["workspaceRevision"] != expected_workspace_revision: + raise WorkspaceLifecycleError( + "revision_conflict", + "Workspace Revision does not match", + { + "expectedWorkspaceRevision": expected_workspace_revision, + "actualWorkspaceRevision": snapshot["workspaceRevision"], + }, + ) + step, schemas = _step_catalog(workspace, step_id) + allowed = {schema.param: schema for schema in schemas} + changed = False + for parameter, value in patch.items(): + schema = lookup_schema(parameter) + if schema is None or schema.pdk_target is not None: + raise WorkspaceLifecycleError("unknown_parameter", f"Unknown parameter: {parameter}") + if parameter not in allowed: + raise WorkspaceLifecycleError( + "parameter_not_applicable", + f"Parameter {parameter} is not configurable at {step}", + ) + normalized, type_error = validate_schema_type(value, schema) + errors = [type_error] if type_error else validate_value(normalized, schema) + if errors: + raise WorkspaceLifecycleError("invalid_parameter", str(errors[0])) + if workspace_param_value(workspace, schema) != normalized: + update_workspace_param_value(workspace, schema, normalized) + changed = True + + if not changed: + _write_workspace_command( + target, + command_id, + fingerprint, + snapshot["workspaceId"], + snapshot["workspaceRevision"], + ) + return workspace + if not save_parameter(workspace.parameters): + raise OSError("Failed to save Workspace parameters") + from chipcompiler.data import refresh_workspace_config + + refresh_workspace_config(workspace) + invalidate_from(EngineFlow(workspace), step) + updated = invalidate_engineering_snapshot( + workspace, + workspace_id=snapshot["workspaceId"], + cause="workspace.step_configuration_updated", + first_invalidated_step=step, + ) + _write_workspace_command( + target, + command_id, + fingerprint, + updated["workspaceId"], + updated["workspaceRevision"], + ) + return _load_committed_workspace(target) + + +def _step_catalog(workspace: Any, step_id: str): + identity = normalize_flow_step(step_id).casefold() + steps = [str(step.get("name", "")) for step in workspace.flow.steps() if step.get("name")] + step = next( + (candidate for candidate in steps if normalize_flow_step(candidate).casefold() == identity), + None, + ) + if step is None: + raise WorkspaceLifecycleError("unknown_flow_step", f"Flow Step not found: {step_id}") + first = normalize_flow_step(steps[0]).casefold() + schemas = tuple( + schema + for schema in list_schemas() + if schema.pdk_target is None + and ( + normalize_flow_step(schema.applies).casefold() == identity + or (schema.applies == "all" and identity == first) + ) + ) + return step, schemas + + +def _public_parameter_record(workspace: Any, schema) -> dict[str, Any]: + record = { + "param": schema.param, + "type": schema.type, + "value": workspace_param_value(workspace, schema), + "default": deepcopy(schema.default), + "applies": schema.applies, + "description": schema.description, + } + for field in ("range", "choices", "unit"): + value = getattr(schema, field) + if value is not None: + record[field] = list(value) if isinstance(value, tuple) else value + return record + + +def read_workspace_configuration_from_directory( + target_directory: str | Path, +) -> dict[str, Any]: + target = Path(target_directory).expanduser().resolve() + workspace = load_workspace(target, read_only=True) + if workspace is None: + raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}") + return read_workspace_configuration(workspace) + + +def _workspace_inputs(workspace: Any) -> tuple[list[dict[str, str]], dict[str, str]]: + values = [] + input_mode = workspace.parameters.data.get("_input_mode") + if input_mode == "postSynthesis" and workspace.design.origin_verilog is not None: + values.append(("netlist", workspace.design.origin_verilog)) + elif workspace.design.input_filelist is not None: + values.append(("filelist", workspace.design.input_filelist)) + elif workspace.design.origin_verilog is not None: + role = "netlist" if workspace.design.origin_def is not None else "rtl" + values.append((role, workspace.design.origin_verilog)) + for role, path in ( + ("def", workspace.design.origin_def), + ("goldenNetlist", workspace.design.golden_verilog), + ("sdc", workspace.pdk.sdc), + ("spef", workspace.pdk.spef), + ): + if path is not None: + values.append((role, path)) + return ( + [{"inputId": role, "role": role} for role, _path in values], + {role: str(path) for role, path in values}, + ) + + +def _workspace_pdk_files(workspace: Any) -> tuple[list[dict[str, str]], dict[str, str]]: + grouped = { + "tech": [workspace.pdk.tech] if workspace.pdk.tech else [], + "lef": list(workspace.pdk.lefs), + "liberty": list(workspace.pdk.libs), + "mapping": [workspace.pdk.mapping_file] if workspace.pdk.mapping_file else [], + } + refs = [] + bindings = {} + for role, paths in grouped.items(): + for index, path in enumerate(paths): + file_id = role if len(paths) == 1 else f"{role}-{index}" + refs.append({"fileId": file_id, "role": role}) + bindings[file_id] = str(path) + return refs, bindings + + +def _read_snapshot_metadata(workspace: Any) -> dict[str, Any]: + try: + snapshot = read_engineering_snapshot(workspace) + except (EngineeringSnapshotError, OSError): + return {"workspaceId": None, "workspaceRevision": None} + return { + "workspaceId": snapshot["workspaceId"], + "workspaceRevision": snapshot["workspaceRevision"], + } + + +def _flow_id(names: list[str]) -> str: + for flow_id, builder in get_flow_builders().items(): + candidate = [str(getattr(step, "value", step)) for step, _tool, _state in builder()] + if candidate == names: + return flow_id + if flow_id == "rtl2gds" and names: + start = next( + (index for index, value in enumerate(candidate) if value == names[0]), None + ) + if start is not None and candidate[start : start + len(names)] == names: + return flow_id + return "custom" + + +def _run_file_transaction(target: Path, apply): + transaction = WorkspaceFileTransaction.begin(target, _workspace_file_paths(target)) + try: + result = apply() + transaction.commit() + return result + except BaseException: + transaction.rollback() + raise + + +def _workspace_file_paths(target: Path) -> list[Path]: + paths = [ + target / "home" / "params.toml", + target / "home" / "flow.json", + target / "home" / SNAPSHOT_FILENAME, + target / "home" / STALE_SNAPSHOT_FILENAME, + target / "home" / "workspace-commands.json", + ] + paths.extend(path for key, path in workspace_config_paths(target).items() if key != "dir") + return paths diff --git a/chipcompiler/engine/workspace_flow.py b/chipcompiler/engine/workspace_flow.py new file mode 100644 index 000000000..0d10bd13d --- /dev/null +++ b/chipcompiler/engine/workspace_flow.py @@ -0,0 +1,89 @@ +from typing import Any + + +def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): + import chipcompiler.engine as engine_api + + engine_flow = engine_api.EngineFlow(workspace=workspace) + if not engine_flow.has_init(): + raise ValueError("Workspace has no committed Flow") + + if create_step_workspaces: + engine_flow.create_step_workspaces() + return engine_flow + + +def workspace_step_from_flow(workspace, name: str): + previous_step = None + loader = getattr(workspace.flow, "steps", None) + steps = loader() if callable(loader) else workspace.flow.data.get("steps", []) + for flow_step in steps: + workspace_step = _build_workspace_step_for_info(workspace, flow_step, previous_step) + if flow_step.get("name") == name: + return workspace_step + if workspace_step is not None: + previous_step = workspace_step + return None + + +def _build_workspace_step_for_info(workspace, flow_step: dict, previous_step): + step_name = flow_step.get("name") + tool = flow_step.get("tool") + if not step_name or not tool: + return None + + if previous_step is None: + input_def = workspace.design.origin_def + input_verilog = workspace.design.origin_verilog + input_db = None + else: + input_def = previous_step.output.def_ or "" + input_verilog = previous_step.output.verilog or "" + input_db = previous_step.output.db or "" + + builder = _load_tool_builder(tool) + if builder is None or not hasattr(builder, "build_step"): + return None + + return builder.build_step( + workspace=workspace, + step_name=step_name, + input_def=input_def, + input_verilog=input_verilog, + input_db=input_db, + ) + + +def _load_tool_builder(tool: str): + import importlib + + module_alias = { + "klayout": "klayout_tool", + "dreamplace": "ecc_dreamplace", + "sizer": "ecc_sizer", + } + module_name = module_alias.get(tool, tool) + return importlib.import_module(f"chipcompiler.tools.{module_name}.builder") + + +def init_db_engine_for_workspace_step(engine_flow, workspace_step): + engine_db = getattr(engine_flow, "engine_db", None) + if engine_db is None: + from chipcompiler.engine import EngineDB + + engine_db = EngineDB(workspace=engine_flow.workspace) + engine_flow.engine_db = engine_db + elif engine_db.has_init(): + return True + + return engine_db.create_db_engine(step=workspace_step) + + +def success_state(): + from chipcompiler.data import StateEnum + + return StateEnum.Success + + +def state_value(state: Any) -> str: + return getattr(state, "value", str(state)) diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py new file mode 100644 index 000000000..726c7cd66 --- /dev/null +++ b/chipcompiler/engine/workspace_lifecycle.py @@ -0,0 +1,476 @@ +import ctypes +import errno +import hashlib +import json +import os +import shutil +import tempfile +from pathlib import Path +from typing import Any + +from chipcompiler.data import PDK, create_workspace, get_pdk, load_workspace +from chipcompiler.data.parameter_schema import ( + build_backend_overrides, + build_config_overrides, + resolve_parameters, +) +from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY +from chipcompiler.engine.snapshot import create_engineering_snapshot +from chipcompiler.engine.workspace_spec import validate_workspace_spec +from chipcompiler.rtl2gds import get_flow_builders + + +class WorkspaceLifecycleError(RuntimeError): + def __init__(self, code: str, message: str, details: dict[str, Any] | None = None): + super().__init__(message) + self.code = code + self.details = details or {} + + +def describe_workspace_binding_requirement( + workspace_directory: str | Path, +) -> dict[str, Any]: + workspace = _load_committed_workspace(Path(workspace_directory).expanduser().resolve()) + return { + "familyId": workspace.pdk.name, + "version": workspace.pdk.version or "unversioned", + "mode": "manual" if workspace.parameters.data.get("pdk_config") else "default", + } + + +def assess_execution_readiness( + workspace_directory: str | Path, + bindings: object | None, +) -> dict[str, Any]: + try: + workspace = _load_committed_workspace(Path(workspace_directory).expanduser().resolve()) + except (OSError, ValueError, WorkspaceLifecycleError): + return {"ready": False, "code": "workspace_invalid"} + binding_map = _string_keyed_dict(bindings) + pdk_binding = _string_keyed_dict(binding_map.get("pdk")) + root = pdk_binding.get("root") + if not isinstance(root, str) or not Path(root).is_dir(): + return {"ready": False, "code": "pdk_binding_missing"} + try: + if workspace.parameters.data.get("pdk_config"): + if workspace.pdk.root is None or Path(root).resolve() != workspace.pdk.root.resolve(): + return {"ready": False, "code": "pdk_binding_mismatch"} + workspace.pdk.validate() + else: + get_pdk(workspace.pdk.name, pdk_root=root).validate() + except (OSError, ValueError): + return {"ready": False, "code": "pdk_binding_mismatch"} + return {"ready": True} + + +def apply_workspace_bindings(workspace, bindings: object) -> None: + binding_map = _string_keyed_dict(bindings) + pdk_binding = _string_keyed_dict(binding_map.get("pdk")) + root = pdk_binding.get("root") + if not isinstance(root, str) or not Path(root).is_dir(): + raise WorkspaceLifecycleError("pdk_binding_missing", "PDK binding root is required") + sdc, spef = workspace.pdk.sdc, workspace.pdk.spef + if workspace.parameters.data.get("pdk_config"): + if workspace.pdk.root is None or Path(root).resolve() != workspace.pdk.root.resolve(): + raise WorkspaceLifecycleError("pdk_binding_mismatch", "PDK binding root does not match") + workspace.pdk.validate() + else: + workspace.pdk = get_pdk(workspace.pdk.name, pdk_root=root) + workspace.pdk.sdc, workspace.pdk.spef = sdc, spef + from chipcompiler.data import refresh_workspace_config + + refresh_workspace_config(workspace) + + +def create_workspace_from_spec( + target_directory: str | Path, + spec: object, + bindings: object, + command_id: str = "", +): + """Create a Workspace Spec target while serializing sibling creators.""" + from chipcompiler.engine.reconcile import _workspace_lock + + target = Path(target_directory).expanduser().resolve() + with _workspace_lock(target): + return _create_workspace_from_spec(target, spec, bindings, command_id) + + +def _create_workspace_from_spec( + target_directory: str | Path, + spec: object, + bindings: object, + command_id: str = "", +): + target = Path(target_directory).expanduser().resolve() + fingerprint = _workspace_command_fingerprint("create", spec, bindings) + if target.exists(): + if command_id and _command_retry_matches(target, command_id, fingerprint): + return _load_committed_workspace(target) + raise WorkspaceLifecycleError("workspace_exists", f"Workspace already exists: {target}") + validation = validate_workspace_spec(spec, bindings) + issues = validation["issues"] + if issues: + raise WorkspaceLifecycleError( + "workspace_spec_invalid", + "Workspace Spec validation failed", + {"issues": issues}, + ) + resolved = validation["resolvedWorkspaceSpec"] + binding_map = _string_keyed_dict(bindings) + input_bindings = _string_keyed_dict(binding_map.get("inputs")) + input_paths = { + item["role"]: str(input_bindings[item["inputId"]]) for item in resolved["inputs"] + } + rtl_paths = [ + str(input_bindings[item["inputId"]]) for item in resolved["inputs"] if item["role"] == "rtl" + ] + parameters, errors = resolve_parameters(toml_overrides=resolved["parameters"]) + if errors: + raise WorkspaceLifecycleError( + "workspace_spec_invalid", + "Workspace parameters are invalid", + {"issues": errors}, + ) + backend_parameters = build_backend_overrides(parameters, include_defaults=True) + config_overrides = build_config_overrides(parameters) + if config_overrides: + backend_parameters[CONFIG_OVERRIDES_KEY] = config_overrides + backend_parameters.update( + { + "design": resolved["design"]["name"], + "top_module": resolved["design"]["topModule"], + "clock": resolved["design"].get("clockPort", ""), + } + ) + flow_steps = get_flow_builders()[resolved["flow"]["flowId"]]() + flow_start = resolved["flow"].get("fromStepId") + flow_end = resolved["flow"].get("throughStepId") + flow_config = { + "start_step": flow_start or str(getattr(flow_steps[0][0], "value", flow_steps[0][0])), + "end_step": flow_end or str(getattr(flow_steps[-1][0], "value", flow_steps[-1][0])), + } + pdk, pdk_root, pdk_overrides = _bound_pdk( + resolved["pdk"], _string_keyed_dict(binding_map["pdk"]) + ) + + generated_filelist: str | None = None + generated_pdk_config: str | None = None + if len(rtl_paths) > 1: + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", suffix=".f", delete=False + ) as handle: + handle.write("\n".join(rtl_paths) + "\n") + generated_filelist = handle.name + if isinstance(pdk, PDK): + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", suffix=".json", delete=False + ) as handle: + json.dump(_pdk_config(pdk), handle) + generated_pdk_config = handle.name + + try: + workspace = create_workspace( + directory=target, + origin_def=input_paths.get("def", ""), + origin_verilog=( + input_paths.get("netlist", "") or (rtl_paths[0] if len(rtl_paths) == 1 else "") + ), + input_filelist=input_paths.get("filelist", "") or generated_filelist or "", + golden_verilog=input_paths.get("goldenNetlist", ""), + sdc=input_paths.get("sdc", ""), + spef=input_paths.get("spef", ""), + pdk=pdk.name if isinstance(pdk, PDK) else pdk, + pdk_root=pdk_root, + pdk_json=generated_pdk_config or "", + pdk_overrides=pdk_overrides, + parameters=backend_parameters, + flow_config=flow_config, + ) + if workspace is None: + raise WorkspaceLifecycleError( + "workspace_create_failed", f"Workspace creation failed: {target}" + ) + workspace.parameters.data["_input_mode"] = resolved["inputMode"] + from chipcompiler.data import save_parameter + + if not save_parameter(workspace.parameters): + raise OSError(f"Failed to persist input mode: {workspace.parameters.path}") + snapshot = create_engineering_snapshot(workspace) + _write_workspace_command( + target, + command_id, + fingerprint, + snapshot["workspaceId"], + snapshot["workspaceRevision"], + ) + return workspace + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise + finally: + if generated_filelist is not None: + Path(generated_filelist).unlink(missing_ok=True) + if generated_pdk_config is not None: + Path(generated_pdk_config).unlink(missing_ok=True) + + +def update_workspace_from_spec( + target_directory: str | Path, + expected_workspace_revision: int, + spec: object, + bindings: object, + command_id: str = "", +): + target = Path(target_directory).expanduser().resolve() + if not target.is_dir(): + raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}") + from chipcompiler.engine.reconcile import _workspace_lock + + with _workspace_lock(target): + return _update_workspace_from_spec( + target, expected_workspace_revision, spec, bindings, command_id + ) + + +def _update_workspace_from_spec( + target_directory: str | Path, + expected_workspace_revision: int, + spec: object, + bindings: object, + command_id: str = "", +): + from chipcompiler.engine.snapshot import ( + EngineeringSnapshotError, + ensure_engineering_snapshot, + read_engineering_snapshot, + ) + + target = Path(target_directory).expanduser().resolve() + fingerprint = _workspace_command_fingerprint( + "update", spec, bindings, expected_workspace_revision + ) + if command_id and _command_retry_matches(target, command_id, fingerprint): + return _load_committed_workspace(target) + current = _load_committed_workspace(target) + snapshot_path = target / "home" / "engineering-snapshot.json" + try: + snapshot = read_engineering_snapshot(current) + except EngineeringSnapshotError as exc: + if snapshot_path.exists() or snapshot_path.is_symlink(): + raise WorkspaceLifecycleError( + "workspace_invalid", "Invalid Engineering Snapshot" + ) from exc + if expected_workspace_revision != 1: + raise WorkspaceLifecycleError( + "revision_conflict", + "Workspace Revision does not match", + { + "expectedWorkspaceRevision": expected_workspace_revision, + "actualWorkspaceRevision": 1, + }, + ) from exc + snapshot = ensure_engineering_snapshot(current) + if snapshot["workspaceRevision"] != expected_workspace_revision: + raise WorkspaceLifecycleError( + "revision_conflict", + "Workspace Revision does not match", + { + "expectedWorkspaceRevision": expected_workspace_revision, + "actualWorkspaceRevision": snapshot["workspaceRevision"], + }, + ) + + staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.staging-", dir=target.parent)) + staging.rmdir() + try: + staged = create_workspace_from_spec(staging, spec, bindings) + create_engineering_snapshot( + staged, + workspace_id=snapshot["workspaceId"], + workspace_revision=snapshot["workspaceRevision"] + 1, + cause="workspace.updated", + ) + _copy_workspace_commands(target, staging) + _write_workspace_command( + staging, + command_id, + fingerprint, + snapshot["workspaceId"], + snapshot["workspaceRevision"] + 1, + ) + _rewrite_workspace_paths(staging, target) + _exchange_directories(target, staging) + try: + return _load_committed_workspace(target) + except Exception: + _exchange_directories(target, staging) + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def _pdk_config(pdk: PDK) -> dict[str, Any]: + return { + "name": pdk.name, + "version": pdk.version, + "root": str(pdk.root or ""), + "tech": str(pdk.tech or ""), + "lefs": [str(path) for path in pdk.lefs], + "libs": [str(path) for path in pdk.libs], + "mapping_file": str(pdk.mapping_file or ""), + "dont_use": pdk.dont_use, + "abc_load": pdk.abc_load, + } + + +def _bound_pdk(spec: dict, binding: dict) -> tuple[PDK | str, str, dict | None]: + root = Path(str(binding["root"])) + overrides = spec.get("overrides", {}) + if spec["mode"] != "manual": + return ( + spec["familyId"], + str(root), + { + **({"dont_use": overrides["dont_use"]} if "dont_use" in overrides else {}), + **({"abc_load": overrides["abc_load"]} if "abc_load" in overrides else {}), + }, + ) + files = binding.get("files", {}) + by_role: dict[str, list[Path]] = {} + for item in spec.get("files", []): + by_role.setdefault(item["role"], []).append(Path(str(files[item["fileId"]]))) + return ( + PDK( + name=spec["familyId"], + version=spec.get("version", ""), + root=root, + tech=by_role["tech"][0], + lefs=by_role.get("lef", []), + libs=by_role.get("liberty", []), + mapping_file=(by_role.get("mapping") or [None])[0], + dont_use=overrides.get("dont_use", []), + abc_load=float(overrides.get("abc_load", 0.015)), + ), + "", + None, + ) + + +def _string_keyed_dict(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + return {str(key): item for key, item in value.items()} + + +def _load_committed_workspace(path: Path): + workspace = load_workspace(path) + if workspace is None: + raise WorkspaceLifecycleError("workspace_invalid", f"Workspace cannot be opened: {path}") + return workspace + + +def _workspace_command_fingerprint( + kind: str, + spec: object, + bindings: object, + expected_revision: int | None = None, +) -> str: + payload = json.dumps( + { + "kind": kind, + "spec": spec, + "bindings": bindings, + "expectedWorkspaceRevision": expected_revision, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(payload).hexdigest() + + +def _workspace_commands(path: Path) -> dict[str, Any]: + command_path = path / "home" / "workspace-commands.json" + try: + payload = json.loads(command_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"schemaVersion": 1, "commands": {}} + if payload.get("schemaVersion") != 1 or not isinstance(payload.get("commands"), dict): + raise WorkspaceLifecycleError("workspace_invalid", "Invalid Workspace command ledger") + return payload + + +def _command_retry_matches(path: Path, command_id: str, fingerprint: str) -> bool: + record = _workspace_commands(path)["commands"].get(command_id) + if record is None: + return False + if not isinstance(record, dict) or record.get("fingerprint") != fingerprint: + raise WorkspaceLifecycleError( + "idempotency_conflict", f"command id reused with different input: {command_id}" + ) + return True + + +def _write_workspace_command( + path: Path, + command_id: str, + fingerprint: str, + workspace_id: str, + workspace_revision: int, +) -> None: + if not command_id: + return + from chipcompiler.utility import json_write + + payload = _workspace_commands(path) + payload["commands"][command_id] = { + "fingerprint": fingerprint, + "result": { + "workspaceId": workspace_id, + "workspaceRevision": workspace_revision, + }, + } + if not json_write(path / "home" / "workspace-commands.json", payload): + raise OSError("Failed to persist Workspace command ledger") + + +def _copy_workspace_commands(source: Path, destination: Path) -> None: + source_path = source / "home" / "workspace-commands.json" + if source_path.is_file(): + shutil.copy2(source_path, destination / "home" / "workspace-commands.json") + + +def _rewrite_workspace_paths(source_root: Path, target_root: Path) -> None: + from chipcompiler.utility import json_write + + source = str(source_root) + target = str(target_root) + for path in source_root.rglob("*.json"): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + rewritten = _replace_string_prefix(value, source, target) + if rewritten != value and not json_write(path, rewritten): + raise OSError(f"Failed to rewrite staged Workspace path: {path}") + + +def _replace_string_prefix(value: Any, source: str, target: str) -> Any: + if isinstance(value, str): + return target + value[len(source) :] if value.startswith(source) else value + if isinstance(value, list): + return [_replace_string_prefix(item, source, target) for item in value] + if isinstance(value, dict): + return {key: _replace_string_prefix(item, source, target) for key, item in value.items()} + return value + + +def _exchange_directories(left: Path, right: Path) -> None: + libc = ctypes.CDLL(None, use_errno=True) + renameat2 = getattr(libc, "renameat2", None) + if os.name != "posix" or renameat2 is None: + raise OSError(errno.ENOTSUP, "atomic Workspace Update is unavailable") + if renameat2(-100, os.fsencode(left), -100, os.fsencode(right), 2) == 0: + return + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) diff --git a/chipcompiler/engine/workspace_spec.py b/chipcompiler/engine/workspace_spec.py new file mode 100644 index 000000000..f95f1c402 --- /dev/null +++ b/chipcompiler/engine/workspace_spec.py @@ -0,0 +1,431 @@ +from copy import deepcopy +from pathlib import Path +from typing import Any + +from chipcompiler.data.parameter_schema import list_schemas, resolve_parameters +from chipcompiler.engine.pdk_binding import pdk_binding_content_hash +from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step +from chipcompiler.utility.filelist import validate_filelist + +SCHEMA_VERSION = 1 +_INPUT_ROLES = frozenset({"rtl", "filelist", "netlist", "goldenNetlist", "def", "sdc", "spef"}) +_PDK_FILE_ROLES = frozenset({"tech", "lef", "liberty", "mapping"}) +_SPEC_FIELDS = frozenset( + {"schemaVersion", "design", "inputMode", "inputs", "pdk", "flow", "mpc", "parameters"} +) + + +def describe_workspace_spec() -> dict[str, Any]: + flows = [] + for flow_id, builder in sorted(get_flow_builders().items()): + steps = [_enum_value(step) for step, _tool, _state in builder()] + flows.append({"flowId": flow_id, "stepIds": steps}) + return { + "schemaVersion": SCHEMA_VERSION, + "parameterCatalog": [ + { + "id": schema.param, + "type": schema.type, + "default": deepcopy(schema.default), + "appliesTo": schema.applies, + "backendMapping": deepcopy(schema.maps_to), + **({"range": list(schema.range)} if schema.range else {}), + **({"choices": list(schema.choices)} if schema.choices else {}), + **({"unit": schema.unit} if schema.unit else {}), + } + for schema in list_schemas() + ], + "flowDefinitions": flows, + "inputRoleRules": { + "roles": sorted(_INPUT_ROLES), + "rtl": {"rtl": "oneOrMore", "filelist": "exactlyOneAlternative"}, + "postSynthesis": {"netlist": "exactlyOne"}, + "manualPdk": { + "tech": "exactlyOne", + "lef": "oneOrMore", + "liberty": "oneOrMore", + "mapping": "zeroOrOne", + }, + }, + } + + +def validate_workspace_spec(spec: object, bindings: object) -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + if not isinstance(spec, dict): + return {"issues": [_issue("invalid_type", "", expected="object")]} + spec = {str(key): value for key, value in spec.items()} + if not isinstance(bindings, dict): + bindings = {} + issues.append(_issue("invalid_type", "/bindings", expected="object")) + else: + bindings = {str(key): value for key, value in bindings.items()} + + _unknown_fields(spec, _SPEC_FIELDS, "", issues) + if spec.get("schemaVersion") != SCHEMA_VERSION or isinstance(spec.get("schemaVersion"), bool): + issues.append( + _issue( + "unsupported_schema_version", + "/schemaVersion", + supported=SCHEMA_VERSION, + ) + ) + + design = _mapping(spec.get("design"), "/design", issues) + _unknown_fields(design, {"name", "topModule", "clockPort"}, "/design", issues) + _required_string(design, "name", "/design/name", issues) + _required_string(design, "topModule", "/design/topModule", issues) + if "clockPort" in design and not _nonempty_string(design["clockPort"]): + issues.append(_issue("invalid_value", "/design/clockPort")) + + input_mode = spec.get("inputMode") + if input_mode not in {"rtl", "postSynthesis"}: + issues.append(_issue("invalid_input_mode", "/inputMode")) + inputs = _refs(spec.get("inputs"), "inputId", _INPUT_ROLES, "/inputs", issues) + input_bindings = _input_bindings(bindings.get("inputs"), issues) + _validate_binding_set(inputs, input_bindings, issues) + _validate_input_roles(input_mode, inputs, issues) + _validate_input_files(inputs, input_bindings, issues) + + pdk = _mapping(spec.get("pdk"), "/pdk", issues) + _unknown_fields(pdk, {"familyId", "version", "mode", "files", "overrides"}, "/pdk", issues) + _required_string(pdk, "familyId", "/pdk/familyId", issues) + mode = pdk.get("mode") + if mode not in {"default", "manual"}: + issues.append(_issue("invalid_pdk_mode", "/pdk/mode")) + overrides = pdk.get("overrides", {}) + if not isinstance(overrides, dict): + issues.append(_issue("invalid_type", "/pdk/overrides", expected="object")) + else: + _unknown_fields(overrides, {"dont_use", "abc_load"}, "/pdk/overrides", issues) + if "dont_use" in overrides and not ( + isinstance(overrides["dont_use"], list) + and all(isinstance(value, str) for value in overrides["dont_use"]) + ): + issues.append(_issue("invalid_type", "/pdk/overrides/dont_use", expected="array")) + if "abc_load" in overrides and ( + isinstance(overrides["abc_load"], bool) + or not isinstance(overrides["abc_load"], (int, float)) + ): + issues.append(_issue("invalid_type", "/pdk/overrides/abc_load", expected="number")) + pdk_bindings = _mapping(bindings.get("pdk"), "/bindings/pdk", issues) + root = pdk_bindings.get("root") + if not isinstance(root, str) or not root.strip() or not Path(root).is_dir(): + issues.append(_issue("pdk_binding_missing", "/bindings/pdk/root")) + _validate_pdk_files(pdk, pdk_bindings, issues) + requested_version = pdk.get("version") + bound_version = pdk_bindings.get("version") + if requested_version and bound_version and requested_version != bound_version: + issues.append( + _issue( + "pdk_binding_mismatch", + "/bindings/pdk/version", + expected=requested_version, + actual=bound_version, + ) + ) + + flow = _mapping(spec.get("flow"), "/flow", issues) + _unknown_fields(flow, {"flowId", "fromStepId", "throughStepId"}, "/flow", issues) + flow_steps = _validate_flow(flow, issues) + + parameters = spec.get("parameters", {}) + if not isinstance(parameters, dict): + issues.append(_issue("invalid_type", "/parameters", expected="object")) + parameters = {} + else: + parameters = {str(key): value for key, value in parameters.items()} + known_parameters = {schema.param for schema in list_schemas()} + for parameter_id in parameters: + if parameter_id not in known_parameters: + issues.append(_issue("unknown_parameter", f"/parameters/{_pointer(parameter_id)}")) + resolved_parameters, parameter_errors = resolve_parameters( + toml_overrides={key: value for key, value in parameters.items() if key in known_parameters} + ) + for error in parameter_errors: + parameter_id = next((key for key in parameters if key in error), "") + issues.append( + _issue( + "invalid_parameter", + f"/parameters/{_pointer(parameter_id)}" if parameter_id else "/parameters", + reason=error, + ) + ) + _validate_parameter_applicability(parameters, flow_steps, issues) + + mpc = spec.get("mpc") + if mpc is not None: + mpc = _mapping(mpc, "/mpc", issues) + _unknown_fields(mpc, {"resourceId", "version", "designId"}, "/mpc", issues) + for key in ("resourceId", "version", "designId"): + _required_string(mpc, key, f"/mpc/{key}", issues) + mpc_binding = _mapping(bindings.get("mpc"), "/bindings/mpc", issues) + if not isinstance(mpc_binding.get("template"), dict): + issues.append(_issue("mpc_binding_missing", "/bindings/mpc/template")) + + if any(issue["severity"] == "error" for issue in issues): + return {"issues": issues} + + resolved = deepcopy(spec) + resolved["parameters"] = _effective_parameter_values(resolved_parameters, flow_steps) + resolved["pdk"] = { + **deepcopy(pdk), + "version": requested_version or bound_version or "unversioned", + "contentHash": pdk_binding_content_hash(pdk, pdk_bindings), + } + return {"resolvedWorkspaceSpec": resolved, "issues": issues} + + +def _validate_input_roles( + input_mode: object, + inputs: list[dict[str, str]], + issues: list[dict[str, Any]], +) -> None: + roles = [item["role"] for item in inputs] + if input_mode == "rtl": + if ("rtl" in roles) == ("filelist" in roles): + issues.append(_issue("conflicting_input_roles", "/inputs")) + if roles.count("filelist") > 1: + issues.append(_issue("filelist_cardinality", "/inputs")) + if "netlist" in roles: + issues.append(_issue("input_role_not_allowed", "/inputs", mode="rtl")) + elif input_mode == "postSynthesis": + if roles.count("netlist") != 1: + issues.append(_issue("netlist_cardinality", "/inputs")) + if any(role in {"rtl", "filelist"} for role in roles): + issues.append(_issue("input_role_not_allowed", "/inputs", mode="postSynthesis")) + for role in ("netlist", "goldenNetlist", "def", "sdc", "spef"): + if roles.count(role) > 1: + issues.append(_issue(f"{role}_cardinality", "/inputs")) + + +def _validate_input_files( + refs: list[dict[str, str]], + bindings: dict[str, str], + issues: list[dict[str, Any]], +) -> None: + for ref in refs: + input_id = ref["inputId"] + path = bindings.get(input_id) + if not path: + continue + source = Path(path) + pointer = f"/bindings/inputs/{_pointer(input_id)}" + if not source.is_file(): + issues.append(_issue("input_unreadable", pointer)) + continue + if ref["role"] != "filelist": + continue + try: + _existing, missing = validate_filelist(str(source)) + except (OSError, ValueError) as exc: + issues.append(_issue("filelist_invalid", pointer, reason=str(exc))) + continue + if missing: + issues.append(_issue("filelist_source_missing", pointer, missing=missing)) + + +def _validate_binding_set( + refs: list[dict[str, str]], + bindings: dict[str, str], + issues: list[dict[str, Any]], +) -> None: + ref_ids = {item["inputId"] for item in refs} + for input_id in sorted(ref_ids - bindings.keys()): + issues.append(_issue("input_binding_missing", f"/bindings/inputs/{_pointer(input_id)}")) + for input_id in sorted(bindings.keys() - ref_ids): + issues.append(_issue("unknown_input_binding", f"/bindings/inputs/{_pointer(input_id)}")) + + +def _validate_pdk_files( + pdk: dict[str, Any], + bindings: dict[str, Any], + issues: list[dict[str, Any]], +) -> None: + mode = pdk.get("mode") + refs = _refs(pdk.get("files", []), "fileId", _PDK_FILE_ROLES, "/pdk/files", issues) + roles = [item["role"] for item in refs] + rules = ( + ( + ("tech", roles.count("tech") == 1), + ("lef", roles.count("lef") >= 1), + ("liberty", roles.count("liberty") >= 1), + ("mapping", roles.count("mapping") <= 1), + ) + if mode == "manual" + else ( + ("tech", roles.count("tech") <= 1), + ("mapping", roles.count("mapping") <= 1), + ) + ) + for role, valid in rules: + if not valid: + issues.append(_issue(f"pdk_{role}_cardinality", "/pdk/files")) + raw_file_bindings = bindings.get("files", {}) + file_bindings = raw_file_bindings if isinstance(raw_file_bindings, dict) else {} + ref_ids = {item["fileId"] for item in refs} + for file_id in sorted(ref_ids - file_bindings.keys()): + issues.append( + _issue("pdk_file_binding_missing", f"/bindings/pdk/files/{_pointer(file_id)}") + ) + for file_id in sorted(file_bindings.keys() - ref_ids): + issues.append( + _issue("unknown_pdk_file_binding", f"/bindings/pdk/files/{_pointer(file_id)}") + ) + for file_id, path in file_bindings.items(): + if file_id in ref_ids and (not isinstance(path, str) or not Path(path).is_file()): + issues.append(_issue("pdk_file_unreadable", f"/bindings/pdk/files/{_pointer(file_id)}")) + + +def _validate_flow(flow: dict[str, Any], issues: list[dict[str, Any]]) -> set[str]: + flow_id = flow.get("flowId") + builder = get_flow_builders().get(flow_id) if isinstance(flow_id, str) else None + if builder is None: + issues.append(_issue("unknown_flow", "/flow/flowId")) + return set() + steps = [_enum_value(step) for step, _tool, _state in builder()] + start = flow.get("fromStepId") + end = flow.get("throughStepId") + if (start is None) != (end is None): + issues.append(_issue("flow_boundaries_incomplete", "/flow")) + return set(steps) + if start is None: + return set(steps) + if start not in steps: + issues.append(_issue("unknown_flow_boundary", "/flow/fromStepId")) + if end not in steps: + issues.append(_issue("unknown_flow_boundary", "/flow/throughStepId")) + if start in steps and end in steps: + start_index, end_index = steps.index(start), steps.index(end) + if start_index > end_index: + issues.append(_issue("flow_boundary_reversed", "/flow")) + else: + return set(steps[start_index : end_index + 1]) + return set() + + +def _validate_parameter_applicability( + explicit: dict[str, Any], + flow_steps: set[str], + issues: list[dict[str, Any]], +) -> None: + normalized_steps = {step.lower() for step in flow_steps} + aliases = { + "synthesis": "synth", + "floorplan": "floor", + "fixfanout": "fanout", + "placement": "place", + "routing": "route", + } + normalized_steps |= {aliases.get(step, step) for step in normalized_steps} + for schema in list_schemas(): + if schema.param not in explicit: + continue + if schema.applies == "all": + continue + applies = aliases.get(schema.applies.lower(), schema.applies.lower()) + if flow_steps and applies not in normalized_steps: + issues.append( + _issue( + "inapplicable_parameter", + f"/parameters/{_pointer(schema.param)}", + appliesTo=schema.applies, + ) + ) + + +def _effective_parameter_values(resolved, steps: set[str]) -> dict[str, object]: + available = {normalize_flow_step(step).casefold() for step in steps} + return { + parameter.param: deepcopy(parameter.value) + for parameter in resolved + if parameter.schema.pdk_target is None + and ( + parameter.schema.applies == "all" + or normalize_flow_step(parameter.schema.applies).casefold() in available + ) + } + + +def _refs( + value: object, + id_key: str, + roles: frozenset[str], + path: str, + issues: list[dict[str, Any]], +) -> list[dict[str, str]]: + if not isinstance(value, list): + issues.append(_issue("invalid_type", path, expected="array")) + return [] + refs = [] + seen = set() + for index, value_item in enumerate(value): + item_path = f"{path}/{index}" + if not isinstance(value_item, dict): + issues.append(_issue("invalid_type", item_path, expected="object")) + continue + item = {str(key): field for key, field in value_item.items()} + _unknown_fields(item, {id_key, "role"}, item_path, issues) + item_id = item.get(id_key) + role = item.get("role") + if not _nonempty_string(item_id): + issues.append(_issue("invalid_value", f"{item_path}/{id_key}")) + continue + if item_id in seen: + issues.append(_issue("duplicate_ref", f"{item_path}/{id_key}")) + continue + seen.add(item_id) + if role not in roles: + issues.append(_issue("unknown_role", f"{item_path}/role")) + continue + refs.append({id_key: item_id, "role": role}) + return refs + + +def _input_bindings(value: object, issues: list[dict[str, Any]]) -> dict[str, str]: + if not isinstance(value, dict): + issues.append(_issue("invalid_type", "/bindings/inputs", expected="object")) + return {} + return { + key: path for key, path in value.items() if isinstance(key, str) and isinstance(path, str) + } + + +def _mapping(value: object, path: str, issues: list[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(value, dict): + issues.append(_issue("invalid_type", path, expected="object")) + return {} + return {str(key): item for key, item in value.items()} + + +def _unknown_fields( + value: dict[str, Any], + allowed: set[str] | frozenset[str], + path: str, + issues: list[dict[str, Any]], +) -> None: + for key in value.keys() - allowed: + issues.append(_issue("unknown_field", f"{path}/{_pointer(key)}")) + + +def _required_string( + value: dict[str, Any], key: str, path: str, issues: list[dict[str, Any]] +) -> None: + if not _nonempty_string(value.get(key)): + issues.append(_issue("required", path)) + + +def _nonempty_string(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _enum_value(value: object) -> str: + return str(getattr(value, "value", value)) + + +def _pointer(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") + + +def _issue(code: str, path: str, **details: Any) -> dict[str, Any]: + return {"code": code, "path": path, "severity": "error", "details": details} diff --git a/chipcompiler/project/__init__.py b/chipcompiler/project/__init__.py new file mode 100644 index 000000000..98ff93c2e --- /dev/null +++ b/chipcompiler/project/__init__.py @@ -0,0 +1,15 @@ +from .api import ( + create_project_manifest, + create_project_workspace, + discover_project_manifest, + load_project_manifest, + mutate_project_manifest, +) + +__all__ = [ + "create_project_manifest", + "create_project_workspace", + "discover_project_manifest", + "load_project_manifest", + "mutate_project_manifest", +] diff --git a/chipcompiler/project/api.py b/chipcompiler/project/api.py new file mode 100644 index 000000000..d40abba20 --- /dev/null +++ b/chipcompiler/project/api.py @@ -0,0 +1,280 @@ +import json +import shutil +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path + +from chipcompiler.project.manifest import ( + _CANONICAL_TO_MANIFEST_STEP, + ManifestError, + load_manifest, +) +from chipcompiler.project.manifest_write import ( + build_project_document, + manifest_workspace_entry, + update_manifest, + write_manifest_if_absent, +) + + +def load_project_manifest(project_dir: str | Path) -> dict: + manifest = load_manifest(str(Path(project_dir).expanduser().resolve())) + document = deepcopy(manifest.raw) + document.update( + { + "schema_version": 1, + "project_id": manifest.project_id, + "name": manifest.name, + "design_name": manifest.design_name, + "root_path": manifest.project_dir, + "base_design": deepcopy(manifest.base_design), + "objectives": deepcopy(manifest.objectives), + "workspaces": [_workspace_document(item) for item in manifest.workspaces], + "qor_baseline": deepcopy(manifest.qor_baseline), + } + ) + return document + + +def discover_project_manifest(directory: str | Path) -> tuple[Path, dict] | None: + current = Path(directory).expanduser().resolve() + if not current.is_dir(): + current = current.parent + for project in (current, *current.parents): + if (project / "project.json").exists() or (project / "project.json").is_symlink(): + return project, load_project_manifest(project) + return None + + +def create_project_manifest( + project_dir: str | Path, + name: str, + design_name: str, + *, + now: str | None = None, + mpc: dict | None = None, +) -> dict: + project = Path(project_dir).expanduser().resolve() + project.mkdir(parents=True, exist_ok=True) + document = build_project_document( + str(project), + design_name=design_name, + base_design={"rtl_list": [], "parameters": {"design": design_name}}, + name=name, + now=now, + mpc=mpc, + ) + if not write_manifest_if_absent(str(project), document): + raise ManifestError(f"Project Manifest already exists: {project / 'project.json'}") + try: + return load_project_manifest(project) + except Exception: + (project / "project.json").unlink(missing_ok=True) + raise + + +def mutate_project_manifest(project_dir: str | Path, mutation: dict) -> dict: + project = Path(project_dir).expanduser().resolve() + load_project_manifest(project) + + def apply(document: dict) -> None: + kind = mutation.get("type") + if kind == "register_workspace": + _register_workspace(document, mutation, project) + elif kind in {"select_qor_baseline", "select_best_workspace"}: + _select_workspace(document, mutation) + elif kind == "archive_workspace": + _archive_workspace(document, mutation) + elif kind == "delete_workspace": + _delete_workspace(document, mutation) + else: + raise ManifestError(f"unsupported Project mutation: {kind}") + + if not update_manifest(str(project), apply): + raise ManifestError("Project Manifest update failed") + return load_project_manifest(project) + + +def create_project_workspace( + project_dir: str | Path, + target_directory: str | Path, + spec: object, + bindings: object, + *, + command_id: str, + workspace_id: str | None = None, + name: str | None = None, + source_workspace_id: str | None = None, + expected_project_id: str | None = None, + now: str | None = None, +): + from chipcompiler.engine import create_workspace_from_spec + + project = Path(project_dir).expanduser().resolve() + target = Path(target_directory).expanduser().resolve() + try: + target.relative_to(project) + except ValueError as exc: + raise ManifestError("Workspace must be inside the Project root") from exc + manifest = load_project_manifest(project) + if expected_project_id is not None and manifest["project_id"] != expected_project_id: + raise ManifestError("Project identity does not match") + existed = target.exists() + workspace = create_workspace_from_spec(str(target), spec, bindings, command_id) + if workspace is None: + raise ManifestError("Workspace creation returned no Workspace") + identity = workspace_id or target.name + try: + mutate_project_manifest( + project, + { + "type": "register_workspace", + "workspace_id": identity, + "name": name or identity, + "workspace_path": str(target), + "source_workspace_id": source_workspace_id, + "created_at": now, + "updated_at": now, + }, + ) + except Exception: + if not existed: + shutil.rmtree(target, ignore_errors=True) + raise + return workspace + + +def _workspace_document(workspace) -> dict: + document = deepcopy(workspace.raw) + document.update( + { + "workspace_id": workspace.workspace_id, + "workspace_path": workspace.workspace_path, + "start_step": workspace.start_step, + "end_step": workspace.end_step, + "status": workspace.status, + "parameter_patch": deepcopy(workspace.parameter_patch), + } + ) + return document + + +def _register_workspace(document: dict, mutation: dict, project: Path) -> None: + workspace_id = _required_string(mutation, "workspace_id") + workspace_path = _resolve_workspace(project, _required_string(mutation, "workspace_path")) + for existing in document.get("workspaces", []): + same_id = existing.get("workspace_id") == workspace_id + same_path = ( + _resolve_workspace(project, str(existing.get("workspace_path", ""))) == workspace_path + ) + if same_id and same_path: + return + if same_id or same_path: + raise ManifestError(f"Workspace registration conflicts with: {workspace_id}") + start_step, end_step = _workspace_range(workspace_path, mutation) + timestamp = str( + mutation.get("updated_at") or mutation.get("created_at") or datetime.now(UTC).isoformat() + ) + entry = manifest_workspace_entry( + workspace_id, + name=str(mutation.get("name") or workspace_id), + workspace_path=str(workspace_path), + start_step=start_step, + end_step=end_step, + status="archived" if mutation.get("lifecycle") == "archived" else "not_started", + now=timestamp, + ) + for key in ( + "source_workspace_id", + "branch_from", + "parameter_patch", + "metrics_summary", + "step_metrics", + ): + if key in mutation: + entry[key] = deepcopy(mutation[key]) + document.setdefault("workspaces", []).append(entry) + document["updated_at"] = timestamp + + +def _workspace_range(workspace: Path, mutation: dict) -> tuple[str, str]: + start = mutation.get("start_step") + end = mutation.get("end_step") + if isinstance(start, str) and isinstance(end, str) and start and end: + return start, end + try: + ledger = json.loads((workspace / "home" / "flow.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "Synth", "Harden" + names = [ + step.get("name") + for step in ledger.get("steps", []) + if isinstance(step, dict) and isinstance(step.get("name"), str) + ] + if not names: + return "Synth", "Harden" + return ( + _CANONICAL_TO_MANIFEST_STEP.get(names[0], names[0]), + _CANONICAL_TO_MANIFEST_STEP.get(names[-1], names[-1]), + ) + + +def _select_workspace(document: dict, mutation: dict) -> None: + workspace_id = _required_string(mutation, "workspace_id") + workspace = _find_workspace(document, workspace_id) + if workspace.get("status") == "archived": + raise ManifestError(f"Workspace is not active: {workspace_id}") + field = "qor_baseline" if mutation["type"] == "select_qor_baseline" else "best_workspace" + document[field] = {"workspace_id": workspace_id, "reason": str(mutation.get("reason") or "")} + document["updated_at"] = str(mutation.get("updated_at") or datetime.now(UTC).isoformat()) + + +def _archive_workspace(document: dict, mutation: dict) -> None: + workspace = _find_workspace(document, _required_string(mutation, "workspace_id")) + workspace["status"] = "archived" + timestamp = str(mutation.get("updated_at") or datetime.now(UTC).isoformat()) + workspace["updated_at"] = timestamp + document["updated_at"] = timestamp + + +def _delete_workspace(document: dict, mutation: dict) -> None: + workspace_id = _required_string(mutation, "workspace_id") + _find_workspace(document, workspace_id) + document["workspaces"] = [ + workspace + for workspace in document.get("workspaces", []) + if workspace.get("workspace_id") != workspace_id + ] + for workspace in document["workspaces"]: + if workspace.get("source_workspace_id") == workspace_id: + workspace["source_workspace_id"] = None + for field in ("qor_baseline", "best_workspace"): + selection = document.get(field) + if isinstance(selection, dict) and selection.get("workspace_id") == workspace_id: + document[field] = None + document["updated_at"] = str(mutation.get("updated_at") or datetime.now(UTC).isoformat()) + + +def _find_workspace(document: dict, workspace_id: str) -> dict: + for workspace in document.get("workspaces", []): + if isinstance(workspace, dict) and workspace.get("workspace_id") == workspace_id: + return workspace + raise ManifestError(f"Workspace is not registered: {workspace_id}") + + +def _required_string(value: dict, key: str) -> str: + result = value.get(key) + if not isinstance(result, str) or not result.strip(): + raise ManifestError(f"Project Manifest {key} is required") + return result.strip() + + +def _resolve_workspace(project: Path, declared: str) -> Path: + workspace = Path(declared) + workspace = workspace if workspace.is_absolute() else project / workspace + resolved = workspace.resolve() + try: + resolved.relative_to(project) + except ValueError as exc: + raise ManifestError("Workspace must be inside the Project root") from exc + return resolved diff --git a/chipcompiler/project/locking.py b/chipcompiler/project/locking.py new file mode 100644 index 000000000..6a8f4082e --- /dev/null +++ b/chipcompiler/project/locking.py @@ -0,0 +1,14 @@ +"""Project-domain file lock helper.""" + +import fcntl +from contextlib import contextmanager + + +@contextmanager +def flock_file(path: str, *, exclusive: bool = True): + with open(path, "a") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + try: + yield lock_file + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/chipcompiler/project/manifest.py b/chipcompiler/project/manifest.py new file mode 100644 index 000000000..99d51ba0f --- /dev/null +++ b/chipcompiler/project/manifest.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python + +"""``project.json`` manifest support for the CLI. + +The manifest is the GUI's project descriptor (schema v1). The CLI reads it +for configuration layering and run discovery, and projects it into +configuration payloads. Write operations (generation, status write-back, +registration) live in chipcompiler.project.manifest_write, which +routes every write through one read-modify-write helper. + +This module sits on the CLI startup path (imported by +cli/core/invocation.py): keep module-level imports cheap — no +chipcompiler.data imports here. +""" + +import json +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +MANIFEST_FILENAME = "project.json" + +# GUI display names for the canonical rtl2gds chain. +MANIFEST_FLOW_STEPS = ( + "Synth", + "LEC", + "Floor", + "Place", + "CTS", + "Legal", + "TimingOpt", + "Route", + "Filler", + "RCX", + "STA", + "LVS", + "PostRouteLEC", + "DRC", + "Harden", +) + +PRESET_MANIFEST_RANGE = { + "syn_sta": ("Synth", "Synth"), + "rtl2gds": ("Synth", "Harden"), + "synthesis_lec": ("Synth", "LEC"), + # Legacy presets removed from the builder; keep resolving them so + # persisted projects still load. + "rcx": ("Synth", "STA"), + "harden": ("Synth", "Harden"), +} + +_CANONICAL_TO_MANIFEST_STEP = { + "Synthesis": "Synth", + "lec": "LEC", + "Floorplan": "Floor", + "place": "Place", + "CTS": "CTS", + "legalization": "Legal", + "Timing optimization": "TimingOpt", + "route": "Route", + "filler": "Filler", + "RCX": "RCX", + "sta": "STA", + "lvs": "LVS", + "postRouteLec": "PostRouteLEC", + "drc": "DRC", + "Harden": "Harden", +} + +_WORKSPACE_STATUSES = frozenset( + {"success", "failed", "running", "in_progress", "not_started", "archived"} +) + + +class ManifestError(ValueError): + """Raised when a project.json manifest cannot be used (manifest_invalid).""" + + +@dataclass(frozen=True) +class ManifestWorkspace: + workspace_id: str + workspace_path: str + start_step: str + end_step: str + status: str + parameter_patch: dict = field(default_factory=dict) + raw: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class ProjectManifest: + project_dir: str + path: str + project_id: str + name: str + design_name: str + base_design: dict + objectives: dict + workspaces: tuple[ManifestWorkspace, ...] + qor_baseline: dict | None + raw: dict + + def active_workspaces(self) -> list[ManifestWorkspace]: + return [w for w in self.workspaces if w.status != "archived"] + + def find_workspace(self, workspace_id: str) -> ManifestWorkspace | None: + """Match a managed workspace by its declared identifier only.""" + for workspace in self.workspaces: + if workspace.workspace_id == workspace_id: + return workspace + return None + + +def _optional_str(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _record(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> ManifestWorkspace: + source = _record(value) + workspace_id = _optional_str(source.get("workspace_id")) + workspace_path = _optional_str(source.get("workspace_path")) + if not workspace_id or not workspace_path: + raise ManifestError(f"workspaces[{index}] requires workspace_id and workspace_path") + resolved = Path(workspace_path) + if not resolved.is_absolute(): + resolved = Path(project_dir) / resolved + try: + canonical = resolved.resolve() + canonical.relative_to(Path(project_dir).resolve()) + except ValueError: + raise ManifestError( + f"workspaces[{index}] workspace_path escapes the project root: {workspace_path}" + ) from None + except RuntimeError as exc: + # e.g. a symlink loop inside the spelled path: invalid manifest + # input, never a traceback. + raise ManifestError( + f"workspaces[{index}] workspace_path cannot be resolved: {workspace_path}" + ) from exc + status = source.get("status") + if not isinstance(status, str) or status not in _WORKSPACE_STATUSES: + status = "not_started" + start_step = _optional_str(source.get("start_step")) or "Synth" + end_step = _optional_str(source.get("end_step")) or "Harden" + for step_name, field_name in ((start_step, "start_step"), (end_step, "end_step")): + if step_name not in MANIFEST_FLOW_STEPS: + raise ManifestError( + f"workspaces[{index}] {field_name} is not on the canonical flow chain: {step_name}" + ) + if MANIFEST_FLOW_STEPS.index(start_step) > MANIFEST_FLOW_STEPS.index(end_step): + raise ManifestError( + f"workspaces[{index}] flow range is reversed: {start_step} -> {end_step}" + ) + return ManifestWorkspace( + workspace_id=workspace_id, + workspace_path=str(canonical), + start_step=start_step, + end_step=end_step, + status=status, + parameter_patch=_record(source.get("parameter_patch")), + raw=dict(source), + ) + + +def _validate_mpc(value: Any) -> None: + """Mirror the GUI parser's mpc rules: null or a well-formed MPC record.""" + if value is None: + return + source = _record(value) + if not source: + raise ManifestError("invalid project manifest: mpc must be an object or null") + resource_id = _optional_str(source.get("resource_id")) + if not resource_id.startswith("mpc:") or len(resource_id) == 4: + raise ManifestError("invalid project manifest: mpc.resource_id must be an MPC id") + for field_name in ("display_name", "installed_version", "path", "spec_path"): + if not _optional_str(source.get(field_name)): + raise ManifestError(f"invalid project manifest: mpc.{field_name} is required") + mpc_path = source["path"].rstrip("/") + if source["spec_path"] != f"{mpc_path}/spec/spec.json.in": + raise ManifestError( + "invalid project manifest: mpc.spec_path must reference spec/spec.json.in" + ) + design = _record(source.get("design")) + index = design.get("index") + if ( + not design + or isinstance(index, bool) # a JSON boolean is not an index (True == 1 in Python) + or not isinstance(index, (int, float)) + # The GUI accepts integral numbers (0.0). is_integer() applies to + # floats only: float(huge_int) raises OverflowError, and ints are + # integral by construction. + or (isinstance(index, float) and not index.is_integer()) + or index < 0 + or not _optional_str(design.get("design_name")) + ): + raise ManifestError( + "invalid project manifest: mpc.design requires a non-negative index and design_name" + ) + if not isinstance(source.get("core_template"), dict): + raise ManifestError("invalid project manifest: mpc.core_template must be an object") + + +def load_manifest(project_dir: str) -> ProjectManifest: + """Load and tolerantly normalize ``/project.json``. + + Mirrors the GUI parser's contract: schema_version 1 and a workspaces + array are required, everything else is default-filled. Raises + ManifestError on parse failure, root_path mismatch, or a workspace + path outside the project root. + """ + path = os.path.join(project_dir, MANIFEST_FILENAME) + try: + with open(path, encoding="utf-8") as f: + source = json.load(f) + except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ManifestError(f"invalid project manifest: {path}: {exc}") from exc + + if not isinstance(source, dict): + raise ManifestError(f"invalid project manifest: {path}: top level must be an object") + schema_version = source.get("schema_version") + # A JSON boolean is not the schema version (True == 1 in Python); the + # GUI parser rejects it, and the CLI must not open what the GUI cannot. + if isinstance(schema_version, bool) or schema_version != 1: + raise ManifestError("invalid project manifest: schema_version 1 is required") + raw_workspaces = source.get("workspaces") + if not isinstance(raw_workspaces, list): + raise ManifestError("invalid project manifest: workspaces must be an array") + + root_path = _optional_str(source.get("root_path")) + if not root_path: + raise ManifestError("invalid project manifest: root_path is required") + if os.path.realpath(root_path) != os.path.realpath(project_dir): + raise ManifestError( + f"invalid project manifest: root_path {root_path} does not match {project_dir}" + ) + design_name = _optional_str(source.get("design_name")) + if not design_name: + raise ManifestError("invalid project manifest: design_name is required") + + name = _optional_str(source.get("name")) or os.path.basename(project_dir) or "project" + base_design = _record(source.get("base_design")) + base_design = {**base_design, "parameters": _record(base_design.get("parameters"))} + # Mirror the GUI parser: primary defaults to "timing", directions keep + # only maximize/minimize entries from the source (no default fill). + objectives_raw = _record(source.get("objectives")) + objectives = dict(objectives_raw) + objectives["primary"] = _optional_str(objectives_raw.get("primary")) or "timing" + objectives["directions"] = { + key: value + for key, value in _record(objectives_raw.get("directions")).items() + if value in ("maximize", "minimize") + } + qor_baseline_raw = _record(source.get("qor_baseline")) + qor_baseline = None + if _optional_str(qor_baseline_raw.get("workspace_id")): + qor_baseline = { + "workspace_id": qor_baseline_raw["workspace_id"], + "reason": _optional_str(qor_baseline_raw.get("reason")) or "Project QoR baseline", + } + + _validate_mpc(source.get("mpc")) + + return ProjectManifest( + project_dir=project_dir, + path=path, + project_id=_optional_str(source.get("project_id")) or f"proj_{_slugify(name)}", + name=name, + design_name=design_name, + base_design=base_design, + objectives=objectives, + workspaces=tuple( + _normalize_workspace_entry(entry, index, project_dir) + for index, entry in enumerate(raw_workspaces) + ), + qor_baseline=qor_baseline, + raw=source, + ) + + +def find_manifest(project_dir: str) -> str | None: + """The manifest path when the entry lexically exists. + + Lexical presence, not readability: a directory, symlink loop, or + dangling symlink still counts as PRESENT so ``load_manifest`` fails + loud (manifest_invalid) instead of silently demoting the project to + the virgin or legacy layout. + """ + path = os.path.join(project_dir, MANIFEST_FILENAME) + return path if os.path.lexists(path) else None + + +def has_legacy_runs_layout(project_dir: str) -> bool: + runs_dir = os.path.join(project_dir, "runs") + if not os.path.isdir(runs_dir): + return False + try: + return any(os.path.isdir(os.path.join(runs_dir, entry)) for entry in os.listdir(runs_dir)) + except OSError: + return False + + +def classify_project(project_dir: str) -> str: + """Classify a project directory: manifest | legacy | virgin.""" + if find_manifest(project_dir) is not None: + return "manifest" + if has_legacy_runs_layout(project_dir): + return "legacy" + return "virgin" + + +def assemble_config(manifest: ProjectManifest, workspace: ManifestWorkspace | None) -> dict: + """Flatten the manifest into a parameter payload (lowest precedence layer). + + ``base_design.parameters`` plus the workspace's ``parameter_patch`` form + the base layer beneath project ecc.toml and --set overrides. + """ + parameters = dict(manifest.base_design.get("parameters") or {}) + if workspace is not None: + for key, change in workspace.parameter_patch.items(): + parameters[key] = ( + change["to"] if isinstance(change, dict) and "to" in change else change + ) + if manifest.design_name and not _optional_str(parameters.get("design")): + parameters["design"] = manifest.design_name + rtl_list = manifest.base_design.get("rtl_list") + if not isinstance(rtl_list, list): + rtl_list = [] + return { + "pdk": _optional_str(manifest.base_design.get("pdk")), + "pdk_root": _optional_str(manifest.base_design.get("pdk_root")), + "design_name": manifest.design_name, + "top_module": _optional_str(manifest.base_design.get("top_module")), + "clock": _optional_str(manifest.base_design.get("clock")), + "rtl_list": [item for item in rtl_list if isinstance(item, str)], + "origin_verilog": _optional_str(manifest.base_design.get("origin_verilog")), + "origin_def": _optional_str(manifest.base_design.get("origin_def")), + "netlist": _optional_str(manifest.base_design.get("netlist")), + "golden_netlist": _optional_str(manifest.base_design.get("golden_netlist")), + "sdc": _optional_str(manifest.base_design.get("sdc")), + "spef": _optional_str(manifest.base_design.get("spef")), + "parameters": parameters, + } + + +def resolved_base_parameters(cfg) -> dict: + """The ecc.toml-resolved base_design.parameters for a generated manifest. + + GUI-flat vocabulary: identity fields plus the [params] overrides, + projected through the geometry converter so positional values surface + as the wizard's aliases (die_width, utilitization, margin, ...). + --set values are run-scoped and never included. + """ + canonical: dict = { + "design": cfg.design_name, + "top_module": cfg.design_top, + "clock": cfg.design_clock_port, + "frequency_max": cfg.design_frequency_mhz, + } + if cfg.params_overrides: + from chipcompiler.data.parameter_schema import ( + build_backend_overrides, + resolve_parameters, + ) + + resolved, _ = resolve_parameters(toml_overrides=cfg.params_overrides) + canonical.update(build_backend_overrides(resolved)) + + from chipcompiler.data.parameter_keys import parameters_to_geometry + + flat = parameters_to_geometry(canonical) + # Exclusive GUI-flat shape: geometry lives only in the aliases — + # the canonical die/core subtrees are consumed, not duplicated. + # Non-positional members (e.g. aspect_ratio) hoist to flat top-level + # keys; positional members are covered by the aliases. + for subtree_name in ("die", "core"): + subtree = flat.pop(subtree_name, None) + if not isinstance(subtree, dict): + continue + for member, value in subtree.items(): + if member in ("size", "utilitization", "margin"): + continue + flat.setdefault(member, value) + return flat + + +def base_design_from_config(cfg, pdk_root: str) -> dict: + """The base_design document for a generated manifest. + + Identity and sources come from the ecc.toml-resolved config with the + DECLARED project source spellings preserved: ``rtl_list`` verbatim, + and ``origin_verilog`` when the single source is plain RTL (empty for + a filelist source; the document builder drops empty keys). Parameters + are the GUI-flat projection. Shared by virgin generation and first + migration so the two writers cannot drift. + """ + from chipcompiler.utility.filelist import FILELIST_SUFFIXES + + first_rtl = cfg.design_rtl[0] if cfg.design_rtl else "" + suffix = os.path.splitext(first_rtl)[1].lower() + origin_verilog = first_rtl if first_rtl and suffix not in FILELIST_SUFFIXES else "" + return { + "pdk": cfg.pdk_name, + "pdk_root": pdk_root, + "top_module": cfg.design_top, + "clock": cfg.design_clock_port, + "rtl_list": cfg.design_rtl, + "origin_verilog": cfg.design_rtl[0] if origin_verilog else "", + "origin_def": cfg.design_def, + "netlist": cfg.design_netlist, + "golden_netlist": cfg.design_golden_netlist, + "sdc": cfg.design_sdc, + "spef": cfg.design_spef, + "parameters": resolved_base_parameters(cfg), + } + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_") + return slug or "project" diff --git a/chipcompiler/project/manifest_write.py b/chipcompiler/project/manifest_write.py new file mode 100644 index 000000000..cf1364d03 --- /dev/null +++ b/chipcompiler/project/manifest_write.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python + +"""Manifest write, mutation, and registration operations for the CLI. + +All ``project.json`` writes go through one read-modify-write helper so +status write-back and migration entry-append share the same atomicity +story. Loading and normalization live in +chipcompiler.project.manifest; this module imports from it, never +the reverse. + +Like manifest.py, this module sits on the CLI startup path (imported by +run dispatch and migration flows): keep module-level imports cheap — no +chipcompiler.data imports here. +""" + +import json +import logging +import os +import tempfile +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from chipcompiler.project.manifest import ( + _CANONICAL_TO_MANIFEST_STEP, + MANIFEST_FILENAME, + PRESET_MANIFEST_RANGE, + ManifestError, + _record, + _slugify, + base_design_from_config, +) + +logger = logging.getLogger(__name__) + +DEFAULT_OBJECTIVES = { + "primary": "timing", + "directions": { + "wns": "maximize", + "tns": "maximize", + "area": "minimize", + "drc_count": "minimize", + "lvs_count": "minimize", + "power": "minimize", + }, +} + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def manifest_workspace_entry( + workspace_id: str, + *, + name: str, + workspace_path: str, + start_step: str, + end_step: str, + status: str, + now: str, +) -> dict: + """One complete schema-v1 workspaces[] entry, every field materialized. + + The single builder for generated manifests and migration previews, so + the previewed entry and the applied entry are the same object shape. + """ + return { + "workspace_id": workspace_id, + "name": name, + "workspace_path": workspace_path, + "source_workspace_id": None, + "branch_from": None, + "start_step": start_step, + "end_step": end_step, + "status": status, + "created_at": now, + "updated_at": now, + "parameter_patch": {}, + "metrics_summary": {}, + "step_metrics": {}, + } + + +def build_manifest_document( + project_dir: str, + *, + design_name: str, + base_design: dict, + workspace_id: str, + workspace_path: str, + start_step: str, + end_step: str, + status: str = "running", +) -> dict: + """Assemble a schema-v1 manifest for a virgin project's first run.""" + now = _now_iso() + document = build_project_document( + project_dir, + design_name=design_name, + base_design=base_design, + now=now, + ) + document["workspaces"] = [ + manifest_workspace_entry( + workspace_id, + name=design_name, + workspace_path=workspace_path, + start_step=start_step, + end_step=end_step, + status=status, + now=now, + ) + ] + document["qor_baseline"] = { + "workspace_id": workspace_id, + "reason": "Default project QoR baseline", + } + return document + + +def build_project_document( + project_dir: str, + *, + design_name: str, + base_design: dict, + name: str | None = None, + now: str | None = None, + mpc: dict | None = None, +) -> dict[str, Any]: + """Assemble a schema-v1 Project Manifest without a Workspace.""" + timestamp = now or _now_iso() + project_name = name or os.path.basename(os.path.normpath(project_dir)) or "project" + return { + "schema_version": 1, + "project_id": f"proj_{_slugify(project_name)}", + "name": project_name, + "design_name": design_name, + "description": "", + "root_path": project_dir, + "created_at": timestamp, + "updated_at": timestamp, + "base_design": { + **{key: value for key, value in base_design.items() if key != "parameters" and value}, + "parameters": _record(base_design.get("parameters")), + "rtl_list": [ + item for item in base_design.get("rtl_list") or [] if isinstance(item, str) + ], + }, + "objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)), + "workspaces": [], + "mpc": deepcopy(mpc), + "best_workspace": None, + "qor_baseline": None, + } + + +def write_manifest_if_absent(project_dir: str, document: dict) -> bool: + """Write the manifest only when it does not exist (virgin generation race). + + Fully written and fsynced at a temp path, then linked into place: + readers never see a partial file, and a concurrent creator wins the + link — ours is discarded and the caller continues read-only. + """ + path = os.path.join(project_dir, MANIFEST_FILENAME) + content = json.dumps(document, indent=2) + "\n" + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + dir=project_dir, + delete=False, + prefix=f".{MANIFEST_FILENAME}.", + suffix=".tmp", + encoding="utf-8", + ) as f: + tmp_path = f.name + f.write(content) + f.flush() + os.fsync(f.fileno()) + # Mode stays the tempfile default (0600), matching json_write's + # convention for newly created state files. + os.link(tmp_path, path) + return True + except FileExistsError: + return False + except OSError as exc: + logger.warning("manifest write failed: %s: %s", path, exc) + return False + finally: + if tmp_path is not None: + Path(tmp_path).unlink(missing_ok=True) + + +def _read_manifest_document(path: str): + try: + with open(path, encoding="utf-8") as f: + document = json.load(f) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return None + return document if isinstance(document, dict) else None + + +def update_manifest(project_dir: str, mutator) -> bool: + """Read-modify-write the manifest atomically (locked re-read + patch + replace). + + The whole read-modify-replace runs under ``.manifest.lock`` (flock): + two cooperating writers can no longer both complete the fresh read + before either replaces, so neither loses the other's update. The + mutator receives the parsed document and edits it in place. When an + unrelated change lands between the read and the write, the mutator is + re-applied to the freshest document instead of overwriting the change. + Project-level fields (including updated_at) are owned by the mutator. + Returns False (with a warning) when the manifest is missing, unreadable, + the lock cannot be taken, or the write fails — callers degrade to a + warning, never a run failure. + """ + from chipcompiler.project.locking import flock_file + + path = os.path.join(project_dir, MANIFEST_FILENAME) + try: + with flock_file(os.path.join(project_dir, ".manifest.lock"), exclusive=True): + return _update_manifest_locked(path, mutator) + except OSError as exc: + # An untakeable lock (e.g. a directory at the lock path) degrades + # like any write failure: a warning, never an uncaught exception — + # the migration registration path relies on False to roll back. + logger.warning("manifest update failed: %s: %s", path, exc) + return False + + +def _update_manifest_locked(path: str, mutator) -> bool: + base = _read_manifest_document(path) + if base is None: + logger.warning("manifest update skipped (unreadable): %s", path) + return False + + document = deepcopy(base) + mutator(document) + + fresh = _read_manifest_document(path) + if fresh is not None and fresh != base: + # An unrelated edit landed after our read: re-apply the mutator to + # the freshest document so the interleaved change survives. + document = fresh + mutator(document) + + target = Path(path) + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + dir=target.parent, + delete=False, + prefix=f".{target.name}.", + suffix=".tmp", + encoding="utf-8", + ) as f: + tmp_path = Path(f.name) + json.dump(document, f, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + # Preserve the existing manifest's permissions: mkstemp's 0600 must + # not silently narrow a shared project.json. + if target.exists(): + os.chmod(tmp_path, target.stat().st_mode & 0o7777) + os.replace(tmp_path, target) + return True + except OSError as exc: + logger.warning("manifest update failed: %s: %s", path, exc) + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + return False + + +def write_back_workspace_status(project_dir: str, workspace_id: str, status: str) -> bool: + """Update one workspace entry's status (and updated_at) after a run.""" + + def mutate(document: dict) -> None: + for entry in document.get("workspaces", []): + if isinstance(entry, dict) and entry.get("workspace_id") == workspace_id: + entry["status"] = status + entry["updated_at"] = _now_iso() + + return update_manifest(project_dir, mutate) + + +def remove_workspace_registration(project_dir: str, workspace_id: str) -> bool: + """Roll back a pre-registration: drop the freshly added entry. + + Used when an overwrite run against an undeclared workspace fails before + the replacement is constructed: the restored previous workspace must not + be shadowed by a stale ``not_started`` entry this invocation created. + """ + + def mutate(document: dict) -> None: + workspaces = document.get("workspaces") + if isinstance(workspaces, list): + document["workspaces"] = [ + entry + for entry in workspaces + if not (isinstance(entry, dict) and entry.get("workspace_id") == workspace_id) + ] + document["updated_at"] = _now_iso() + + return update_manifest(project_dir, mutate) + + +def manifest_range_for_flow(cfg, flow_config: dict | None) -> tuple[str, str]: + """Return the GUI manifest range for a workspace's effective target.""" + if isinstance(flow_config, dict) and flow_config.get("start_step"): + from chipcompiler.rtl2gds import normalize_flow_step + + start = normalize_flow_step(flow_config["start_step"]) + end = normalize_flow_step(flow_config.get("end_step") or start) + try: + return (_CANONICAL_TO_MANIFEST_STEP[start], _CANONICAL_TO_MANIFEST_STEP[end]) + except KeyError as exc: + raise ManifestError(f"unknown workspace flow step: {exc.args[0]}") from None + return PRESET_MANIFEST_RANGE.get(cfg.flow_preset, ("Synth", "Harden")) + + +def pre_register_workspace( + project_dir: str, + *, + cfg, + pdk_root: str, + workspace_id: str, + workspace_path: str, + flow_config: dict | None, +) -> str: + """Atomically register a fresh managed workspace before filesystem creation. + + Returns ``registered``, ``existing``, ``conflict``, or ``failed``. A + workspace entry intentionally contains no input snapshot: copied files and + the workspace config are the reproducibility boundary. + """ + try: + start_step, end_step = manifest_range_for_flow(cfg, flow_config) + except ManifestError: + return "failed" + now = _now_iso() + manifest_path = os.path.join(project_dir, MANIFEST_FILENAME) + if not os.path.lexists(manifest_path): + document = build_manifest_document( + project_dir, + design_name=cfg.design_name, + base_design=base_design_from_config(cfg, pdk_root), + workspace_id=workspace_id, + workspace_path=workspace_path, + start_step=start_step, + end_step=end_step, + status="not_started", + ) + if write_manifest_if_absent(project_dir, document): + return "registered" + # A concurrent creator won the link race: fall through and apply the + # same registration mutation under the manifest lock instead of + # aborting this run. A genuine I/O failure fails again below. + + outcome = "registered" + + def mutate(document: dict) -> None: + nonlocal outcome + workspaces = document.get("workspaces") + if not isinstance(workspaces, list): + outcome = "failed" + return + for entry in workspaces: + if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id: + continue + if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath( + workspace_path + ): + outcome = "existing" + else: + outcome = "conflict" + return + workspaces.append( + manifest_workspace_entry( + workspace_id, + name=cfg.design_name, + workspace_path=workspace_path, + start_step=start_step, + end_step=end_step, + status="not_started", + now=now, + ) + ) + document["updated_at"] = now + + if not update_manifest(project_dir, mutate): + return "failed" + return outcome diff --git a/chipcompiler/runtime/__init__.py b/chipcompiler/runtime/__init__.py deleted file mode 100644 index 6777b640b..000000000 --- a/chipcompiler/runtime/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Private ECC runtime support for long-lived sidecar sessions.""" diff --git a/chipcompiler/runtime/events.py b/chipcompiler/runtime/events.py deleted file mode 100644 index aa59bba27..000000000 --- a/chipcompiler/runtime/events.py +++ /dev/null @@ -1,68 +0,0 @@ -import os -import sys -from contextlib import contextmanager, suppress - -from chipcompiler.utility.log import stdio_redirect_lock - - -@contextmanager -def redirect_stdout_to_stderr(): - acquired = stdio_redirect_lock.acquire(blocking=False) - if not acquired: - # A tool already owns fd 1/2. Its protocol writer uses a duplicated fd, - # so the RPC can proceed without installing a competing redirect. - yield - return - try: - with _redirect_stdout_to_stderr(): - yield - finally: - stdio_redirect_lock.release() - - -@contextmanager -def _redirect_stdout_to_stderr(): - saved_stdout = sys.stdout - saved_stderr = sys.stderr - saved_stdout_fd = None - saved_stderr_fd = None - - with suppress(Exception): - sys.stdout.flush() - with suppress(Exception): - sys.stderr.flush() - - try: - saved_stdout_fd = os.dup(1) - saved_stderr_fd = os.dup(2) - os.dup2(2, 1) - sys.stdout = sys.stderr - except OSError: - with suppress(Exception): - if saved_stdout_fd is not None: - os.close(saved_stdout_fd) - if saved_stderr_fd is not None: - os.close(saved_stderr_fd) - sys.stdout = sys.stderr - try: - yield - finally: - sys.stdout = saved_stdout - sys.stderr = saved_stderr - return - - try: - yield - finally: - with suppress(Exception): - sys.stdout.flush() - with suppress(Exception): - sys.stderr.flush() - try: - os.dup2(saved_stdout_fd, 1) - os.dup2(saved_stderr_fd, 2) - finally: - os.close(saved_stdout_fd) - os.close(saved_stderr_fd) - sys.stdout = saved_stdout - sys.stderr = saved_stderr diff --git a/chipcompiler/runtime/methods.py b/chipcompiler/runtime/methods.py deleted file mode 100644 index f17f4e507..000000000 --- a/chipcompiler/runtime/methods.py +++ /dev/null @@ -1,213 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Final, Generic, TypeVar - -from chipcompiler.runtime.requests import ( - DbEnsureRequest, - DbReleaseRequest, - FloorplanEditInspectRequest, - FloorplanEditRunAutoRequest, - FloorplanEditValidateRequest, - FlowRunRequest, - FlowRunStepRequest, - LayoutEditApplyRequest, - LayoutEditBeginRequest, - LayoutEditDiscardRequest, - LayoutEditSaveRequest, - OperationAckStepRenderedRequest, - OperationIdRequest, - OperationStartFlowRequest, - OperationStartStepRequest, - WorkspaceCloseRequest, - WorkspaceCreateRequest, - WorkspaceExportSignoffRequest, - WorkspaceIdRequest, - WorkspaceInfoRequest, - WorkspaceInspectSignoffRequest, - WorkspaceOpenRequest, - WorkspaceRecoverInterruptedRequest, - WorkspaceSyncConfigRequest, -) - -RequestT = TypeVar("RequestT") - - -@dataclass(frozen=True) -class RuntimeMethodSpec(Generic[RequestT]): - method_name: str - request_model: type[RequestT] - handler_name: str - - -RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( - RuntimeMethodSpec( - method_name="workspace.create", - request_model=WorkspaceCreateRequest, - handler_name="create_workspace", - ), - RuntimeMethodSpec( - method_name="workspace.open", - request_model=WorkspaceOpenRequest, - handler_name="open_workspace", - ), - RuntimeMethodSpec( - method_name="workspace.close", - request_model=WorkspaceCloseRequest, - handler_name="close_workspace", - ), - RuntimeMethodSpec( - method_name="workspace.home", - request_model=WorkspaceIdRequest, - handler_name="workspace_home", - ), - RuntimeMethodSpec( - method_name="workspace.info", - request_model=WorkspaceInfoRequest, - handler_name="workspace_info", - ), - RuntimeMethodSpec( - method_name="workspace.refresh_config", - request_model=WorkspaceIdRequest, - handler_name="refresh_config", - ), - RuntimeMethodSpec( - method_name="workspace.sync_config", - request_model=WorkspaceSyncConfigRequest, - handler_name="sync_config", - ), - RuntimeMethodSpec( - method_name="workspace.reset_flow", - request_model=WorkspaceIdRequest, - handler_name="reset_flow", - ), - RuntimeMethodSpec( - method_name="workspace.export_signoff", - request_model=WorkspaceExportSignoffRequest, - handler_name="export_signoff", - ), - RuntimeMethodSpec( - method_name="workspace.inspect_signoff", - request_model=WorkspaceInspectSignoffRequest, - handler_name="inspect_signoff", - ), - RuntimeMethodSpec( - method_name="flow.run", - request_model=FlowRunRequest, - handler_name="flow_run", - ), - RuntimeMethodSpec( - method_name="flow.run_step", - request_model=FlowRunStepRequest, - handler_name="flow_run_step", - ), - RuntimeMethodSpec( - method_name="operation.start_flow", - request_model=OperationStartFlowRequest, - handler_name="start_flow_operation", - ), - RuntimeMethodSpec( - method_name="operation.start_step", - request_model=OperationStartStepRequest, - handler_name="start_step_operation", - ), - RuntimeMethodSpec( - method_name="operation.status", - request_model=OperationIdRequest, - handler_name="operation_status", - ), - RuntimeMethodSpec( - method_name="operation.cancel", - request_model=OperationIdRequest, - handler_name="cancel_operation", - ), - RuntimeMethodSpec( - method_name="operation.ack_step_rendered", - request_model=OperationAckStepRenderedRequest, - handler_name="acknowledge_step_rendered", - ), - RuntimeMethodSpec( - method_name="workspace.snapshot", - request_model=WorkspaceIdRequest, - handler_name="workspace_snapshot", - ), - RuntimeMethodSpec( - method_name="workspace.recover_interrupted", - request_model=WorkspaceRecoverInterruptedRequest, - handler_name="recover_interrupted", - ), -) - - -PERSISTENT_DB_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( - RuntimeMethodSpec( - method_name="db.ensure", - request_model=DbEnsureRequest, - handler_name="db_ensure", - ), - RuntimeMethodSpec( - method_name="db.release", - request_model=DbReleaseRequest, - handler_name="db_release", - ), - RuntimeMethodSpec( - method_name="layout.edit.begin", - request_model=LayoutEditBeginRequest, - handler_name="layout_edit_begin", - ), - RuntimeMethodSpec( - method_name="layout.edit.apply", - request_model=LayoutEditApplyRequest, - handler_name="layout_edit_apply", - ), - RuntimeMethodSpec( - method_name="layout.edit.save", - request_model=LayoutEditSaveRequest, - handler_name="layout_edit_save", - ), - RuntimeMethodSpec( - method_name="layout.edit.discard", - request_model=LayoutEditDiscardRequest, - handler_name="layout_edit_discard", - ), - RuntimeMethodSpec( - method_name="floorplan.edit.inspect", - request_model=FloorplanEditInspectRequest, - handler_name="floorplan_edit_inspect", - ), - RuntimeMethodSpec( - method_name="floorplan.edit.run_auto", - request_model=FloorplanEditRunAutoRequest, - handler_name="floorplan_edit_run_auto", - ), - RuntimeMethodSpec( - method_name="floorplan.edit.validate", - request_model=FloorplanEditValidateRequest, - handler_name="floorplan_edit_validate", - ), -) - - -def runtime_methods(*, persistent_db_enabled: bool = False) -> tuple[RuntimeMethodSpec[Any], ...]: - if persistent_db_enabled: - return (*RUNTIME_METHODS, *PERSISTENT_DB_METHODS) - return RUNTIME_METHODS - - -def runtime_method_names(*, persistent_db_enabled: bool = False) -> tuple[str, ...]: - return tuple( - spec.method_name for spec in runtime_methods(persistent_db_enabled=persistent_db_enabled) - ) - - -def persistent_db_method_names() -> tuple[str, ...]: - return tuple(spec.method_name for spec in PERSISTENT_DB_METHODS) - - -def runtime_method_by_name( - method_name: str, - *, - persistent_db_enabled: bool = False, -) -> RuntimeMethodSpec[Any] | None: - for spec in runtime_methods(persistent_db_enabled=persistent_db_enabled): - if spec.method_name == method_name: - return spec - return None diff --git a/chipcompiler/runtime/operations.py b/chipcompiler/runtime/operations.py deleted file mode 100644 index eed9292a2..000000000 --- a/chipcompiler/runtime/operations.py +++ /dev/null @@ -1,763 +0,0 @@ -from __future__ import annotations - -import threading -import time -from collections.abc import Callable -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any -from uuid import uuid4 - -_LOG_POLL_INTERVAL_SECONDS = 0.25 -_MAX_LOG_CHUNK_BYTES = 16 * 1024 -_MAX_FINAL_LOG_BYTES = 64 * 1024 -_RENDER_ACK_RETRY_SECONDS = 5.0 -_RENDER_ACK_PAUSE_SECONDS = 30.0 -_RENDER_ACK_ABORT_SECONDS = 300.0 -_TERMINAL_OPERATION_STATES = frozenset({"succeeded", "failed", "cancelled"}) - - -@dataclass -class _StepLogTail: - """A bounded worker-side reader for one active step log.""" - - operation_id: str - path: Path - step: str - tool: str - cursor: int - stopped: threading.Event = field(default_factory=threading.Event) - thread: threading.Thread | None = None - - -class RuntimeOperationConflict(RuntimeError): - """A workspace already owns a non-terminal runtime operation.""" - - -class RuntimeOperationCancelled(RuntimeError): - """Cancellation was accepted at a safe step boundary.""" - - -@dataclass -class RuntimeOperation: - operation_id: str - run_session_id: str - runtime_instance_id: str - workspace_id: str - kind: str - origin: str - rerun: bool - step: str = "" - idempotency_key: str = "" - state: str = "queued" - current_step: str = "" - current_tool: str = "" - error: dict[str, Any] | None = None - result: dict[str, Any] | None = None - created_at: float = field(default_factory=time.time) - updated_at: float = field(default_factory=time.time) - sequence: int = 0 - awaiting_event_id: str | None = None - awaiting_event: dict[str, Any] | None = None - awaiting_step_commit_id: str | None = None - workspace_revision: int = 0 - render_sync_state: str = "idle" - render_retry_count: int = 0 - render_wait_started_at: float | None = None - last_render_ack_at: float | None = None - render_sync_degraded: bool = False - acked_event_ids: set[str] = field(default_factory=set) - cancel_requested: bool = False - interruptibility: str = "deferred" - - -class RuntimeOperationManager: - """Owns asynchronous GUI operations and their exactly-once event stream.""" - - def __init__(self, publisher: Callable[[dict[str, Any]], None] | None = None): - self._publisher = publisher - self._lock = threading.RLock() - self._render_gate = threading.Condition(self._lock) - self._operations: dict[str, RuntimeOperation] = {} - self._active_by_workspace: dict[str, str] = {} - self._idempotency: dict[tuple[str, str], str] = {} - self._step_log_tails: dict[str, _StepLogTail] = {} - self._runtime_instance_id = uuid4().hex - self._workspace_sequences: dict[str, int] = {} - - def set_publisher(self, publisher: Callable[[dict[str, Any]], None] | None) -> None: - with self._lock: - self._publisher = publisher - - def start( - self, - *, - workspace_id: str, - kind: str, - origin: str, - rerun: bool, - step: str, - idempotency_key: str, - runner: Callable[[RuntimeFlowObserver], dict[str, Any]], - ) -> dict[str, Any]: - with self._lock: - if idempotency_key: - known_id = self._idempotency.get((workspace_id, idempotency_key)) - if known_id is not None: - return { - **self._operation_payload(self._operations[known_id]), - "deduplicated": True, - } - - active_id = self._active_by_workspace.get(workspace_id) - if active_id is not None: - active = self._operations[active_id] - raise RuntimeOperationConflict( - f"workspace already has an active operation: {active.operation_id}" - ) - - operation = RuntimeOperation( - operation_id=f"operation-{uuid4().hex}", - run_session_id=uuid4().hex, - runtime_instance_id=self._runtime_instance_id, - workspace_id=workspace_id, - kind=kind, - origin=origin, - rerun=rerun, - step=step, - idempotency_key=idempotency_key, - ) - self._operations[operation.operation_id] = operation - self._active_by_workspace[workspace_id] = operation.operation_id - if idempotency_key: - self._idempotency[(workspace_id, idempotency_key)] = operation.operation_id - queued_event = self._new_event_locked(operation, "operation.queued", {}) - - self._publish(queued_event) - thread = threading.Thread( - target=self._run, - args=(operation.operation_id, runner), - name=f"ecc-runtime-{operation.operation_id}", - daemon=True, - ) - thread.start() - return self.operation_status(operation.operation_id) - - def operation_status(self, operation_id: str) -> dict[str, Any]: - with self._lock: - operation = self._operations.get(operation_id) - if operation is None: - raise KeyError(operation_id) - return self._operation_payload(operation) - - def is_active(self, operation_id: str) -> bool: - with self._lock: - operation = self._operations.get(operation_id) - return operation is not None and operation.state not in _TERMINAL_OPERATION_STATES - - def workspace_snapshot(self, workspace_id: str) -> dict[str, Any]: - with self._lock: - operations = [ - self._operation_payload(operation) - for operation in self._operations.values() - if operation.workspace_id == workspace_id - ] - return { - "workspaceId": workspace_id, - "runtimeInstanceId": self._runtime_instance_id, - "lastEventId": ( - f"{self._runtime_instance_id}:{workspace_id}:" - f"{self._workspace_sequences.get(workspace_id, 0)}" - ), - "operations": operations, - } - - def acknowledge_step_rendered( - self, - operation_id: str, - event_id: str, - step_commit_id: str = "", - workspace_revision: int | None = None, - ) -> dict[str, Any]: - with self._render_gate: - operation = self._operations.get(operation_id) - if operation is None: - raise KeyError(operation_id) - if event_id in operation.acked_event_ids: - return { - "accepted": True, - "duplicate": True, - "operationId": operation_id, - "eventId": event_id, - } - if operation.awaiting_event_id != event_id: - return { - "accepted": False, - "duplicate": False, - "operationId": operation_id, - "eventId": event_id, - } - if step_commit_id and operation.awaiting_step_commit_id != step_commit_id: - return { - "accepted": False, - "duplicate": False, - "operationId": operation_id, - "eventId": event_id, - } - if ( - workspace_revision is not None - and workspace_revision != operation.workspace_revision - ): - return { - "accepted": False, - "duplicate": False, - "operationId": operation_id, - "eventId": event_id, - } - operation.acked_event_ids.add(event_id) - operation.awaiting_event_id = None - operation.awaiting_event = None - operation.awaiting_step_commit_id = None - operation.render_sync_state = "idle" - operation.render_wait_started_at = None - operation.last_render_ack_at = time.time() - operation.updated_at = time.time() - self._render_gate.notify_all() - return { - "accepted": True, - "duplicate": False, - "operationId": operation_id, - "eventId": event_id, - } - - def request_cancel(self, operation_id: str) -> dict[str, Any]: - with self._render_gate: - operation = self._operations.get(operation_id) - if operation is None: - raise KeyError(operation_id) - if operation.state in _TERMINAL_OPERATION_STATES: - return {"accepted": False, "operationId": operation_id, "state": operation.state} - operation.cancel_requested = True - operation.updated_at = time.time() - event = self._new_event_locked(operation, "operation.cancel_requested", {}) - self._render_gate.notify_all() - self._publish(event) - return {"accepted": True, "operationId": operation_id, "state": operation.state} - - def shutdown_barrier(self) -> dict[str, Any] | None: - with self._lock: - for operation_id in self._active_by_workspace.values(): - operation = self._operations[operation_id] - return { - "operationId": operation.operation_id, - "workspaceId": operation.workspace_id, - "state": operation.state, - "step": operation.current_step, - "interruptibility": operation.interruptibility, - "safeToStop": bool(operation.awaiting_event_id), - "cancelRequested": operation.cancel_requested, - } - return None - - def _run( - self, - operation_id: str, - runner: Callable[[RuntimeFlowObserver], dict[str, Any]], - ) -> None: - with self._lock: - operation = self._operations[operation_id] - operation.state = "running" - operation.updated_at = time.time() - started_event = self._new_event_locked(operation, "operation.started", {}) - observer = RuntimeFlowObserver(self, operation_id) - try: - self._publish(started_event) - try: - result = runner(observer) - with self._lock: - operation = self._operations[operation_id] - if operation.cancel_requested: - raise RuntimeOperationCancelled("operation cancelled at a step boundary") - operation.state = "succeeded" - operation.result = result - operation.updated_at = time.time() - event = self._new_event_locked( - operation, - "operation.completed", - {"result": result}, - ) - except RuntimeOperationCancelled as exc: - with self._lock: - operation = self._operations[operation_id] - if operation.error is not None: - operation.state = "failed" - event_type = "operation.failed" - else: - operation.state = "cancelled" - operation.error = { - "message": str(exc), - "code": "cancelled", - } - event_type = "operation.cancelled" - operation.updated_at = time.time() - event = self._new_event_locked( - operation, - event_type, - {"error": operation.error}, - ) - except Exception as exc: - with self._lock: - operation = self._operations[operation_id] - if operation.cancel_requested and operation.error is None: - operation.state = "cancelled" - operation.error = {"message": str(exc), "code": "cancelled"} - event_type = "operation.cancelled" - else: - operation.state = "failed" - operation.error = operation.error or { - "message": str(exc), - "code": "command_failed", - } - event_type = "operation.failed" - operation.updated_at = time.time() - payload = {"error": operation.error} - if operation.error: - payload.update( - { - key: operation.error[key] - for key in ("step", "tool", "logFile") - if key in operation.error - } - ) - event = self._new_event_locked(operation, event_type, payload) - self._publish(event) - finally: - try: - self._stop_step_log_tail(operation_id) - finally: - with self._lock: - self._active_by_workspace.pop( - self._operations[operation_id].workspace_id, - None, - ) - - def step_started(self, operation_id: str, workspace_step: Any) -> None: - self._stop_step_log_tail(operation_id) - with self._lock: - operation = self._operations[operation_id] - operation.current_step = str(getattr(workspace_step, "name", "")) - operation.current_tool = str(getattr(workspace_step, "tool", "")) - operation.updated_at = time.time() - event = self._new_event_locked( - operation, - "step.started", - { - "step": operation.current_step, - "tool": operation.current_tool, - "state": "Ongoing", - }, - ) - log_tail = _step_log_tail_for( - operation_id, - getattr(workspace_step, "log", None), - operation.current_step, - operation.current_tool, - ) - if log_tail is not None: - self._step_log_tails[operation_id] = log_tail - self._publish(event) - if log_tail is not None: - thread = threading.Thread( - target=self._tail_step_log, - args=(log_tail,), - name=f"ecc-runtime-log-{operation_id}", - daemon=True, - ) - log_tail.thread = thread - thread.start() - - def rerun_prepared( - self, - operation_id: str, - *, - affected_steps: list[str], - scope: str, - target_step: str = "", - ) -> None: - """Publish the idempotent GUI reset boundary before a rerun starts.""" - with self._lock: - operation = self._operations[operation_id] - operation.updated_at = time.time() - event = self._new_event_locked( - operation, - "operation.rerun_prepared", - { - "affectedSteps": affected_steps, - "scope": scope, - "targetStep": target_step, - }, - ) - self._publish(event) - - def step_completed( - self, - operation_id: str, - workspace_step: Any, - state: Any, - error: str | None = None, - ) -> None: - self._stop_step_log_tail(operation_id) - state_value = str(getattr(state, "value", state)) - final_log = _read_final_log(getattr(workspace_step, "log", None)) - with self._render_gate: - operation = self._operations[operation_id] - operation.current_step = str(getattr(workspace_step, "name", "")) - operation.current_tool = str(getattr(workspace_step, "tool", "")) - operation.updated_at = time.time() - payload: dict[str, Any] = { - "finalLog": final_log, - "step": operation.current_step, - "tool": operation.current_tool, - "state": state_value, - } - if error: - log_file = str(getattr(getattr(workspace_step, "log", None), "file", "") or "") - operation.error = { - "code": "tool_failed", - "message": error, - "step": operation.current_step, - "tool": operation.current_tool, - "logFile": log_file, - } - payload["error"] = operation.error - payload["logFile"] = log_file - event = self._new_event_locked(operation, "step.completed", payload) - if state_value == "Success": - operation.workspace_revision += 1 - step_commit_id = f"{operation.operation_id}:step:{operation.workspace_revision}" - payload["stepCommitId"] = step_commit_id - payload["workspaceRevision"] = operation.workspace_revision - if not operation.render_sync_degraded: - operation.awaiting_event_id = event["eventId"] - operation.awaiting_event = event - operation.awaiting_step_commit_id = step_commit_id - operation.render_sync_state = "waiting_for_gui_sync" - operation.render_retry_count = 0 - operation.render_wait_started_at = time.monotonic() - operation.state = "waiting_for_gui_sync" - self._publish(event) - - def subflow_stage( - self, - operation_id: str, - workspace_step: Any, - subflow_step: dict[str, Any], - ) -> None: - """Publish a saved inner-flow state without waiting for a render ACK.""" - with self._lock: - operation = self._operations[operation_id] - step = str(getattr(workspace_step, "name", "")) - tool = str(getattr(workspace_step, "tool", "")) - event = self._new_event_locked( - operation, - "subflow.stage", - { - "peakMemory": subflow_step.get("peak memory (mb)", 0), - "runtime": str(subflow_step.get("runtime", "")), - "state": str(subflow_step.get("state", "Unstart")), - "step": step, - "subflowStep": str(subflow_step.get("name", "")), - "tool": tool, - }, - ) - self._publish(event) - - def step_skipped(self, operation_id: str, workspace_step: Any) -> None: - self._stop_step_log_tail(operation_id) - with self._lock: - operation = self._operations[operation_id] - operation.current_step = str(getattr(workspace_step, "name", "")) - operation.current_tool = str(getattr(workspace_step, "tool", "")) - operation.updated_at = time.time() - event = self._new_event_locked( - operation, - "step.completed", - { - "step": operation.current_step, - "tool": operation.current_tool, - "state": "Skipped", - }, - ) - self._publish(event) - - def wait_for_step_rendered(self, operation_id: str) -> bool: - while True: - degraded_event: dict[str, Any] | None = None - replay_event: dict[str, Any] | None = None - pause_event: dict[str, Any] | None = None - with self._render_gate: - operation = self._operations[operation_id] - if operation.cancel_requested: - return False - if not operation.awaiting_event_id: - if operation.state in { - "waiting_for_gui_sync", - "paused_for_gui_recovery", - }: - operation.state = "running" - operation.updated_at = time.time() - return True - - started_at = operation.render_wait_started_at or time.monotonic() - elapsed = time.monotonic() - started_at - if elapsed >= _RENDER_ACK_ABORT_SECONDS: - awaiting_event_id = operation.awaiting_event_id - awaiting_step_commit_id = operation.awaiting_step_commit_id - operation.awaiting_event_id = None - operation.awaiting_event = None - operation.awaiting_step_commit_id = None - operation.render_sync_state = "gui_sync_degraded" - operation.render_sync_degraded = True - operation.state = "running" - operation.updated_at = time.time() - degraded_event = self._new_event_locked( - operation, - "operation.gui_sync_degraded", - { - "eventId": awaiting_event_id, - "stepCommitId": awaiting_step_commit_id, - "workspaceRevision": operation.workspace_revision, - }, - ) - elif ( - elapsed >= _RENDER_ACK_PAUSE_SECONDS - and operation.render_sync_state != "paused_for_gui_recovery" - ): - operation.render_sync_state = "paused_for_gui_recovery" - operation.state = "paused_for_gui_recovery" - operation.updated_at = time.time() - pause_event = self._new_event_locked( - operation, - "operation.gui_sync_paused", - { - "eventId": operation.awaiting_event_id, - "stepCommitId": operation.awaiting_step_commit_id, - "workspaceRevision": operation.workspace_revision, - }, - ) - - if degraded_event is None: - operation.render_retry_count += 1 - if operation.awaiting_event is not None: - replay_event = { - **operation.awaiting_event, - "payload": { - **operation.awaiting_event["payload"], - "replayed": True, - "retryCount": operation.render_retry_count, - }, - } - self._render_gate.wait(timeout=_RENDER_ACK_RETRY_SECONDS) - - if degraded_event is not None: - self._publish(degraded_event) - return True - if pause_event is not None: - self._publish(pause_event) - if replay_event is not None: - self._publish(replay_event) - - def _tail_step_log(self, log_tail: _StepLogTail) -> None: - while not log_tail.stopped.is_set(): - self._publish_step_log_delta(log_tail) - log_tail.stopped.wait(_LOG_POLL_INTERVAL_SECONDS) - - def _publish_step_log_delta(self, log_tail: _StepLogTail) -> None: - try: - size = log_tail.path.stat().st_size - if size < log_tail.cursor: - # A rerun may truncate or replace a log file. The renderer treats - # this as a new bounded stream for the same step attempt. - log_tail.cursor = 0 - if size <= log_tail.cursor: - return - with log_tail.path.open("rb") as log_file: - log_file.seek(log_tail.cursor) - chunk = log_file.read(_MAX_LOG_CHUNK_BYTES) - except OSError: - return - - if not chunk: - return - log_tail.cursor += len(chunk) - text = chunk.decode("utf-8", errors="replace") - with self._lock: - if self._step_log_tails.get(log_tail.operation_id) is not log_tail: - return - operation = self._operations.get(log_tail.operation_id) - if operation is None: - return - event = self._new_event_locked( - operation, - "step.log", - { - "chunk": text, - "cursor": log_tail.cursor, - "step": log_tail.step, - "tool": log_tail.tool, - }, - ) - self._publish(event) - - def _stop_step_log_tail(self, operation_id: str) -> None: - with self._lock: - log_tail = self._step_log_tails.pop(operation_id, None) - if log_tail is None: - return - log_tail.stopped.set() - if log_tail.thread is not None and log_tail.thread is not threading.current_thread(): - log_tail.thread.join(timeout=_LOG_POLL_INTERVAL_SECONDS + 0.25) - - def _new_event_locked( - self, - operation: RuntimeOperation, - event_type: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - sequence = self._workspace_sequences.get(operation.workspace_id, 0) + 1 - self._workspace_sequences[operation.workspace_id] = sequence - operation.sequence = sequence - return { - "eventId": f"{self._runtime_instance_id}:{operation.operation_id}:{sequence}", - "runtimeInstanceId": self._runtime_instance_id, - "runSessionId": operation.run_session_id, - "sequence": sequence, - "type": event_type, - "workspaceId": operation.workspace_id, - "operationId": operation.operation_id, - "origin": operation.origin, - "kind": operation.kind, - "rerun": operation.rerun, - "timestamp": time.time(), - "payload": payload, - } - - @staticmethod - def _operation_payload(operation: RuntimeOperation) -> dict[str, Any]: - return { - "operationId": operation.operation_id, - "runSessionId": operation.run_session_id, - "runtimeInstanceId": operation.runtime_instance_id, - "workspaceId": operation.workspace_id, - "kind": operation.kind, - "origin": operation.origin, - "rerun": operation.rerun, - "step": operation.step, - "state": operation.state, - "currentStep": operation.current_step, - "currentTool": operation.current_tool, - "error": operation.error, - "result": operation.result, - "awaitingEventId": operation.awaiting_event_id, - "awaitingStepCommitId": operation.awaiting_step_commit_id, - "workspaceRevision": operation.workspace_revision, - "renderSyncState": operation.render_sync_state, - "renderRetryCount": operation.render_retry_count, - "lastRenderAckAt": operation.last_render_ack_at, - "cancelRequested": operation.cancel_requested, - "interruptibility": operation.interruptibility, - "safeToStop": bool(operation.awaiting_event_id), - "shutdownBarrier": operation.state not in _TERMINAL_OPERATION_STATES, - "createdAt": operation.created_at, - "updatedAt": operation.updated_at, - } - - def _publish(self, event: dict[str, Any]) -> None: - publisher = self._publisher - if publisher is not None: - publisher(event) - - -class RuntimeFlowObserver: - def __init__(self, manager: RuntimeOperationManager, operation_id: str): - self._manager = manager - self._operation_id = operation_id - - @property - def runtime_operation(self) -> dict[str, Any]: - return { - "schema": 1, - "operation_id": self._operation_id, - "runtime_instance_id": self._manager._runtime_instance_id, - } - - def on_step_started(self, workspace_step: Any) -> None: - self._manager.step_started(self._operation_id, workspace_step) - - def on_rerun_prepared( - self, - *, - affected_steps: list[str], - scope: str, - target_step: str = "", - ) -> None: - self._manager.rerun_prepared( - self._operation_id, - affected_steps=affected_steps, - scope=scope, - target_step=target_step, - ) - - def on_step_completed( - self, - workspace_step: Any, - state: Any, - error: str | None = None, - ) -> None: - self._manager.step_completed(self._operation_id, workspace_step, state, error) - - def on_subflow_stage(self, workspace_step: Any, subflow_step: dict[str, Any]) -> None: - self._manager.subflow_stage(self._operation_id, workspace_step, subflow_step) - - def on_step_skipped(self, workspace_step: Any) -> None: - self._manager.step_skipped(self._operation_id, workspace_step) - - def wait_for_step_rendered(self, _workspace_step: Any, _state: Any) -> bool: - return self._manager.wait_for_step_rendered(self._operation_id) - - -def _read_final_log(log: Any) -> str: - path = getattr(log, "file", None) - if not path: - return "" - try: - with Path(path).open("rb") as log_file: - log_file.seek(0, 2) - size = log_file.tell() - log_file.seek(max(0, size - _MAX_FINAL_LOG_BYTES)) - return log_file.read().decode("utf-8", errors="replace") - except OSError: - return "" - - -def _step_log_tail_for( - operation_id: str, - log: Any, - step: str, - tool: str, -) -> _StepLogTail | None: - path = getattr(log, "file", None) - if not path: - return None - log_path = Path(path) - try: - cursor = log_path.stat().st_size - except OSError: - cursor = 0 - return _StepLogTail( - operation_id=operation_id, - path=log_path, - step=step, - tool=tool, - cursor=cursor, - ) diff --git a/chipcompiler/runtime/recovery.py b/chipcompiler/runtime/recovery.py deleted file mode 100644 index ca85e85c9..000000000 --- a/chipcompiler/runtime/recovery.py +++ /dev/null @@ -1,53 +0,0 @@ -from copy import deepcopy -from pathlib import Path -from typing import Any - -from chipcompiler.data import StateEnum, Workspace -from chipcompiler.runtime.operations import RuntimeOperationManager -from chipcompiler.utility import json_write - - -def recover_interrupted_operation( - workspace: Workspace, - operations: RuntimeOperationManager, - operation_id: str = "", -) -> dict[str, list[dict[str, str]]]: - flow_data = deepcopy(workspace.flow.data) - recovered = [] - for step in flow_data.get("steps", []): - marker = step.get("info", {}).get("runtime_operation") - if ( - step.get("state") != "Ongoing" - or not isinstance(marker, dict) - or marker.get("schema") != 1 - or not isinstance(marker.get("operation_id"), str) - or not marker["operation_id"] - or not isinstance(marker.get("runtime_instance_id"), str) - or not marker["runtime_instance_id"] - or not isinstance(marker.get("started_at"), (int, float)) - or (operation_id and marker.get("operation_id") != operation_id) - or operations.is_active(str(marker["operation_id"])) - ): - continue - log_file = _step_log_file(workspace.directory, step) - step["state"] = StateEnum.Imcomplete.value - step["info"].pop("runtime_operation", None) - recovered.append( - { - "step": str(step.get("name", "")), - "tool": str(step.get("tool", "")), - "operationId": str(marker["operation_id"]), - "logFile": log_file, - } - ) - if recovered: - if not json_write(workspace.flow.path, flow_data): - raise OSError(f"failed to save recovered flow state: {workspace.flow.path}") - workspace.flow.data = flow_data - return {"recovered": recovered} - - -def _step_log_file(directory: str | Path, step: dict[str, Any]) -> str: - name = str(step.get("name", "")) - tool = str(step.get("tool", "")) - return str(Path(directory) / f"{name}_{tool}" / "log" / f"{name}.log") diff --git a/chipcompiler/runtime/requests.py b/chipcompiler/runtime/requests.py deleted file mode 100644 index 8eb6116de..000000000 --- a/chipcompiler/runtime/requests.py +++ /dev/null @@ -1,259 +0,0 @@ -from dataclasses import MISSING, dataclass, fields -from typing import Any - - -@dataclass(frozen=True) -class WorkspaceCreateRequest: - directory: str - pdk: str = "" - pdk_root: str = "" - pdk_json: Any = None - parameters: dict[str, Any] | None = None - origin_def: str = "" - origin_verilog: str = "" - filelist: str = "" - rtl_list: list[str] | None = None - sdc: str = "" - flow_config: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class WorkspaceOpenRequest: - directory: str - - -@dataclass(frozen=True) -class WorkspaceIdRequest: - workspace_id: str - - -@dataclass(frozen=True) -class WorkspaceRecoverInterruptedRequest: - workspace_id: str - operation_id: str = "" - - -@dataclass(frozen=True) -class WorkspaceCloseRequest: - workspace_id: str - - -@dataclass(frozen=True) -class WorkspaceSyncConfigRequest: - workspace_id: str - config_path: str - - -@dataclass(frozen=True) -class WorkspaceExportSignoffRequest: - workspace_id: str - output_path: str - additional_files: list[dict[str, str]] | None = None - - -@dataclass(frozen=True) -class WorkspaceInspectSignoffRequest: - workspace_id: str - - -@dataclass(frozen=True) -class WorkspaceInfoRequest: - workspace_id: str - step: str - info_id: str - - -@dataclass(frozen=True) -class FlowRunRequest: - workspace_id: str - rerun: bool = False - - -@dataclass(frozen=True) -class FlowRunStepRequest: - workspace_id: str - step: str - rerun: bool = False - - -@dataclass(frozen=True) -class OperationStartFlowRequest: - workspace_id: str - rerun: bool = False - origin: str = "gui" - idempotency_key: str = "" - - -@dataclass(frozen=True) -class OperationStartStepRequest: - workspace_id: str - step: str - rerun: bool = False - reset_dependents: bool = False - origin: str = "gui" - idempotency_key: str = "" - - -@dataclass(frozen=True) -class OperationIdRequest: - operation_id: str - - -@dataclass(frozen=True) -class OperationAckStepRenderedRequest: - operation_id: str - event_id: str - step_commit_id: str = "" - workspace_revision: int | None = None - - -@dataclass(frozen=True) -class DbEnsureRequest: - workspace_id: str - step: str = "" - - -@dataclass(frozen=True) -class DbReleaseRequest: - workspace_id: str - - -@dataclass(frozen=True) -class LayoutEditBeginRequest: - workspace_id: str - step: str - expected_source_fingerprint: str = "" - - -@dataclass(frozen=True) -class LayoutEditApplyRequest: - edit_session_id: str - command_id: str - base_revision: int - operation: dict[str, Any] - - -@dataclass(frozen=True) -class LayoutEditSaveRequest: - edit_session_id: str - expected_revision: int - - -@dataclass(frozen=True) -class LayoutEditDiscardRequest: - edit_session_id: str - - -@dataclass(frozen=True) -class FloorplanEditInspectRequest: - edit_session_id: str - - -@dataclass(frozen=True) -class FloorplanEditRunAutoRequest: - edit_session_id: str - command_id: str - base_revision: int - request: dict[str, Any] - - -@dataclass(frozen=True) -class FloorplanEditValidateRequest: - edit_session_id: str - scope: str = "all" - - -class RequestValidationError(ValueError): - def __init__(self, reason: str): - super().__init__(reason) - self.reason = reason - - -FIELD_ALIASES = { - "flowConfig": "flow_config", - "pdkRoot": "pdk_root", - "pdkJson": "pdk_json", - "originDef": "origin_def", - "originVerilog": "origin_verilog", - "paramJson": "parameters", - "rtlList": "rtl_list", - "workspaceId": "workspace_id", - "operationId": "operation_id", - "eventId": "event_id", - "stepCommitId": "step_commit_id", - "workspaceRevision": "workspace_revision", - "idempotencyKey": "idempotency_key", - "resetDependents": "reset_dependents", - "configPath": "config_path", - "outputPath": "output_path", - "infoId": "info_id", - "editSessionId": "edit_session_id", - "commandId": "command_id", - "baseRevision": "base_revision", - "expectedRevision": "expected_revision", - "expectedSourceFingerprint": "expected_source_fingerprint", - "id": "info_id", - "additionalFiles": "additional_files", -} - - -def parse_request_model(model: type, params: object): - if not isinstance(params, dict): - raise RequestValidationError("params must be an object") - - normalized = _normalize_fields(params) - model_fields = {field.name: field for field in fields(model)} - for key in normalized: - if key not in model_fields: - raise RequestValidationError(f"unknown field: {key}") - - values: dict[str, Any] = {} - for field in fields(model): - required = field.default is MISSING and field.default_factory is MISSING - - if field.name in normalized: - values[field.name] = normalized[field.name] - elif not required: - values[field.name] = field.default - else: - raise RequestValidationError(f"missing required field: {field.name}") - - if required and _is_missing(values[field.name]): - raise RequestValidationError(f"missing required field: {field.name}") - - if field.name in {"rerun", "reset_dependents"} and not isinstance(values[field.name], bool): - raise RequestValidationError(f"{field.name} must be a boolean") - if field.name == "additional_files": - _validate_additional_files(values[field.name]) - - return model(**values) - - -def _normalize_fields(params: dict) -> dict[str, Any]: - normalized: dict[str, Any] = {} - for key, value in params.items(): - normalized_key = FIELD_ALIASES.get(str(key), str(key)) - if normalized_key in normalized: - raise RequestValidationError(f"duplicate field: {normalized_key}") - normalized[normalized_key] = value - return normalized - - -def _is_missing(value: object) -> bool: - return value is None or (isinstance(value, str) and not value.strip()) - - -def _validate_additional_files(value: object) -> None: - if value is None: - return - if not isinstance(value, list): - raise RequestValidationError("additional_files must be a list") - for index, item in enumerate(value): - if not isinstance(item, dict): - raise RequestValidationError(f"additional_files[{index}] must be an object") - if not isinstance(item.get("archivePath"), str) or not item["archivePath"]: - raise RequestValidationError( - f"additional_files[{index}].archivePath must be a non-empty string" - ) - if not isinstance(item.get("content"), str): - raise RequestValidationError(f"additional_files[{index}].content must be a string") diff --git a/chipcompiler/runtime/rpc_dispatch.py b/chipcompiler/runtime/rpc_dispatch.py deleted file mode 100644 index 89ad3f916..000000000 --- a/chipcompiler/runtime/rpc_dispatch.py +++ /dev/null @@ -1,40 +0,0 @@ -from collections.abc import Callable -from functools import wraps -from typing import Any - -from jsonrpcserver import Success, dispatch -from oslash.either import Left, Right - -from chipcompiler.runtime.events import redirect_stdout_to_stderr - -JsonRpcHandler = Callable[..., Any] - - -class RpcDispatcher: - def __init__(self): - self._methods: dict[str, JsonRpcHandler] = {} - - def add_method(self, name: str, handler: JsonRpcHandler) -> None: - self._methods[name] = self._wrap_handler(handler) - - def method(self, name: str) -> Callable[[JsonRpcHandler], JsonRpcHandler]: - def decorator(handler: JsonRpcHandler) -> JsonRpcHandler: - self.add_method(name, handler) - return handler - - return decorator - - def dispatch(self, payload: bytes | str) -> str: - request_text = payload.decode("utf-8") if isinstance(payload, bytes) else payload - return dispatch(request_text, methods=self._methods) - - def _wrap_handler(self, handler: JsonRpcHandler) -> JsonRpcHandler: - @wraps(handler) - def wrapped(*args: Any, **kwargs: Any): - with redirect_stdout_to_stderr(): - result = handler(*args, **kwargs) - if isinstance(result, Left | Right): - return result - return Success(result) - - return wrapped diff --git a/chipcompiler/runtime/server.py b/chipcompiler/runtime/server.py deleted file mode 100644 index 70167ea3a..000000000 --- a/chipcompiler/runtime/server.py +++ /dev/null @@ -1,138 +0,0 @@ -from collections.abc import Callable - -from jsonrpcserver import Error - -import chipcompiler -from chipcompiler.runtime import methods -from chipcompiler.runtime.requests import RequestValidationError, parse_request_model -from chipcompiler.runtime.rpc_dispatch import RpcDispatcher -from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi - -PROTOCOL_VERSION = 1 -BASE_CAPABILITIES = ( - "rpc.hello", - "rpc.ping", - "rpc.shutdown", - "runtime.v2", - "operation.events", -) - -ERROR_CODES = { - "workspace_session_not_found": -32010, - "command_failed": -32020, - "invalid_request": -32602, -} - - -class RuntimeServer: - def __init__( - self, - api: WorkspaceRuntimeApi | None = None, - *, - persistent_db_enabled: bool = False, - ): - self.persistent_db_enabled = persistent_db_enabled - self.dispatcher = RpcDispatcher() - self.api = api or WorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled) - self.should_exit = False - self._notification_sink: Callable[[str, dict], None] | None = None - set_event_publisher = getattr(self.api, "set_event_publisher", None) - if callable(set_event_publisher): - set_event_publisher(self._publish_runtime_event) - self._register_base_methods() - self._register_runtime_methods() - - @property - def capabilities(self) -> tuple[str, ...]: - return ( - *BASE_CAPABILITIES, - *methods.runtime_method_names( - persistent_db_enabled=self.persistent_db_enabled, - ), - ) - - def dispatch(self, payload: bytes | str) -> str: - return self.dispatcher.dispatch(payload) - - def set_notification_sink(self, sink: Callable[[str, dict], None] | None) -> None: - self._notification_sink = sink - - def _publish_runtime_event(self, event: dict) -> None: - sink = self._notification_sink - if sink is not None: - sink("runtime.event", event) - - def _register_base_methods(self) -> None: - self.dispatcher.add_method("rpc.hello", self._hello) - self.dispatcher.add_method("rpc.ping", self._ping) - self.dispatcher.add_method("rpc.shutdown", self._shutdown) - - def _hello(self, version: int): - if version != PROTOCOL_VERSION: - return Error( - -32001, - "unsupported_version", - {"supportedVersion": PROTOCOL_VERSION, "requestedVersion": version}, - ) - return { - "version": PROTOCOL_VERSION, - "eccVersion": getattr(chipcompiler, "__version__", "unknown"), - "capabilities": list(self.capabilities), - } - - def _ping(self) -> dict: - return {"ok": True} - - def _shutdown(self) -> dict: - operations = getattr(self.api, "operations", None) - shutdown_barrier = getattr(operations, "shutdown_barrier", None) - barrier = shutdown_barrier() if callable(shutdown_barrier) else None - if barrier is not None: - return {"ok": False, "deferred": True, "shutdownBarrier": barrier} - self.should_exit = True - sessions = getattr(self.api, "sessions", None) - if sessions is not None and hasattr(sessions, "close_all"): - sessions.close_all() - return {"ok": True} - - def _register_runtime_methods(self) -> None: - for spec in methods.runtime_methods( - persistent_db_enabled=self.persistent_db_enabled, - ): - api_method = getattr(self.api, spec.handler_name, None) - if not callable(api_method): - raise TypeError( - f"runtime method {spec.method_name} handler {spec.handler_name} is not callable" - ) - self.dispatcher.add_method( - spec.method_name, - self._runtime_method_handler(spec, api_method), - ) - - def _runtime_method_handler(self, spec, api_method): - def handler(**params): - try: - request = parse_request_model(spec.request_model, params) - except RequestValidationError as exc: - return Error( - -32602, - "invalid_request", - {"message": exc.reason}, - ) - - try: - return api_method(request) - except RuntimeApiError as exc: - return Error( - ERROR_CODES.get(exc.code, -32000), - exc.code, - {"message": exc.message, **exc.data}, - ) - except Exception as exc: - return Error( - ERROR_CODES["command_failed"], - "command_failed", - {"message": str(exc)}, - ) - - return handler diff --git a/chipcompiler/runtime/sessions.py b/chipcompiler/runtime/sessions.py deleted file mode 100644 index c975ac104..000000000 --- a/chipcompiler/runtime/sessions.py +++ /dev/null @@ -1,151 +0,0 @@ -import shutil -import threading -from collections.abc import Callable -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - - -@dataclass -class WorkspaceSession: - workspace_id: str - directory: Path - workspace: Any - db_handle: Any = None - layout_edit_session: "LayoutEditSession | None" = None - mutation_lock: threading.Lock = field(default_factory=threading.Lock) - - -@dataclass -class LayoutEditSession: - edit_session_id: str - workspace_id: str - step_name: str - workspace_step: Any - db_handle: Any - source_kind: str - source_paths: tuple[Path, ...] - source_fingerprint: str - geometry_output_dir: Path - revision: int = 0 - geometry_revision: int = 0 - dirty: bool = False - command_results: dict[str, dict[str, Any]] = field(default_factory=dict) - floorplan_plan: dict[str, Any] = field(default_factory=dict) - pdn_plan: dict[str, Any] = field(default_factory=dict) - config_patch: dict[str, Any] = field(default_factory=dict) - parameters_patch: dict[str, Any] = field(default_factory=dict) - requires_verilog: bool = False - used_floorplan_editor: bool = False - validation_diagnostics: list[dict[str, Any]] = field(default_factory=list) - - -class WorkspaceSessionNotFound(KeyError): - pass - - -def _close_db_handle(db_handle: Any) -> None: - close = getattr(db_handle, "close", None) - if callable(close): - close() - - -class WorkspaceSessionRegistry: - def __init__(self, db_releaser: Callable[[Any], None] | None = _close_db_handle): - self._next_id = 1 - self._sessions: dict[str, WorkspaceSession] = {} - self._sessions_by_directory: dict[Path, str] = {} - self._db_releaser = db_releaser - self._lock = threading.Lock() - - def create_session(self, directory: str | Path, *, workspace: Any) -> WorkspaceSession: - resolved_directory = Path(directory).resolve() - with self._lock: - existing_id = self._sessions_by_directory.get(resolved_directory) - if existing_id is not None: - self._remove_session(existing_id) - return self._create_session(resolved_directory, workspace=workspace) - - def open_session(self, directory: str | Path, *, workspace: Any) -> WorkspaceSession: - resolved_directory = Path(directory).resolve() - with self._lock: - existing_id = self._sessions_by_directory.get(resolved_directory) - if existing_id is not None: - return self._sessions[existing_id] - - return self._create_session(resolved_directory, workspace=workspace) - - def get_session(self, workspace_id: str) -> WorkspaceSession: - try: - return self._sessions[workspace_id] - except KeyError as exc: - raise WorkspaceSessionNotFound(workspace_id) from exc - - def close_session(self, workspace_id: str) -> None: - with self._lock: - self._remove_session(workspace_id) - - def close_all(self) -> None: - with self._lock: - for session in self._sessions.values(): - self._release_session_db(session) - self._release_layout_edit_session(session) - self._sessions.clear() - self._sessions_by_directory.clear() - - def release_session_db(self, session: WorkspaceSession) -> bool: - return self._release_session_db(session) - - def _create_session(self, directory: Path, *, workspace: Any) -> WorkspaceSession: - workspace_id = f"workspace-{self._next_id}" - self._next_id += 1 - session = WorkspaceSession( - workspace_id=workspace_id, - directory=directory, - workspace=workspace, - ) - self._sessions[workspace_id] = session - self._sessions_by_directory[directory] = workspace_id - return session - - def _remove_session(self, workspace_id: str) -> None: - session = self._sessions.pop(workspace_id, None) - if session is None: - raise WorkspaceSessionNotFound(workspace_id) - self._release_session_db(session) - self._release_layout_edit_session(session) - self._sessions_by_directory.pop(session.directory, None) - - def _release_session_db(self, session: WorkspaceSession) -> bool: - db_handle = session.db_handle - if db_handle is None: - return False - - session.db_handle = None - if self._db_releaser is not None: - self._db_releaser(db_handle) - return True - - def release_layout_edit_session(self, session: WorkspaceSession) -> bool: - return self._release_layout_edit_session(session) - - def _release_layout_edit_session(self, session: WorkspaceSession) -> bool: - layout_edit_session = session.layout_edit_session - if layout_edit_session is None: - return False - - session.layout_edit_session = None - _reset_layout_edit_geometry_session(layout_edit_session.db_handle) - if self._db_releaser is not None: - self._db_releaser(layout_edit_session.db_handle) - shutil.rmtree(layout_edit_session.geometry_output_dir.parent, ignore_errors=True) - return True - - -def _reset_layout_edit_geometry_session(db_handle: Any) -> None: - module = getattr(db_handle, "engine", None) - if module is None: - module = getattr(db_handle, "ecc_module", None) - reset_geometry_session = getattr(module, "reset_geometry_session", None) - if callable(reset_geometry_session): - reset_geometry_session() diff --git a/chipcompiler/runtime/signoff_export.py b/chipcompiler/runtime/signoff_export.py deleted file mode 100644 index 8206a964c..000000000 --- a/chipcompiler/runtime/signoff_export.py +++ /dev/null @@ -1,285 +0,0 @@ -import os -import shutil -import tempfile -from pathlib import Path - -from chipcompiler.engine import EngineFlow, SignoffPackageOptions -from chipcompiler.runtime.workspace_api import RuntimeApiError -from chipcompiler.utility import json_read - -_REVIEW_GROUPS = ( - ("initial", "Initial"), - ("config", "Config"), - ("harden", "Harden"), - ("final_design", "Final Design"), - ("sta", "STA"), - ("spef", "SPEF"), - ("reports", "Reports"), -) - - -def inspect_signoff_package(workspace) -> dict: - """Refresh current outputs, then render the home checklist contract only.""" - EngineFlow(workspace).collect_signoff_package( - SignoffPackageOptions(archive=False, materialize=False, refresh_analysis=True) - ) - checklist_path = Path(workspace.directory) / "home" / "checklist.json" - checklist_data = json_read(checklist_path) - if ( - not isinstance(checklist_data, dict) - or checklist_data.get("schema_version") != 3 - or checklist_data.get("kind") != "signoff_checklist" - ): - return _unavailable_review() - - groups = { - group_id: { - "id": group_id, - "label": label, - "available": 0, - "expected": 0, - "blocked_details": [], - "attention_details": [], - } - for group_id, label in _REVIEW_GROUPS - } - for item in checklist_data.get("checklist", []): - if not isinstance(item, dict): - continue - group = groups[_review_group_for_item(item)] - group["expected"] += 1 - if item.get("state") == "pass": - group["available"] += 1 - elif item.get("blocked") is True: - group["blocked_details"].append(_review_detail(item)) - else: - group["attention_details"].append(_review_detail(item)) - - review_groups = [] - risks = [] - for group_id, _label in _REVIEW_GROUPS: - group = groups[group_id] - blocked_details = group["blocked_details"] - attention_details = group["attention_details"] - available = group["available"] - expected = group["expected"] - if blocked_details: - status = "blocked" - summary = f"{len(blocked_details)} blocking checklist requirements" - risks.append( - { - "severity": "blocked", - "title": f"{group['label']} signoff requirements block export", - "summary": summary, - "details": blocked_details, - } - ) - if attention_details: - risks.append( - { - "severity": "warning", - "title": f"{group['label']} signoff attention", - "summary": ( - f"{len(attention_details)} attention-only checklist requirements" - ), - "details": attention_details, - } - ) - elif attention_details: - status = "attention" - summary = f"{len(attention_details)} attention-only checklist requirements" - risks.append( - { - "severity": "warning", - "title": f"{group['label']} signoff attention", - "summary": summary, - "details": attention_details, - } - ) - else: - status = "ready" - summary = ( - f"{available} of {expected} requirements ready" if expected else "No requirements" - ) - review_groups.append( - { - "id": group_id, - "label": group["label"], - "status": status, - "available": available, - "expected": expected, - "summary": summary, - } - ) - - risks.sort(key=lambda risk: risk["severity"] != "blocked") - status = checklist_data.get("status") - return { - "status": status if status in {"ready", "attention", "blocked"} else "blocked", - "groups": review_groups, - "risks": risks, - } - - -def _unavailable_review() -> dict: - detail = { - "kind": "freshness", - "label": "Signoff checklist", - "location": "home/checklist.json", - "reason": "The current signoff checklist could not be generated.", - "owner": "checklist", - "policy": "block", - "state": "unavailable", - "evidence": [], - } - return { - "status": "blocked", - "groups": [ - { - "id": group_id, - "label": label, - "status": "blocked" if group_id == "reports" else "ready", - "available": 0, - "expected": 0, - "summary": "Checklist unavailable" if group_id == "reports" else "No requirements", - } - for group_id, label in _REVIEW_GROUPS - ], - "risks": [ - { - "severity": "blocked", - "title": "Signoff checklist unavailable", - "summary": "Re-run signoff inspection after current-output analysis completes.", - "details": [detail], - } - ], - } - - -def _review_detail(item: dict) -> dict: - source = item.get("source", {}) - source = source if isinstance(source, dict) else {} - evidence = item.get("evidence", []) - return { - "kind": item.get("category", "checklist"), - "label": item.get("title", "Checklist item"), - "location": source.get("path", "home/checklist.json"), - "reason": item.get("summary", ""), - "owner": item.get("owner", "checklist"), - "policy": item.get("policy", "warn"), - "state": item.get("state", "unavailable"), - "evidence": evidence if isinstance(evidence, list) else [], - } - - -def _review_group_for_item(item: dict) -> str: - step = str(item.get("step", "")) - category = str(item.get("category", "")) - source = item.get("source", {}) - path = source.get("path", "") if isinstance(source, dict) else "" - if category == "configuration" or path.startswith("config/"): - return "config" - if category == "provenance" or path.startswith(("origin/", "initial/")): - return "initial" - if step == "Harden" or path.startswith(("Harden_ecc/", "harden/")): - return "harden" - if step == "sta" or path.startswith(("sta_ecc/", "final/timing/sta/")): - return "sta" - if step == "RCX" or path.startswith(("RCX_ecc/", "final/timing/spef/")): - return "spef" - if step in {"route", "drc", "lvs", "filler"} or path.startswith( - ("route_ecc/", "drc_ecc/", "lvs_ecc/", "filler_ecc/", "final/design/") - ): - return "final_design" - return "reports" - - -def export_signoff_package_archive( - workspace, - output_path: str, - additional_files: list[dict[str, str]] | None = None, - *, - include_debug: bool = False, -) -> str: - raw_destination = Path(output_path).expanduser() - destination = raw_destination.parent.resolve() / raw_destination.name - - with tempfile.TemporaryDirectory(prefix="ecc-signoff-") as temporary_root: - result = EngineFlow(workspace).collect_signoff_package( - SignoffPackageOptions( - output_dir=temporary_root, - archive=False, - include_debug=include_debug, - refresh_analysis=True, - ) - ) - if not result.ok: - missing = ", ".join(result.missing_required) or "unknown required resources" - raise RuntimeApiError( - "command_failed", - f"signoff package is incomplete: {missing}", - ) - if not result.package_dir: - raise RuntimeApiError( - "command_failed", - "signoff package directory was not created", - ) - - package_dir = Path(result.package_dir) - - if additional_files: - package_root = package_dir.resolve() - for file_info in additional_files: - archive_path = file_info.get("archivePath") if isinstance(file_info, dict) else None - content = file_info.get("content") if isinstance(file_info, dict) else None - if not isinstance(archive_path, str) or not archive_path: - raise RuntimeApiError( - "invalid_request", - "additionalFiles entries require a non-empty archivePath", - ) - if not isinstance(content, str): - raise RuntimeApiError( - "invalid_request", - "additionalFiles entries require string content", - ) - relative_path = Path(archive_path) - if ( - not relative_path.parts - or relative_path.is_absolute() - or ".." in relative_path.parts - ): - raise RuntimeApiError( - "invalid_request", - f"additional file path escapes signoff package: {archive_path}", - ) - p = package_dir / relative_path - try: - p.resolve().relative_to(package_root) - except ValueError as exc: - raise RuntimeApiError( - "invalid_request", - f"additional file path escapes signoff package: {archive_path}", - ) from exc - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(content, encoding="utf-8") - - archive = package_dir.with_suffix(".tar.gz") - import tarfile - - with tarfile.open(archive, "w:gz") as tar: - tar.add(package_dir, arcname=package_dir.name) - - destination.parent.mkdir(parents=True, exist_ok=True) - descriptor, staged_name = tempfile.mkstemp( - dir=destination.parent, - prefix=f".{destination.name}.", - ) - os.close(descriptor) - staged_path = Path(staged_name) - try: - shutil.copy2(archive, staged_path) - os.replace(staged_path, destination) - finally: - staged_path.unlink(missing_ok=True) - - return str(destination) diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py deleted file mode 100644 index de0f9ced6..000000000 --- a/chipcompiler/runtime/stdio_server.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -import queue -import sys -import threading -from typing import BinaryIO - -from chipcompiler.runtime.server import RuntimeServer -from chipcompiler.runtime.transport import ( - ContentLengthDecoder, - TransportError, - encode_content_length_frame, -) - -_STOP = object() - - -class _ProtocolWriter: - """Serialize protocol output through an fd immune to tool stdio redirects.""" - - def __init__(self, output_stream: BinaryIO): - self._output_stream = output_stream - self._output_fd: int | None = None - try: - self._output_fd = os.dup(output_stream.fileno()) - except (AttributeError, OSError): - # BytesIO is used by unit tests and has no file descriptor. - self._output_fd = None - self._messages: queue.Queue[bytes | object] = queue.Queue() - self._thread = threading.Thread(target=self._write_loop, name="ecc-rpc-writer", daemon=True) - self._thread.start() - - def send_response(self, response: str) -> None: - self._messages.put(encode_content_length_frame(response)) - - def send_notification(self, method: str, params: dict) -> None: - import json - - payload = json.dumps( - {"jsonrpc": "2.0", "method": method, "params": params}, - separators=(",", ":"), - ) - self._messages.put(encode_content_length_frame(payload)) - - def close(self) -> None: - self._messages.put(_STOP) - self._thread.join() - if self._output_fd is not None: - os.close(self._output_fd) - self._output_fd = None - - def _write_loop(self) -> None: - while True: - message = self._messages.get() - if message is _STOP: - return - assert isinstance(message, bytes) - if self._output_fd is None: - self._output_stream.write(message) - self._output_stream.flush() - continue - _write_all(self._output_fd, message) - - -def _write_all(fd: int, data: bytes) -> None: - view = memoryview(data) - while view: - written = os.write(fd, view) - view = view[written:] - - -def run_stdio_server( - input_stream: BinaryIO, - output_stream: BinaryIO, - *, - server: RuntimeServer | None = None, - persistent_db_enabled: bool = False, -) -> int: - runtime_server = server or RuntimeServer(persistent_db_enabled=persistent_db_enabled) - decoder = ContentLengthDecoder() - writer = _ProtocolWriter(output_stream) - runtime_server.set_notification_sink(writer.send_notification) - - try: - while not runtime_server.should_exit: - chunk = _read_chunk(input_stream) - if not chunk: - break - try: - messages = decoder.feed(chunk) - except TransportError as exc: - print(f"transport error: {exc}", file=sys.stderr) - return 1 - - for message in messages: - response = runtime_server.dispatch(message) - if runtime_server.should_exit and not response: - break - if response: - writer.send_response(response) - if runtime_server.should_exit: - break - return 0 - finally: - runtime_server.set_notification_sink(None) - writer.close() - - -def _read_chunk(input_stream: BinaryIO) -> bytes: - read1 = getattr(input_stream, "read1", None) - if read1 is not None: - return read1(8192) - return input_stream.read(8192) - - -def main(*, persistent_db_enabled: bool = False) -> int: - return run_stdio_server( - sys.stdin.buffer, - sys.stdout.buffer, - persistent_db_enabled=persistent_db_enabled, - ) diff --git a/chipcompiler/runtime/transport.py b/chipcompiler/runtime/transport.py deleted file mode 100644 index 666a99484..000000000 --- a/chipcompiler/runtime/transport.py +++ /dev/null @@ -1,61 +0,0 @@ -HEADER_SEPARATOR = b"\r\n\r\n" -DEFAULT_MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 - - -class TransportError(ValueError): - """Raised when stdio framing is malformed before JSON-RPC dispatch.""" - - -def encode_content_length_frame(payload: bytes | str) -> bytes: - payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload - return b"Content-Length: %d\r\n\r\n" % len(payload_bytes) + payload_bytes - - -class ContentLengthDecoder: - def __init__(self, *, max_payload_size: int = DEFAULT_MAX_PAYLOAD_SIZE): - self._buffer = bytearray() - self._max_payload_size = max_payload_size - - def feed(self, data: bytes) -> list[bytes]: - self._buffer.extend(data) - messages: list[bytes] = [] - - while True: - header_end = self._buffer.find(HEADER_SEPARATOR) - if header_end < 0: - return messages - - header = bytes(self._buffer[:header_end]).decode("ascii", errors="replace") - content_length = self._parse_content_length(header) - payload_start = header_end + len(HEADER_SEPARATOR) - payload_end = payload_start + content_length - if len(self._buffer) < payload_end: - return messages - - messages.append(bytes(self._buffer[payload_start:payload_end])) - del self._buffer[:payload_end] - - def _parse_content_length(self, header: str) -> int: - length_values: list[str] = [] - for line in header.split("\r\n"): - if not line: - continue - name, separator, value = line.partition(":") - if not separator: - raise TransportError("malformed header line before Content-Length") - if name.lower() == "content-length": - length_values.append(value.strip()) - - if len(length_values) != 1: - raise TransportError("exactly one Content-Length header is required") - - try: - content_length = int(length_values[0]) - except ValueError as exc: - raise TransportError("Content-Length must be an integer") from exc - - if content_length < 0: - raise TransportError("Content-Length must be non-negative") - if content_length > self._max_payload_size: - raise TransportError("Content-Length exceeds maximum payload size") - return content_length diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py deleted file mode 100644 index ca72b9127..000000000 --- a/chipcompiler/runtime/workspace_api.py +++ /dev/null @@ -1,2256 +0,0 @@ -import hashlib -import inspect -import json -import os -import shutil -import tempfile -import threading -from collections.abc import Callable -from copy import copy, deepcopy -from dataclasses import replace -from pathlib import Path -from typing import Any, TypeVar - -from chipcompiler.runtime.operations import ( - RuntimeOperationConflict, - RuntimeOperationManager, -) -from chipcompiler.runtime.requests import ( - DbEnsureRequest, - DbReleaseRequest, - FloorplanEditInspectRequest, - FloorplanEditRunAutoRequest, - FloorplanEditValidateRequest, - FlowRunRequest, - FlowRunStepRequest, - LayoutEditApplyRequest, - LayoutEditBeginRequest, - LayoutEditDiscardRequest, - LayoutEditSaveRequest, - OperationAckStepRenderedRequest, - OperationIdRequest, - OperationStartFlowRequest, - OperationStartStepRequest, - WorkspaceCreateRequest, - WorkspaceExportSignoffRequest, - WorkspaceIdRequest, - WorkspaceInfoRequest, - WorkspaceInspectSignoffRequest, - WorkspaceOpenRequest, - WorkspaceRecoverInterruptedRequest, - WorkspaceSyncConfigRequest, -) -from chipcompiler.runtime.sessions import ( - LayoutEditSession, - WorkspaceSession, - WorkspaceSessionNotFound, - WorkspaceSessionRegistry, -) -from chipcompiler.runtime.workspace_config_io import ( - canonical_request_parameters, - read_workspace_state, - workspace_state_bytes, -) -from chipcompiler.utility.path import path_is_within, stringify_paths - -_T = TypeVar("_T") - - -class RuntimeApiError(RuntimeError): - def __init__(self, code: str, message: str, data: dict | None = None): - super().__init__(message) - self.code = code - self.message = message - self.data = data or {} - - -class WorkspaceRuntimeApi: - def __init__( - self, - sessions: WorkspaceSessionRegistry | None = None, - *, - persistent_db_enabled: bool = False, - event_publisher: Callable[[dict[str, Any]], None] | None = None, - ): - self.persistent_db_enabled = persistent_db_enabled - self.sessions = sessions or WorkspaceSessionRegistry(db_releaser=_close_db_handle) - self._next_layout_edit_id = 1 - self._layout_edit_sessions: dict[str, LayoutEditSession] = {} - # ecc_tools_bin currently owns one process-global GeometryEditSession. - # Keep its native DB and derived GeometryStore exclusive until that - # session is reset, so a second workspace cannot replace the store - # underneath an active editor. - self._layout_edit_lock = threading.RLock() - self.operations = RuntimeOperationManager(event_publisher) - - def set_event_publisher(self, publisher: Callable[[dict[str, Any]], None] | None) -> None: - self.operations.set_publisher(publisher) - - def create_workspace(self, request: WorkspaceCreateRequest) -> dict: - if not request.directory: - raise RuntimeApiError("invalid_request", "missing required field: directory") - - temp_filelist_dir = None - input_filelist = request.filelist - if not input_filelist: - rtl_paths = _normalize_rtl_list(request.rtl_list or []) - if rtl_paths: - temp_filelist_dir = tempfile.TemporaryDirectory(prefix="ecc-workspace-filelist-") - input_filelist = _write_filelist(temp_filelist_dir.name, rtl_paths) - - import chipcompiler.data as data_api - - pdk_json, pdk_json_temp_path = _materialize_inline_pdk_json(request.pdk_json) - try: - workspace = data_api.create_workspace( - directory=request.directory, - pdk=request.pdk, - parameters=canonical_request_parameters(request.parameters), - origin_def=request.origin_def, - origin_verilog=request.origin_verilog, - input_filelist=input_filelist, - pdk_root=request.pdk_root, - pdk_json=pdk_json, - sdc=request.sdc, - flow_config=request.flow_config, - ) - finally: - if pdk_json_temp_path is not None: - pdk_json_temp_path.unlink(missing_ok=True) - if temp_filelist_dir is not None: - temp_filelist_dir.cleanup() - if workspace is None: - raise RuntimeApiError( - "command_failed", - f"create workspace failed : {os.path.abspath(request.directory)}", - ) - - build_flow_for_workspace(workspace) - session = self.sessions.create_session(workspace.directory, workspace=workspace) - return _workspace_session_result(session) - - def open_workspace(self, request: WorkspaceOpenRequest) -> dict: - workspace = self._load_workspace(request.directory) - build_flow_for_workspace(workspace, create_step_workspaces=False) - session = self.sessions.open_session(workspace.directory, workspace=workspace) - return _workspace_session_result(session) - - def recover_interrupted(self, request: WorkspaceRecoverInterruptedRequest) -> dict: - from chipcompiler.runtime.recovery import recover_interrupted_operation - - return self._with_session_mutation_lock( - request.workspace_id, - lambda session: recover_interrupted_operation( - session.workspace, - self.operations, - request.operation_id, - ), - ) - - def workspace_home(self, request: WorkspaceIdRequest) -> dict: - session = self._get_session(request.workspace_id) - path = Path(session.workspace.home.path).resolve() - if not path.exists(): - raise RuntimeApiError("command_failed", f"get home failed : {path}") - return {"path": str(path)} - - def workspace_info(self, request: WorkspaceInfoRequest) -> dict: - session = self._get_session(request.workspace_id) - workspace_step = _workspace_step_from_flow(session.workspace, request.step) - if workspace_step is None: - raise RuntimeApiError("command_failed", f"step not found: {request.step}") - - import chipcompiler.tools as tools_api - - info = tools_api.get_step_info( - workspace=session.workspace, - step=workspace_step, - id=request.info_id, - ) - return { - "step": request.step, - "id": request.info_id, - "info": stringify_paths(info or {}), - } - - def refresh_config(self, request: WorkspaceIdRequest) -> dict: - def refresh(session: WorkspaceSession) -> dict: - self._release_session_db(session) - self._refresh_workspace_config(session.workspace) - return {"directory": str(session.directory), "refreshed": True} - - return self._with_session_mutation_lock(request.workspace_id, refresh) - - def sync_config(self, request: WorkspaceSyncConfigRequest) -> dict: - def sync(session: WorkspaceSession) -> dict: - config_path = Path(request.config_path).resolve() - config_dir = session.directory / "config" - if not path_is_within(config_path, config_dir): - raise RuntimeApiError( - "invalid_request", - f"config path outside workspace config directory : {config_path}", - ) - - import chipcompiler.data as data_api - - parameters_changed = data_api.sync_workspace_config_to_parameters( - session.workspace, - config_path, - ) - refreshed = False - if parameters_changed: - self._release_session_db(session) - self._refresh_workspace_config(session.workspace) - refreshed = True - return { - "directory": str(session.directory), - "configPath": str(config_path), - "parametersChanged": bool(parameters_changed), - "refreshed": refreshed, - } - - return self._with_session_mutation_lock(request.workspace_id, sync) - - def reset_flow(self, request: WorkspaceIdRequest) -> dict: - def reset(session: WorkspaceSession) -> dict: - self._release_session_db(session) - engine_flow = build_flow_for_workspace(session.workspace) - self._prepare_workspace_for_rerun(session.workspace, engine_flow) - return {"directory": str(session.directory)} - - return self._with_session_mutation_lock(request.workspace_id, reset) - - def export_signoff(self, request: WorkspaceExportSignoffRequest) -> dict: - def export(session: WorkspaceSession) -> dict: - from chipcompiler.runtime.signoff_export import ( - export_signoff_package_archive, - ) - - output_path = export_signoff_package_archive( - session.workspace, - request.output_path, - request.additional_files, - ) - return {"outputPath": output_path} - - return self._with_session_mutation_lock(request.workspace_id, export) - - def inspect_signoff(self, request: WorkspaceInspectSignoffRequest) -> dict: - def inspect(session: WorkspaceSession) -> dict: - from chipcompiler.runtime.signoff_export import inspect_signoff_package - - return inspect_signoff_package(session.workspace) - - return self._with_session_mutation_lock(request.workspace_id, inspect) - - def close_workspace(self, request: WorkspaceIdRequest) -> dict: - def close(session: WorkspaceSession) -> dict: - self._discard_layout_edit_session(session) - self.sessions.close_session(session.workspace_id) - return {"ok": True} - - return self._with_session_mutation_lock(request.workspace_id, close) - - def flow_run(self, request: FlowRunRequest) -> dict: - return self._flow_run(request) - - def _flow_run( - self, - request: FlowRunRequest, - *, - observer=None, - preserve_user_inputs: bool = False, - ) -> dict: - def run(session: WorkspaceSession) -> dict: - should_capture = self._should_capture_session_db(session) - previous_db = session.db_handle if should_capture else None - if request.rerun and should_capture: - self._release_session_db(session) - previous_db = None - - engine_flow = self._build_flow_for_session( - session, - attach_session_db=should_capture and not request.rerun, - ) - if request.rerun: - affected_steps = list(getattr(engine_flow, "workspace_steps", [])) - self._prepare_workspace_for_rerun( - session.workspace, - engine_flow, - preserve_user_inputs=preserve_user_inputs, - ) - self._notify_rerun_prepared( - observer, - affected_steps, - scope="flow", - ) - try: - ok = _run_engine_flow_steps(engine_flow, rerun=request.rerun, observer=observer) - finally: - if should_capture: - self._capture_flow_db( - session, - engine_flow, - previous_handle=previous_db, - ) - else: - self._close_transient_flow_db(engine_flow) - if not ok: - raise RuntimeApiError( - "command_failed", - f"run flow failed : {session.directory}", - {"rerun": request.rerun}, - ) - return {"rerun": request.rerun} - - return self._with_session_mutation_lock(request.workspace_id, run) - - def flow_run_step(self, request: FlowRunStepRequest) -> dict: - return self._flow_run_step(request) - - def _flow_run_step( - self, - request: FlowRunStepRequest, - *, - observer=None, - reset_dependents: bool = False, - ) -> dict: - def run_step(session: WorkspaceSession) -> dict: - should_capture = self._should_capture_session_db(session) - previous_db = session.db_handle if should_capture else None - if request.rerun and should_capture: - self._release_session_db(session) - previous_db = None - - engine_flow = self._build_flow_for_session( - session, - attach_session_db=should_capture and not request.rerun, - ) - if request.rerun: - if session.layout_edit_session is not None: - raise RuntimeApiError( - "layout_edit_active", - "close the rendered layout before rerunning this step", - ) - self._refresh_workspace_config(session.workspace) - - workspace_step = engine_flow.get_workspace_step(request.step) - if workspace_step is None: - raise RuntimeApiError("command_failed", f"step not found: {request.step}") - if request.rerun: - affected_steps = self._rerun_affected_steps( - engine_flow, - workspace_step, - reset_dependents=reset_dependents, - ) - self._prepare_steps_for_rerun( - session.workspace, - engine_flow, - affected_steps, - ) - self._notify_rerun_prepared( - observer, - affected_steps, - scope="step", - target_step=workspace_step.name, - ) - - try: - step_already_succeeded = not request.rerun and engine_flow.check_state( - name=workspace_step.name, - tool=workspace_step.tool, - state=_success_state(), - ) - if not step_already_succeeded: - _init_db_engine_for_workspace_step(engine_flow, workspace_step) - state = _run_engine_flow_step( - engine_flow, - workspace_step, - rerun=request.rerun, - observer=observer, - ) - finally: - if should_capture: - self._capture_flow_db( - session, - engine_flow, - previous_handle=previous_db, - ) - else: - self._close_transient_flow_db(engine_flow) - - state_value = _state_value(state) - result = {"step": request.step, "state": state_value} - if state_value != "Success": - raise RuntimeApiError( - "command_failed", - f"run step {request.step} failed with state {state_value}", - result, - ) - return result - - return self._with_session_mutation_lock(request.workspace_id, run_step) - - def start_flow_operation(self, request: OperationStartFlowRequest) -> dict: - self._require_gui_operation_origin(request.origin) - self._get_session(request.workspace_id) - try: - return self.operations.start( - workspace_id=request.workspace_id, - kind="flow", - origin=request.origin, - rerun=request.rerun, - step="", - idempotency_key=request.idempotency_key, - runner=lambda observer: self._flow_run( - FlowRunRequest(workspace_id=request.workspace_id, rerun=request.rerun), - observer=observer, - preserve_user_inputs=request.rerun, - ), - ) - except RuntimeOperationConflict as exc: - raise RuntimeApiError("command_failed", str(exc)) from exc - - def start_step_operation(self, request: OperationStartStepRequest) -> dict: - self._require_gui_operation_origin(request.origin) - self._get_session(request.workspace_id) - try: - return self.operations.start( - workspace_id=request.workspace_id, - kind="step", - origin=request.origin, - rerun=request.rerun, - step=request.step, - idempotency_key=request.idempotency_key, - runner=lambda observer: self._flow_run_step( - FlowRunStepRequest( - workspace_id=request.workspace_id, - step=request.step, - rerun=request.rerun, - ), - observer=observer, - reset_dependents=request.reset_dependents, - ), - ) - except RuntimeOperationConflict as exc: - raise RuntimeApiError("command_failed", str(exc)) from exc - - def operation_status(self, request: OperationIdRequest) -> dict: - try: - return self.operations.operation_status(request.operation_id) - except KeyError as exc: - raise RuntimeApiError( - "invalid_request", - f"operation not found: {request.operation_id}", - ) from exc - - def cancel_operation(self, request: OperationIdRequest) -> dict: - try: - return self.operations.request_cancel(request.operation_id) - except KeyError as exc: - raise RuntimeApiError( - "invalid_request", - f"operation not found: {request.operation_id}", - ) from exc - - def acknowledge_step_rendered(self, request: OperationAckStepRenderedRequest) -> dict: - try: - return self.operations.acknowledge_step_rendered( - request.operation_id, - request.event_id, - request.step_commit_id, - request.workspace_revision, - ) - except KeyError as exc: - raise RuntimeApiError( - "invalid_request", - f"operation not found: {request.operation_id}", - ) from exc - - def workspace_snapshot(self, request: WorkspaceIdRequest) -> dict: - session = self._get_session(request.workspace_id) - flow_data = getattr(getattr(session.workspace, "flow", None), "data", {}) - raw_steps = flow_data.get("steps", []) if isinstance(flow_data, dict) else [] - steps = [ - { - "name": str(step.get("name", "")), - "tool": str(step.get("tool", "")), - "state": str(step.get("state", "Unstart")), - "runtime": str(step.get("runtime", "")), - "peakMemory": step.get("peak memory (mb)", 0), - } - for step in raw_steps - if isinstance(step, dict) - ] - from chipcompiler.data.parameter import ( - parameters_have_chip_identity, - reload_parameter, - ) - - parameters = getattr(session.workspace, "parameters", None) - parameters_data = getattr(parameters, "data", {}) or {} - if not parameters_have_chip_identity(parameters_data): - parameter_path = getattr(parameters, "path", None) - if parameter_path: - session.workspace.parameters = reload_parameter(parameter_path, parameters) - parameters_data = session.workspace.parameters.data or {} - - home_data = deepcopy(getattr(session.workspace.home, "data", {}) or {}) - if not str(home_data.get("parameters", "")).strip(): - parameter_path = getattr(session.workspace.parameters, "path", None) - if parameter_path is None: - from chipcompiler.data.workspace_config import workspace_config_path - - parameter_path = workspace_config_path(session.directory) - home_data["parameters"] = str(parameter_path) - - return { - **self.operations.workspace_snapshot(request.workspace_id), - "directory": str(session.directory), - "flow": {"steps": steps}, - "home": stringify_paths(home_data), - "parameters": stringify_paths(deepcopy(parameters_data)), - } - - def db_ensure(self, request: DbEnsureRequest) -> dict: - self._require_persistent_db() - - def ensure(session: WorkspaceSession) -> dict: - db_handle = session.db_handle - if _db_handle_is_initialized(db_handle): - return _db_ensure_result( - workspace_id=session.workspace_id, - step=request.step, - active=True, - reused=True, - ) - - engine_flow = build_flow_for_workspace(session.workspace) - if db_handle is not None: - engine_flow.engine_db = db_handle - - if request.step: - workspace_step = engine_flow.get_workspace_step(request.step) - if workspace_step is None: - raise RuntimeApiError( - "command_failed", - f"step not found: {request.step}", - ) - initialized = _init_db_engine_for_workspace_step(engine_flow, workspace_step) - else: - initialized = engine_flow.init_db_engine() - - flow_db = getattr(engine_flow, "engine_db", None) - active = bool(initialized and _db_handle_is_initialized(flow_db)) - session.db_handle = flow_db if active else None - return _db_ensure_result( - workspace_id=session.workspace_id, - step=request.step, - active=active, - reused=False, - ) - - return self._with_session_mutation_lock(request.workspace_id, ensure) - - def db_release(self, request: DbReleaseRequest) -> dict: - self._require_persistent_db() - - def release(session: WorkspaceSession) -> dict: - released = self._release_session_db(session) - return {"workspaceId": session.workspace_id, "released": released} - - return self._with_session_mutation_lock(request.workspace_id, release) - - def layout_edit_begin(self, request: LayoutEditBeginRequest) -> dict: - self._require_persistent_db() - - def begin(session: WorkspaceSession) -> dict: - with self._layout_edit_lock: - active_session = session.layout_edit_session - if active_session is not None: - if active_session.step_name != request.step: - raise RuntimeApiError( - "layout_edit_active", - "layout edit session already active for step: " - f"{active_session.step_name}", - {"editSessionId": active_session.edit_session_id}, - ) - if ( - request.expected_source_fingerprint - and request.expected_source_fingerprint != active_session.source_fingerprint - ): - raise RuntimeApiError( - "source_changed", - "layout edit source fingerprint does not match", - { - "expectedSourceFingerprint": request.expected_source_fingerprint, - "actualSourceFingerprint": active_session.source_fingerprint, - }, - ) - return _layout_edit_begin_result(active_session, reused=True) - - active_session = next(iter(self._layout_edit_sessions.values()), None) - if active_session is not None: - raise RuntimeApiError( - "layout_edit_active", - "layout edit session already active for another workspace", - { - "editSessionId": active_session.edit_session_id, - "workspaceId": active_session.workspace_id, - }, - ) - - engine_flow = build_flow_for_workspace(session.workspace) - workspace_step = engine_flow.get_workspace_step(request.step) - if workspace_step is None: - raise RuntimeApiError("command_failed", f"step not found: {request.step}") - - source_kind, source_paths = _layout_edit_source(workspace_step) - source_fingerprint = _artifact_fingerprint(source_paths) - if ( - request.expected_source_fingerprint - and request.expected_source_fingerprint != source_fingerprint - ): - raise RuntimeApiError( - "source_changed", - "layout edit source fingerprint does not match", - { - "expectedSourceFingerprint": request.expected_source_fingerprint, - "actualSourceFingerprint": source_fingerprint, - }, - ) - - edit_step = _layout_edit_workspace_step( - workspace_step, - source_kind=source_kind, - source_paths=source_paths, - ) - initialized = _init_db_engine_for_workspace_step(engine_flow, edit_step) - db_handle = getattr(engine_flow, "engine_db", None) - if not initialized or not _db_handle_is_initialized(db_handle): - _close_db_handle(db_handle) - raise RuntimeApiError( - "command_failed", - f"failed to initialize layout edit DB for step: {request.step}", - ) - - module = _db_engine_module(db_handle) - initialize_geometry = getattr(module, "initialize_geometry_session", None) - if not callable(initialize_geometry) or not initialize_geometry(): - _close_db_handle(db_handle) - raise RuntimeApiError( - "command_failed", - f"failed to initialize layout geometry for step: {request.step}", - ) - - edit_session_id = self._new_layout_edit_id() - geometry_output_dir = ( - Path(tempfile.mkdtemp(prefix=f"ecc-{edit_session_id}-geometry-")) / "geometry-0" - ) - try: - _write_layout_edit_geometry_snapshot(module, geometry_output_dir) - except Exception: - _close_db_handle(db_handle) - shutil.rmtree(geometry_output_dir.parent, ignore_errors=True) - raise - - edit_session = LayoutEditSession( - edit_session_id=edit_session_id, - workspace_id=session.workspace_id, - step_name=request.step, - workspace_step=workspace_step, - db_handle=db_handle, - source_kind=source_kind, - source_paths=source_paths, - source_fingerprint=source_fingerprint, - geometry_output_dir=geometry_output_dir, - ) - session.layout_edit_session = edit_session - self._layout_edit_sessions[edit_session.edit_session_id] = edit_session - return _layout_edit_begin_result(edit_session, reused=False) - - return self._with_session_mutation_lock(request.workspace_id, begin) - - def layout_edit_apply(self, request: LayoutEditApplyRequest) -> dict: - def apply(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: - return _apply_layout_edit_operation( - edit_session, - command_id=request.command_id, - base_revision=request.base_revision, - operation=request.operation, - ) - - return self._with_layout_edit_session_mutation_lock(request.edit_session_id, apply) - - def floorplan_edit_inspect(self, request: FloorplanEditInspectRequest) -> dict: - def inspect(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: - module = _db_engine_module(edit_session.db_handle) - module_state = _floorplan_editor_inspect(module) - return { - "editSessionId": edit_session.edit_session_id, - "revision": edit_session.revision, - "geometryRevision": edit_session.geometry_revision, - "geometryManifestPath": str(edit_session.geometry_output_dir / "geometry.manifest"), - "dirty": edit_session.dirty, - "floorplanPlan": deepcopy(edit_session.floorplan_plan), - "pdnPlan": deepcopy(edit_session.pdn_plan), - "diagnostics": deepcopy(edit_session.validation_diagnostics), - "state": module_state, - } - - return self._with_layout_edit_session_mutation_lock(request.edit_session_id, inspect) - - def floorplan_edit_run_auto(self, request: FloorplanEditRunAutoRequest) -> dict: - def run_auto(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: - if not isinstance(request.request, dict): - raise RuntimeApiError("invalid_request", "request must be an object") - return _apply_layout_edit_operation( - edit_session, - command_id=request.command_id, - base_revision=request.base_revision, - operation={"kind": "run_auto", "request": request.request}, - ) - - return self._with_layout_edit_session_mutation_lock(request.edit_session_id, run_auto) - - def floorplan_edit_validate(self, request: FloorplanEditValidateRequest) -> dict: - def validate(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: - scope = request.scope.strip() if isinstance(request.scope, str) else "" - if not scope: - raise RuntimeApiError("invalid_request", "scope must be a non-empty string") - module = _db_engine_module(edit_session.db_handle) - result = _floorplan_editor_validate(module, scope) - edit_session.validation_diagnostics = _floorplan_diagnostics(result) - return { - "editSessionId": edit_session.edit_session_id, - "revision": edit_session.revision, - "scope": scope, - "valid": _floorplan_validation_ok(result), - "diagnostics": deepcopy(edit_session.validation_diagnostics), - } - - return self._with_layout_edit_session_mutation_lock(request.edit_session_id, validate) - - def layout_edit_save(self, request: LayoutEditSaveRequest) -> dict: - def save(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: - _validate_layout_edit_revision(request.expected_revision, "expected_revision") - if request.expected_revision != edit_session.revision: - raise RuntimeApiError( - "version_conflict", - "layout edit revision does not match", - { - "expectedRevision": request.expected_revision, - "actualRevision": edit_session.revision, - }, - ) - if not edit_session.dirty: - return _layout_edit_save_result(edit_session, saved=False) - - current_fingerprint = _artifact_fingerprint(edit_session.source_paths) - if current_fingerprint != edit_session.source_fingerprint: - raise RuntimeApiError( - "source_changed", - "layout edit source changed after the session began", - { - "expectedSourceFingerprint": edit_session.source_fingerprint, - "actualSourceFingerprint": current_fingerprint, - }, - ) - - module = _db_engine_module(edit_session.db_handle) - if edit_session.used_floorplan_editor: - validation = _floorplan_editor_validate(module, "all") - edit_session.validation_diagnostics = _floorplan_diagnostics(validation) - if not _floorplan_validation_ok(validation): - raise RuntimeApiError( - "floorplan_validation_failed", - "floorplan edit validation failed", - {"diagnostics": deepcopy(edit_session.validation_diagnostics)}, - ) - _merge_floorplan_export_intent( - edit_session, _floorplan_editor_export_intent(module) - ) - - artifacts = _publish_layout_edit_artifacts(edit_session, session.workspace) - output_db = _path_or_none( - _workspace_step_output_value(edit_session.workspace_step, "db") - ) - if output_db is None: - raise RuntimeApiError("command_failed", "layout edit output DB is missing") - edit_session.source_kind = "db" - edit_session.source_paths = (output_db,) - edit_session.source_fingerprint = _artifact_fingerprint(edit_session.source_paths) - edit_session.dirty = False - return _layout_edit_save_result(edit_session, saved=True, artifacts=artifacts) - - return self._with_layout_edit_session_mutation_lock(request.edit_session_id, save) - - def layout_edit_discard(self, request: LayoutEditDiscardRequest) -> dict: - def discard(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: - dirty = edit_session.dirty - self._discard_layout_edit_session(session) - return { - "editSessionId": request.edit_session_id, - "discarded": True, - "dirty": dirty, - } - - return self._with_layout_edit_session_mutation_lock(request.edit_session_id, discard) - - def _load_workspace(self, directory: str): - if not directory: - raise RuntimeApiError("invalid_request", "missing required field: directory") - if not _looks_like_old_workspace(directory): - raise RuntimeApiError("invalid_request", f"invalid workspace directory: {directory}") - - import chipcompiler.data as data_api - - workspace = data_api.load_workspace(directory=directory) - if workspace is None: - raise RuntimeApiError("command_failed", f"load workspace failed : {directory}") - return workspace - - def _get_session(self, workspace_id: str) -> WorkspaceSession: - try: - return self.sessions.get_session(workspace_id) - except WorkspaceSessionNotFound as exc: - raise RuntimeApiError( - "workspace_session_not_found", - f"workspace session not found: {workspace_id}", - ) from exc - - def _require_persistent_db(self) -> None: - if not self.persistent_db_enabled: - raise RuntimeApiError("command_failed", "persistent_db_disabled") - - @staticmethod - def _require_gui_operation_origin(origin: str) -> None: - if origin != "gui": - raise RuntimeApiError("invalid_request", "operation origin must be gui") - - def _new_layout_edit_id(self) -> str: - edit_session_id = f"layout-edit-{self._next_layout_edit_id}" - self._next_layout_edit_id += 1 - return edit_session_id - - def _with_layout_edit_session_mutation_lock( - self, - edit_session_id: str, - operation: Callable[[WorkspaceSession, LayoutEditSession], _T], - ) -> _T: - edit_session = self._layout_edit_sessions.get(edit_session_id) - if edit_session is None: - raise RuntimeApiError( - "layout_edit_session_not_found", - f"layout edit session not found: {edit_session_id}", - ) - - def run(session: WorkspaceSession) -> _T: - if session.layout_edit_session is not edit_session: - self._layout_edit_sessions.pop(edit_session_id, None) - raise RuntimeApiError( - "layout_edit_session_not_found", - f"layout edit session not found: {edit_session_id}", - ) - return operation(session, edit_session) - - return self._with_session_mutation_lock(edit_session.workspace_id, run) - - def _discard_layout_edit_session(self, session: WorkspaceSession) -> bool: - with self._layout_edit_lock: - edit_session = session.layout_edit_session - if edit_session is None: - return False - self._layout_edit_sessions.pop(edit_session.edit_session_id, None) - return self.sessions.release_layout_edit_session(session) - - def _release_session_db(self, session: WorkspaceSession) -> bool: - return self.sessions.release_session_db(session) - - def _should_capture_session_db(self, session: WorkspaceSession) -> bool: - return self.persistent_db_enabled and _db_handle_is_initialized(session.db_handle) - - def _build_flow_for_session( - self, - session: WorkspaceSession, - *, - attach_session_db: bool, - ): - engine_flow = build_flow_for_workspace(session.workspace) - if attach_session_db: - engine_flow.engine_db = session.db_handle - return engine_flow - - def _capture_flow_db( - self, - session: WorkspaceSession, - engine_flow, - *, - previous_handle, - ) -> None: - flow_db = getattr(engine_flow, "engine_db", None) - if _db_handle_is_initialized(flow_db): - session.db_handle = flow_db - if previous_handle is not None and previous_handle is not flow_db: - _close_db_handle(previous_handle) - return - - session.db_handle = None - if previous_handle is not None: - _close_db_handle(previous_handle) - - def _close_transient_flow_db(self, engine_flow) -> None: - _close_db_handle(getattr(engine_flow, "engine_db", None)) - - def _with_session_mutation_lock( - self, - workspace_id: str, - operation: Callable[[WorkspaceSession], _T], - ) -> _T: - session = self._get_session(workspace_id) - with session.mutation_lock: - return operation(session) - - def _refresh_workspace_config(self, workspace) -> None: - import chipcompiler.data as data_api - - data_api.refresh_workspace_config(workspace) - - def _prepare_workspace_for_rerun( - self, - workspace, - engine_flow, - *, - preserve_user_inputs: bool = False, - ) -> None: - import chipcompiler.data as data_api - - data_api.prepare_workspace_for_rerun( - workspace, - engine_flow, - preserve_user_inputs=preserve_user_inputs, - ) - - @staticmethod - def _rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool): - if not reset_dependents: - return [workspace_step] - workspace_steps = list(getattr(engine_flow, "workspace_steps", [])) - try: - start_index = workspace_steps.index(workspace_step) - except ValueError: - return [workspace_step] - return workspace_steps[start_index:] - - @staticmethod - def _notify_rerun_prepared( - observer, - workspace_steps, - *, - scope: str, - target_step: str = "", - ) -> None: - callback = getattr(observer, "on_rerun_prepared", None) - if callback is None: - return - callback( - affected_steps=[str(getattr(step, "name", "")) for step in workspace_steps], - scope=scope, - target_step=target_step, - ) - - @staticmethod - def _prepare_step_for_rerun(workspace, engine_flow, workspace_step) -> None: - WorkspaceRuntimeApi._prepare_steps_for_rerun( - workspace, - engine_flow, - [workspace_step], - ) - - @staticmethod - def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: - workspace_root = Path(workspace.directory).resolve() - unique_steps = [] - known_step_keys = set() - for workspace_step in workspace_steps: - key = ( - str(getattr(workspace_step, "name", "")), - str(getattr(workspace_step, "tool", "")), - ) - if key in known_step_keys: - continue - known_step_keys.add(key) - unique_steps.append(workspace_step) - - artifact_directories = [] - known_directories = set() - for workspace_step in unique_steps: - for directory in WorkspaceRuntimeApi._step_artifact_dirs(workspace_step): - resolved = WorkspaceRuntimeApi._validate_step_artifact_dir( - workspace_root, - directory, - workspace_step.name, - ) - if resolved in known_directories: - continue - known_directories.add(resolved) - artifact_directories.append((workspace_step.name, directory)) - - for step_name, directory in artifact_directories: - WorkspaceRuntimeApi._clear_step_artifact_dir( - workspace_root, - directory, - step_name, - ) - - updated_record = False - for workspace_step in unique_steps: - record = engine_flow.get_step(workspace_step.name, workspace_step.tool) - if record is None: - continue - record.update( - { - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - ) - updated_record = True - if updated_record: - engine_flow.save() - - for workspace_step in unique_steps: - WorkspaceRuntimeApi._reset_step_subflow(workspace_step) - WorkspaceRuntimeApi._reset_step_checklist(workspace_step) - - @staticmethod - def _reset_step_subflow(workspace_step) -> None: - from chipcompiler.utility import json_read, json_write - - subflow = getattr(workspace_step, "subflow", None) - path = getattr(subflow, "path", None) - if not path: - return - subflow_path = Path(path) - data = json_read(subflow_path) - steps = data.get("steps", []) if isinstance(data, dict) else [] - if not isinstance(steps, list): - return - for step in steps: - if not isinstance(step, dict): - continue - step.update( - { - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - ) - json_write(subflow_path, {"path": str(subflow_path), "steps": steps}) - subflow.steps = steps - - @staticmethod - def _reset_step_checklist(workspace_step) -> None: - from chipcompiler.data import Checklist - - checklist = getattr(workspace_step, "checklist", None) - path = getattr(checklist, "path", None) - if not path: - return - checklist_path = Path(path) - Checklist(checklist_path).replace([]) - checklist.checklist = [] - - @staticmethod - def _step_artifact_dirs(step) -> tuple[Path, ...]: - directories: list[Path] = [] - for field in ("output", "data", "feature", "analysis", "report", "log"): - value = getattr(step, field, {}) - directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None) - if directory: - directories.append(Path(directory)) - return tuple(dict.fromkeys(directories)) - - @staticmethod - def _clear_step_artifact_dir( - workspace_root: Path, - directory: Path, - step_name: str, - ) -> None: - WorkspaceRuntimeApi._validate_step_artifact_dir(workspace_root, directory, step_name) - if directory.exists(): - if not directory.is_dir(): - raise RuntimeApiError( - "command_failed", - f"step artifact is not a directory: {step_name}", - ) - shutil.rmtree(directory) - directory.mkdir(parents=True, exist_ok=True) - - @staticmethod - def _validate_step_artifact_dir( - workspace_root: Path, - directory: Path, - step_name: str, - ) -> Path: - resolved = directory.resolve() - if ( - resolved == workspace_root - or not path_is_within(resolved, workspace_root) - or directory.is_symlink() - ): - raise RuntimeApiError( - "command_failed", - f"step artifact escapes workspace: {step_name}", - ) - if directory.exists() and not directory.is_dir(): - raise RuntimeApiError( - "command_failed", - f"step artifact is not a directory: {step_name}", - ) - return resolved - - -def _layout_edit_begin_result(edit_session: LayoutEditSession, *, reused: bool) -> dict: - return { - "editSessionId": edit_session.edit_session_id, - "workspaceId": edit_session.workspace_id, - "step": edit_session.step_name, - "source": edit_session.source_kind, - "sourceFingerprint": edit_session.source_fingerprint, - "geometryManifestPath": str(edit_session.geometry_output_dir / "geometry.manifest"), - "revision": edit_session.revision, - "geometryRevision": edit_session.geometry_revision, - "dirty": edit_session.dirty, - "reused": reused, - } - - -def _layout_edit_save_result( - edit_session: LayoutEditSession, - *, - saved: bool, - artifacts: dict[str, str] | None = None, -) -> dict: - if artifacts is None: - artifacts = _layout_edit_published_artifacts(edit_session.workspace_step) - return { - "editSessionId": edit_session.edit_session_id, - "revision": edit_session.revision, - "geometryRevision": edit_session.geometry_revision, - "dirty": edit_session.dirty, - "saved": saved, - "sourceFingerprint": edit_session.source_fingerprint, - "artifacts": artifacts, - } - - -def _layout_edit_published_artifacts(workspace_step) -> dict: - geometry_dir = _path_or_none(_workspace_step_output_value(workspace_step, "geometry")) - geometry_manifest = _path_or_none( - _workspace_step_output_value(workspace_step, "geometry_manifest") - ) - if geometry_manifest is None and geometry_dir is not None: - geometry_manifest = geometry_dir / "geometry.manifest" - return { - "defPath": _path_text(_workspace_step_output_value(workspace_step, "def")), - "dbPath": _path_text(_workspace_step_output_value(workspace_step, "db")), - "gdsPath": _path_text(_workspace_step_output_value(workspace_step, "gds")), - "geometryManifestPath": str(geometry_manifest) if geometry_manifest else "", - } - - -def _layout_edit_source(workspace_step) -> tuple[str, tuple[Path, ...]]: - output_db = _path_or_none(_workspace_step_output_value(workspace_step, "db")) - if output_db is not None and output_db.is_dir(): - return "db", (output_db,) - - output_def = _existing_layout_def_path(_workspace_step_output_value(workspace_step, "def")) - if output_def is not None: - return "def", (output_def,) - - raise RuntimeApiError( - "command_failed", - f"layout edit source missing for step: {getattr(workspace_step, 'name', '')}", - ) - - -def _layout_edit_workspace_step( - workspace_step, - *, - source_kind: str, - source_paths: tuple[Path, ...], -): - edit_input = copy(getattr(workspace_step, "input", {})) - output_def = _existing_layout_def_path(_workspace_step_output_value(workspace_step, "def")) - if isinstance(edit_input, dict): - edit_input["db"] = source_paths[0] if source_kind == "db" else None - edit_input["def"] = output_def - edit_step = copy(workspace_step) - edit_step.input = edit_input - return edit_step - - edit_input.db = source_paths[0] if source_kind == "db" else None - edit_input.def_ = output_def - return replace(workspace_step, input=edit_input) - - -_FLOORPLAN_EDITOR_OPERATION_KINDS = frozenset( - { - "set_floorplan_outline", - "replace_tracks", - "upsert_io_pin_port", - "upsert_blockage", - "delete_blockage", - "upsert_instance_halo", - "delete_instance_halo", - "pdn.plan.patch", - "pdn.manual_segment.upsert", - "pdn.manual_segment.delete", - "pdn.manual_via.upsert", - "pdn.manual_via.delete", - "run_auto", - } -) - - -def _apply_layout_edit_operation( - edit_session: LayoutEditSession, - *, - command_id: object, - base_revision: object, - operation: object, -) -> dict: - if not isinstance(command_id, str) or not command_id.strip(): - raise RuntimeApiError("invalid_request", "missing required field: command_id") - - previous_result = edit_session.command_results.get(command_id) - if previous_result is not None: - return previous_result - - _validate_layout_edit_revision(base_revision, "base_revision") - if base_revision != edit_session.revision: - raise RuntimeApiError( - "version_conflict", - "layout edit revision does not match", - { - "expectedRevision": base_revision, - "actualRevision": edit_session.revision, - }, - ) - if not isinstance(operation, dict): - raise RuntimeApiError("invalid_request", "operation must be an object") - - kind = operation.get("kind") - if kind == "place_instance": - result = _apply_layout_edit_place_instance(edit_session, command_id, operation) - else: - result = _apply_floorplan_editor_operation(edit_session, command_id, operation) - edit_session.command_results[command_id] = result - return result - - -def _apply_layout_edit_place_instance( - edit_session: LayoutEditSession, - command_id: str, - operation: dict[str, Any], -) -> dict: - placement = _layout_edit_place_instance_operation(operation) - module = _db_engine_module(edit_session.db_handle) - place_instance = getattr(module, "place_instance", None) - if not callable(place_instance): - raise RuntimeApiError("command_failed", "place_instance is unavailable") - - accepted = place_instance( - inst_name=placement["inst_name"], - llx=placement["llx"], - lly=placement["lly"], - orient=placement["orient"], - cellmaster=placement["cellmaster"], - source=placement["source"], - placement_status=placement["placement_status"], - create_if_missing=placement["create_if_missing"], - ) - if not accepted: - raise RuntimeApiError( - "placement_rejected", - "place_instance rejected the requested placement", - {"instanceName": placement["inst_name"]}, - ) - - geometry_delta = _sync_layout_edit_instance_geometry(module, placement["inst_name"]) - geometry_manifest_path = _advance_layout_edit_geometry_snapshot(edit_session, module) - edit_session.revision += 1 - edit_session.geometry_revision += 1 - edit_session.dirty = True - if placement["create_if_missing"]: - edit_session.requires_verilog = True - return { - "editSessionId": edit_session.edit_session_id, - "commandId": command_id, - "revision": edit_session.revision, - "geometryRevision": edit_session.geometry_revision, - "geometryManifestPath": str(geometry_manifest_path), - "dirty": True, - "operation": { - "kind": "place_instance", - "instanceName": placement["inst_name"], - "origin": {"x": placement["llx"], "y": placement["lly"]}, - "orient": placement["orient"], - }, - "geometryDelta": geometry_delta, - } - - -def _apply_floorplan_editor_operation( - edit_session: LayoutEditSession, - command_id: str, - operation: dict[str, Any], -) -> dict: - kind = operation.get("kind") - if not isinstance(kind, str) or kind not in _FLOORPLAN_EDITOR_OPERATION_KINDS: - raise RuntimeApiError("invalid_request", "unsupported layout edit operation") - - module = _db_engine_module(edit_session.db_handle) - editor_result = _floorplan_editor_apply(module, operation) - if not _floorplan_editor_accepted(editor_result): - diagnostics = _floorplan_diagnostics(editor_result) - raise RuntimeApiError( - "floorplan_rejected", - "floorplan editor rejected the requested operation", - {"operationKind": kind, "diagnostics": diagnostics}, - ) - - model_patch = _floorplan_model_patch(editor_result) - _merge_floorplan_model_patch(edit_session, model_patch) - diagnostics = _floorplan_diagnostics(editor_result) - edit_session.validation_diagnostics = diagnostics - changed = bool(editor_result.get("changed", True)) - geometry_delta = _floorplan_geometry_delta(editor_result) - geometry_manifest_path = edit_session.geometry_output_dir / "geometry.manifest" - if changed: - geometry_manifest_path = _advance_layout_edit_geometry_snapshot(edit_session, module) - edit_session.revision += 1 - edit_session.geometry_revision += 1 - edit_session.dirty = True - edit_session.used_floorplan_editor = True - if _floorplan_bool(editor_result, "instancesChanged", "instances_changed") or _floorplan_bool( - model_patch, - "instancesChanged", - "instances_changed", - ): - edit_session.requires_verilog = True - - return { - "editSessionId": edit_session.edit_session_id, - "commandId": command_id, - "revision": edit_session.revision, - "geometryRevision": edit_session.geometry_revision, - "geometryManifestPath": str(geometry_manifest_path), - "dirty": edit_session.dirty, - "operation": {"kind": kind}, - "affectedRefs": _floorplan_affected_refs(editor_result), - "geometryDelta": geometry_delta, - "modelPatch": model_patch, - "diagnostics": diagnostics, - "changed": changed, - } - - -def _advance_layout_edit_geometry_snapshot(edit_session: LayoutEditSession, module) -> Path: - next_geometry_output_dir = ( - edit_session.geometry_output_dir.parent / f"geometry-{edit_session.geometry_revision + 1}" - ) - geometry_manifest_path = _write_layout_edit_geometry_snapshot(module, next_geometry_output_dir) - previous_geometry_output_dir = edit_session.geometry_output_dir - edit_session.geometry_output_dir = next_geometry_output_dir - shutil.rmtree(previous_geometry_output_dir, ignore_errors=True) - return geometry_manifest_path - - -def _floorplan_editor_apply(module, operation: dict[str, Any]) -> dict[str, Any]: - apply = getattr(module, "floorplan_editor_apply", None) - if not callable(apply): - apply = getattr(module, "floorplan_edit_apply", None) - if not callable(apply): - raise RuntimeApiError("command_failed", "floorplan editor is unavailable") - payload = deepcopy(operation) - result = _call_floorplan_editor_apply(apply, payload) - if not isinstance(result, dict): - raise RuntimeApiError("command_failed", "floorplan editor returned an invalid result") - return result - - -def _call_floorplan_editor_apply(apply, payload: dict[str, Any]): - try: - signature = inspect.signature(apply) - except (TypeError, ValueError): - return apply(request=payload) - - parameters = signature.parameters.values() - accepts_keyword_request = "request" in signature.parameters or any( - parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters - ) - if accepts_keyword_request: - return apply(request=payload) - return apply(payload) - - -def _floorplan_editor_inspect(module) -> dict[str, Any]: - inspect = getattr(module, "floorplan_editor_inspect", None) - if not callable(inspect): - inspect = getattr(module, "floorplan_edit_inspect", None) - if not callable(inspect): - return {} - result = inspect() - return deepcopy(result) if isinstance(result, dict) else {} - - -def _floorplan_editor_validate(module, scope: str) -> dict[str, Any]: - validate = getattr(module, "floorplan_editor_validate", None) - if not callable(validate): - validate = getattr(module, "floorplan_validate", None) - if not callable(validate): - return {"ok": True, "diagnostics": []} - try: - result = validate(scope=scope) - except TypeError: - result = validate(scope) - if not isinstance(result, dict): - raise RuntimeApiError("command_failed", "floorplan validation returned an invalid result") - return result - - -def _floorplan_editor_export_intent(module) -> dict[str, Any]: - export_intent = getattr(module, "floorplan_editor_export_intent", None) - if not callable(export_intent): - export_intent = getattr(module, "floorplan_export_intent", None) - if not callable(export_intent): - raise RuntimeApiError("command_failed", "floorplan editor export is unavailable") - result = export_intent() - if not isinstance(result, dict): - raise RuntimeApiError( - "command_failed", "floorplan editor export returned an invalid result" - ) - if not _floorplan_editor_accepted(result): - raise RuntimeApiError( - "command_failed", - "floorplan editor failed to export edit intent", - {"diagnostics": _floorplan_diagnostics(result)}, - ) - return result - - -def _floorplan_editor_accepted(result: dict[str, Any]) -> bool: - return bool(result.get("accepted", result.get("ok", False))) - - -def _floorplan_validation_ok(result: dict[str, Any]) -> bool: - return bool(result.get("ok", result.get("accepted", False))) - - -def _floorplan_diagnostics(result: dict[str, Any]) -> list[dict[str, Any]]: - raw = result.get("diagnostics", []) - if not isinstance(raw, list): - return [] - diagnostics = [] - for item in raw: - if isinstance(item, dict): - diagnostics.append(deepcopy(item)) - elif isinstance(item, str): - diagnostics.append({"message": item}) - return diagnostics - - -def _floorplan_affected_refs(result: dict[str, Any]) -> list[Any]: - refs = result.get("affectedRefs", result.get("affected_refs", [])) - return deepcopy(refs) if isinstance(refs, list) else [] - - -def _floorplan_geometry_delta(result: dict[str, Any]) -> dict[str, Any]: - delta = result.get("geometryDelta", result.get("geometry_delta", {})) - if not isinstance(delta, dict): - delta = {} - return { - "ok": bool(delta.get("ok", True)), - "snapshotRequired": bool( - delta.get( - "snapshotRequired", - delta.get( - "snapshot_required", - result.get("snapshotRequired", result.get("snapshot_required", False)), - ), - ) - ), - "updatedShapeCount": _geometry_delta_count(delta, "updatedShapeCount"), - "insertedShapeCount": _geometry_delta_count(delta, "insertedShapeCount"), - "deletedShapeCount": _geometry_delta_count(delta, "deletedShapeCount"), - "missingShapeCount": _geometry_delta_count(delta, "missingShapeCount"), - "events": deepcopy(delta.get("events", [])) - if isinstance(delta.get("events", []), list) - else [], - } - - -def _floorplan_model_patch(result: dict[str, Any]) -> dict[str, Any]: - patch = result.get("modelPatch", result.get("model_patch", {})) - if patch is None: - return {} - if not isinstance(patch, dict): - raise RuntimeApiError("command_failed", "floorplan editor returned an invalid model patch") - return deepcopy(patch) - - -def _floorplan_bool(data: dict[str, Any], *keys: str) -> bool: - return any(bool(data.get(key, False)) for key in keys) - - -def _merge_floorplan_model_patch( - edit_session: LayoutEditSession, model_patch: dict[str, Any] -) -> None: - floorplan_plan = _floorplan_patch_mapping(model_patch, "floorplanPlan", "floorplan_plan") - pdn_plan = _floorplan_patch_mapping(model_patch, "pdnPlan", "pdn_plan") - config_patch = _floorplan_patch_mapping(model_patch, "configPatch", "config_patch") - parameters_patch = _floorplan_patch_mapping( - model_patch, - "parametersPatch", - "parameters_patch", - ) - _deep_merge(edit_session.floorplan_plan, floorplan_plan) - _deep_merge(edit_session.pdn_plan, pdn_plan) - _deep_merge(edit_session.config_patch, config_patch) - _deep_merge(edit_session.parameters_patch, parameters_patch) - - -def _merge_floorplan_export_intent(edit_session: LayoutEditSession, intent: dict[str, Any]) -> None: - nested_intent = intent.get("intent") - if isinstance(nested_intent, dict): - intent = nested_intent - _merge_floorplan_model_patch(edit_session, intent) - if _floorplan_bool(intent, "requiresVerilog", "requires_verilog"): - edit_session.requires_verilog = True - - -def _floorplan_patch_mapping(data: dict[str, Any], *keys: str) -> dict[str, Any]: - present = [key for key in keys if key in data] - if len(present) > 1: - raise RuntimeApiError("command_failed", f"duplicate floorplan model patch field: {keys[0]}") - if not present: - return {} - value = data[present[0]] - if not isinstance(value, dict): - raise RuntimeApiError( - "command_failed", f"floorplan model patch field must be an object: {keys[0]}" - ) - return deepcopy(value) - - -def _deep_merge(target: dict[str, Any], patch: dict[str, Any]) -> None: - for key, value in patch.items(): - current = target.get(key) - if isinstance(current, dict) and isinstance(value, dict): - _deep_merge(current, value) - else: - target[key] = deepcopy(value) - - -def _layout_edit_place_instance_operation(operation: object) -> dict[str, Any]: - if not isinstance(operation, dict): - raise RuntimeApiError("invalid_request", "operation must be an object") - if operation.get("kind") != "place_instance": - raise RuntimeApiError("invalid_request", "unsupported layout edit operation") - - inst_name = _layout_edit_text(operation, "inst_name", "instName") - cellmaster = _layout_edit_optional_text(operation, "cellmaster", "cellMaster") - source = _layout_edit_optional_text(operation, "source") - placement_status = ( - _layout_edit_optional_text( - operation, - "placement_status", - "placementStatus", - ) - or "preserve" - ) - create_if_missing = _layout_edit_optional_bool( - operation, - "create_if_missing", - "createIfMissing", - default=False, - ) - orient = _layout_edit_optional_text(operation, "orient") - if not orient and (placement_status != "preserve" or create_if_missing): - raise RuntimeApiError("invalid_request", "missing required operation field: orient") - return { - "inst_name": inst_name, - "llx": _layout_edit_integer(operation, "llx"), - "lly": _layout_edit_integer(operation, "lly"), - "orient": orient, - "cellmaster": cellmaster, - "source": source, - "placement_status": placement_status, - "create_if_missing": create_if_missing, - } - - -def _layout_edit_text(operation: dict, *keys: str) -> str: - value = _layout_edit_value(operation, *keys) - if not isinstance(value, str) or not value.strip(): - raise RuntimeApiError("invalid_request", f"missing required operation field: {keys[0]}") - return value - - -def _layout_edit_optional_text(operation: dict, *keys: str) -> str: - value = _layout_edit_value(operation, *keys, default="") - if not isinstance(value, str): - raise RuntimeApiError("invalid_request", f"operation field must be a string: {keys[0]}") - return value - - -def _layout_edit_integer(operation: dict, *keys: str) -> int: - value = _layout_edit_value(operation, *keys) - if isinstance(value, bool) or not isinstance(value, int): - raise RuntimeApiError("invalid_request", f"operation field must be an integer: {keys[0]}") - return value - - -def _layout_edit_optional_bool( - operation: dict, - *keys: str, - default: bool, -) -> bool: - value = _layout_edit_value(operation, *keys, default=default) - if not isinstance(value, bool): - raise RuntimeApiError("invalid_request", f"operation field must be a boolean: {keys[0]}") - return value - - -def _layout_edit_value(operation: dict, *keys: str, default: object = None): - present = [key for key in keys if key in operation] - if len(present) > 1: - raise RuntimeApiError("invalid_request", f"duplicate operation field: {keys[0]}") - return operation[present[0]] if present else default - - -def _validate_layout_edit_revision(value: object, field_name: str) -> None: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise RuntimeApiError("invalid_request", f"{field_name} must be a non-negative integer") - - -def _sync_layout_edit_instance_geometry(module, inst_name: str) -> dict: - sync_instance = getattr(module, "sync_instance_geometry", None) - if not callable(sync_instance): - return { - "ok": False, - "snapshotRequired": True, - "updatedShapeCount": 0, - "insertedShapeCount": 0, - "deletedShapeCount": 0, - "missingShapeCount": 0, - "events": [], - } - - result = sync_instance(inst_name) - if not isinstance(result, dict): - return { - "ok": False, - "snapshotRequired": True, - "updatedShapeCount": 0, - "insertedShapeCount": 0, - "deletedShapeCount": 0, - "missingShapeCount": 0, - "events": [], - } - return { - "ok": bool(result.get("ok", False)), - "snapshotRequired": bool(result.get("snapshotRequired", False)), - "updatedShapeCount": _geometry_delta_count(result, "updatedShapeCount"), - "insertedShapeCount": _geometry_delta_count(result, "insertedShapeCount"), - "deletedShapeCount": _geometry_delta_count(result, "deletedShapeCount"), - "missingShapeCount": _geometry_delta_count(result, "missingShapeCount"), - "events": result.get("events", []) if isinstance(result.get("events", []), list) else [], - } - - -def _write_layout_edit_geometry_snapshot(module, output_dir: Path) -> Path: - save_snapshot = getattr(module, "geometry_session_snapshot_save", None) - if not callable(save_snapshot): - save_snapshot = getattr(module, "geometry_snapshot_save", None) - if not callable(save_snapshot) or not save_snapshot(output_dir=str(output_dir)): - raise RuntimeApiError("command_failed", "failed to write layout edit geometry snapshot") - manifest = output_dir / "geometry.manifest" - if not manifest.is_file(): - raise RuntimeApiError("command_failed", "layout edit geometry manifest is missing") - return manifest - - -def _geometry_delta_count(delta: dict, key: str) -> int: - value = delta.get(key, 0) - return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0 - - -def _publish_layout_edit_artifacts(edit_session: LayoutEditSession, workspace) -> dict[str, str]: - targets = _layout_edit_publish_targets(edit_session.workspace_step) - staged_workspace_data = _layout_edit_workspace_staging(edit_session, workspace) - targets.update(staged_workspace_data["targets"]) - stage_parent = _layout_edit_stage_parent(targets.values()) - stage_root = Path( - tempfile.mkdtemp(prefix=f".layout-edit-{edit_session.edit_session_id}-", dir=stage_parent) - ) - staged = {key: stage_root / key / target.name for key, target in targets.items()} - try: - module = _db_engine_module(edit_session.db_handle) - for staged_path in staged.values(): - staged_path.parent.mkdir(parents=True, exist_ok=True) - module.def_save(def_path=str(staged["def"])) - module.save_data(path=str(staged["db"])) - module.gds_save(output_path=str(staged["gds"])) - if not module.geometry_snapshot_save(output_dir=str(staged["geometry"])): - raise RuntimeApiError("command_failed", "failed to export layout geometry snapshot") - _stage_layout_edit_workspace_state(staged, staged_workspace_data, workspace) - _stage_layout_edit_verilog(module, staged, edit_session) - _validate_layout_edit_staging(staged, targets) - _validate_layout_edit_workspace_staging(staged, staged_workspace_data) - _publish_staged_layout_edit_artifacts(stage_root, staged, targets) - except RuntimeApiError: - raise - except Exception as exc: - raise RuntimeApiError("command_failed", f"failed to publish layout edit: {exc}") from exc - finally: - shutil.rmtree(stage_root, ignore_errors=True) - - _apply_layout_edit_workspace_staging(workspace, staged_workspace_data) - artifacts = _layout_edit_published_artifacts(edit_session.workspace_step) - artifacts.update(staged_workspace_data["artifacts"]) - return artifacts - - -def _layout_edit_workspace_staging( - edit_session: LayoutEditSession, - workspace, -) -> dict[str, Any]: - targets: dict[str, Path] = {} - json_data: dict[str, dict[str, Any]] = {} - artifacts: dict[str, str] = {} - result: dict[str, Any] = { - "targets": targets, - "json": json_data, - "artifacts": artifacts, - "parametersData": None, - "flowData": None, - } - - if edit_session.used_floorplan_editor: - config_target = _floorplan_config_target(workspace) - config_data = _read_layout_edit_json(config_target) - _deep_merge(config_data, edit_session.config_patch) - config_data["FloorplanPlan"] = deepcopy(edit_session.floorplan_plan) - config_data["PdnPlan"] = deepcopy(edit_session.pdn_plan) - targets["config"] = config_target - json_data["config"] = config_data - artifacts["configPath"] = str(config_target) - - if edit_session.parameters_patch: - parameter_target = _workspace_path(workspace, "parameters", "path") - if parameter_target is None: - raise RuntimeApiError("command_failed", "workspace parameters path is missing") - parameter_data = deepcopy(getattr(getattr(workspace, "parameters", None), "data", {}) or {}) - if not isinstance(parameter_data, dict): - raise RuntimeApiError("command_failed", "workspace parameters are invalid") - from chipcompiler.data.parameter_keys import normalize_parameter_dict - - _deep_merge(parameter_data, normalize_parameter_dict(edit_session.parameters_patch)) - targets["parameters"] = parameter_target - json_data["parameters"] = parameter_data - artifacts["parametersPath"] = str(parameter_target) - result["parametersData"] = parameter_data - - if edit_session.requires_verilog: - verilog_target = _path_or_none( - _workspace_step_output_value(edit_session.workspace_step, "verilog") - ) - if verilog_target is None: - raise RuntimeApiError("command_failed", "layout edit output Verilog is missing") - targets["verilog"] = verilog_target - artifacts["verilogPath"] = str(verilog_target) - - flow_target = _workspace_path(workspace, "flow", "path") - flow_data = deepcopy(getattr(getattr(workspace, "flow", None), "data", {}) or {}) - if ( - flow_target is not None - and isinstance(flow_data, dict) - and _mark_placement_and_later_stale(flow_data, edit_session.step_name) - ): - targets["flow"] = flow_target - json_data["flow"] = flow_data - artifacts["flowPath"] = str(flow_target) - result["flowData"] = flow_data - - return result - - -def _floorplan_config_target(workspace) -> Path: - config = getattr(workspace, "config", {}) - if isinstance(config, dict): - config_target = _path_or_none(config.get("Floorplan")) - if config_target is not None: - return config_target - workspace_directory = _path_or_none(getattr(workspace, "directory", None)) - if workspace_directory is None: - raise RuntimeApiError("command_failed", "workspace directory is missing") - return workspace_directory / "config" / "floorplan_ecc.json" - - -def _workspace_path(workspace, owner_name: str, path_name: str) -> Path | None: - owner = getattr(workspace, owner_name, None) - return _path_or_none(getattr(owner, path_name, None)) - - -def _read_layout_edit_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeApiError("command_failed", f"failed to read JSON artifact: {path}") from exc - if not isinstance(data, dict): - raise RuntimeApiError("command_failed", f"JSON artifact must contain an object: {path}") - return data - - -def _stage_layout_edit_workspace_state( - staged: dict[str, Path], - workspace_staging: dict[str, Any], - workspace, -) -> None: - for key, data in workspace_staging["json"].items(): - staged_path = staged[key] - staged_path.parent.mkdir(parents=True, exist_ok=True) - try: - staged_path.write_bytes(workspace_state_bytes(key, data, workspace, staged_path)) - except ValueError as exc: - raise RuntimeApiError("command_failed", str(exc)) from exc - - -def _stage_layout_edit_verilog( - module, staged: dict[str, Path], edit_session: LayoutEditSession -) -> None: - if "verilog" not in staged: - return - save_verilog = getattr(module, "verilog_save", None) - if not callable(save_verilog): - raise RuntimeApiError("command_failed", "verilog_save is unavailable") - staged["verilog"].parent.mkdir(parents=True, exist_ok=True) - save_verilog(output_verilog=str(staged["verilog"])) - - -def _validate_layout_edit_workspace_staging( - staged: dict[str, Path], - workspace_staging: dict[str, Any], -) -> None: - for key in workspace_staging["json"]: - if not staged[key].is_file(): - raise RuntimeApiError("command_failed", f"layout edit staged {key} is missing") - try: - read_workspace_state(key, staged[key]) - except ValueError as exc: - raise RuntimeApiError("command_failed", str(exc)) from exc - if "verilog" in staged and not staged["verilog"].is_file(): - raise RuntimeApiError("command_failed", "layout edit staged Verilog is missing") - - -def _apply_layout_edit_workspace_staging(workspace, workspace_staging: dict[str, Any]) -> None: - parameters_data = workspace_staging["parametersData"] - if parameters_data is not None: - workspace.parameters.data = parameters_data - flow_data = workspace_staging["flowData"] - if flow_data is not None: - workspace.flow.data = flow_data - - -def _mark_placement_and_later_stale(flow_data: dict[str, Any], step_name: str) -> bool: - if step_name != "Floorplan": - return False - steps = flow_data.get("steps") - if not isinstance(steps, list): - return False - placement_index = next( - ( - index - for index, step in enumerate(steps) - if isinstance(step, dict) and step.get("name") == "place" - ), - None, - ) - if placement_index is None: - return False - changed = False - for step in steps[placement_index:]: - if not isinstance(step, dict): - continue - desired = { - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - } - for key, value in desired.items(): - if step.get(key) != value: - step[key] = value - changed = True - return changed - - -def _layout_edit_publish_targets(workspace_step) -> dict[str, Path]: - targets = { - key: _path_or_none(_workspace_step_output_value(workspace_step, key)) - for key in ("def", "db", "gds", "geometry") - } - missing = [key for key, path in targets.items() if path is None] - if missing: - raise RuntimeApiError( - "command_failed", - f"layout edit output paths missing: {', '.join(missing)}", - ) - return targets - - -def _layout_edit_stage_parent(paths) -> Path: - parents = [str(path.parent) for path in paths] - common_parent = Path(os.path.commonpath(parents)) - common_parent.mkdir(parents=True, exist_ok=True) - return common_parent - - -def _validate_layout_edit_staging(staged: dict[str, Path], targets: dict[str, Path]) -> None: - manifest = _geometry_manifest_path(staged["geometry"], targets["geometry"], targets) - missing = [] - if not staged["def"].is_file(): - missing.append("def") - if not staged["gds"].is_file(): - missing.append("gds") - if not staged["db"].is_dir() or not any(staged["db"].iterdir()): - missing.append("db") - if not manifest.is_file(): - missing.append("geometry_manifest") - if missing: - raise RuntimeApiError( - "command_failed", - f"layout edit staging validation failed: {', '.join(missing)}", - ) - - -def _geometry_manifest_path( - geometry_dir: Path, - target_geometry_dir: Path, - targets: dict[str, Path], -) -> Path: - target_manifest = targets.get("geometry_manifest") - if target_manifest is None: - return geometry_dir / "geometry.manifest" - try: - return geometry_dir / target_manifest.relative_to(target_geometry_dir) - except ValueError: - return geometry_dir / "geometry.manifest" - - -def _publish_staged_layout_edit_artifacts( - stage_root: Path, - staged: dict[str, Path], - targets: dict[str, Path], -) -> None: - artifact_keys = tuple(targets) - backup_dir = stage_root / "backup" - backup_dir.mkdir() - journal_path = stage_root / "publish.journal" - journal_path.write_text( - json.dumps({"state": "prepared", "artifacts": artifact_keys}), - encoding="utf-8", - ) - backups: list[str] = [] - published: list[str] = [] - try: - for key in artifact_keys: - target = targets[key] - if target.exists() or target.is_symlink(): - os.replace(target, backup_dir / key) - backups.append(key) - journal_path.write_text( - json.dumps({"state": "backed_up", "artifacts": backups}), - encoding="utf-8", - ) - for key in artifact_keys: - target = targets[key] - target.parent.mkdir(parents=True, exist_ok=True) - os.replace(staged[key], target) - published.append(key) - journal_path.write_text( - json.dumps({"state": "published", "artifacts": published}), - encoding="utf-8", - ) - except Exception: - for key in reversed(published): - _remove_layout_edit_path(targets[key]) - for key in reversed(backups): - os.replace(backup_dir / key, targets[key]) - raise - - -def _remove_layout_edit_path(path: Path) -> None: - if path.is_dir() and not path.is_symlink(): - shutil.rmtree(path) - else: - path.unlink(missing_ok=True) - - -def _db_engine_module(db_handle): - module = getattr(db_handle, "engine", None) - if module is None: - module = getattr(db_handle, "ecc_module", None) - if module is None: - raise RuntimeApiError("command_failed", "layout edit DB module is unavailable") - return module - - -def _path_or_none(value: object) -> Path | None: - if value is None or value == "": - return None - return Path(value) - - -def _workspace_step_output_value(workspace_step, key: str): - output = getattr(workspace_step, "output", {}) - if isinstance(output, dict): - return output.get(key) - return getattr(output, "def_" if key == "def" else key, None) - - -def _path_text(value: object) -> str: - path = _path_or_none(value) - return str(path) if path is not None else "" - - -def _existing_layout_def_path(value: object) -> Path | None: - path = _path_or_none(value) - if path is None: - return None - candidates = (path, Path(f"{path}.gz")) - for candidate in candidates: - if candidate.is_file(): - return candidate - return None - - -def _artifact_fingerprint(paths: tuple[Path, ...]) -> str: - digest = hashlib.sha256() - for path in paths: - resolved_path = path.resolve() - digest.update(str(resolved_path).encode("utf-8")) - if resolved_path.is_dir(): - for item in sorted(resolved_path.rglob("*")): - if not item.is_file(): - continue - stat = item.stat() - digest.update(str(item.relative_to(resolved_path)).encode("utf-8")) - digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("ascii")) - elif resolved_path.is_file(): - stat = resolved_path.stat() - digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("ascii")) - else: - digest.update(b"missing") - return digest.hexdigest() - - -def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): - import chipcompiler.engine as engine_api - import chipcompiler.rtl2gds as rtl2gds_api - - engine_flow = engine_api.EngineFlow(workspace=workspace) - if not engine_flow.has_init(): - for step, tool, state in rtl2gds_api.build_rtl2gds_flow(): - engine_flow.add_step(step=step, tool=tool, state=state) - - if create_step_workspaces: - engine_flow.create_step_workspaces() - return engine_flow - - -def _workspace_session_result(session: WorkspaceSession) -> dict: - return {"workspaceId": session.workspace_id, "directory": str(session.directory)} - - -def _db_ensure_result( - *, - workspace_id: str, - step: str, - active: bool, - reused: bool, -) -> dict: - return { - "workspaceId": workspace_id, - "enabled": True, - "active": active, - "reused": reused, - "step": step, - } - - -def _db_handle_is_initialized(db_handle) -> bool: - return db_handle is not None and db_handle.has_init() - - -def _close_db_handle(db_handle) -> None: - close = getattr(db_handle, "close", None) - if callable(close): - close() - - -def _normalize_rtl_list(rtl_list: list[str]) -> list[str]: - result: list[str] = [] - seen = set() - for item in rtl_list: - path = str(item).strip() - if not path or path in seen: - continue - seen.add(path) - result.append(path) - return result - - -def _write_filelist(directory: str, rtl_paths: list[str]) -> str: - os.makedirs(directory, exist_ok=True) - filelist_path = os.path.join(directory, "filelist") - with open(filelist_path, "w", encoding="utf-8") as f: - for path in rtl_paths: - f.write(f'"{path}"\n' if any(ch.isspace() for ch in path) else f"{path}\n") - return filelist_path - - -def _materialize_inline_pdk_json(pdk_json: Any) -> tuple[Any, Path | None]: - if not isinstance(pdk_json, dict): - return pdk_json, None - - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - prefix="ecc-pdk-", - suffix=".json", - delete=False, - ) as f: - json.dump(pdk_json, f) - return f.name, Path(f.name) - - -def _looks_like_old_workspace(directory: str) -> bool: - if not os.path.isdir(directory): - return False - home = os.path.join(directory, "home") - if not os.path.isfile(os.path.join(home, "home.json")): - return False - from chipcompiler.data.workspace_config import ( - LEGACY_PARAMETERS_FILENAME, - WORKSPACE_CONFIG_FILENAME, - ) - - return os.path.isfile(os.path.join(home, WORKSPACE_CONFIG_FILENAME)) or os.path.isfile( - os.path.join(home, LEGACY_PARAMETERS_FILENAME) - ) - - -def _workspace_step_from_flow(workspace, name: str): - previous_step = None - for flow_step in workspace.flow.data.get("steps", []): - workspace_step = _build_workspace_step_for_info(workspace, flow_step, previous_step) - if flow_step.get("name") == name: - return workspace_step - if workspace_step is not None: - previous_step = workspace_step - return None - - -def _build_workspace_step_for_info(workspace, flow_step: dict, previous_step): - step_name = flow_step.get("name") - tool = flow_step.get("tool") - if not step_name or not tool: - return None - - if previous_step is None: - input_def = workspace.design.origin_def - input_verilog = workspace.design.origin_verilog - input_db = None - else: - input_def = previous_step.output.def_ or "" - input_verilog = previous_step.output.verilog or "" - input_db = previous_step.output.db or "" - - builder = _load_tool_builder(tool) - if builder is None or not hasattr(builder, "build_step"): - return None - - return builder.build_step( - workspace=workspace, - step_name=step_name, - input_def=input_def, - input_verilog=input_verilog, - input_db=input_db, - ) - - -def _load_tool_builder(tool: str): - import importlib - - module_alias = { - "klayout": "klayout_tool", - "dreamplace": "ecc_dreamplace", - "sizer": "ecc_sizer", - } - module_name = module_alias.get(tool, tool) - return importlib.import_module(f"chipcompiler.tools.{module_name}.builder") - - -def _init_db_engine_for_workspace_step(engine_flow, workspace_step): - engine_db = getattr(engine_flow, "engine_db", None) - if engine_db is None: - from chipcompiler.engine import EngineDB - - engine_db = EngineDB(workspace=engine_flow.workspace) - engine_flow.engine_db = engine_db - elif engine_db.has_init(): - return True - - return engine_db.create_db_engine(step=workspace_step) - - -def _success_state(): - from chipcompiler.data import StateEnum - - return StateEnum.Success - - -def _state_value(state: Any) -> str: - return getattr(state, "value", str(state)) - - -def _run_engine_flow_steps(engine_flow, *, rerun: bool, observer) -> bool: - run_steps = engine_flow.run_steps - if observer is not None and _callable_accepts_keyword(run_steps, "observer"): - return run_steps(rerun=rerun, observer=observer) - return run_steps(rerun=rerun) - - -def _run_engine_flow_step(engine_flow, workspace_step, *, rerun: bool, observer): - run_step = engine_flow.run_step - if observer is not None and _callable_accepts_keyword(run_step, "observer"): - return run_step(workspace_step, rerun=rerun, observer=observer) - return run_step(workspace_step, rerun=rerun) - - -def _callable_accepts_keyword(callback, keyword: str) -> bool: - try: - parameters = inspect.signature(callback).parameters.values() - except (TypeError, ValueError): - return False - return any( - parameter.name == keyword or parameter.kind == inspect.Parameter.VAR_KEYWORD - for parameter in parameters - ) diff --git a/chipcompiler/runtime/workspace_config_io.py b/chipcompiler/runtime/workspace_config_io.py deleted file mode 100644 index 611fed01a..000000000 --- a/chipcompiler/runtime/workspace_config_io.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python - -"""Workspace configuration IO helpers for the runtime API. - -Config-staging policy extracted from the runtime API: RPC creation payload -conversion and the format-agnostic staging used by layout-edit publish. -""" - -import json -import tomllib -from pathlib import Path -from typing import Any - - -def canonical_request_parameters(parameters: dict[str, Any] | None) -> dict[str, Any]: - """Normalize an RPC creation payload to the canonical flat vocabulary. - - GUI flat keys (including the positional geometry aliases) and any legacy - long keys are both converted here; the result is merged verbatim by the - workspace layer. - """ - if not parameters: - return {} - from chipcompiler.data.parameter_keys import geometry_to_parameters - - return geometry_to_parameters(parameters) - - -def read_workspace_config_toml(path: Path) -> None: - """Validate that a staged workspace config artifact is parseable TOML.""" - try: - with open(path, "rb") as file: - tomllib.load(file) - except (OSError, tomllib.TOMLDecodeError) as exc: - raise ValueError(f"failed to read workspace config artifact: {path}") from exc - - -def workspace_config_bytes(data: dict[str, Any], workspace) -> bytes: - """Render a workspace configuration TOML document for staging.""" - from chipcompiler.data.workspace_config import render_workspace_config - - workspace_dir = getattr(workspace, "directory", None) - if workspace_dir is None: - raise ValueError("workspace directory is missing") - payload = dict(data) - flow = payload.pop("_flow", None) - return render_workspace_config(Path(workspace_dir), payload, flow) - - -def workspace_state_bytes(kind: str, data: dict[str, Any], workspace, target: Path) -> bytes: - """Serialize one staged workspace-state artifact in its persisted format. - - The parameters artifact persists as the TOML workspace config when its - target carries a .toml suffix; every other artifact is JSON. - """ - if kind == "parameters" and target.suffix == ".toml": - return workspace_config_bytes(data, workspace) - return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8") - - -def read_workspace_state(kind: str, path: Path) -> None: - """Validate that a staged artifact parses in its persisted format.""" - if kind == "parameters" and path.suffix == ".toml": - read_workspace_config_toml(path) - return - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ValueError(f"failed to read JSON artifact: {path}") from exc - if not isinstance(data, dict): - raise ValueError(f"JSON artifact must contain an object: {path}") diff --git a/chipcompiler/tools/ecc/checklist_render.py b/chipcompiler/tools/ecc/checklist_render.py new file mode 100644 index 000000000..03c381e68 --- /dev/null +++ b/chipcompiler/tools/ecc/checklist_render.py @@ -0,0 +1,13 @@ +"""Non-persisting checklist rendering for read-only package inspection.""" + +from pathlib import Path + +from chipcompiler.data import Checklist + + +def render_checklist(path: Path | str, items, *, persist: bool = False) -> dict: + if persist: + checklist = Checklist(path) + checklist.replace(list(items)) + return checklist.data + return Checklist.from_items(path, items) diff --git a/chipcompiler/tools/ecc/metrics.py b/chipcompiler/tools/ecc/metrics.py index fe067833f..64f3b61ad 100644 --- a/chipcompiler/tools/ecc/metrics.py +++ b/chipcompiler/tools/ecc/metrics.py @@ -5,6 +5,7 @@ from pathlib import Path from chipcompiler.data import EccStep, StateEnum, StepEnum, StepMetrics, Workspace, WorkspaceStep +from chipcompiler.tools.ecc.qor_detail_facts import database_fact_summary, lvs_detail_summary from chipcompiler.tools.ecc.sta_qor import ( POST_SYNTHESIS_STA_CORNER, STA_POWER_SUMMARY_FILENAME, @@ -54,7 +55,7 @@ "name": "synthesis_power_internal_uw", "display_name": "Synthesis Internal Power", "unit": "uW", - "dimension": "power", + "dimension": "power_integrity", "polarity": "trend_only", "confidence": "medium", }, @@ -62,7 +63,7 @@ "name": "synthesis_power_switching_uw", "display_name": "Synthesis Switching Power", "unit": "uW", - "dimension": "power", + "dimension": "power_integrity", "polarity": "trend_only", "confidence": "medium", }, @@ -70,7 +71,7 @@ "name": "synthesis_power_dynamic_uw", "display_name": "Synthesis Dynamic Power", "unit": "uW", - "dimension": "power", + "dimension": "power_integrity", "polarity": "trend_only", "confidence": "medium", }, @@ -78,7 +79,7 @@ "name": "synthesis_power_leakage_uw", "display_name": "Synthesis Leakage Power", "unit": "uW", - "dimension": "power", + "dimension": "power_integrity", "polarity": "trend_only", "confidence": "medium", }, @@ -138,6 +139,13 @@ "dimension": "area_cost", "polarity": "trend_only", }, + "instance_area": { + "name": "instance_area", + "display_name": "Instance Area", + "unit": "um^2", + "dimension": "area_cost", + "polarity": "trend_only", + }, "Total nets": { "name": "net_count", "display_name": "Net Count", @@ -145,6 +153,62 @@ "dimension": "routability_physical", "polarity": "trend_only", }, + "macro_count": { + "name": "macro_count", + "display_name": "Macro Count", + "unit": "count", + "dimension": "area_cost", + "polarity": "trend_only", + }, + "macro_area": { + "name": "macro_area", + "display_name": "Macro Area", + "unit": "um^2", + "dimension": "area_cost", + "polarity": "trend_only", + }, + "std_cell_count": { + "name": "std_cell_count", + "display_name": "Standard Cell Count", + "unit": "count", + "dimension": "area_cost", + "polarity": "trend_only", + }, + "std_cell_area": { + "name": "std_cell_area", + "display_name": "Standard Cell Area", + "unit": "um^2", + "dimension": "area_cost", + "polarity": "trend_only", + }, + "clock_count": { + "name": "clock_count", + "display_name": "Clock Cell Count", + "unit": "count", + "dimension": "area_cost", + "polarity": "trend_only", + }, + "clock_area": { + "name": "clock_area", + "display_name": "Clock Cell Area", + "unit": "um^2", + "dimension": "area_cost", + "polarity": "trend_only", + }, + "io_pad_count": { + "name": "io_pad_count", + "display_name": "IO Pad Count", + "unit": "count", + "dimension": "routability_physical", + "polarity": "trend_only", + }, + "io_pad_area": { + "name": "io_pad_area", + "display_name": "IO Pad Area", + "unit": "um^2", + "dimension": "area_cost", + "polarity": "trend_only", + }, "GP HPWL": { "name": "place_hpwl", "display_name": "Place HPWL", @@ -1856,7 +1920,16 @@ def _sta_corner_context(step: WorkspaceStep, corner: str | None) -> dict | None: "core_utilization": "/Design Layout/core_usage", "io_pin_count": "/Design Statis/num_iopins", "instance_count": "/Design Statis/num_instances", + "instance_area": "/Instances/total/area", "net_count": "/Design Statis/num_nets", + "macro_count": "/Instances/macros/num", + "macro_area": "/Instances/macros/area", + "std_cell_count": "/Instances/logic/num", + "std_cell_area": "/Instances/logic/area", + "clock_count": "/Instances/clock/num", + "clock_area": "/Instances/clock/area", + "io_pad_count": "/Instances/iopads/num", + "io_pad_area": "/Instances/iopads/area", } @@ -2099,6 +2172,22 @@ def _metric_feature_source( def _qor_detail_records(step: WorkspaceStep, step_metrics: StepMetrics) -> list[dict]: details = [] + database_path = getattr(step.feature, "db", None) + database_summary = database_fact_summary(database_path) + database_source = _relative_step_path(step, database_path) + if database_summary is not None and database_source is not None: + details.append( + { + "id": "database_facts", + "presentation": "database_facts", + "summary": database_summary, + "feature_source": { + "kind": "feature", + "path": database_source, + "selector": "", + }, + } + ) detail_specs = ( ("place_map_metrics", "place_map_summary", getattr(step.feature, "map", None)), ("cts_clock_skew_metrics", "cts_clock_skew_table", getattr(step.feature, "step", None)), @@ -2165,6 +2254,23 @@ def _qor_detail_records(step: WorkspaceStep, step_metrics: StepMetrics) -> list[ }, } ) + if step.name == StepEnum.LVS.value: + feature_path = getattr(step.feature, "step", None) + source_path = _relative_step_path(step, feature_path) + summary = lvs_detail_summary(feature_path) + if source_path is not None and summary is not None: + details.append( + { + "id": "lvs_connectivity_summary", + "presentation": "lvs_connectivity_tables", + "summary": summary, + "feature_source": { + "kind": "feature", + "path": source_path, + "selector": "/lvs", + }, + } + ) return details @@ -3505,6 +3611,23 @@ def build_metrics_db(workspace: Workspace, step: EccStep) -> dict: ): _add_number_metric(metrics, label, statistics.get(key)) + instances = data.get("Instances", {}) + instances = instances if isinstance(instances, dict) else {} + for metric_id, instance_kind, key in ( + ("instance_area", "total", "area"), + ("macro_count", "macros", "num"), + ("macro_area", "macros", "area"), + ("std_cell_count", "logic", "num"), + ("std_cell_area", "logic", "area"), + ("clock_count", "clock", "num"), + ("clock_area", "clock", "area"), + ("io_pad_count", "iopads", "num"), + ("io_pad_area", "iopads", "area"), + ): + values = instances.get(instance_kind, {}) + if isinstance(values, dict): + _add_number_metric(metrics, metric_id, values.get(key)) + metrics.update(build_metrics_timing(workspace=workspace, step=step)) return metrics diff --git a/chipcompiler/tools/ecc/qor_detail_facts.py b/chipcompiler/tools/ecc/qor_detail_facts.py new file mode 100644 index 000000000..7777d1343 --- /dev/null +++ b/chipcompiler/tools/ecc/qor_detail_facts.py @@ -0,0 +1,210 @@ +"""Bounded dashboard detail facts derived from step feature JSON.""" + +from math import isfinite +from typing import Any + +from chipcompiler.utility import json_read + +_INSTANCE_CLASS_LIMIT = 32 +_LAYER_RECORD_LIMIT = 64 +_PIN_DISTRIBUTION_LIMIT = 64 +_LVS_RECORD_LIMIT = 100 + + +def qor_number(value: Any) -> int | float | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) if value.is_integer() else value if isfinite(value) else None + if isinstance(value, str): + text = value.strip() + if not text: + return None + percent = text.endswith("%") + if percent: + text = text[:-1].strip() + text = text.replace(",", "") + try: + number = float(text) + except ValueError: + return None + if percent: + number = number / 100.0 + if not isfinite(number): + return None + return int(number) if number.is_integer() else number + return None + + +def database_fact_summary(feature_path) -> dict | None: + data = json_read(feature_path or "") + if not isinstance(data, dict): + return None + layout = data.get("Design Layout") + statistics = data.get("Design Statis") + instances = data.get("Instances") + pins = data.get("Pins") + layers = data.get("Layers") + nets = data.get("Nets") + layout = layout if isinstance(layout, dict) else {} + statistics = statistics if isinstance(statistics, dict) else {} + instances = instances if isinstance(instances, dict) else {} + pins = pins if isinstance(pins, dict) else {} + layers = layers if isinstance(layers, dict) else {} + nets = nets if isinstance(nets, dict) else {} + + def instance_record(kind, value): + value = value if isinstance(value, dict) else {} + return { + "kind": kind, + "count": qor_number(value.get("num")), + "area": qor_number(value.get("area")), + "pin_count": qor_number(value.get("pin_num")), + } + + def layer_records(value, source_metric, output_metric): + if not isinstance(value, list): + return [] + records = [] + for index, item in enumerate(value[:_LAYER_RECORD_LIMIT]): + if not isinstance(item, dict): + continue + layer = item.get("layer_name") + records.append( + { + "layer": layer if isinstance(layer, str) and layer else f"Layer {index + 1}", + output_metric: qor_number(item.get(source_metric)), + } + ) + return records + + pin_distribution = pins.get("pin_distribution") + summary = { + "schema_version": 1, + "layout": { + "die_area": qor_number(layout.get("die_area")), + "die_usage": qor_number(layout.get("die_usage")), + "die_width": qor_number(layout.get("die_bounding_width")), + "die_height": qor_number(layout.get("die_bounding_height")), + "core_area": qor_number(layout.get("core_area")), + "core_usage": qor_number(layout.get("core_usage")), + "core_width": qor_number(layout.get("core_bounding_width")), + "core_height": qor_number(layout.get("core_bounding_height")), + "dbu": qor_number(layout.get("design_dbu")), + }, + "statistics": { + "io_pins": qor_number(statistics.get("num_iopins")), + "instances": qor_number(statistics.get("num_instances")), + "nets": qor_number(statistics.get("num_nets")), + "pdn": qor_number(statistics.get("num_pdn")), + }, + "instance_classes": [ + instance_record(kind, value) + for kind, value in sorted(instances.items()) + if kind != "total" and isinstance(kind, str) and isinstance(value, dict) + ][:_INSTANCE_CLASS_LIMIT], + "instance_total": instance_record("total", instances.get("total")), + "pin_distribution": [ + { + "pin_count": int(pin_count), + "instance_count": qor_number(item.get("inst_num")), + "net_count": qor_number(item.get("net_num")), + } + for item in ( + pin_distribution[:_PIN_DISTRIBUTION_LIMIT] + if isinstance(pin_distribution, list) + else [] + ) + if isinstance(item, dict) + and (pin_count := qor_number(item.get("pin_num"))) is not None + and pin_count >= 0 + and float(pin_count).is_integer() + ], + "cut_layers": layer_records(layers.get("cut_layers"), "via_num", "via_count"), + "routing_layers": layer_records(layers.get("routing_layers"), "wire_len", "wire_length"), + "wire_length": qor_number(nets.get("wire_len")), + "via_count": qor_number(nets.get("num_via")), + } + scalar_groups = (summary["layout"], summary["statistics"]) + has_instance_total = any( + summary["instance_total"][key] is not None for key in ("count", "area", "pin_count") + ) + if ( + not has_instance_total + and not any(value is not None for group in scalar_groups for value in group.values()) + and not any( + summary[key] + for key in ("instance_classes", "pin_distribution", "cut_layers", "routing_layers") + ) + and summary["wire_length"] is None + and summary["via_count"] is None + ): + return None + return summary + + +def lvs_text(value) -> str: + if isinstance(value, list): + return ", ".join(str(item).strip() for item in value if str(item).strip()) + return value.strip() if isinstance(value, str) else "" + + +def lvs_detail_summary(feature_path) -> dict | None: + feature = json_read(feature_path or "") + if not isinstance(feature, dict): + return None + section = feature.get("lvs", feature) + if not isinstance(section, dict): + return None + entities = [] + raw_entities = section.get("entity", []) + for item in raw_entities[:_LVS_RECORD_LIMIT] if isinstance(raw_entities, list) else []: + if not isinstance(item, dict) or not lvs_text(item.get("entity")): + continue + entities.append( + { + "entity": lvs_text(item.get("entity")), + "netlist": qor_number(item.get("netlist")), + "def": qor_number(item.get("def")), + "difference": qor_number(item.get("difference")), + } + ) + connectivity = [] + raw_connectivity = section.get("connectivity", []) + for item in raw_connectivity[:_LVS_RECORD_LIMIT] if isinstance(raw_connectivity, list) else []: + if not isinstance(item, dict) or not lvs_text(item.get("connectivity")): + continue + connectivity.append( + { + "connectivity": lvs_text(item.get("connectivity")), + "open": qor_number(item.get("open")), + "short": qor_number(item.get("short")), + "connected": qor_number(item.get("connected")), + "total": qor_number(item.get("total")), + } + ) + violations = [] + raw_violations = section.get("violations", []) + for item in raw_violations[:_LVS_RECORD_LIMIT] if isinstance(raw_violations, list) else []: + if not isinstance(item, dict): + continue + violation_type = lvs_text(item.get("type")) + if not violation_type: + continue + violations.append( + { + "type": violation_type, + "net": lvs_text(item.get("net")), + "instance": lvs_text(item.get("instance")), + "terminals": lvs_text(item.get("terminals")), + "components": lvs_text(item.get("components")), + } + ) + return { + "schema_version": 1, + "entities": entities, + "connectivity": connectivity, + "violations": violations, + } diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 74bde795b..18ef25752 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -883,7 +883,7 @@ def run_sta(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No sub_flow.update_step(step_name=EccSubFlowEnum.run_sta.value, state=StateEnum.Imcomplete) return False - if not os.path.exists(workspace.pdk.sdc): + if not workspace.pdk.sdc or not os.path.exists(workspace.pdk.sdc): workspace.logger.error("STA SDC does not exist: %s", workspace.pdk.sdc) sub_flow.update_step(step_name=EccSubFlowEnum.run_sta.value, state=StateEnum.Imcomplete) return False diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 25cd09847..5c9ab93a5 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -639,7 +639,9 @@ def _package_items(resource_issues) -> list[dict]: return items -def rebuild_home_checklist(workspace: Workspace, resource_issues=None) -> dict: +def rebuild_home_checklist( + workspace: Workspace, resource_issues=None, *, persist: bool = True +) -> dict: """Replace the aggregate workspace checklist from current step snapshots.""" workspace_directory = getattr(workspace, "directory", None) if not workspace_directory: @@ -690,8 +692,8 @@ def rebuild_home_checklist(workspace: Workspace, resource_issues=None) -> dict: # Recover home.json files whose checklist path was cleared by an older # home.reset(); persist so later checklist updates resolve as well. checklist_path = workspace_dir / "home" / "checklist.json" - if workspace.home.path is not None: + if persist and workspace.home.path is not None: workspace.home.set_checklist(checklist_path) - checklist = Checklist(checklist_path) - checklist.replace(list(deduplicated.values())) - return checklist.data + from chipcompiler.tools.ecc.checklist_render import render_checklist + + return render_checklist(checklist_path, deduplicated.values(), persist=persist) diff --git a/chipcompiler/tools/ecc/subflow.py b/chipcompiler/tools/ecc/subflow.py index 626ed157c..256f39b19 100644 --- a/chipcompiler/tools/ecc/subflow.py +++ b/chipcompiler/tools/ecc/subflow.py @@ -193,7 +193,7 @@ def update_step(self, step_name: str, state: str | StateEnum, info: dict | None self.save() - from chipcompiler.runtime.subflow_events import publish_subflow_stage + from chipcompiler.engine.subflow_events import publish_subflow_stage publish_subflow_stage(self.workspace, self.workspace_step, step_dict) diff --git a/chipcompiler/tools/ecc_sizer/builder.py b/chipcompiler/tools/ecc_sizer/builder.py index 6009ebebd..0723038a6 100644 --- a/chipcompiler/tools/ecc_sizer/builder.py +++ b/chipcompiler/tools/ecc_sizer/builder.py @@ -2,7 +2,7 @@ import shutil from pathlib import Path -from chipcompiler.data import EccStep, Workspace +from chipcompiler.data import EccStep, Workspace, step_storage_name from chipcompiler.tools.ecc import builder as ecc_builder from .utility import find_sizer_root @@ -22,7 +22,7 @@ def step_shape( Dependency-free (no rosettakit import at module scope of this module's callers) so deferred creation and selected creation share one shape. """ - safe_step_name = "_".join(step_name.split()).lower() + safe_step_name = step_storage_name(step_name, "sizer") step_directory = Path(workspace.directory) / f"{safe_step_name}_sizer" if output_def is None: output_def = step_directory / "output" / f"{workspace.design.name}_{safe_step_name}.def.gz" diff --git a/chipcompiler/tools/ecc_sizer/subflow.py b/chipcompiler/tools/ecc_sizer/subflow.py index b3cd76a8f..79c43fd8f 100644 --- a/chipcompiler/tools/ecc_sizer/subflow.py +++ b/chipcompiler/tools/ecc_sizer/subflow.py @@ -143,7 +143,7 @@ def update_step( step_dict["info"] = info self.save() - from chipcompiler.runtime.subflow_events import publish_subflow_stage + from chipcompiler.engine.subflow_events import publish_subflow_stage publish_subflow_stage(self.workspace, self.workspace_step, step_dict) diff --git a/chipcompiler/tools/yosys/subflow.py b/chipcompiler/tools/yosys/subflow.py index eca2cf7b4..861192a96 100644 --- a/chipcompiler/tools/yosys/subflow.py +++ b/chipcompiler/tools/yosys/subflow.py @@ -109,7 +109,7 @@ def update_step(self, step_name: str, state: str | StateEnum, info: dict | None self.save() - from chipcompiler.runtime.subflow_events import publish_subflow_stage + from chipcompiler.engine.subflow_events import publish_subflow_stage publish_subflow_stage(self.workspace, self.workspace_step, step_dict) diff --git a/chipcompiler/tools/yosys_lec/subflow.py b/chipcompiler/tools/yosys_lec/subflow.py index be356deaa..97fdf3da1 100644 --- a/chipcompiler/tools/yosys_lec/subflow.py +++ b/chipcompiler/tools/yosys_lec/subflow.py @@ -88,7 +88,7 @@ def update_step(self, step_name: str, state: str | StateEnum, info: dict | None step_dict["info"] = info self.save() - from chipcompiler.runtime.subflow_events import publish_subflow_stage + from chipcompiler.engine.subflow_events import publish_subflow_stage publish_subflow_stage(self.workspace, self.workspace_step, step_dict) break diff --git a/docs/development.cn.md b/docs/development.cn.md index 0b14b99a0..24c81ec45 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -247,8 +247,7 @@ chipcompiler/cli/commands/ # typer 命令定义层(薄) ├── project_config.py # project 子应用(set/unset/add/remove/show) ├── workspace.py # workspace 子应用(refresh) ├── signoff.py # signoff 子应用(inspect/export) - ├── report.py # report 子应用(summary/qor/checklist/step) - └── rpc.py # rpc 子应用(serve) + └── report.py # report 子应用(summary/qor/checklist/step) chipcompiler/cli/command_handlers/ # 业务处理层(唯一的处理器包,有状态/重逻辑) ├── project.py # init / check / run / migrate / workspace refresh(含 preset 解析与环境预检) ├── inspect.py # status / log / config @@ -274,7 +273,8 @@ chipcompiler/cli/inspection/ # 只读探查逻辑 chipcompiler/cli/project/ # config.py(ecc.toml 解析校验)/ config_fields.py(`ecc project` 的项目声明 schema)/ params.py(参数注册表)/ workspace_params.py(workspace 局部覆盖记录)/ manifest.py(项目形态分类)/ effective_config.py / config_params/(直配参数 schema)/ migrate*.py(旧布局迁移)/ run_*.py(run 目标解析与分发) chipcompiler/cli/rendering/ # 输出渲染(render / renderers / pretty / progress) chipcompiler/engine/signoff/ # 签核收集器 + 设计/checklist 报告(包,见下文) -chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植) +chipcompiler/engine/qor_scoring.py # QoR 评分规则(阈值/权重/选指标,Studio Snapshot 与 CLI 共用) +chipcompiler/engine/qor_report.py # CLI QoR 报告(读当前 analysis,调用 qor_scoring) ``` 模块归属由 `test/cli/test_cli_module_layout.py` 强制:核心框架必须在 `cli/core/`、命令注册在 `cli/commands/`、全部处理器在唯一的 `cli/command_handlers/` 包、只读探查在 `cli/inspection/`、渲染在 `cli/rendering/`;旧的 `chipcompiler/cli/*.py` 平铺模块必须不可导入。新增文件时放进对应子包,不要在 `cli/` 根下新建模块。 @@ -309,7 +309,7 @@ chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植) - 错误记录用 `core/records.py::error_record(...)`,产出 `{"kind": "error", "error": "<机器可读错误码>", ...}`;TEXT 模式下由 `render_error` 打成 `[error]` 块。错误码是稳定契约(如 `missing_config`、`run_exists`、`unknown_parameter`、`invalid_value`),测试会对它们断言。 - 给用户的「下一步」提示统一用 `core/output.py::disclosure_cmd("ecc status", project, run_id)` 生成可复制的完整命令,记录里放在 `inspect` / `log_cmd` / `run` 等字段。 -`ecc version` 直接格式化版本元数据;另有一个隐藏的 `--json` 选项(单对象、版本专用 schema)预留给桌面应用,不出现在 `--help` 中。`ecc rpc serve` 与 `ecc layout-image` 有意不使用 records 渲染器输出模式。 +`ecc version` 直接格式化版本元数据;另有一个隐藏的 `--json` 选项(单对象、版本专用 schema)预留给桌面应用,不出现在 `--help` 中。`ecc layout-image` 有意不使用 records 渲染器输出模式。 ### 新增一个命令 @@ -422,20 +422,17 @@ config_param( #### 扩展签核(`ecc signoff inspect/export`) -- **CLI 层**:`cli/commands/signoff.py` + `cli/command_handlers/signoff.py`。`inspection/discovery.py::resolve_loaded_workspace()` 在选定项目中解析受管 `--workspace NAME`(或唯一活跃 workspace)。inspect 复用 `runtime/signoff_export.py::inspect_signoff_package`(blocked 也 rc=0);export 复用 `export_signoff_package_archive`(`RuntimeApiError` → `signoff_incomplete`)。 +- **CLI 层**:`cli/commands/signoff.py` + `cli/command_handlers/signoff.py`。`inspection/discovery.py::resolve_loaded_workspace()` 在选定项目中解析受管 `--workspace NAME`(或唯一活跃 workspace)。inspect 复用 `engine/signoff_export.py::inspect_signoff_package`(blocked 也 rc=0);export 复用 `export_signoff_package_archive`(`SignoffExportError` → `signoff_incomplete`)。 - **引擎层**:`chipcompiler/engine/signoff/` 包负责签核收集器 `SignoffPackageCollector`,以及就绪度检查和归档导出所使用的包级 API。 #### 扩展报告(`ecc report summary/qor/checklist/step`) - **设计总结**:`ecc report summary` 调用 `chipcompiler.engine.signoff.generate_text_report`。其实现按职责分模块(`report.py` 编排 / `report_data.py` 数据契约 / `report_extract.py` 解析器+workspace 收集 / `report_sections.py` 分区抽取 / `report_timing.py` timing 链 / `report_text.py` 格式化),全部经包 `__init__` 对外暴露。新增报告分区时,在 `report_sections.py`(或 timing 链)增加 `_extract_(q)`,并在 `report.py` 编排处注册。 -- `engine/qor_report.py`:GUI `projectQorTrend.ts` 的单 workspace 移植——常量表(`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`)+ 归一化 + 项目级记录选择(role 优先级 final>gate>trend、area_cost 只取最后成功的 area 步)+ `score_record` 计分公式 + 维度加权(不重归一化)。新增可计分指标 = 在 GUI 与 `METRIC_FAIL_VALUES` 同步加阈值。 +- `engine/qor_scoring.py`:唯一的 QoR 评分规则。无 I/O;负责 metric 选择(role 优先级 final>gate>trend、area_cost 只取最后成功的 area 步)、单指标阈值(`METRIC_FAIL_VALUES`)、维度平均、权重(缺项不重归一化)和 overall score。Studio 经 Snapshot `qorAssessment`(`engine/qor.py`)消费同一套规则;不要在 GUI 或 CLI 再抄一份阈值表。新增可计分指标 = 只在 `METRIC_FAIL_VALUES` 加阈值。 +- `engine/qor_report.py`:CLI `ecc report qor` 的采集与文本。读取当前 workspace 的 v3 `qor_metrics.json`,交给 `score_qor`,再渲染总分、维度表和逐指标明细。不拥有评分公式。 - `engine/signoff/report_checklist.py`:只读渲染 `home/checklist.json`(不合法时报 unavailable,绝不回写文件)。 - CLI:`cli/commands/report.py` + `cli/command_handlers/report.py`;workspace 解析复用 `inspection/discovery.py`(`resolve_workspace_path` 是无副作用核心,`resolve_command_workspace` 是核心加 `load_workspace`;signoff、report 与只读的 status/log/config 共用)。 -#### 扩展 RPC(`ecc rpc serve`) - -`rpc serve --stdio` 启动 JSON-RPC 2.0 sidecar(`chipcompiler/runtime/stdio_server.py`)。方法在 `chipcompiler/runtime/methods.py::RUNTIME_METHODS` 声明(`method_name` + pydantic `request_model` + `handler_name`),handler 实现在 `chipcompiler/runtime/workspace_api.py`,由 `runtime/server.py` 统一挂载;协议细节见 [rpc-guide.md](rpc-guide.md)。新增方法 = 加一个 `RuntimeMethodSpec` + 对应 API 方法 + 请求模型,无需改 CLI 层。 - #### 扩展项目声明(`ecc project *` / `ecc workspace refresh`) - `ecc project set/unset/add/remove/show` 的可编辑键在 `cli/project/config_fields.py::PROJECT_FIELDS` 声明(`key` / TOML 表 / 字段名 / 类型 / `list_value`)。加一个字段五个子命令自动生效;`add`/`remove` 硬性只支持 `design.rtl`(其余键报 `unsupported_project_collection`)。 @@ -490,7 +487,7 @@ uv run ecc run --project gcd --preset rtl2gds ### 报告 -`ecc report qor` 按 GUI 项目看板相同的方式给 workspace 打分(每指标对固定 fail 阈值计分、维度求均值、加权总分——缺失维度不做权重重归一化);`ecc report checklist` 渲染签核清单状态;`ecc report summary` 写出与 GUI 一致的文本设计总结。三者默认写入 `/signoff/`,接受 `-o` 以及常规的 `--project` 和可选的受管 `--workspace NAME` 选择器: +`ecc report qor` 用共享的 `qor_scoring` 规则给 workspace 打分(每指标对固定 fail 阈值计分、维度求均值、加权总分——缺失维度不做权重重归一化);Studio Snapshot `qorAssessment` 用同一套计分器。`ecc report checklist` 渲染签核清单状态;`ecc report summary` 写出与 GUI 一致的文本设计总结。三者默认写入 `/signoff/`,接受 `-o` 以及常规的 `--project` 和可选的受管 `--workspace NAME` 选择器: ```bash uv run ecc report qor --project gcd @@ -670,6 +667,5 @@ Python 层调试可直接调同一 CLI 模块: ## 相关文档 -- [rpc-guide.md](rpc-guide.md) - RPC sidecar 协议 - [examples/](examples/) - 示例项目与 CLI 用法 - [English version](development.md) diff --git a/docs/development.md b/docs/development.md index bb23cba1d..6794d9c98 100644 --- a/docs/development.md +++ b/docs/development.md @@ -276,8 +276,7 @@ chipcompiler/cli/commands/ # typer command definition layer (thin) ├── project_config.py # project sub-app (set/unset/add/remove/show) ├── workspace.py # workspace sub-app (refresh) ├── signoff.py # signoff sub-app (inspect/export) - ├── report.py # report sub-app (summary/qor/checklist/step) - └── rpc.py # rpc sub-app (serve) + └── report.py # report sub-app (summary/qor/checklist/step) chipcompiler/cli/command_handlers/ # business logic layer (stateful / heavy) ├── project.py # init / check / run / migrate / workspace refresh (preset resolution and environment preflight) ├── inspect.py # status / log / config @@ -303,7 +302,8 @@ chipcompiler/cli/inspection/ # read-only probing logic chipcompiler/cli/project/ # config.py (ecc.toml parsing and validation) / config_fields.py (project declaration schema for `ecc project`) / params.py (parameter registry) / workspace_params.py (workspace-local override records) / manifest.py (project-state classification) / effective_config.py / config_params/ (direct-config schemas) / migrate*.py (legacy-layout migration) / run_*.py (workspace target resolution and dispatch) chipcompiler/cli/rendering/ # output rendering (render / renderers / pretty / progress) chipcompiler/engine/signoff/ # signoff collector + design/checklist reports (package, see below) -chipcompiler/engine/qor_report.py # overall QoR scoring (port of the GUI rules) +chipcompiler/engine/qor_scoring.py # QoR scoring rules (thresholds/weights/selection; shared with Studio Snapshot) +chipcompiler/engine/qor_report.py # CLI QoR report (reads current analysis, calls qor_scoring) ``` Module placement is enforced by `test/cli/test_cli_module_layout.py`: the core @@ -350,8 +350,8 @@ Commands dispatched through `execute_command()` use a "list of records": `ecc version` formats version metadata directly; it also has a hidden `--json` flag (a single object with a version-specific schema) reserved for the desktop -app and kept out of `--help`. `ecc rpc serve` and `ecc layout-image` -intentionally do not use record-renderer output modes. +app and kept out of `--help`. `ecc layout-image` intentionally does not use +record-renderer output modes. ### Adding A New Command @@ -554,9 +554,9 @@ required by doctor. - **CLI layer**: `cli/commands/signoff.py` + `cli/command_handlers/signoff.py`. `inspection/discovery.py::resolve_loaded_workspace()` resolves a managed `--workspace NAME` in the selected project (or its sole active workspace). - inspect reuses `runtime/signoff_export.py::inspect_signoff_package` (blocked + inspect reuses `engine/signoff_export.py::inspect_signoff_package` (blocked still exits 0); export reuses `export_signoff_package_archive` - (`RuntimeApiError` → `signoff_incomplete`). + (`SignoffExportError` → `signoff_incomplete`). - **Engine layer**: the `chipcompiler/engine/signoff/` package owns the signoff collector `SignoffPackageCollector` and the package-export APIs used by readiness inspection and archive generation. @@ -571,13 +571,18 @@ required by doctor. / `report_text.py` formatting), all exposed through the package `__init__`. Add a report section through an `_extract_(q)` in `report_sections.py` (or the timing chain) and register it from `report.py`. -- `engine/qor_report.py`: the single-workspace port of the GUI's - `projectQorTrend.ts` — constant tables - (`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`) + - normalization + project-level record selection (role priority - final>gate>trend; area_cost only from the last successful area step) + the - `score_record` formulas + dimension weighting (no renormalization). Adding a - scoreable metric = adding its threshold here and in the GUI. +- `engine/qor_scoring.py`: the only QoR scoring rules. No I/O; owns metric + selection (role priority final>gate>trend; area_cost only from the last + successful area step), per-metric thresholds (`METRIC_FAIL_VALUES`), + dimension averages, weights (absent dimensions are not renormalized), and + the overall score. Studio consumes the same rules through Snapshot + `qorAssessment` (`engine/qor.py`); do not copy the threshold table into the + GUI or CLI. Adding a scoreable metric = adding its threshold in + `METRIC_FAIL_VALUES` only. +- `engine/qor_report.py`: collection and text for `ecc report qor`. Reads the + workspace's current v3 `qor_metrics.json` files, calls `score_qor`, then + renders the overall score, dimension table, and per-metric detail. It does + not own the scoring formulas. - `engine/signoff/report_checklist.py`: read-only rendering of `home/checklist.json` (reports unavailable on an invalid file; never writes back). @@ -586,17 +591,6 @@ required by doctor. side-effect-free core, `resolve_command_workspace` = core + `load_workspace`; shared by signoff, report, and the read-only status/log/config commands). -#### Extending the RPC (`ecc rpc serve`) - -`rpc serve --stdio` starts the JSON-RPC 2.0 sidecar -(`chipcompiler/runtime/stdio_server.py`). Methods are declared in -`chipcompiler/runtime/methods.py::RUNTIME_METHODS` (`method_name` + a pydantic -`request_model` + `handler_name`), handler implementations live in -`chipcompiler/runtime/workspace_api.py`, and `runtime/server.py` mounts them -uniformly; protocol details in [rpc-guide.md](rpc-guide.md). Adding a -method = one `RuntimeMethodSpec` + the matching API method + a request model; -no CLI-layer changes needed. - #### Extending project declarations (`ecc project *` / `ecc workspace refresh`) - The editable keys of `ecc project set/unset/add/remove/show` are declared in @@ -677,9 +671,10 @@ uv run ecc run --project gcd --preset rtl2gds ### Reports -`ecc report qor` scores the workspace the same way the GUI project dashboard -does (per-metric scores against fixed fail thresholds, dimension averages, +`ecc report qor` scores the workspace with the shared `qor_scoring` rules +(per-metric scores against fixed fail thresholds, dimension averages, weighted overall — weights are not renormalized over missing dimensions); +Studio Snapshot `qorAssessment` uses the same scorer. `ecc report checklist` renders the signoff checklist status, and `ecc report summary` writes the GUI-parity text design summary. All three write to `/signoff/` by default and accept `-o` plus the usual @@ -890,6 +885,5 @@ standards). ## Related Documentation -- [RPC Guide](rpc-guide.md) - RPC sidecar protocol - [Examples](examples/) - Example projects and CLI usage - [中文开发指南](development.cn.md) diff --git a/docs/index.md b/docs/index.md index 72997594c..3988f6492 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,10 +11,9 @@ The `ecc` command-line tool ships bilingual guides (`.en.md` / `.cn.md`): - First project, the 15-step `rtl2gds` flow, signoff package, and reports - Tuning parameters, workspaces, and rerun scenarios - **[CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md)** / **[中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md)** - All currently supported commands - - Every command and option: `init`/`check`/`run`/`status`/`log`/`config`/`doctor`/`param`/`pdk`/`project`/`workspace`/`signoff`/`report`/`rpc`/`layout-image` + - Every command and option: `init`/`check`/`run`/`status`/`log`/`config`/`doctor`/`param`/`pdk`/`project`/`workspace`/`signoff`/`report`/`layout-image` - Run selectors (`--resume`/`--from`/`--to`/`--only`), error-code reference, end-to-end workflows - **[CLI Config Reference](../chipcompiler/docs/ecc-config-ref.en.md)** / **[中文配置参考](../chipcompiler/docs/ecc-config-ref.cn.md)** - `ecc.toml`, workspace files, and the parameter system -- **[RPC Guide](rpc-guide.md)** - Private JSON-RPC runtime sidecar protocol (`ecc rpc serve`) ## Core Documentation @@ -56,7 +55,6 @@ ChipCompiler supports various EDA file formats. Technical specifications for par - **Look up an `ecc` command or option** → [CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md) / [中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md) - **Understand `ecc.toml` / workspace files / parameters** → [CLI Config Reference](../chipcompiler/docs/ecc-config-ref.en.md) / [中文配置参考](../chipcompiler/docs/ecc-config-ref.cn.md) - **Extend the CLI with new commands** → [CLI Dev Guide](development.md#extending-the-cli) -- **Use legacy workspace commands** → [RPC Guide](rpc-guide.md) - **Set up development environment** → [Development Guide](development.md) - **Create a release** → [Release Guide](release.md) - **Add new tools** → [Development Guide - Adding EDA Tools](development.md#add-a-new-eda-tool) diff --git a/docs/rpc-guide.md b/docs/rpc-guide.md deleted file mode 100644 index e8225fb8f..000000000 --- a/docs/rpc-guide.md +++ /dev/null @@ -1,282 +0,0 @@ -# Runtime Sidecar RPC Guide - -Workspace operations are exposed through the private ECC runtime sidecar: - -```bash -ecc rpc serve --stdio -``` - -Persistent ECC DB reuse is disabled by default. To expose the explicit DB -lifecycle methods, start the sidecar with: - -```bash -ecc rpc serve --stdio --persistent-db -``` - -The sidecar speaks JSON-RPC 2.0 over stdio. Each JSON-RPC payload is framed with -a `Content-Length` header. Stdout is reserved for framed protocol messages; -diagnostics and tool output belong on stderr. - -The public CLI supports only `ecc workspace refresh NAME` from this resource -area. It reconstructs a workspace already declared in `project.json` from the -current `ecc.toml`, without executing the flow. The legacy workspace create/run -commands and their custom server-shaped JSON envelope are not supported. -Project commands such as `ecc init`, `ecc run`, `ecc status`, `ecc config`, and -`ecc param` remain ordinary stateless CLI commands. - -## Framing - -Each request is UTF-8 JSON preceded by a byte length: - -```text -Content-Length: 63 - -{"jsonrpc":"2.0","method":"rpc.ping","params":{},"id":"ping-1"} -``` - -The server returns another framed payload: - -```text -Content-Length: 52 - -{"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"} -``` - -## Handshake - -Call `rpc.hello` first to verify protocol compatibility and discover the -first-slice method list: - -```json -{ - "jsonrpc": "2.0", - "method": "rpc.hello", - "params": { - "version": 1 - }, - "id": "hello-1" -} -``` - -The result includes `version`, `eccVersion`, and `capabilities`. - -Default `ecc rpc serve --stdio` capabilities do not include persistent DB -methods. When `--persistent-db` is enabled, `rpc.hello` also advertises -`db.ensure` and `db.release`. - -## Open A Workspace - -Open an existing workspace directory: - -```json -{ - "jsonrpc": "2.0", - "method": "workspace.open", - "params": { - "directory": "/path/to/gcd" - }, - "id": "open-1" -} -``` - -The result returns a session identifier: - -```json -{ - "workspaceId": "workspace-1", - "directory": "/path/to/gcd" -} -``` - -Follow-up workspace and flow calls use `workspaceId`. They do not take the -workspace directory again unless the method explicitly documents a directory -parameter. - -Opening or creating a workspace does not initialize persistent native DB state. -Persistent DB reuse starts only after an explicit `db.ensure` call in a sidecar -process started with `--persistent-db`. - -## Create A Workspace - -Create accepts the workspace directory, PDK name, optional PDK paths, design -parameters, and optional input files: - -```json -{ - "jsonrpc": "2.0", - "method": "workspace.create", - "params": { - "directory": "/path/to/gcd", - "pdk": "ics55", - "pdkRoot": "/path/to/icsprout55-pdk", - "parameters": { - "design": "gcd", - "top_module": "gcd", - "clock": "clk", - "frequency_max": 100 - }, - "originVerilog": "/path/to/gcd.v", - "rtlList": ["/path/to/gcd.v"] - }, - "id": "create-1" -} -``` - -If `filelist` is omitted and `rtlList` is present, ECC writes a workspace-local -filelist before creating the workspace. - -## Inspect A Workspace - -Use the returned `workspaceId` to inspect session state: - -```json -{ - "jsonrpc": "2.0", - "method": "workspace.home", - "params": { - "workspaceId": "workspace-1" - }, - "id": "home-1" -} -``` - -Tool-specific step information is available through `workspace.info`: - -```json -{ - "jsonrpc": "2.0", - "method": "workspace.info", - "params": { - "workspaceId": "workspace-1", - "step": "Synthesis", - "id": "layout" - }, - "id": "info-1" -} -``` - -Common info ids include `views`, `layout`, `metrics`, `subflow`, `analysis`, -`maps`, `checklist`, `sta`, and `config`. - -## Mutating Workspace Calls - -The runtime serializes mutating calls for the same workspace session. Supported -first-slice mutation methods are: - -- `workspace.refresh_config` -- `workspace.sync_config` -- `workspace.reset_flow` -- `flow.run` -- `flow.run_step` -- `workspace.close` - -`workspace.sync_config` requires `configPath` to be inside the workspace -`config/` directory: - -```json -{ - "jsonrpc": "2.0", - "method": "workspace.sync_config", - "params": { - "workspaceId": "workspace-1", - "configPath": "/path/to/gcd/config/route.json" - }, - "id": "sync-1" -} -``` - -Run a single step: - -```json -{ - "jsonrpc": "2.0", - "method": "flow.run_step", - "params": { - "workspaceId": "workspace-1", - "step": "Synthesis", - "rerun": false - }, - "id": "step-1" -} -``` - -## Persistent DB Lifecycle - -Persistent DB lifecycle calls are private runtime capabilities and are available -only when the sidecar was started with `--persistent-db`. - -Ensure a session-scoped DB handle: - -```json -{ - "jsonrpc": "2.0", - "method": "db.ensure", - "params": { - "workspaceId": "workspace-1", - "step": "Floorplan" - }, - "id": "db-ensure-1" -} -``` - -The `step` field is optional. When omitted, ECC uses the existing flow rule for -selecting the first unfinished step. A successful result reports whether the -handle is active and whether an existing handle was reused: - -```json -{ - "workspaceId": "workspace-1", - "enabled": true, - "active": true, - "reused": false, - "step": "Floorplan" -} -``` - -Release the active session DB handle: - -```json -{ - "jsonrpc": "2.0", - "method": "db.release", - "params": { - "workspaceId": "workspace-1" - }, - "id": "db-release-1" -} -``` - -`db.release` is idempotent and returns `released: false` when the session has no -active DB handle. Workspace refresh, changed config sync, reset, rerun, close, -replacement, and shutdown release stale handles. `flow.run` and `flow.run_step` -reuse and capture DB state only when the session already has an active handle -from `db.ensure`; otherwise their DB use remains transient. - -## Shutdown - -End the sidecar with `rpc.shutdown`: - -```json -{ - "jsonrpc": "2.0", - "method": "rpc.shutdown", - "id": "shutdown-1" -} -``` - -The server closes workspace sessions and exits after the response is written. - -## Errors - -JSON-RPC validation errors use standard JSON-RPC error objects. Runtime errors -use ECC-specific code strings in the JSON-RPC error `message` field, with -human-readable details in `data.message` when available. - -Common runtime error messages: - -- `unsupported_version`: `rpc.hello` used an incompatible protocol version. -- `workspace_session_not_found`: the supplied `workspaceId` is unknown or - closed. -- `invalid_request`: params are missing required fields or include unknown - fields. -- `command_failed`: workspace or flow execution failed. diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index d678d39c1..7ac9d2921 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -136,7 +136,6 @@ Current implementation status: | `ecc report summary/qor/checklist/step` | `--plain` | | `ecc doc` | `--plain` | | `ecc version` | hidden `--json` only (desktop app contract) | -| `ecc rpc serve` | none (machine protocol) | | `ecc layout-image` | none (tool invocation; produces a file) | When `--plain` is given, the implementation renders plain records; otherwise it @@ -188,8 +187,8 @@ agent-specific disclosure fields inside core flow APIs. ### Core Commands The current root surface is a Typer command graph. The project-first command -surface stays small, with version reporting and the private runtime sidecar -available as explicit root entries: +surface stays small, with version reporting and layout rendering as explicit +root entries: ```bash ecc --version @@ -208,7 +207,6 @@ ecc project ecc workspace ecc signoff ecc report -ecc rpc ecc layout-image ``` @@ -232,7 +230,6 @@ Responsibilities: | `ecc workspace` | Refresh a declared workspace from current `ecc.toml` without running it | | `ecc signoff` | Inspect package readiness and export the tar.gz package | | `ecc report` | Write design-summary, QoR, and checklist reports; show step evidence | -| `ecc rpc` | Serve the private JSON-RPC runtime sidecar over stdio | | `ecc layout-image` | Render a GDS file into an image | `ecc run` preflights the tools its preset needs (yosys for synthesis, @@ -277,7 +274,7 @@ The command graph follows these rules; new commands must follow them too: `check`, `run`, `status`, `log`, `config`, `doctor`, `migrate`, `version`) plus the frozen tool invocations (`layout-image`). Resource management and reporting live in noun groups (`param`, `pdk`, `project`, `workspace`, - `signoff`, `report`, `rpc`). + `signoff`, `report`). - **Subcommand verbs.** Mutable resources use the CRUD set (`list`, `show`, `set`, `unset`, `diff`). The `report` group names its artifacts instead (`summary`, `qor`, `checklist`, `step`) @@ -294,10 +291,10 @@ The command graph follows these rules; new commands must follow them too: registration in `cli/commands/`, framework in `cli/core/`, read-only probing in `cli/inspection/`, all rendering in `cli/rendering/` behind a single registry keyed by full command path (top-level name, or `group:sub`). -- **Frozen surfaces.** The GUI invokes `ecc rpc serve --stdio - [--persistent-db]`, `ecc version --json` (schema: `schema_version`, `runtime`, - `ecc`, `dreamplace`, `ecc_tools`, `tools`), the `ecc --version` single line, - and `ecc layout-image --gds --image ` as subprocess contracts. +- **Frozen surfaces.** The GUI invokes `ecc version --json` (schema: + `schema_version`, `runtime`, `ecc`, `dreamplace`, `ecc_tools`, `tools`), the + `ecc --version` single line, and + `ecc layout-image --gds --image ` as subprocess contracts. Additive optional flags are allowed; these names, flags, and output schemas must not change. @@ -427,56 +424,6 @@ version` prints fixed-order text lines for `ecc`, `dreamplace`, `ecc_tools`, and reported as `unknown`, except the `ecc` field may fall back to the source package `__version__`. -### Runtime Sidecar RPC - -The old workspace create/run compatibility commands are not exposed as a public -CLI namespace. The supported runtime session surface is the private stdio -sidecar: - -```bash -ecc rpc serve --stdio -ecc rpc serve --stdio --persistent-db -``` - -The sidecar uses JSON-RPC 2.0 payloads framed with `Content-Length` headers. -After `workspace.create` or `workspace.open`, follow-up calls use the returned -`workspaceId` rather than repeatedly passing the workspace directory. The -default sidecar does not advertise or persist native DB handles. - -First-slice runtime methods include: - -```text -rpc.hello -rpc.ping -rpc.shutdown -workspace.create -workspace.open -workspace.close -workspace.home -workspace.info -workspace.refresh_config -workspace.sync_config -workspace.reset_flow -flow.run -flow.run_step -``` - -`--persistent-db` is an opt-in process capability. When enabled, `rpc.hello` -also advertises: - -```text -db.ensure -db.release -``` - -These DB methods are not part of the default first-slice method list. They start -and stop session-scoped DB reuse explicitly; `workspace.open`, -`workspace.create`, `flow.run`, and `flow.run_step` must not start persistent DB -reuse for a session that has not called `db.ensure`. - -The former custom workspace JSON object is not part of the supported output -contract. See `docs/rpc-guide.md` for framing examples and method payloads. - ## Output Contracts ### Summary Line Format @@ -727,7 +674,6 @@ Success criteria: - [x] `ecc config` - [x] Managed workspace selection for inspection commands with `--workspace NAME` - [x] Parameter overrides with `ecc param` and `ecc run --set` -- [x] Private runtime sidecar under `ecc rpc serve --stdio` - [ ] Run tags and run comparison basics Success criteria: @@ -767,9 +713,8 @@ NAME` for a workspace declared in the project manifest. It persists a workspace-local override and refreshes the affected configuration without running the flow. PDK resources and input references are project-level values: edit `ecc.toml` through `ecc project` (or the matching `ecc pdk`/`ecc param` -commands), then use `ecc workspace refresh NAME`. Old workspace create/run -automation should use the private JSON-RPC runtime sidecar. The long-term -default is project-oriented and configuration-driven through `ecc.toml` and +commands), then use `ecc workspace refresh NAME`. The supported integration +surface is project-oriented and configuration-driven through `ecc.toml` and subcommands such as `ecc run --project `. The project-level Python APIs should remain compatible with existing Python diff --git a/ecc.spec b/ecc.spec index 0f1dba9b5..1f256a20b 100644 --- a/ecc.spec +++ b/ecc.spec @@ -164,10 +164,6 @@ def collect_ecc_resources(): return datas -def collect_jsonrpcserver_resources(): - return collect_data_files("jsonrpcserver") - - def collect_dreamplace_thirdparty_files(): datas = [] thirdparty_root = ECC_DIR / "chipcompiler" / "thirdparty" / "ecc-dreamplace" @@ -283,7 +279,6 @@ datas.extend(dreamplace_datas) datas.extend(torch_datas) datas.extend(collect_required_metadata()) datas.extend(collect_ecc_resources()) -datas.extend(collect_jsonrpcserver_resources()) datas.extend(collect_dreamplace_thirdparty_files()) datas.extend(collect_doc_guides()) diff --git a/flake.nix b/flake.nix index 81137fbbf..bc01b60ca 100644 --- a/flake.nix +++ b/flake.nix @@ -42,54 +42,9 @@ pythonImportsCheck = [ "rosettakit" ]; }; - # Not in the pinned nixpkgs; required by chipcompiler's runtime server. - # Use the wheel: the sdist's bundled versioneer is incompatible with - # Python 3.13 (configparser.SafeConfigParser was removed). - oslash = { - fetchPypi, - python3Packages, - }: python3Packages.buildPythonPackage rec { - pname = "OSlash"; - version = "0.6.3"; - format = "wheel"; - - src = fetchPypi { - inherit pname version format; - dist = "py3"; - python = "py3"; - hash = "sha256-ibl4RDt9s6wmZhBr3DaArdPIhqbY/N0C/QYq+G0pSU8="; - }; - - dependencies = [ python3Packages.typing-extensions ]; - - pythonImportsCheck = [ "oslash" ]; - }; - - jsonrpcserver = { - fetchPypi, - oslash, - python3Packages, - }: python3Packages.buildPythonPackage rec { - pname = "jsonrpcserver"; - version = "5.0.9"; - pyproject = true; - - src = fetchPypi { - inherit pname version; - hash = "sha256-px+yz6GFQcgJNfYJh/knVdlNdBQSSMdDiEe5bu5cRII="; - }; - - build-system = with python3Packages; [ setuptools ]; - - dependencies = [ python3Packages.jsonschema oslash ]; - - pythonImportsCheck = [ "jsonrpcserver" ]; - }; - chipcompiler = { ecc-dreamplace, ecc-tools, - jsonrpcserver, rosettakit, yosysWithSlang, lib, @@ -114,13 +69,10 @@ dependencies = with python3Packages; [ ecc-dreamplace ecc-tools - fastapi - jsonrpcserver klayout matplotlib numpy pandas - pydantic pyjson5 pyyaml pyarrow @@ -130,7 +82,6 @@ tomli-w tqdm typer - uvicorn pip ]; @@ -157,7 +108,6 @@ 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; }; diff --git a/pyproject.toml b/pyproject.toml index 514f5ab92..57116b539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,15 +22,12 @@ classifiers = [ dependencies = [ "ecc-dreamplace==0.1.0a7", "ecc-tools-bin==0.1.0a13", - "fastapi>=0.109", - "jsonrpcserver>=5.0.9", "klayout>=0.30.2", "matplotlib>=3.4", "numpy>=1.21", "pandas>=1.3", "pyarrow>=15", "pip>=25.0.1", - "pydantic>=2.5", "pyjson5>=1.6", "pyyaml>=6", "rosettakit==0.2.0", @@ -39,7 +36,6 @@ dependencies = [ "torch>=1.6.0", "tqdm>=4.67.1", "typer>=0.12", - "uvicorn>=0.27", ] scripts.ecc = "chipcompiler.cli.main:main" diff --git a/test/cli/commands/test_signoff.py b/test/cli/commands/test_signoff.py index 9df845104..07947cf93 100644 --- a/test/cli/commands/test_signoff.py +++ b/test/cli/commands/test_signoff.py @@ -63,7 +63,7 @@ def fake_load_workspace(path): def _patch_inspect(monkeypatch, review=REVIEW): monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.inspect_signoff_package", + "chipcompiler.engine.signoff_export.inspect_signoff_package", lambda workspace: review, ) @@ -85,7 +85,7 @@ def fake_export(workspace, output_path, additional_files=None, *, include_debug= return destination monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.export_signoff_package_archive", fake_export + "chipcompiler.engine.signoff_export.export_signoff_package_archive", fake_export ) return calls @@ -226,13 +226,13 @@ def test_export_forwards_include_debug( def test_export_incomplete_maps_to_error( self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub, plain_records ): - from chipcompiler.runtime.workspace_api import RuntimeApiError + from chipcompiler.engine.signoff_export import SignoffExportError project_dir = create_cli_project() os.makedirs(os.path.join(project_dir, "default")) _patch_export( monkeypatch, - error=RuntimeApiError("command_failed", "signoff package is incomplete: x"), + error=SignoffExportError("signoff package is incomplete: x"), ) rc = cli_main.run( diff --git a/test/cli/commands/test_workspace_range.py b/test/cli/commands/test_workspace_range.py index 3cfce1a91..fe585e3bf 100644 --- a/test/cli/commands/test_workspace_range.py +++ b/test/cli/commands/test_workspace_range.py @@ -53,6 +53,7 @@ def test_new_workspace_range_uses_ecc_toml_inputs_and_registers_before_execution assert create_kwargs["directory"] == str(Path(project_dir) / "cts-only") assert create_kwargs["origin_def"] == str(design_def) assert create_kwargs["origin_verilog"] == str(netlist) + assert create_kwargs["parameters"]["_input_mode"] == "postSynthesis" assert create_kwargs["flow_config"] == {"start_step": "CTS", "end_step": "CTS"} manifest = json.loads((Path(project_dir) / "project.json").read_text()) entry = manifest["workspaces"][0] diff --git a/test/cli/params/test_flow_filters.py b/test/cli/params/test_flow_filters.py new file mode 100644 index 000000000..e9486dc1b --- /dev/null +++ b/test/cli/params/test_flow_filters.py @@ -0,0 +1,72 @@ +from chipcompiler.cli import main as cli_main + + +def test_param_list_synthesis_includes_global_parameters(capsys, create_cli_project, plain_records): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--step", "synthesis", "--project", project_dir, "--plain"]) + + assert rc == 0 + assert [record["param"] for record in plain_records(capsys.readouterr().out)] == [ + "design.frequency_mhz", + "flow.run_analysis", + ] + + +def test_param_set_rejects_parameter_outside_project_flow( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + config_path = tmp_path / "gcd" / "ecc.toml" + config_path.write_text( + config_path.read_text().replace('preset = "rtl2gds"', 'preset = "syn_sta"') + ) + + rc = cli_main.run( + [ + "param", + "set", + "place.target_density", + "0.65", + "--project", + project_dir, + "--plain", + ] + ) + + assert rc == 1 + assert plain_records(capsys.readouterr().out)[0]["error"] == "parameter_not_in_flow" + assert "target_density" not in config_path.read_text() + + +def test_param_list_excludes_steps_absent_from_selected_flow( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + config_path = tmp_path / "gcd" / "ecc.toml" + config_path.write_text( + config_path.read_text().replace('preset = "rtl2gds"', 'preset = "synthesis_lec"') + ) + + rc = cli_main.run(["param", "list", "--step", "floorplan", "--project", project_dir, "--plain"]) + + assert rc == 0 + assert plain_records(capsys.readouterr().out) == [] + + +def test_param_list_default_excludes_explicit_absent_step_parameters( + tmp_path, capsys, create_cli_project, plain_records +): + project_dir = create_cli_project() + config_path = tmp_path / "gcd" / "ecc.toml" + config_path.write_text( + config_path.read_text().replace('preset = "rtl2gds"', 'preset = "synthesis_lec"') + + "\n[params.place]\ntarget_density = 0.65\n" + ) + + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) + + assert rc == 0 + assert all( + record["param"] != "place.target_density" + for record in plain_records(capsys.readouterr().out) + ) diff --git a/test/cli/params/test_workspace_commands.py b/test/cli/params/test_workspace_commands.py index 684988f2d..cdfa0aa92 100644 --- a/test/cli/params/test_workspace_commands.py +++ b/test/cli/params/test_workspace_commands.py @@ -200,3 +200,34 @@ def test_workspace_param_list_honors_step_filter( assert plain_records(capsys.readouterr().out) == [ {"param": "list", "status": "clean", "workspace": "baseline"} ] + + +def test_workspace_param_list_matches_first_step_configuration( + capsys, create_cli_project, monkeypatch, plain_records +): + project_dir = create_cli_project() + workspace_dir = Path(project_dir) / "baseline" + _write_manifest(project_dir) + workspace = _workspace(workspace_dir) + monkeypatch.setattr("chipcompiler.data.load_workspace", lambda _path: workspace) + + rc = cli_main.run( + [ + "param", + "list", + "--workspace", + "baseline", + "--step", + "synthesis", + "--all", + "--project", + project_dir, + "--plain", + ] + ) + + assert rc == 0 + assert [record["param"] for record in plain_records(capsys.readouterr().out)] == [ + "design.frequency_mhz", + "flow.run_analysis", + ] diff --git a/test/cli/test_cli_module_layout.py b/test/cli/test_cli_module_layout.py index 50b081547..1405ddcb9 100644 --- a/test/cli/test_cli_module_layout.py +++ b/test/cli/test_cli_module_layout.py @@ -51,7 +51,7 @@ def test_core_modules_live_under_core_package(): def test_command_registration_modules_live_under_commands_package(): - for module_name in ("project", "doctor", "param", "pdk", "signoff", "report", "rpc"): + for module_name in ("project", "doctor", "param", "pdk", "signoff", "report"): module = importlib.import_module(f"chipcompiler.cli.commands.{module_name}") assert module.__name__ == f"chipcompiler.cli.commands.{module_name}" diff --git a/test/cli/test_rpc_cli.py b/test/cli/test_rpc_cli.py deleted file mode 100644 index 0a3781eb4..000000000 --- a/test/cli/test_rpc_cli.py +++ /dev/null @@ -1,32 +0,0 @@ -from chipcompiler.cli import main as cli_main - - -def test_rpc_help_returns_zero_and_lists_serve(capsys): - rc = cli_main.run(["rpc", "--help"]) - - out = capsys.readouterr().out - assert rc == 0 - assert "serve" in out - - -def test_rpc_serve_help_returns_zero_and_lists_stdio(capsys): - rc = cli_main.run(["rpc", "serve", "--help"]) - - out = capsys.readouterr().out - assert rc == 0 - assert "--stdio" in out - assert "--persistent-db" in out - - -def test_rpc_serve_requires_stdio(capsys): - rc = cli_main.run(["rpc", "serve"]) - - assert rc != 0 - assert "--stdio" in capsys.readouterr().err - - -def test_run_help_does_not_list_persistent_db(capsys): - rc = cli_main.run(["run", "--help"]) - - assert rc == 0 - assert "--persistent-db" not in capsys.readouterr().out diff --git a/test/cli/test_typer_cli.py b/test/cli/test_typer_cli.py index 54efc15e3..aa0c7c924 100644 --- a/test/cli/test_typer_cli.py +++ b/test/cli/test_typer_cli.py @@ -29,7 +29,6 @@ def test_root_help_returns_zero_and_lists_commands(capsys): "workspace", "signoff", "report", - "rpc", ): assert command in out for removed_command in ("metrics", "artifacts", "diagnose"): @@ -208,19 +207,11 @@ def fake_run(command_input, ctx): } -def test_rpc_routes_through_root_typer(monkeypatch): - seen = {} - - def fake_invoke(argv): - seen["argv"] = argv - return 17 - - monkeypatch.setattr("chipcompiler.cli.app.invoke_typer_app", fake_invoke) - +def test_removed_rpc_command_returns_unknown_command(capsys): rc = cli_main.run(["rpc", "serve", "--stdio"]) - assert rc == 17 - assert seen["argv"] == ["rpc", "serve", "--stdio"] + assert rc != 0 + assert "No such command" in capsys.readouterr().err def test_run_default_argv_uses_sys_argv(monkeypatch): @@ -230,13 +221,13 @@ def fake_invoke(argv): seen["argv"] = argv return 17 - monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "rpc", "serve", "--stdio"]) + monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "status"]) monkeypatch.setattr("chipcompiler.cli.app.invoke_typer_app", fake_invoke) rc = cli_main.run() assert rc == 17 - assert seen["argv"] == ["rpc", "serve", "--stdio"] + assert seen["argv"] == ["status"] def test_main_exits_with_run_code(monkeypatch): @@ -246,7 +237,7 @@ def fake_invoke(argv): seen["argv"] = argv return 17 - monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "rpc", "serve", "--stdio"]) + monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "status"]) monkeypatch.setattr("chipcompiler.cli.app.invoke_typer_app", fake_invoke) try: @@ -257,7 +248,7 @@ def fake_invoke(argv): raise AssertionError("main() did not exit") assert code == 17 - assert seen["argv"] == ["rpc", "serve", "--stdio"] + assert seen["argv"] == ["status"] def test_old_top_level_workspace_form_is_root_parser_error(capsys): diff --git a/test/data/test_step_storage_name.py b/test/data/test_step_storage_name.py new file mode 100644 index 000000000..1785b1ad7 --- /dev/null +++ b/test/data/test_step_storage_name.py @@ -0,0 +1,7 @@ +from chipcompiler.data import StepEnum, step_storage_name + + +def test_step_storage_name_sanitizes_sizer_timing_opt(): + assert step_storage_name(StepEnum.TIMING_OPT.value, "sizer") == "timing_optimization" + assert step_storage_name(StepEnum.TIMING_OPT.value, "Sizer") == "timing_optimization" + assert step_storage_name(StepEnum.FLOORPLAN.value, "ecc") == StepEnum.FLOORPLAN.value diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 198bc4413..8fae64eb9 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -521,7 +521,7 @@ def test_build_flow_for_dynamic_workspace_initializes_step_metadata_files( }, ) - from chipcompiler.runtime.workspace_api import build_flow_for_workspace + from chipcompiler.engine.workspace_flow import build_flow_for_workspace build_flow_for_workspace(workspace) diff --git a/test/data/test_workspace_config.py b/test/data/test_workspace_config.py index 94ec050f4..790f56633 100644 --- a/test/data/test_workspace_config.py +++ b/test/data/test_workspace_config.py @@ -144,7 +144,7 @@ def test_flow_validation_accepts_canonical_names(): def test_flow_validation_rejects_display_name_aliases(): # Workspace files carry canonical names only; aliases translate at the - # manifest/RPC boundary. + # manifest/adapter boundary. with pytest.raises(WorkspaceFlowTargetError): validate_flow_config({"start": "Synth", "end": "Filler"}) diff --git a/test/engine/test_execution.py b/test/engine/test_execution.py new file mode 100644 index 000000000..0efef4ef7 --- /dev/null +++ b/test/engine/test_execution.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +from chipcompiler.data import StateEnum +from chipcompiler.engine.execution import ExecutionPlan, execute + + +def test_execution_plan_dispatches_full_flow_and_single_step(): + calls = [] + + class Flow: + def run_steps(self, *, rerun=False, observer=None): + calls.append(("flow", rerun, observer)) + return True + + def get_workspace_step(self, step_id): + return SimpleNamespace(name=step_id) + + def run_step(self, step, *, rerun=False, observer=None): + calls.append((step.name, rerun, observer)) + return StateEnum.Success + + observer = object() + flow = Flow() + + assert execute(flow, ExecutionPlan(intent="run"), event_sink=observer).succeeded + step_result = execute( + flow, + ExecutionPlan(intent="rerun", step_id="Floorplan"), + event_sink=observer, + ) + + assert step_result == step_result.__class__( + succeeded=True, + state=StateEnum.Success.value, + step_id="Floorplan", + ) + assert calls == [("flow", False, observer), ("Floorplan", True, observer)] + + +def test_execution_failure_keeps_main_blocking_semantics(): + class Flow: + workspace = SimpleNamespace(flow=SimpleNamespace(data={"steps": []})) + + def run_steps(self, *, rerun=False, observer=None): + return False + + result = execute(Flow(), ExecutionPlan(intent="run"), event_sink=object()) + + assert not result.succeeded + assert result.state == StateEnum.Imcomplete.value + + +def test_default_execution_observer_commits_completed_steps(monkeypatch, tmp_path): + from chipcompiler.engine import execution + + committed = [] + + class Flow: + workspace = SimpleNamespace(directory=tmp_path) + + def run_steps(self, *, rerun=False, observer=None): + observer.on_step_completed(SimpleNamespace(name="synthesis"), StateEnum.Success) + return True + + monkeypatch.setattr( + execution, + "_EngineeringCommitSink", + lambda workspace: SimpleNamespace( + snapshot={"workspaceId": "workspace-1", "workspaceRevision": 1}, + on_step_completed=lambda step, state, error=None: committed.append((step.name, state)), + ), + ) + + result = execution.execute(Flow(), execution.ExecutionPlan(intent="run")) + + assert result.succeeded + assert committed == [("synthesis", StateEnum.Success)] diff --git a/test/engine/test_qor_scoring.py b/test/engine/test_qor_scoring.py new file mode 100644 index 000000000..2f01abc23 --- /dev/null +++ b/test/engine/test_qor_scoring.py @@ -0,0 +1,94 @@ +from chipcompiler.engine.qor_scoring import QorScoringMetric, score_metric, score_qor + + +def _metric(step, metric_id, value, dimension, direction="lower_is_better", **kwargs): + return QorScoringMetric( + step=step, + metric_id=metric_id, + value=value, + dimension=dimension, + direction=direction, + scope="workspace", + corner=None, + project_role=kwargs.get("project_role", "final"), + rating_score=kwargs.get("rating_score", True), + ) + + +def test_qor_scoring_selects_latest_area_and_combines_dimension_weights(): + result = score_qor( + [ + _metric("Floor", "die_area", 300, "area_cost"), + _metric("STA", "sta_setup_wns", -0.1, "timing", "higher_is_better"), + _metric("Harden", "die_area", 1500, "area_cost"), + _metric("DRC", "drc_count", 0, "routability_physical"), + ] + ) + + assert result.area_scoring_step == "Harden" + assert result.dimensions == { + "timing": (50.0, 1), + "routability_physical": (100.0, 1), + "area_cost": (50.0, 1), + } + assert result.overall_score == 42.5 + + +def test_qor_scoring_ignores_forward_version_dimensions_and_steps(): + result = score_qor( + [ + _metric("future-step", "future_metric", 1, "future_dimension"), + _metric("Harden", "die_area", 1500, "area_cost"), + ] + ) + + assert result.dimensions == {"area_cost": (50.0, 1)} + + +def test_qor_scoring_uses_flow_order_for_area_step_when_provided(): + result = score_qor( + [ + _metric("Harden", "die_area", 1500, "area_cost"), + _metric("DRC", "die_area", 300, "area_cost"), + ], + flow_order=("STA", "DRC", "Harden"), + ) + + assert result.area_scoring_step == "Harden" + assert result.dimensions == {"area_cost": (50.0, 1)} + + +def test_analysis_maps_legacy_power_category_to_power_integrity(tmp_path): + from chipcompiler.engine.analysis import _analysis_file + from chipcompiler.utility import json_write + + path = tmp_path / "qor_metrics.json" + json_write( + path, + { + "schema_version": 3, + "metrics": [ + { + "id": "synthesis_power_dynamic_uw", + "display_name": "Synthesis Dynamic Power", + "value": 18.5, + "category": "power", + "direction": "trend_only", + "scope": "synthesis", + "rating": {"gate": False, "score": False, "trend": True}, + } + ], + }, + ) + + payload = _analysis_file(path, "artifact-metrics", 3, tmp_path) + assert payload["status"] == "available" + assert payload["data"]["metrics"][0]["category"] == "power_integrity" + + +def test_score_metric_matches_fail_threshold_formulas(): + slack = _metric("STA", "sta_setup_wns", -0.1, "timing", "higher_is_better") + assert score_metric(slack) == 50.0 + assert score_metric(_metric("DRC", "drc_count", 0, "routability_physical")) == 100.0 + utilization = _metric("Harden", "core_utilization", 0.55, "area_cost", "target_range") + assert score_metric(utilization) == 100.0 diff --git a/test/engine/test_signoff_assessment.py b/test/engine/test_signoff_assessment.py new file mode 100644 index 000000000..d237b3a80 --- /dev/null +++ b/test/engine/test_signoff_assessment.py @@ -0,0 +1,52 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +from chipcompiler.engine.signoff_assessment import build_signoff_assessment +from chipcompiler.engine.signoff_export import SignoffExportError, _additional_file_path + + +def test_stale_checklist_cannot_make_incomplete_flow_ready(tmp_path): + home = tmp_path / "home" + home.mkdir() + (home / "checklist.json").write_text( + json.dumps( + { + "schema_version": 3, + "kind": "signoff_checklist", + "status": "ready", + "checklist": [], + } + ) + ) + workspace = SimpleNamespace( + directory=Path(tmp_path), + flow=SimpleNamespace(steps=lambda: [{"name": "Synthesis", "state": "Unstart"}]), + ) + + result = build_signoff_assessment(workspace) + + assert result["status"] == "blocked" + + +def test_malformed_checklist_is_reported_as_unavailable(tmp_path): + home = tmp_path / "home" + home.mkdir() + (home / "checklist.json").write_text("[]") + workspace = SimpleNamespace(directory=Path(tmp_path), flow=None) + + result = build_signoff_assessment(workspace) + + assert result["status"] == "blocked" + + +def test_signoff_additional_file_path_rejects_escape(tmp_path): + package = tmp_path / "package" + package.mkdir() + + for value in ("/tmp/escape", "../escape", "", ".", "bad\x00path"): + try: + _additional_file_path(package, value) + except SignoffExportError: + continue + raise AssertionError(f"unsafe path accepted: {value!r}") diff --git a/test/engine/test_sizer_snapshot_directory.py b/test/engine/test_sizer_snapshot_directory.py new file mode 100644 index 000000000..e52c120a2 --- /dev/null +++ b/test/engine/test_sizer_snapshot_directory.py @@ -0,0 +1,89 @@ +import json +from types import SimpleNamespace + +from chipcompiler.engine.snapshot import create_engineering_snapshot + + +def test_snapshot_resolves_canonical_sizer_step_directory(tmp_path): + step_dir = tmp_path / "timing_optimization_sizer" + analysis = step_dir / "analysis" + output = step_dir / "output" + report = step_dir / "report" + analysis.mkdir(parents=True) + output.mkdir() + report.mkdir() + metric = { + "id": "instance_count", + "display_name": "Instance Count", + "value": 298, + "unit": "count", + "category": "area_cost", + "direction": "trend_only", + "scope": "timing_optimization", + "corner": None, + "analysis_group": "timing optimization_metrics", + "rating": {"gate": False, "score": False, "trend": True}, + "project_role": "trend", + "step_role": "primary", + "confidence": "high", + "source": {}, + } + (analysis / "qor_metrics.json").write_text( + json.dumps({"schema_version": 3, "metrics": [metric]}), encoding="utf-8" + ) + (analysis / "qor_summary.json").write_text( + json.dumps( + { + "schema_version": 4, + "analysis_status": "valid", + "quality_status": "pass", + "gates": [], + "missing_metrics": [], + } + ), + encoding="utf-8", + ) + (analysis / "qor_hotspots.json").write_text( + json.dumps({"schema_version": 3, "hotspots": []}), encoding="utf-8" + ) + (output / "gcd_Timing optimization.png").write_bytes(b"layout") + (report / "Timing optimization.db.rpt").write_text("report", encoding="utf-8") + (step_dir / "subflow.json").write_text( + json.dumps({"steps": [{"name": "run sizer", "state": "Success"}]}), + encoding="utf-8", + ) + workspace = SimpleNamespace( + directory=tmp_path, + design=SimpleNamespace(name="gcd"), + flow=SimpleNamespace( + data={ + "steps": [ + { + "name": "Timing optimization", + "tool": "sizer", + "state": "Success", + } + ] + } + ), + parameters=SimpleNamespace(data={}), + home=SimpleNamespace(data={}), + ) + + snapshot = create_engineering_snapshot(workspace, workspace_id="engineering-a") + + step = snapshot["analysis"]["steps"][0] + assert step["stepId"] == "Timing optimization" + assert step["metrics"]["data"]["metrics"] == [metric] + assert step["subflow"]["status"] == "available" + artifacts = {artifact["kind"]: artifact for artifact in snapshot["artifacts"]} + assert artifacts["qor_metrics"]["reference"] == ( + "timing_optimization_sizer/analysis/qor_metrics.json" + ) + assert artifacts["qor_metrics"]["availability"] == "available" + assert artifacts["layout_image"]["availability"] == "available" + assert artifacts["layout_image"]["reference"] == ( + "timing_optimization_sizer/output/gcd_Timing optimization.png" + ) + reports = [artifact for artifact in snapshot["artifacts"] if artifact["kind"] == "report_text"] + assert reports[0]["availability"] == "available" diff --git a/test/runtime/test_subflow_events.py b/test/engine/test_subflow_events.py similarity index 92% rename from test/runtime/test_subflow_events.py rename to test/engine/test_subflow_events.py index 88814109f..9143c7267 100644 --- a/test/runtime/test_subflow_events.py +++ b/test/engine/test_subflow_events.py @@ -2,7 +2,7 @@ import pytest -from chipcompiler.runtime import subflow_events +from chipcompiler.engine import subflow_events def test_interrupted_subflow_write_failure_restores_ongoing_state(monkeypatch, tmp_path): diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py new file mode 100644 index 000000000..d1ef9a290 --- /dev/null +++ b/test/engine/test_workspace_configuration.py @@ -0,0 +1,266 @@ +import json +import os +from copy import deepcopy +from pathlib import Path + +import pytest + +from chipcompiler.data import load_workspace +from chipcompiler.engine import ( + EngineFlow, + WorkspaceLifecycleError, + create_workspace_from_spec, + read_step_configuration, + read_workspace_configuration_from_directory, + update_workspace_configuration, + update_workspace_step_configuration, +) +from chipcompiler.engine.snapshot import create_engineering_snapshot, read_engineering_snapshot + + +def _workspace_spec_fixture() -> tuple[dict, dict]: + root = Path(__file__).parents[1] / "fixtures" / "workspace_spec" + payload = json.loads((root / "valid.json").read_text(encoding="utf-8")) + bindings = deepcopy(payload["workspaceBindings"]) + bindings["inputs"] = {key: str(root / value) for key, value in bindings["inputs"].items()} + return payload["workspaceSpec"], bindings + + +def test_workspace_configuration_update_is_revision_aware_and_idempotent( + tmp_path, minimal_ics55_pdk_factory +): + spec, bindings = _workspace_spec_fixture() + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1") + initial = read_engineering_snapshot(workspace) + + updated = update_workspace_configuration( + workspace.directory, + initial["workspaceRevision"], + {"parameters": {"design.frequency_mhz": 250.0}}, + bindings, + "configuration-1", + ) + current = read_engineering_snapshot(updated) + repeated = update_workspace_configuration( + workspace.directory, + initial["workspaceRevision"], + {"parameters": {"design.frequency_mhz": 250.0}}, + bindings, + "configuration-1", + ) + + assert initial["workspaceRevision"] == 1 + assert current["workspaceRevision"] == 2 + assert read_engineering_snapshot(repeated)["workspaceRevision"] == 2 + assert repeated.parameters.data["frequency_max"] == 250.0 + assert ( + read_workspace_configuration_from_directory(workspace.directory)["workspaceSpec"][ + "parameters" + ]["design.frequency_mhz"] + == 250.0 + ) + + with pytest.raises(WorkspaceLifecycleError) as conflict: + update_workspace_configuration( + workspace.directory, + 1, + {"parameters": {"design.frequency_mhz": 300.0}}, + bindings, + "configuration-2", + ) + assert conflict.value.code == "revision_conflict" + assert read_engineering_snapshot(updated)["workspaceRevision"] == 2 + + +def test_workspace_configuration_update_rolls_back_refresh_failure( + tmp_path, minimal_ics55_pdk_factory, monkeypatch +): + spec, bindings = _workspace_spec_fixture() + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1") + before_config = (workspace.directory / "home" / "params.toml").read_bytes() + before_snapshot = read_engineering_snapshot(workspace) + + def fail_refresh(_workspace): + raise RuntimeError("config regeneration failed") + + monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", fail_refresh) + with pytest.raises(RuntimeError, match="config regeneration failed"): + update_workspace_configuration( + workspace.directory, + before_snapshot["workspaceRevision"], + {"parameters": {"design.frequency_mhz": 250.0}}, + bindings, + "configuration-1", + ) + + assert (workspace.directory / "home" / "params.toml").read_bytes() == before_config + assert read_engineering_snapshot(workspace) == before_snapshot + + +def test_configuration_update_recovers_after_process_exit_before_snapshot( + tmp_path, minimal_ics55_pdk_factory +): + spec, bindings = _workspace_spec_fixture() + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1") + before = read_engineering_snapshot(workspace) + + child = os.fork() + if child == 0: + import chipcompiler.engine.workspace_configuration as configuration_module + + configuration_module.invalidate_engineering_snapshot = lambda *_args, **_kwargs: os._exit( + 23 + ) + update_workspace_configuration( + workspace.directory, + before["workspaceRevision"], + {"parameters": {"design.frequency_mhz": 250.0}}, + bindings, + "configuration-crash", + ) + os._exit(99) + + _pid, status = os.waitpid(child, 0) + assert os.waitstatus_to_exitcode(status) == 23 + + updated = update_workspace_configuration( + workspace.directory, + before["workspaceRevision"], + {"parameters": {"design.frequency_mhz": 250.0}}, + bindings, + "configuration-crash", + ) + + assert updated.parameters.data["frequency_max"] == 250.0 + assert read_engineering_snapshot(updated)["workspaceRevision"] == ( + before["workspaceRevision"] + 1 + ) + + +def test_configuration_update_recovers_after_process_exit_during_commit_cleanup( + tmp_path, minimal_ics55_pdk_factory +): + spec, bindings = _workspace_spec_fixture() + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1") + before = read_engineering_snapshot(workspace) + + child = os.fork() + if child == 0: + import chipcompiler.data.workspace_transaction as transaction_module + + original_rmtree = transaction_module.shutil.rmtree + + def crash_cleanup(path, *args, **kwargs): + if Path(path).name == ".workspace-configuration-backup.discard": + os._exit(24) + return original_rmtree(path, *args, **kwargs) + + transaction_module.shutil.rmtree = crash_cleanup + update_workspace_configuration( + workspace.directory, + before["workspaceRevision"], + {"parameters": {"design.frequency_mhz": 250.0}}, + bindings, + "configuration-cleanup-crash", + ) + os._exit(99) + + _pid, status = os.waitpid(child, 0) + assert os.waitstatus_to_exitcode(status) == 24 + + recovered = load_workspace(workspace.directory) + assert recovered.parameters.data["frequency_max"] == 250.0 + assert read_engineering_snapshot(recovered)["workspaceRevision"] == ( + before["workspaceRevision"] + 1 + ) + + +def test_read_workspace_configuration_does_not_materialize_missing_home_files( + tmp_path, minimal_ics55_pdk_factory +): + import shutil + + spec, bindings = _workspace_spec_fixture() + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings) + home_file = workspace.directory / "home" / "home.json" + checklist_file = workspace.directory / "home" / "checklist.json" + log_dir = workspace.directory / "log" + home_file.unlink() + checklist_file.unlink() + if log_dir.exists(): + shutil.rmtree(log_dir) + shutil.rmtree(Path(bindings["pdk"]["root"])) + + configuration = read_workspace_configuration_from_directory(workspace.directory) + readonly_workspace = load_workspace(workspace.directory, read_only=True) + from chipcompiler.tools.ecc.signoff_checklist import rebuild_home_checklist + + rebuild_home_checklist(readonly_workspace, persist=False) + + assert configuration["workspaceSpec"]["pdk"]["familyId"] == "ics55" + assert not home_file.exists() + assert not checklist_file.exists() + assert not log_dir.exists() + + +def test_step_configuration_update_invalidates_only_target_suffix( + tmp_path, minimal_ics55_pdk_factory +): + spec, bindings = _workspace_spec_fixture() + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1") + flow = EngineFlow(workspace) + for step in flow.workspace.flow.data["steps"][:3]: + step["state"] = "Success" + assert flow.save() + initial = read_engineering_snapshot(workspace) + create_engineering_snapshot( + workspace, + workspace_id=initial["workspaceId"], + workspace_revision=initial["workspaceRevision"], + ) + + configuration = read_step_configuration(workspace, "floor-plan") + synthesis = read_step_configuration(workspace, "synthesis") + updated = update_workspace_step_configuration( + workspace.directory, + configuration["workspaceRevision"], + "Floorplan", + {"floorplan.core_util": 0.55}, + "step-configuration-1", + ) + + records = {record["param"]: record for record in configuration["parameters"]} + synthesis_params = {record["param"] for record in synthesis["parameters"]} + assert {"design.frequency_mhz", "flow.run_analysis"} <= synthesis_params + assert "floorplan.core_util" in records + assert set(records["floorplan.core_util"]) == { + "param", + "type", + "value", + "default", + "applies", + "description", + "range", + } + states = {step["name"]: step["state"] for step in updated.flow.steps()} + assert states["Synthesis"] == "Success" + assert states["lec"] == "Success" + assert states["Floorplan"] == "Unstart" + assert read_engineering_snapshot(updated)["workspaceRevision"] == 2 + + with pytest.raises(WorkspaceLifecycleError) as inapplicable: + update_workspace_step_configuration( + workspace.directory, + 2, + "Floorplan", + {"place.target_density": 0.6}, + "step-configuration-2", + ) + assert inapplicable.value.code == "parameter_not_applicable" + assert read_engineering_snapshot(updated)["workspaceRevision"] == 2 diff --git a/test/engine/test_workspace_flow.py b/test/engine/test_workspace_flow.py new file mode 100644 index 000000000..06d54bf7f --- /dev/null +++ b/test/engine/test_workspace_flow.py @@ -0,0 +1,14 @@ +from chipcompiler.engine.workspace_flow import build_flow_for_workspace + + +def test_build_flow_requires_committed_flow(tmp_path): + from chipcompiler.data import Workspace + + workspace = Workspace(directory=tmp_path) + + try: + build_flow_for_workspace(workspace, create_step_workspaces=False) + except ValueError as error: + assert str(error) == "Workspace has no committed Flow" + else: + raise AssertionError("missing committed Flow was accepted") diff --git a/test/engine/test_workspace_spec.py b/test/engine/test_workspace_spec.py new file mode 100644 index 000000000..e87820249 --- /dev/null +++ b/test/engine/test_workspace_spec.py @@ -0,0 +1,342 @@ +import json +from copy import deepcopy +from pathlib import Path + +import pytest + +from chipcompiler.engine.workspace_spec import ( + describe_workspace_spec, + validate_workspace_spec, +) + + +def _valid_spec(): + return { + "schemaVersion": 1, + "design": {"name": "gcd", "topModule": "gcd", "clockPort": "clk"}, + "inputMode": "rtl", + "inputs": [{"inputId": "rtl-main", "role": "rtl"}], + "pdk": {"familyId": "ics55", "mode": "default"}, + "flow": {"flowId": "rtl2gds"}, + "parameters": {"design.frequency_mhz": 200.0}, + } + + +def _shared_fixture(name: str): + root = Path(__file__).parents[1] / "fixtures" / "workspace_spec" + payload = json.loads((root / name).read_text(encoding="utf-8")) + bindings = deepcopy(payload["workspaceBindings"]) + bindings["inputs"] = {key: str(root / value) for key, value in bindings["inputs"].items()} + bindings["pdk"]["root"] = str(root / bindings["pdk"]["root"]) + return payload, bindings + + +def test_shared_workspace_spec_fixtures_cover_valid_and_invalid_contracts(): + valid, valid_bindings = _shared_fixture("valid.json") + assert validate_workspace_spec(valid["workspaceSpec"], valid_bindings)["issues"] == [] + + invalid, invalid_bindings = _shared_fixture("invalid.json") + result = validate_workspace_spec(invalid["workspaceSpec"], invalid_bindings) + assert {issue["code"] for issue in result["issues"]} >= set(invalid["expectedIssueCodes"]) + + +def test_workspace_spec_discovery_and_validation_are_canonical_and_side_effect_free(tmp_path): + rtl = tmp_path / "gcd.v" + rtl.write_text("module gcd(input clk); endmodule\n") + pdk = tmp_path / "pdk" + pdk.mkdir() + before = sorted(str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")) + + discovery = describe_workspace_spec() + result = validate_workspace_spec( + _valid_spec(), + {"inputs": {"rtl-main": str(rtl)}, "pdk": {"root": str(pdk)}}, + ) + + assert discovery["schemaVersion"] == 1 + assert {item["id"] for item in discovery["parameterCatalog"]} >= { + "design.frequency_mhz", + "floorplan.core_util", + } + assert result["issues"] == [] + assert result["resolvedWorkspaceSpec"]["parameters"]["design.frequency_mhz"] == 200.0 + assert result["resolvedWorkspaceSpec"]["parameters"]["floorplan.core_util"] == 0.4 + assert len(result["resolvedWorkspaceSpec"]["pdk"]["contentHash"]) == 64 + assert str(pdk) not in json.dumps(result["resolvedWorkspaceSpec"]) + assert sorted(str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")) == before + + +def test_workspace_spec_reports_structured_binding_and_input_cardinality_issues(tmp_path): + filelist = tmp_path / "sources.f" + filelist.write_text("missing.v\n") + spec = _valid_spec() + spec["inputs"] = [ + {"inputId": "rtl-main", "role": "rtl"}, + {"inputId": "sources", "role": "filelist"}, + ] + + result = validate_workspace_spec( + spec, + { + "inputs": {"sources": str(filelist), "unknown": str(filelist)}, + "pdk": {"root": str(tmp_path)}, + }, + ) + + assert {(issue["code"], issue["path"]) for issue in result["issues"]} >= { + ("conflicting_input_roles", "/inputs"), + ("input_binding_missing", "/bindings/inputs/rtl-main"), + ("unknown_input_binding", "/bindings/inputs/unknown"), + ("filelist_source_missing", "/bindings/inputs/sources"), + } + assert "resolvedWorkspaceSpec" not in result + + +def test_workspace_spec_rejects_multiple_manual_pdk_technology_files(tmp_path): + rtl = tmp_path / "gcd.v" + rtl.write_text("module gcd; endmodule\n") + for name in ("a.tech.lef", "b.tech.lef", "cells.lef", "typ.lib"): + (tmp_path / name).write_text(name) + spec = _valid_spec() + spec["pdk"] = { + "familyId": "ics55", + "mode": "manual", + "files": [ + {"fileId": "tech-a", "role": "tech"}, + {"fileId": "tech-b", "role": "tech"}, + {"fileId": "cells", "role": "lef"}, + {"fileId": "lib", "role": "liberty"}, + ], + } + + result = validate_workspace_spec( + json.loads(json.dumps(spec)), + { + "inputs": {"rtl-main": str(rtl)}, + "pdk": { + "root": str(tmp_path), + "files": { + "tech-a": str(tmp_path / "a.tech.lef"), + "tech-b": str(tmp_path / "b.tech.lef"), + "cells": str(tmp_path / "cells.lef"), + "lib": str(tmp_path / "typ.lib"), + }, + }, + }, + ) + + assert any(issue["code"] == "pdk_tech_cardinality" for issue in result["issues"]) + + +def test_validated_workspace_spec_creates_main_compatible_workspace( + tmp_path, minimal_ics55_pdk_factory +): + from chipcompiler.data import load_workspace + from chipcompiler.engine import create_workspace_from_spec + + payload, bindings = _shared_fixture("valid.json") + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + target = tmp_path / "workspace" + + created = create_workspace_from_spec( + target, + payload["workspaceSpec"], + bindings, + "create-1", + ) + reopened = load_workspace(target) + + assert created.directory == target + assert reopened.directory == target + assert reopened.design.name == "gcd" + assert reopened.parameters.data["frequency_max"] == 200.0 + assert reopened.flow.steps()[0]["name"] == "Synthesis" + + +def test_workspace_spec_applies_config_target_parameters(tmp_path, minimal_ics55_pdk_factory): + from chipcompiler.data import load_workspace + from chipcompiler.engine import create_workspace_from_spec + + payload, bindings = _shared_fixture("valid.json") + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + spec = deepcopy(payload["workspaceSpec"]) + spec["parameters"]["cts.skew_bound"] = "0.12" + + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings) + reopened = load_workspace(workspace.directory) + cts = json.loads(reopened.config["CTS"].read_text(encoding="utf-8")) + + assert cts["skew_bound"] == "0.12" + + +def test_manual_pdk_workspace_reopens_through_main_persistence(tmp_path): + from chipcompiler.data import load_workspace + from chipcompiler.engine import ( + assess_execution_readiness, + create_workspace_from_spec, + describe_workspace_binding_requirement, + ) + + rtl = tmp_path / "gcd.v" + rtl.write_text("module gcd(input clk); endmodule\n") + pdk_root = tmp_path / "pdk" + pdk_root.mkdir() + for name in ("tech.lef", "cells.lef", "typ.lib"): + (pdk_root / name).write_text(name) + spec = _valid_spec() + spec["flow"] = {"flowId": "syn_sta"} + spec["pdk"] = { + "familyId": "ics55", + "mode": "manual", + "files": [ + {"fileId": "tech", "role": "tech"}, + {"fileId": "cells", "role": "lef"}, + {"fileId": "lib", "role": "liberty"}, + ], + } + bindings = { + "inputs": {"rtl-main": str(rtl)}, + "pdk": { + "root": str(pdk_root), + "files": { + "tech": str(pdk_root / "tech.lef"), + "cells": str(pdk_root / "cells.lef"), + "lib": str(pdk_root / "typ.lib"), + }, + }, + } + target = tmp_path / "workspace" + + create_workspace_from_spec(target, spec, bindings) + reopened = load_workspace(target) + + assert reopened.pdk.tech == pdk_root / "tech.lef" + assert reopened.pdk.lefs == [pdk_root / "cells.lef"] + assert describe_workspace_binding_requirement(target)["mode"] == "manual" + assert assess_execution_readiness(target, bindings) == {"ready": True} + + +def test_partial_flow_spec_preserves_requested_boundaries(tmp_path, minimal_ics55_pdk_factory): + from chipcompiler.data import load_workspace + from chipcompiler.engine import create_workspace_from_spec + + payload, bindings = _shared_fixture("valid.json") + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + spec = deepcopy(payload["workspaceSpec"]) + spec["flow"] = { + "flowId": "rtl2gds", + "fromStepId": "Synthesis", + "throughStepId": "lec", + } + spec["parameters"] = {"design.frequency_mhz": 200.0} + workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings) + + assert [step["name"] for step in load_workspace(workspace.directory).flow.steps()] == [ + "Synthesis", + "lec", + ] + + +def test_workspace_spec_update_is_atomic_revisioned_and_idempotent( + tmp_path, minimal_ics55_pdk_factory +): + from chipcompiler.engine import ( + WorkspaceLifecycleError, + create_workspace_from_spec, + update_workspace_from_spec, + ) + from chipcompiler.engine.snapshot import read_engineering_snapshot + + payload, bindings = _shared_fixture("valid.json") + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + target = tmp_path / "workspace" + created = create_workspace_from_spec(target, payload["workspaceSpec"], bindings, "create-1") + before = read_engineering_snapshot(created) + updated_spec = deepcopy(payload["workspaceSpec"]) + updated_spec["parameters"]["design.frequency_mhz"] = 250.0 + + updated = update_workspace_from_spec( + target, + before["workspaceRevision"], + updated_spec, + bindings, + "update-1", + ) + after = read_engineering_snapshot(updated) + repeated = update_workspace_from_spec( + target, + before["workspaceRevision"], + updated_spec, + bindings, + "update-1", + ) + + assert after["workspaceId"] == before["workspaceId"] + assert after["workspaceRevision"] == before["workspaceRevision"] + 1 + assert read_engineering_snapshot(repeated) == after + assert repeated.parameters.data["frequency_max"] == 250.0 + + with pytest.raises(WorkspaceLifecycleError) as conflict: + update_workspace_from_spec( + target, + before["workspaceRevision"], + payload["workspaceSpec"], + bindings, + "update-2", + ) + assert conflict.value.code == "revision_conflict" + assert read_engineering_snapshot(repeated) == after + + +def test_workspace_spec_stale_revision_does_not_create_missing_snapshot( + tmp_path, minimal_ics55_pdk_factory +): + from chipcompiler.engine import WorkspaceLifecycleError, create_workspace_from_spec + + payload, bindings = _shared_fixture("valid.json") + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + target = tmp_path / "workspace" + create_workspace_from_spec(target, payload["workspaceSpec"], bindings) + snapshot = target / "home" / "engineering-snapshot.json" + snapshot.unlink() + + with pytest.raises(WorkspaceLifecycleError) as conflict: + from chipcompiler.engine import update_workspace_from_spec + + update_workspace_from_spec( + target, + 2, + payload["workspaceSpec"], + bindings, + "stale-update", + ) + + assert conflict.value.code == "revision_conflict" + assert not snapshot.exists() + + +def test_workspace_spec_rejects_invalid_existing_snapshot(tmp_path, minimal_ics55_pdk_factory): + from chipcompiler.engine import ( + WorkspaceLifecycleError, + create_workspace_from_spec, + update_workspace_from_spec, + ) + + payload, bindings = _shared_fixture("valid.json") + bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk")) + target = tmp_path / "workspace" + create_workspace_from_spec(target, payload["workspaceSpec"], bindings) + snapshot = target / "home" / "engineering-snapshot.json" + snapshot.write_text('{"schemaVersion": 2}', encoding="utf-8") + + with pytest.raises(WorkspaceLifecycleError) as invalid: + update_workspace_from_spec( + target, + 1, + payload["workspaceSpec"], + bindings, + "invalid-snapshot-update", + ) + + assert invalid.value.code == "workspace_invalid" + assert snapshot.read_text(encoding="utf-8") == '{"schemaVersion": 2}' diff --git a/test/fixtures/workspace_spec/input/gcd.v b/test/fixtures/workspace_spec/input/gcd.v new file mode 100644 index 000000000..5705cb05d --- /dev/null +++ b/test/fixtures/workspace_spec/input/gcd.v @@ -0,0 +1,3 @@ +module gcd(input clk, output y); + assign y = clk; +endmodule diff --git a/test/fixtures/workspace_spec/input/sources.f b/test/fixtures/workspace_spec/input/sources.f new file mode 100644 index 000000000..369c2e562 --- /dev/null +++ b/test/fixtures/workspace_spec/input/sources.f @@ -0,0 +1 @@ +missing.v diff --git a/test/fixtures/workspace_spec/invalid.json b/test/fixtures/workspace_spec/invalid.json new file mode 100644 index 000000000..b179d6795 --- /dev/null +++ b/test/fixtures/workspace_spec/invalid.json @@ -0,0 +1,67 @@ +{ + "cliConfig": { + "design_name": "gcd", + "design_rtl": ["input/gcd.v"], + "design_top": "gcd", + "flow_preset": "rtl2gds", + "pdk_name": "ics55", + "pdk_overrides": { + "mystery": "unsupported" + }, + "pdk_root": "pdk" + }, + "expectedCliError": "unsupported_legacy_field: pdk.overrides.mystery", + "expectedIssueCodes": [ + "conflicting_input_roles", + "filelist_source_missing" + ], + "expectedStudioError": "unsupported_legacy_field: parameters.mystery_knob", + "studioDraft": { + "commandId": "workspace-create-invalid-shared-fixture", + "designInputMode": "rtl", + "directory": "workspace", + "originVerilog": "input/gcd.v", + "parameters": { + "design": "gcd", + "mystery_knob": 3, + "top_module": "gcd" + }, + "pdk": "ics55", + "pdkRoot": "pdk" + }, + "workspaceBindings": { + "inputs": { + "filelist": "input/sources.f", + "rtl-main": "input/gcd.v" + }, + "pdk": { + "root": "pdk" + } + }, + "workspaceSpec": { + "design": { + "name": "gcd", + "topModule": "gcd" + }, + "flow": { + "flowId": "rtl2gds" + }, + "inputMode": "rtl", + "inputs": [ + { + "inputId": "rtl-main", + "role": "rtl" + }, + { + "inputId": "filelist", + "role": "filelist" + } + ], + "parameters": {}, + "pdk": { + "familyId": "ics55", + "mode": "default" + }, + "schemaVersion": 1 + } +} diff --git a/test/fixtures/workspace_spec/pdk/.keep b/test/fixtures/workspace_spec/pdk/.keep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/test/fixtures/workspace_spec/pdk/.keep @@ -0,0 +1 @@ + diff --git a/test/fixtures/workspace_spec/valid.json b/test/fixtures/workspace_spec/valid.json new file mode 100644 index 000000000..94a43fe0c --- /dev/null +++ b/test/fixtures/workspace_spec/valid.json @@ -0,0 +1,68 @@ +{ + "cliConfig": { + "design_clock_port": "clk", + "design_frequency_mhz": 200, + "design_name": "gcd", + "design_rtl": ["input/gcd.v"], + "design_top": "gcd", + "flow_preset": "rtl2gds", + "params_overrides": { + "floorplan.core_util": 0.6, + "place.target_density": 0.7 + }, + "pdk_name": "ics55", + "pdk_root": "pdk" + }, + "studioDraft": { + "commandId": "workspace-create-shared-fixture", + "designInputMode": "rtl", + "directory": "workspace", + "originVerilog": "input/gcd.v", + "parameters": { + "clock": "clk", + "core_utilization": 0.6, + "design": "gcd", + "frequency_max": 200, + "target_density": 0.7, + "top_module": "gcd" + }, + "pdk": "ics55", + "pdkConfigMode": "default", + "pdkRoot": "pdk" + }, + "workspaceBindings": { + "inputs": { + "rtl-main": "input/gcd.v" + }, + "pdk": { + "root": "pdk" + } + }, + "workspaceSpec": { + "design": { + "clockPort": "clk", + "name": "gcd", + "topModule": "gcd" + }, + "flow": { + "flowId": "rtl2gds" + }, + "inputMode": "rtl", + "inputs": [ + { + "inputId": "rtl-main", + "role": "rtl" + } + ], + "parameters": { + "design.frequency_mhz": 200.0, + "floorplan.core_util": 0.6, + "place.target_density": 0.7 + }, + "pdk": { + "familyId": "ics55", + "mode": "default" + }, + "schemaVersion": 1 + } +} diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index de400ac1f..4f17d158c 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -12,14 +12,14 @@ def test_ecc_console_script_in_pyproject(self): assert data["project"]["scripts"]["ecc"] == "chipcompiler.cli.main:main" assert set(data["project"]["scripts"]) == {"ecc"} - def test_pyinstaller_spec_collects_jsonrpcserver_data_files(self): + def test_pyinstaller_spec_excludes_removed_jsonrpcserver_data_files(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) spec_path = os.path.join(project_root, "ecc.spec") with open(spec_path, encoding="utf-8") as f: source = f.read() - assert 'collect_data_files("jsonrpcserver")' in source + assert 'collect_data_files("jsonrpcserver")' not in source def test_pyinstaller_spec_filters_payloads_before_analysis(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/test/project/__init__.py b/test/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/project/test_manifest.py b/test/project/test_manifest.py new file mode 100644 index 000000000..e9f04b013 --- /dev/null +++ b/test/project/test_manifest.py @@ -0,0 +1,77 @@ +import json + +from chipcompiler.cli.project.manifest import load_manifest +from chipcompiler.project import ( + create_project_manifest, + discover_project_manifest, + load_project_manifest, + mutate_project_manifest, +) + + +def test_domain_created_manifest_is_cli_readable(tmp_path): + created = create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z") + + loaded = load_manifest(str(tmp_path)) + + assert created["root_path"] == str(tmp_path.resolve()) + assert loaded.project_id == created["project_id"] + assert loaded.design_name == "gcd" + assert loaded.workspaces == () + + +def test_project_manifest_is_discovered_from_nested_workspace(tmp_path): + workspace = tmp_path / "experiment" + workspace.mkdir() + created = create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z") + + assert discover_project_manifest(workspace) == (tmp_path.resolve(), created) + + +def test_register_workspace_uses_main_manifest_shape(tmp_path): + workspace = tmp_path / "experiment" + (workspace / "home").mkdir(parents=True) + (workspace / "home" / "flow.json").write_text( + json.dumps({"steps": [{"name": "Synthesis", "state": "Unstart"}]}) + ) + create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z") + + updated = mutate_project_manifest( + tmp_path, + { + "type": "register_workspace", + "workspace_id": "experiment", + "workspace_path": str(workspace), + "name": "Experiment", + "created_at": "2026-02-01T00:00:00Z", + "updated_at": "2026-02-01T00:00:00Z", + }, + ) + + assert updated == load_project_manifest(tmp_path) + assert updated["workspaces"][0]["start_step"] == "Synth" + assert updated["workspaces"][0]["end_step"] == "Synth" + + +def test_manifest_mutation_without_timestamp_keeps_audit_timestamp(tmp_path): + workspace = tmp_path / "experiment" + (workspace / "home").mkdir(parents=True) + (workspace / "home" / "flow.json").write_text(json.dumps({"steps": []})) + create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z") + mutate_project_manifest( + tmp_path, + { + "type": "register_workspace", + "workspace_id": "experiment", + "workspace_path": str(workspace), + "name": "Experiment", + }, + ) + + updated = mutate_project_manifest( + tmp_path, + {"type": "archive_workspace", "workspace_id": "experiment"}, + ) + + assert updated["updated_at"] + assert updated["workspaces"][0]["updated_at"] diff --git a/test/runtime/test_events.py b/test/runtime/test_events.py deleted file mode 100644 index ca2b12e17..000000000 --- a/test/runtime/test_events.py +++ /dev/null @@ -1,156 +0,0 @@ -import os -import sys -import threading -from contextlib import nullcontext - -import pytest - -from chipcompiler.runtime.events import redirect_stdout_to_stderr -from chipcompiler.utility.log import capture_stdio_to_file - - -def test_redirect_stdout_to_stderr_restores_fd_1_and_fd_2(tmp_path): - stdout_target = tmp_path / "stdout.txt" - stderr_target = tmp_path / "stderr.txt" - redirected_target = tmp_path / "redirected.txt" - - saved_stdout_fd = os.dup(1) - saved_stderr_fd = os.dup(2) - with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: - os.dup2(stdout_file.fileno(), 1) - os.dup2(stderr_file.fileno(), 2) - try: - with redirect_stdout_to_stderr(), open(redirected_target, "wb") as redirected_file: - os.dup2(redirected_file.fileno(), 2) - os.write(1, b"captured stdout\n") - os.write(2, b"captured stderr\n") - - os.write(1, b"restored stdout\n") - os.write(2, b"restored stderr\n") - finally: - os.dup2(saved_stdout_fd, 1) - os.dup2(saved_stderr_fd, 2) - os.close(saved_stdout_fd) - os.close(saved_stderr_fd) - sys.stdout = sys.__stdout__ - sys.stderr = sys.__stderr__ - - assert stdout_target.read_text() == "restored stdout\n" - assert stderr_target.read_text() == "captured stdout\nrestored stderr\n" - assert redirected_target.read_text() == "captured stderr\n" - - -@pytest.mark.parametrize("error", [None, RuntimeError("failed"), SystemExit(0)]) -def test_capture_stdio_to_file_restores_fd_1_and_fd_2(tmp_path, error): - original_fds = (os.dup(1), os.dup(2)) - stdout_target = tmp_path / "stdout.txt" - stderr_target = tmp_path / "stderr.txt" - step_log = tmp_path / "step.log" - with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: - os.dup2(stdout_file.fileno(), 1) - os.dup2(stderr_file.fileno(), 2) - try: - with ( - pytest.raises(type(error)) if error else nullcontext(), - capture_stdio_to_file(str(step_log)), - ): - os.write(1, b"step stdout\n") - os.write(2, b"step stderr\n") - if error: - raise error - os.write(1, b"restored stdout\n") - os.write(2, b"restored stderr\n") - finally: - os.dup2(original_fds[0], 1) - os.dup2(original_fds[1], 2) - os.close(original_fds[0]) - os.close(original_fds[1]) - - assert step_log.read_text() == "step stdout\nstep stderr\n" - assert stdout_target.read_text() == "restored stdout\n" - assert stderr_target.read_text() == "restored stderr\n" - - -def test_capture_stdio_to_file_serializes_process_fds(tmp_path): - first_entered = threading.Event() - second_attempted = threading.Event() - second_entered = threading.Event() - release_first = threading.Event() - - def capture_first(): - with capture_stdio_to_file(str(tmp_path / "first.log")): - os.write(1, b"first\n") - first_entered.set() - release_first.wait(timeout=2) - - def capture_second(): - first_entered.wait(timeout=2) - second_attempted.set() - with capture_stdio_to_file(str(tmp_path / "second.log")): - second_entered.set() - os.write(1, b"second\n") - - first = threading.Thread(target=capture_first) - second = threading.Thread(target=capture_second) - first.start() - second.start() - try: - assert first_entered.wait(timeout=2) - assert second_attempted.wait(timeout=2) - assert not second_entered.wait(timeout=0.05) - finally: - release_first.set() - first.join(timeout=2) - second.join(timeout=2) - - assert not first.is_alive() - assert not second.is_alive() - assert second_entered.is_set() - assert (tmp_path / "first.log").read_text() == "first\n" - assert (tmp_path / "second.log").read_text() == "second\n" - - -def test_rpc_redirect_does_not_restore_step_capture_after_capture_exits(tmp_path): - original_fds = (os.dup(1), os.dup(2)) - stdout_target = tmp_path / "stdout.txt" - stderr_target = tmp_path / "stderr.txt" - step_log = tmp_path / "step.log" - capture_entered = threading.Event() - release_capture = threading.Event() - capture_exited = threading.Event() - - def capture_step(): - with capture_stdio_to_file(str(step_log)): - capture_entered.set() - release_capture.wait(timeout=2) - capture_exited.set() - - def dispatch_rpc(): - capture_entered.wait(timeout=2) - with redirect_stdout_to_stderr(): - release_capture.set() - capture_exited.wait(timeout=2) - - with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: - os.dup2(stdout_file.fileno(), 1) - os.dup2(stderr_file.fileno(), 2) - first = threading.Thread(target=capture_step) - second = threading.Thread(target=dispatch_rpc) - try: - first.start() - second.start() - first.join(timeout=2) - second.join(timeout=2) - assert not first.is_alive() - assert not second.is_alive() - os.write(1, b"after capture\n") - finally: - os.dup2(original_fds[0], 1) - os.dup2(original_fds[1], 2) - os.close(original_fds[0]) - os.close(original_fds[1]) - sys.stdout = sys.__stdout__ - sys.stderr = sys.__stderr__ - - assert stdout_target.read_text() == "after capture\n" - assert "after capture" not in step_log.read_text() diff --git a/test/runtime/test_layout_edit.py b/test/runtime/test_layout_edit.py deleted file mode 100644 index 4e43d54d6..000000000 --- a/test/runtime/test_layout_edit.py +++ /dev/null @@ -1,608 +0,0 @@ -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from chipcompiler.runtime.requests import ( - FloorplanEditInspectRequest, - FloorplanEditRunAutoRequest, - FloorplanEditValidateRequest, - LayoutEditApplyRequest, - LayoutEditBeginRequest, - LayoutEditDiscardRequest, - LayoutEditSaveRequest, -) -from chipcompiler.runtime.sessions import WorkspaceSessionRegistry -from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi - - -class FakeLayoutModule: - def __init__(self): - self.initialize_calls = 0 - self.reset_calls = 0 - self.place_calls = [] - self.sync_calls = [] - self.export_calls = [] - self.session_snapshot_calls = [] - self.editor_calls = [] - self.validation_result = {"ok": True, "diagnostics": []} - self.export_intent = {"ok": True} - - def initialize_geometry_session(self): - self.initialize_calls += 1 - return True - - def reset_geometry_session(self): - self.reset_calls += 1 - return True - - def place_instance(self, **kwargs): - self.place_calls.append(kwargs) - return True - - def sync_instance_geometry(self, inst_name): - self.sync_calls.append(inst_name) - return { - "ok": True, - "snapshotRequired": False, - "updatedShapeCount": 1, - "insertedShapeCount": 0, - "deletedShapeCount": 0, - "missingShapeCount": 0, - "events": [{"shapeId": 11, "op": "update"}], - } - - def def_save(self, def_path): - self.export_calls.append("def") - Path(def_path).write_text("new def", encoding="utf-8") - - def save_data(self, path): - self.export_calls.append("db") - db_path = Path(path) - db_path.mkdir(parents=True) - (db_path / "metadata.idb").write_text("new db", encoding="utf-8") - return True - - def gds_save(self, output_path): - self.export_calls.append("gds") - Path(output_path).write_text("new gds", encoding="utf-8") - - def geometry_snapshot_save(self, output_dir): - self.export_calls.append("geometry") - geometry_dir = Path(output_dir) - geometry_dir.mkdir(parents=True) - (geometry_dir / "geometry.manifest").write_text("new geometry", encoding="utf-8") - return True - - def geometry_session_snapshot_save(self, output_dir): - self.session_snapshot_calls.append(Path(output_dir)) - geometry_dir = Path(output_dir) - geometry_dir.mkdir(parents=True) - (geometry_dir / "geometry.manifest").write_text("session geometry", encoding="utf-8") - return True - - def floorplan_editor_apply(self, request): - self.editor_calls.append(request) - return { - "accepted": True, - "changed": True, - "affectedRefs": [{"kind": "blockage", "id": "blockage-1"}], - "geometryDelta": { - "ok": True, - "snapshotRequired": True, - "updatedShapeCount": 0, - "insertedShapeCount": 1, - "deletedShapeCount": 0, - "missingShapeCount": 0, - "events": [{"shapeId": 31, "op": "insert"}], - }, - "modelPatch": { - "floorplanPlan": {"placement_blockages": [{"id": "blockage-1"}]}, - "pdnPlan": {"manual_segments": [{"id": "segment-1"}]}, - "configPatch": {"editor": {"enabled": True}}, - "parametersPatch": {"Floorplan": {"edited": True}}, - }, - "diagnostics": [{"severity": "warning", "message": "preview"}], - } - - def floorplan_editor_validate(self, scope): - assert scope - return self.validation_result - - def floorplan_editor_export_intent(self): - return self.export_intent - - def floorplan_editor_inspect(self): - return {"ownerCount": 3} - - def verilog_save(self, output_verilog): - self.export_calls.append("verilog") - Path(output_verilog).write_text("module gcd; endmodule\n", encoding="utf-8") - - -class FakeEngineDb: - def __init__(self, module): - self.ecc_module = module - self.initialized = False - self.created_for = [] - self.close_calls = 0 - - @property - def engine(self): - return self.ecc_module - - def has_init(self): - return self.initialized - - def create_db_engine(self, step): - self.created_for.append(step) - self.initialized = True - return True - - def close(self): - self.close_calls += 1 - self.initialized = False - - -class FakeFlow: - def __init__(self, workspace, step, module): - self.workspace = workspace - self.workspace_step = step - self.engine_db = FakeEngineDb(module) - - def get_workspace_step(self, name): - return self.workspace_step if name == self.workspace_step.name else None - - -def _make_layout_workspace(tmp_path, *, with_db=False, with_editor_workspace=False): - workspace_dir = tmp_path / "workspace" - output_dir = workspace_dir / "Floorplan_ecc" / "output" - output_dir.mkdir(parents=True) - output_def = output_dir / "gcd_Floorplan.def.gz" - output_def.write_text("old def", encoding="utf-8") - output_db = output_dir / "gcd_Floorplan_db" - if with_db: - output_db.mkdir() - (output_db / "metadata.idb").write_text("old db", encoding="utf-8") - step = SimpleNamespace( - name="Floorplan", - input={"def": None, "verilog": None, "db": None}, - output={ - "def": output_def, - "db": output_db, - "gds": output_dir / "gcd_Floorplan.gds", - "geometry": output_dir / "geometry", - "geometry_manifest": output_dir / "geometry" / "geometry.manifest", - "verilog": output_dir / "gcd_Floorplan.v", - }, - ) - workspace = SimpleNamespace(directory=workspace_dir) - if with_editor_workspace: - config_path = workspace_dir / "config" / "floorplan_ecc.json" - config_path.parent.mkdir() - config_path.write_text('{"legacy": true}\n', encoding="utf-8") - parameters_path = workspace_dir / "parameters.json" - parameters_path.write_text('{"floorplan": {"edited": false}}\n', encoding="utf-8") - flow_path = workspace_dir / "flow.json" - flow_data = { - "steps": [ - {"name": "Floorplan", "state": "Success", "runtime": "1s"}, - {"name": "place", "state": "Success", "runtime": "2s"}, - {"name": "route", "state": "Success", "runtime": "3s"}, - ] - } - flow_path.write_text(json.dumps(flow_data), encoding="utf-8") - workspace.config = {"Floorplan": config_path} - workspace.parameters = SimpleNamespace( - path=parameters_path, - data={"floorplan": {"edited": False}}, - ) - workspace.flow = SimpleNamespace(path=flow_path, data=flow_data) - return workspace, step - - -def _open_api(monkeypatch, tmp_path, *, with_db=False, with_editor_workspace=False): - workspace, step = _make_layout_workspace( - tmp_path, - with_db=with_db, - with_editor_workspace=with_editor_workspace, - ) - module = FakeLayoutModule() - flow_calls = [] - - def build_flow(_workspace): - flow = FakeFlow(_workspace, step, module) - flow_calls.append(flow) - return flow - - monkeypatch.setattr("chipcompiler.runtime.workspace_api.build_flow_for_workspace", build_flow) - registry = WorkspaceSessionRegistry() - session = registry.open_session(workspace.directory, workspace=workspace) - api = WorkspaceRuntimeApi(sessions=registry, persistent_db_enabled=True) - return api, session, step, module, flow_calls - - -def _begin(api, workspace_id, **kwargs): - return api.layout_edit_begin( - LayoutEditBeginRequest(workspace_id=workspace_id, step="Floorplan", **kwargs) - ) - - -def _apply(api, edit_session_id, *, revision=0, command_id="move-1"): - return api.layout_edit_apply( - LayoutEditApplyRequest( - edit_session_id=edit_session_id, - command_id=command_id, - base_revision=revision, - operation={ - "kind": "place_instance", - "instName": "u_sram_0", - "llx": 1200, - "lly": 3400, - "orient": "N", - "cellmaster": "", - "source": "", - "placementStatus": "preserve", - "createIfMissing": False, - }, - ) - ) - - -def test_layout_edit_begin_loads_output_def_when_output_db_is_absent(monkeypatch, tmp_path): - api, session, step, module, flow_calls = _open_api(monkeypatch, tmp_path) - - result = _begin(api, session.workspace_id) - - assert result["source"] == "def" - assert result["dirty"] is False - assert Path(result["geometryManifestPath"]).is_file() - assert module.initialize_calls == 1 - loaded_step = flow_calls[0].engine_db.created_for[0] - assert loaded_step is not step - assert loaded_step.input["db"] is None - assert loaded_step.input["def"] == step.output["def"] - - -def test_layout_edit_begin_prefers_selected_step_output_db(monkeypatch, tmp_path): - api, session, step, _module, flow_calls = _open_api(monkeypatch, tmp_path, with_db=True) - - result = _begin(api, session.workspace_id) - - assert result["source"] == "db" - loaded_step = flow_calls[0].engine_db.created_for[0] - assert loaded_step.input["db"] == step.output["db"] - - -def test_layout_edit_begin_rejects_another_workspace_until_active_session_is_discarded( - monkeypatch, - tmp_path, -): - api, first_session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - second_workspace = SimpleNamespace(directory=tmp_path / "second-workspace") - second_session = api.sessions.open_session( - second_workspace.directory, - workspace=second_workspace, - ) - - first = _begin(api, first_session.workspace_id) - - with pytest.raises(RuntimeApiError) as exc_info: - _begin(api, second_session.workspace_id) - - assert exc_info.value.code == "layout_edit_active" - assert exc_info.value.data == { - "editSessionId": first["editSessionId"], - "workspaceId": first_session.workspace_id, - } - assert module.initialize_calls == 1 - - api.layout_edit_discard(LayoutEditDiscardRequest(first["editSessionId"])) - second = _begin(api, second_session.workspace_id) - - assert second["workspaceId"] == second_session.workspace_id - assert module.initialize_calls == 2 - - -def test_layout_edit_apply_calls_place_instance_without_persisting_artifacts(monkeypatch, tmp_path): - api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - original_def = step.output["def"].read_text(encoding="utf-8") - - result = _apply(api, begin["editSessionId"]) - - assert result["revision"] == 1 - assert result["dirty"] is True - assert result["geometryDelta"]["events"] == [{"shapeId": 11, "op": "update"}] - assert module.place_calls == [ - { - "inst_name": "u_sram_0", - "llx": 1200, - "lly": 3400, - "orient": "N", - "cellmaster": "", - "source": "", - "placement_status": "preserve", - "create_if_missing": False, - } - ] - assert module.sync_calls == ["u_sram_0"] - assert module.export_calls == [] - assert len(module.session_snapshot_calls) == 2 - assert Path(result["geometryManifestPath"]).is_file() - assert step.output["def"].read_text(encoding="utf-8") == original_def - assert not step.output["db"].exists() - assert not step.output["gds"].exists() - assert not step.output["geometry"].exists() - - -def test_layout_edit_save_publishes_staged_outputs_only_after_explicit_save(monkeypatch, tmp_path): - api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - apply = _apply(api, begin["editSessionId"]) - - saved = api.layout_edit_save( - LayoutEditSaveRequest( - edit_session_id=begin["editSessionId"], - expected_revision=apply["revision"], - ) - ) - - assert saved["saved"] is True - assert saved["dirty"] is False - assert saved["artifacts"] == { - "defPath": str(step.output["def"]), - "dbPath": str(step.output["db"]), - "gdsPath": str(step.output["gds"]), - "geometryManifestPath": str(step.output["geometry_manifest"]), - } - assert module.export_calls == ["def", "db", "gds", "geometry"] - assert step.output["def"].read_text(encoding="utf-8") == "new def" - assert (step.output["db"] / "metadata.idb").read_text(encoding="utf-8") == "new db" - assert step.output["gds"].read_text(encoding="utf-8") == "new gds" - geometry_manifest = step.output["geometry"] / "geometry.manifest" - assert geometry_manifest.read_text(encoding="utf-8") == "new geometry" - assert not list(step.output["def"].parent.glob(".layout-edit-*")) - - -def test_layout_edit_discard_drops_in_memory_db_without_publishing(monkeypatch, tmp_path): - api, session, step, module, flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - _apply(api, begin["editSessionId"]) - geometry_root = Path(begin["geometryManifestPath"]).parent.parent - - result = api.layout_edit_discard(LayoutEditDiscardRequest(begin["editSessionId"])) - - assert result == { - "editSessionId": begin["editSessionId"], - "discarded": True, - "dirty": True, - } - assert flow_calls[0].engine_db.close_calls == 1 - assert module.reset_calls == 1 - assert not geometry_root.exists() - assert step.output["def"].read_text(encoding="utf-8") == "old def" - assert module.export_calls == [] - with pytest.raises(RuntimeApiError, match="layout edit session not found"): - _apply(api, begin["editSessionId"]) - - -def test_layout_edit_save_rejects_external_source_change(monkeypatch, tmp_path): - api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - apply = _apply(api, begin["editSessionId"]) - step.output["def"].write_text("external change", encoding="utf-8") - - with pytest.raises(RuntimeApiError) as exc_info: - api.layout_edit_save( - LayoutEditSaveRequest( - edit_session_id=begin["editSessionId"], - expected_revision=apply["revision"], - ) - ) - - assert exc_info.value.code == "source_changed" - assert module.export_calls == [] - assert step.output["def"].read_text(encoding="utf-8") == "external change" - - -def test_layout_edit_save_rolls_back_when_publish_fails(monkeypatch, tmp_path): - api, session, step, _module, _flow_calls = _open_api(monkeypatch, tmp_path, with_db=True) - step.output["gds"].write_text("old gds", encoding="utf-8") - step.output["geometry"].mkdir() - (step.output["geometry"] / "geometry.manifest").write_text( - "old geometry", - encoding="utf-8", - ) - begin = _begin(api, session.workspace_id) - apply = _apply(api, begin["editSessionId"]) - real_replace = __import__("os").replace - - def fail_gds_publish(source, destination): - if Path(source).name == step.output["gds"].name and Path(destination) == step.output["gds"]: - raise OSError("simulated publish failure") - real_replace(source, destination) - - monkeypatch.setattr("chipcompiler.runtime.workspace_api.os.replace", fail_gds_publish) - - with pytest.raises(RuntimeApiError, match="simulated publish failure"): - api.layout_edit_save( - LayoutEditSaveRequest( - edit_session_id=begin["editSessionId"], - expected_revision=apply["revision"], - ) - ) - - assert step.output["def"].read_text(encoding="utf-8") == "old def" - assert (step.output["db"] / "metadata.idb").read_text(encoding="utf-8") == "old db" - assert step.output["gds"].read_text(encoding="utf-8") == "old gds" - geometry_manifest = step.output["geometry"] / "geometry.manifest" - assert geometry_manifest.read_text(encoding="utf-8") == "old geometry" - - -def test_layout_edit_apply_rejects_stale_revision_before_mutation(monkeypatch, tmp_path): - api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - _apply(api, begin["editSessionId"]) - - with pytest.raises(RuntimeApiError) as exc_info: - _apply(api, begin["editSessionId"], revision=0, command_id="move-2") - - assert exc_info.value.code == "version_conflict" - assert len(module.place_calls) == 1 - - -def test_layout_edit_apply_replays_completed_command_before_revision_validation( - monkeypatch, - tmp_path, -): - api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - first = _apply(api, begin["editSessionId"]) - - replay = _apply(api, begin["editSessionId"], revision=0) - - assert replay == first - assert len(module.place_calls) == 1 - - -def test_layout_edit_apply_allows_preserve_orientation_for_existing_instance( - monkeypatch, - tmp_path, -): - api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - - result = api.layout_edit_apply( - LayoutEditApplyRequest( - edit_session_id=begin["editSessionId"], - command_id="move-with-preserved-orient", - base_revision=0, - operation={ - "kind": "place_instance", - "instName": "u_sram_0", - "llx": 1200, - "lly": 3400, - "orient": "", - "placementStatus": "preserve", - "createIfMissing": False, - }, - ) - ) - - assert result["revision"] == 1 - assert module.place_calls[0]["orient"] == "" - - -def test_floorplan_editor_apply_inspect_validate_and_save_publish_editor_artifacts( - monkeypatch, - tmp_path, -): - api, session, step, module, _flow_calls = _open_api( - monkeypatch, - tmp_path, - with_editor_workspace=True, - ) - begin = _begin(api, session.workspace_id) - - applied = api.layout_edit_apply( - LayoutEditApplyRequest( - edit_session_id=begin["editSessionId"], - command_id="blockage-1", - base_revision=0, - operation={"kind": "upsert_blockage", "id": "blockage-1"}, - ) - ) - - assert applied["revision"] == 1 - assert applied["affectedRefs"] == [{"kind": "blockage", "id": "blockage-1"}] - assert applied["modelPatch"]["floorplanPlan"]["placement_blockages"] == [{"id": "blockage-1"}] - assert module.editor_calls == [{"kind": "upsert_blockage", "id": "blockage-1"}] - config_path = session.workspace.config["Floorplan"] - assert config_path.read_text(encoding="utf-8") == '{"legacy": true}\n' - - inspected = api.floorplan_edit_inspect( - FloorplanEditInspectRequest(edit_session_id=begin["editSessionId"]) - ) - assert inspected["state"] == {"ownerCount": 3} - assert inspected["floorplanPlan"]["placement_blockages"] == [{"id": "blockage-1"}] - - validated = api.floorplan_edit_validate( - FloorplanEditValidateRequest(edit_session_id=begin["editSessionId"], scope="pdn") - ) - assert validated["valid"] is True - - module.export_intent = { - "ok": True, - "floorplanPlan": {"outline": {"die": [0, 0, 100, 100]}}, - "pdnPlan": {"manual_vias": [{"id": "via-1"}]}, - "parametersPatch": {"PDN": {"edited": True}}, - "requiresVerilog": True, - } - saved = api.layout_edit_save( - LayoutEditSaveRequest( - edit_session_id=begin["editSessionId"], - expected_revision=applied["revision"], - ) - ) - - assert saved["saved"] is True - assert saved["artifacts"]["configPath"] == str(config_path) - assert saved["artifacts"]["parametersPath"] == str(session.workspace.parameters.path) - assert saved["artifacts"]["verilogPath"] == str(step.output["verilog"]) - assert saved["artifacts"]["flowPath"] == str(session.workspace.flow.path) - assert module.export_calls == ["def", "db", "gds", "geometry", "verilog"] - saved_config = json.loads(config_path.read_text(encoding="utf-8")) - assert saved_config["FloorplanPlan"]["outline"] == {"die": [0, 0, 100, 100]} - assert saved_config["PdnPlan"]["manual_vias"] == [{"id": "via-1"}] - assert saved_config["editor"] == {"enabled": True} - saved_parameters = json.loads(session.workspace.parameters.path.read_text(encoding="utf-8")) - assert saved_parameters["floorplan"]["edited"] is True - assert saved_parameters["pdn"] == {"edited": True} - assert step.output["verilog"].is_file() - stale_steps = session.workspace.flow.data["steps"][1:] - assert [item["state"] for item in stale_steps] == ["Unstart", "Unstart"] - assert [item["runtime"] for item in stale_steps] == ["", ""] - - -def test_floorplan_editor_run_auto_is_idempotent_and_save_rejects_invalid_result( - monkeypatch, - tmp_path, -): - api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) - begin = _begin(api, session.workspace_id) - - first = api.floorplan_edit_run_auto( - FloorplanEditRunAutoRequest( - edit_session_id=begin["editSessionId"], - command_id="auto-1", - base_revision=0, - request={"mode": "macro"}, - ) - ) - replay = api.floorplan_edit_run_auto( - FloorplanEditRunAutoRequest( - edit_session_id=begin["editSessionId"], - command_id="auto-1", - base_revision=0, - request={"mode": "macro"}, - ) - ) - - assert replay == first - assert module.editor_calls == [{"kind": "run_auto", "request": {"mode": "macro"}}] - module.validation_result = { - "ok": False, - "diagnostics": [{"severity": "error", "message": "bad outline"}], - } - with pytest.raises(RuntimeApiError) as exc_info: - api.layout_edit_save( - LayoutEditSaveRequest( - edit_session_id=begin["editSessionId"], - expected_revision=first["revision"], - ) - ) - - assert exc_info.value.code == "floorplan_validation_failed" - assert module.export_calls == [] diff --git a/test/runtime/test_methods.py b/test/runtime/test_methods.py deleted file mode 100644 index b3fb0d126..000000000 --- a/test/runtime/test_methods.py +++ /dev/null @@ -1,154 +0,0 @@ -from dataclasses import is_dataclass - -import chipcompiler.runtime.requests as requests -from chipcompiler.runtime.requests import ( - DbEnsureRequest, - DbReleaseRequest, - FloorplanEditInspectRequest, - FloorplanEditRunAutoRequest, - FloorplanEditValidateRequest, - WorkspaceOpenRequest, -) -from chipcompiler.runtime.server import BASE_CAPABILITIES, RuntimeServer - - -def test_runtime_method_registry_contains_current_methods_once(): - from chipcompiler.runtime.methods import RUNTIME_METHODS, runtime_method_names - - expected_methods = ( - "workspace.create", - "workspace.open", - "workspace.close", - "workspace.home", - "workspace.info", - "workspace.refresh_config", - "workspace.sync_config", - "workspace.reset_flow", - "workspace.export_signoff", - "workspace.inspect_signoff", - "flow.run", - "flow.run_step", - "operation.start_flow", - "operation.start_step", - "operation.status", - "operation.cancel", - "operation.ack_step_rendered", - "workspace.snapshot", - "workspace.recover_interrupted", - ) - - assert runtime_method_names() == expected_methods - assert len(runtime_method_names()) == len(set(runtime_method_names())) - assert len(RUNTIME_METHODS) == len(expected_methods) - - -def test_persistent_db_method_registry_is_separate_and_opt_in(): - from chipcompiler.runtime.methods import ( - PERSISTENT_DB_METHODS, - persistent_db_method_names, - runtime_method_names, - ) - - expected_methods = ( - "db.ensure", - "db.release", - "layout.edit.begin", - "layout.edit.apply", - "layout.edit.save", - "layout.edit.discard", - "floorplan.edit.inspect", - "floorplan.edit.run_auto", - "floorplan.edit.validate", - ) - - assert persistent_db_method_names() == expected_methods - enabled_methods = runtime_method_names(persistent_db_enabled=True) - assert enabled_methods[-len(expected_methods) :] == expected_methods - assert "db.ensure" not in runtime_method_names() - assert len(PERSISTENT_DB_METHODS) == len(expected_methods) - - -def test_runtime_method_registry_entries_are_typed(): - from chipcompiler.runtime.methods import runtime_methods - - for spec in runtime_methods(persistent_db_enabled=True): - assert spec.method_name - assert isinstance(spec.request_model, type) - assert is_dataclass(spec.request_model) - assert spec.handler_name - - -def test_runtime_method_lookup_returns_spec(): - from chipcompiler.runtime.methods import runtime_method_by_name - - spec = runtime_method_by_name("workspace.open") - - assert spec is not None - assert spec.request_model is WorkspaceOpenRequest - assert spec.handler_name == "open_workspace" - export_spec = runtime_method_by_name("workspace.export_signoff") - assert export_spec is not None - assert export_spec.request_model is requests.WorkspaceExportSignoffRequest - assert export_spec.handler_name == "export_signoff" - inspect_spec = runtime_method_by_name("workspace.inspect_signoff") - assert inspect_spec is not None - assert inspect_spec.request_model is requests.WorkspaceInspectSignoffRequest - assert inspect_spec.handler_name == "inspect_signoff" - assert runtime_method_by_name("db.ensure") is None - - -def test_persistent_db_method_lookup_requires_enabled_capability(): - from chipcompiler.runtime.methods import runtime_method_by_name - - ensure_spec = runtime_method_by_name("db.ensure", persistent_db_enabled=True) - release_spec = runtime_method_by_name("db.release", persistent_db_enabled=True) - - assert ensure_spec is not None - assert ensure_spec.request_model is DbEnsureRequest - assert ensure_spec.handler_name == "db_ensure" - assert release_spec is not None - assert release_spec.request_model is DbReleaseRequest - assert release_spec.handler_name == "db_release" - inspect_spec = runtime_method_by_name("floorplan.edit.inspect", persistent_db_enabled=True) - assert inspect_spec is not None - assert inspect_spec.request_model is FloorplanEditInspectRequest - auto_spec = runtime_method_by_name("floorplan.edit.run_auto", persistent_db_enabled=True) - assert auto_spec is not None - assert auto_spec.request_model is FloorplanEditRunAutoRequest - validate_spec = runtime_method_by_name("floorplan.edit.validate", persistent_db_enabled=True) - assert validate_spec is not None - assert validate_spec.request_model is FloorplanEditValidateRequest - - -def test_default_server_capabilities_are_generated_from_runtime_registry(): - from chipcompiler.runtime.methods import runtime_method_names - - server = RuntimeServer() - - assert server.capabilities == (*BASE_CAPABILITIES, *runtime_method_names()) - - -def test_persistent_db_server_capabilities_include_db_methods(): - from chipcompiler.runtime.methods import runtime_method_names - - server = RuntimeServer(persistent_db_enabled=True) - - assert server.capabilities == ( - *BASE_CAPABILITIES, - *runtime_method_names(persistent_db_enabled=True), - ) - assert "db.ensure" in server.capabilities - assert "db.release" in server.capabilities - assert "layout.edit.begin" in server.capabilities - assert "layout.edit.save" in server.capabilities - assert "floorplan.edit.inspect" in server.capabilities - - -def test_requests_module_does_not_own_runtime_method_table(): - assert not hasattr(requests, "REQUEST_MODELS") - - -def test_server_module_does_not_own_runtime_method_table(): - import chipcompiler.runtime.server as server - - assert not hasattr(server, "RUNTIME_METHODS") diff --git a/test/runtime/test_operations.py b/test/runtime/test_operations.py deleted file mode 100644 index 70c9e77e6..000000000 --- a/test/runtime/test_operations.py +++ /dev/null @@ -1,518 +0,0 @@ -import threading -from types import SimpleNamespace - -from chipcompiler.data import StateEnum -from chipcompiler.runtime import operations -from chipcompiler.runtime.operations import RuntimeOperationManager - - -def test_successful_step_waits_for_matching_render_ack_before_completing(): - events = [] - entered_render_gate = threading.Event() - completed = threading.Event() - manager = RuntimeOperationManager(events.append) - step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file="")) - - def runner(observer): - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Success) - entered_render_gate.set() - assert observer.wait_for_step_rendered(step, StateEnum.Success) - completed.set() - return {"rerun": False} - - started = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-1", - runner=runner, - ) - - assert started["state"] in {"queued", "running", "waiting_for_gui_sync"} - assert entered_render_gate.wait(timeout=1) - assert not completed.wait(timeout=0.05) - step_completed = next(event for event in events if event["type"] == "step.completed") - assert step_completed["payload"]["stepCommitId"] - assert step_completed["payload"]["workspaceRevision"] == 1 - assert not manager.acknowledge_step_rendered( - started["operationId"], - step_completed["eventId"], - "wrong-step-commit", - step_completed["payload"]["workspaceRevision"], - )["accepted"] - - assert manager.acknowledge_step_rendered( - started["operationId"], - step_completed["eventId"], - step_completed["payload"]["stepCommitId"], - step_completed["payload"]["workspaceRevision"], - ) == { - "accepted": True, - "duplicate": False, - "operationId": started["operationId"], - "eventId": step_completed["eventId"], - } - assert completed.wait(timeout=1) - assert manager.operation_status(started["operationId"])["state"] == "succeeded" - assert events[-1]["type"] == "operation.completed" - - -def test_subflow_stage_is_emitted_for_the_active_workspace_step(): - events = [] - released = threading.Event() - manager = RuntimeOperationManager(events.append) - step = SimpleNamespace(name="Floorplan", tool="ecc", log=SimpleNamespace(file="")) - - def runner(observer): - observer.on_step_started(step) - observer.on_subflow_stage( - step, - { - "name": "init floorplan", - "state": "Ongoing", - "runtime": "0:0:1", - "peak memory (mb)": 12.5, - }, - ) - assert released.wait(timeout=1) - return {"rerun": False} - - started = manager.start( - workspace_id="workspace-1", - kind="step", - origin="gui", - rerun=True, - step="Floorplan", - idempotency_key="subflow-stage", - runner=runner, - ) - - event = _wait_for_event(events, "subflow.stage") - assert event["operationId"] == started["operationId"] - assert event["payload"] == { - "peakMemory": 12.5, - "runtime": "0:0:1", - "state": "Ongoing", - "step": "Floorplan", - "subflowStep": "init floorplan", - "tool": "ecc", - } - released.set() - assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" - - -def test_render_ack_replays_one_commit_then_pauses_before_a_bounded_timeout(monkeypatch): - monkeypatch.setattr(operations, "_RENDER_ACK_RETRY_SECONDS", 0.01) - monkeypatch.setattr(operations, "_RENDER_ACK_PAUSE_SECONDS", 0.02) - monkeypatch.setattr(operations, "_RENDER_ACK_ABORT_SECONDS", 0.5) - events = [] - entered_render_gate = threading.Event() - manager = RuntimeOperationManager(events.append) - step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file="")) - - def runner(observer): - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Success) - entered_render_gate.set() - assert observer.wait_for_step_rendered(step, StateEnum.Success) - return {"rerun": False} - - started = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-replay", - runner=runner, - ) - assert entered_render_gate.wait(timeout=1) - step_completed = _wait_for_event(events, "step.completed") - paused = _wait_for_event(events, "operation.gui_sync_paused") - replays = [ - event - for event in events - if event["type"] == "step.completed" and event["payload"].get("replayed") - ] - assert paused["payload"]["stepCommitId"] == step_completed["payload"]["stepCommitId"] - assert replays - assert all(event["eventId"] == step_completed["eventId"] for event in replays) - assert ( - manager.operation_status(started["operationId"])["renderSyncState"] - == "paused_for_gui_recovery" - ) - - assert manager.acknowledge_step_rendered( - started["operationId"], - step_completed["eventId"], - step_completed["payload"]["stepCommitId"], - step_completed["payload"]["workspaceRevision"], - )["accepted"] - assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" - - -def test_ack_and_start_requests_are_idempotent(): - events = [] - release = threading.Event() - manager = RuntimeOperationManager(events.append) - - def runner(_observer): - assert release.wait(timeout=1) - return {"rerun": False} - - first = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-1", - runner=runner, - ) - duplicate = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-1", - runner=runner, - ) - - assert duplicate["operationId"] == first["operationId"] - assert duplicate["deduplicated"] is True - assert manager.acknowledge_step_rendered(first["operationId"], "workspace-1:missing") == { - "accepted": False, - "duplicate": False, - "operationId": first["operationId"], - "eventId": "workspace-1:missing", - } - release.set() - assert _wait_for_terminal(manager, first["operationId"])["state"] == "succeeded" - - -def test_event_identity_is_unique_across_sidecar_operation_managers(): - first_events = [] - second_events = [] - first = RuntimeOperationManager(first_events.append) - second = RuntimeOperationManager(second_events.append) - - first.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="first", - runner=lambda _observer: {"rerun": False}, - ) - second.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=True, - step="", - idempotency_key="second", - runner=lambda _observer: {"rerun": True}, - ) - - first_queued = _wait_for_event(first_events, "operation.queued") - second_queued = _wait_for_event(second_events, "operation.queued") - assert first_queued["sequence"] == second_queued["sequence"] == 1 - assert first_queued["eventId"] != second_queued["eventId"] - assert first_queued["runtimeInstanceId"] != second_queued["runtimeInstanceId"] - assert first_queued["operationId"] != second_queued["operationId"] - assert first_queued["runSessionId"] != second_queued["runSessionId"] - - -def test_rerun_prepared_event_carries_the_affected_steps_once(): - events = [] - manager = RuntimeOperationManager(events.append) - - def runner(observer): - observer.on_rerun_prepared( - scope="step", - target_step="Floorplan", - affected_steps=["Floorplan", "route"], - ) - return {"rerun": True} - - first = manager.start( - workspace_id="workspace-1", - kind="step", - origin="gui", - rerun=True, - step="Floorplan", - idempotency_key="rerun-prepared", - runner=runner, - ) - duplicate = manager.start( - workspace_id="workspace-1", - kind="step", - origin="gui", - rerun=True, - step="Floorplan", - idempotency_key="rerun-prepared", - runner=runner, - ) - - assert duplicate["operationId"] == first["operationId"] - prepared = _wait_for_event(events, "operation.rerun_prepared") - assert prepared["payload"] == { - "affectedSteps": ["Floorplan", "route"], - "scope": "step", - "targetStep": "Floorplan", - } - assert len([event for event in events if event["type"] == "operation.rerun_prepared"]) == 1 - - -def test_render_ack_timeout_degrades_and_allows_the_flow_to_continue(monkeypatch): - monkeypatch.setattr(operations, "_RENDER_ACK_RETRY_SECONDS", 0.01) - monkeypatch.setattr(operations, "_RENDER_ACK_PAUSE_SECONDS", 0.02) - monkeypatch.setattr(operations, "_RENDER_ACK_ABORT_SECONDS", 0.04) - events = [] - manager = RuntimeOperationManager(events.append) - step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file="")) - - def runner(observer): - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Success) - assert observer.wait_for_step_rendered(step, StateEnum.Success) - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Success) - assert observer.wait_for_step_rendered(step, StateEnum.Success) - return {"rerun": False} - - started = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-degraded-sync", - runner=runner, - ) - - degraded = _wait_for_event(events, "operation.gui_sync_degraded") - assert degraded["payload"]["stepCommitId"] - assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" - completed_events = [ - event - for event in events - if event["type"] == "step.completed" and not event["payload"].get("replayed") - ] - assert len(completed_events) == 2 - assert completed_events[1]["payload"]["stepCommitId"] - assert ( - manager.operation_status(started["operationId"])["renderSyncState"] == "gui_sync_degraded" - ) - assert not any(event["type"] == "operation.failed" for event in events) - - -def test_active_operation_reports_a_shutdown_barrier_and_safe_boundary(): - release = threading.Event() - manager = RuntimeOperationManager() - started = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-1", - runner=lambda _observer: (release.wait(timeout=1), {"rerun": False})[1], - ) - - barrier = manager.shutdown_barrier() - status = manager.operation_status(started["operationId"]) - - assert barrier is not None - assert barrier["operationId"] == started["operationId"] - assert barrier["interruptibility"] == "deferred" - assert status["shutdownBarrier"] is True - assert status["safeToStop"] is False - release.set() - - -def test_cancel_at_render_ack_boundary_releases_the_waiting_flow(): - entered_render_gate = threading.Event() - manager = RuntimeOperationManager() - step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file="")) - - def runner(observer): - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Success) - entered_render_gate.set() - if not observer.wait_for_step_rendered(step, StateEnum.Success): - raise RuntimeError("operation cancelled at a render boundary") - return {"rerun": False} - - started = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-cancel-at-gate", - runner=runner, - ) - assert entered_render_gate.wait(timeout=1) - - assert manager.request_cancel(started["operationId"])["accepted"] is True - assert _wait_for_terminal(manager, started["operationId"])["state"] == "cancelled" - - -def test_step_log_events_stream_only_new_log_bytes_and_keep_final_tail(tmp_path): - events = [] - step_started = threading.Event() - complete_step = threading.Event() - manager = RuntimeOperationManager(events.append) - log_file = tmp_path / "Synthesis.log" - log_file.write_text("previous run\n", encoding="utf-8") - step = SimpleNamespace( - name="Synthesis", - tool="yosys", - log=SimpleNamespace(file=str(log_file)), - ) - - def runner(observer): - observer.on_step_started(step) - step_started.set() - assert complete_step.wait(timeout=2) - observer.on_step_completed(step, StateEnum.Success) - assert observer.wait_for_step_rendered(step, StateEnum.Success) - return {"rerun": False} - - started = manager.start( - workspace_id="workspace-1", - kind="flow", - origin="gui", - rerun=False, - step="", - idempotency_key="request-log-stream", - runner=runner, - ) - assert step_started.wait(timeout=1) - with log_file.open("a", encoding="utf-8") as handle: - handle.write("live line one\nlive line two\n") - - step_log = _wait_for_event(events, "step.log") - assert step_log["payload"]["chunk"] == "live line one\nlive line two\n" - assert step_log["payload"]["cursor"] == log_file.stat().st_size - - complete_step.set() - step_complete = _wait_for_event(events, "step.completed") - assert step_complete["payload"]["finalLog"] == ("previous run\nlive line one\nlive line two\n") - assert manager.acknowledge_step_rendered(started["operationId"], step_complete["eventId"])[ - "accepted" - ] - assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" - - -def test_step_error_survives_generic_runner_error_and_releases_workspace(tmp_path): - events = [] - manager = RuntimeOperationManager(events.append) - log_file = tmp_path / "place.log" - log_file.write_text("traceback\n", encoding="utf-8") - step = SimpleNamespace( - name="place", - tool="dreamplace", - log=SimpleNamespace(file=log_file), - ) - - def runner(observer): - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Imcomplete, "movable utilization is 100.0%") - raise RuntimeError("run step place failed with state Imcomplete") - - started = manager.start( - workspace_id="workspace-1", - kind="step", - origin="gui", - rerun=False, - step="place", - idempotency_key="failed-place", - runner=runner, - ) - - status = _wait_for_terminal(manager, started["operationId"]) - assert status["error"] == { - "code": "tool_failed", - "message": "movable utilization is 100.0%", - "step": "place", - "tool": "dreamplace", - "logFile": str(log_file), - } - assert _wait_for_event(events, "operation.failed")["payload"]["error"] == status["error"] - second = manager.start( - workspace_id="workspace-1", - kind="step", - origin="gui", - rerun=True, - step="place", - idempotency_key="retry-place", - runner=lambda _observer: {"state": "Success"}, - ) - assert _wait_for_terminal(manager, second["operationId"])["state"] == "succeeded" - - -def test_cancel_does_not_replace_a_specific_tool_error(tmp_path): - manager = RuntimeOperationManager() - log_file = tmp_path / "place.log" - step = SimpleNamespace( - name="place", - tool="dreamplace", - log=SimpleNamespace(file=log_file), - ) - step_failed = threading.Event() - release_runner = threading.Event() - - def runner(observer): - observer.on_step_started(step) - observer.on_step_completed(step, StateEnum.Imcomplete, "utilization is larger than 0.99") - step_failed.set() - assert release_runner.wait(timeout=2) - raise RuntimeError("run step place failed with state Incomplete") - - started = manager.start( - workspace_id="workspace-1", - kind="step", - origin="gui", - rerun=False, - step="place", - idempotency_key="cancelled-failed-place", - runner=runner, - ) - assert step_failed.wait(timeout=1) - assert manager.request_cancel(started["operationId"])["accepted"] is True - release_runner.set() - - status = _wait_for_terminal(manager, started["operationId"]) - assert status["state"] == "failed" - assert status["error"] == { - "code": "tool_failed", - "message": "utilization is larger than 0.99", - "step": "place", - "tool": "dreamplace", - "logFile": str(log_file), - } - - -def _wait_for_event(events: list[dict], event_type: str) -> dict: - for _ in range(200): - for event in events: - if event["type"] == event_type: - return event - threading.Event().wait(0.01) - raise AssertionError(f"event not received: {event_type}") - - -def _wait_for_terminal(manager: RuntimeOperationManager, operation_id: str) -> dict: - for _ in range(100): - status = manager.operation_status(operation_id) - if status["state"] in {"succeeded", "failed", "cancelled"}: - return status - threading.Event().wait(0.01) - return manager.operation_status(operation_id) diff --git a/test/runtime/test_requests.py b/test/runtime/test_requests.py deleted file mode 100644 index 93079b6c4..000000000 --- a/test/runtime/test_requests.py +++ /dev/null @@ -1,352 +0,0 @@ -from dataclasses import is_dataclass - -import pytest - -from chipcompiler.runtime.methods import runtime_method_by_name -from chipcompiler.runtime.requests import ( - DbEnsureRequest, - DbReleaseRequest, - FloorplanEditInspectRequest, - FloorplanEditRunAutoRequest, - FloorplanEditValidateRequest, - FlowRunRequest, - FlowRunStepRequest, - LayoutEditApplyRequest, - LayoutEditBeginRequest, - LayoutEditDiscardRequest, - LayoutEditSaveRequest, - OperationStartStepRequest, - RequestValidationError, - WorkspaceCloseRequest, - WorkspaceCreateRequest, - WorkspaceExportSignoffRequest, - WorkspaceIdRequest, - WorkspaceInfoRequest, - WorkspaceInspectSignoffRequest, - WorkspaceOpenRequest, - WorkspaceSyncConfigRequest, - parse_request_model, -) - - -def _parse_runtime_request(method: str, params: object, *, persistent_db_enabled=False): - spec = runtime_method_by_name(method, persistent_db_enabled=persistent_db_enabled) - assert spec is not None - return parse_request_model(spec.request_model, params) - - -def test_workspace_create_maps_camel_case_fields_and_preserves_pdk_json(): - pdk_json = {"name": "ics55", "lef": ["tech.lef"]} - flow_config = { - "start_step": "Synthesis", - "end_step": "Harden", - "steps": ["Synthesis", "RCX", "sta", "Harden"], - } - - request = _parse_runtime_request( - "workspace.create", - { - "directory": "/work/ws", - "pdk": "ics55", - "pdkRoot": "/pdk", - "pdkJson": pdk_json, - "originDef": "/in.def", - "originVerilog": "/in.v", - "paramJson": {"design": "gcd"}, - "rtlList": ["a.v"], - "sdc": "/constraints/top.sdc", - "flowConfig": flow_config, - }, - ) - - assert isinstance(request, WorkspaceCreateRequest) - assert is_dataclass(request) - assert request.directory == "/work/ws" - assert request.pdk_root == "/pdk" - assert request.pdk_json == pdk_json - assert request.origin_def == "/in.def" - assert request.origin_verilog == "/in.v" - assert request.parameters == {"design": "gcd"} - assert request.rtl_list == ["a.v"] - assert request.sdc == "/constraints/top.sdc" - assert request.flow_config == flow_config - - -@pytest.mark.parametrize( - ("method", "params", "request_type"), - [ - ("workspace.open", {"directory": "/work/ws"}, WorkspaceOpenRequest), - ("workspace.close", {"workspaceId": "ws-1"}, WorkspaceCloseRequest), - ("workspace.home", {"workspaceId": "ws-1"}, WorkspaceIdRequest), - ("workspace.refresh_config", {"workspaceId": "ws-1"}, WorkspaceIdRequest), - ("workspace.reset_flow", {"workspaceId": "ws-1"}, WorkspaceIdRequest), - ( - "workspace.export_signoff", - {"workspaceId": "ws-1", "outputPath": "/exports/custom.tar.gz"}, - WorkspaceExportSignoffRequest, - ), - ( - "workspace.inspect_signoff", - {"workspaceId": "ws-1"}, - WorkspaceInspectSignoffRequest, - ), - ( - "workspace.sync_config", - {"workspaceId": "ws-1", "configPath": "/work/ws/config/route.json"}, - WorkspaceSyncConfigRequest, - ), - ( - "workspace.info", - {"workspaceId": "ws-1", "step": "Synthesis", "id": "layout"}, - WorkspaceInfoRequest, - ), - ("flow.run", {"workspaceId": "ws-1", "rerun": True}, FlowRunRequest), - ( - "flow.run_step", - { - "workspaceId": "ws-1", - "step": "Synthesis", - "rerun": True, - }, - FlowRunStepRequest, - ), - ( - "operation.start_step", - { - "workspaceId": "ws-1", - "step": "Synthesis", - "resetDependents": True, - }, - OperationStartStepRequest, - ), - ], -) -def test_first_slice_payloads_parse_to_typed_request_models(method, params, request_type): - request = _parse_runtime_request(method, params) - - assert isinstance(request, request_type) - assert is_dataclass(request) - - -@pytest.mark.parametrize( - ("method", "params", "request_type"), - [ - ( - "db.ensure", - {"workspaceId": "ws-1", "step": "Floorplan"}, - DbEnsureRequest, - ), - ("db.ensure", {"workspaceId": "ws-1"}, DbEnsureRequest), - ("db.release", {"workspaceId": "ws-1"}, DbReleaseRequest), - ( - "layout.edit.begin", - {"workspaceId": "ws-1", "step": "Floorplan"}, - LayoutEditBeginRequest, - ), - ( - "layout.edit.apply", - { - "editSessionId": "layout-edit-1", - "commandId": "move-1", - "baseRevision": 0, - "operation": {"kind": "place_instance"}, - }, - LayoutEditApplyRequest, - ), - ( - "layout.edit.save", - {"editSessionId": "layout-edit-1", "expectedRevision": 1}, - LayoutEditSaveRequest, - ), - ( - "layout.edit.discard", - {"editSessionId": "layout-edit-1"}, - LayoutEditDiscardRequest, - ), - ( - "floorplan.edit.inspect", - {"editSessionId": "layout-edit-1"}, - FloorplanEditInspectRequest, - ), - ( - "floorplan.edit.run_auto", - { - "editSessionId": "layout-edit-1", - "commandId": "auto-1", - "baseRevision": 1, - "request": {"mode": "macro"}, - }, - FloorplanEditRunAutoRequest, - ), - ( - "floorplan.edit.validate", - {"editSessionId": "layout-edit-1", "scope": "pdn"}, - FloorplanEditValidateRequest, - ), - ], -) -def test_persistent_db_payloads_parse_to_typed_request_models(method, params, request_type): - request = _parse_runtime_request(method, params, persistent_db_enabled=True) - - assert isinstance(request, request_type) - assert is_dataclass(request) - if hasattr(request, "workspace_id"): - assert request.workspace_id == "ws-1" - else: - assert request.edit_session_id == "layout-edit-1" - - -def test_db_ensure_step_is_optional(): - request = _parse_runtime_request( - "db.ensure", - {"workspaceId": "ws-1"}, - persistent_db_enabled=True, - ) - - assert isinstance(request, DbEnsureRequest) - assert request.step == "" - - -def test_layout_edit_begin_accepts_source_fingerprint_alias(): - request = _parse_runtime_request( - "layout.edit.begin", - { - "workspaceId": "ws-1", - "step": "Floorplan", - "expectedSourceFingerprint": "abc123", - }, - persistent_db_enabled=True, - ) - - assert isinstance(request, LayoutEditBeginRequest) - assert request.expected_source_fingerprint == "abc123" - - -def test_missing_required_field_reports_field_name(): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request("flow.run_step", {"workspaceId": "ws-1"}) - - assert exc_info.value.reason == "missing required field: step" - - -def test_unknown_fields_are_rejected(): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request("workspace.open", {"directory": "/work/ws", "extra": True}) - - assert exc_info.value.reason == "unknown field: extra" - - -def test_db_method_unknown_fields_are_rejected(): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request( - "db.ensure", - {"workspaceId": "ws-1", "extra": True}, - persistent_db_enabled=True, - ) - - assert exc_info.value.reason == "unknown field: extra" - - -def test_db_method_blank_workspace_id_is_rejected(): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request( - "db.release", - {"workspaceId": " "}, - persistent_db_enabled=True, - ) - - assert exc_info.value.reason == "missing required field: workspace_id" - - -def test_params_must_be_an_object(): - with pytest.raises(RequestValidationError, match="params must be an object"): - _parse_runtime_request("workspace.open", None) - - -def test_workspace_info_accepts_info_id_alias(): - request = _parse_runtime_request( - "workspace.info", - {"workspaceId": "ws-1", "step": "Synthesis", "infoId": "timing"}, - ) - - assert isinstance(request, WorkspaceInfoRequest) - assert request.info_id == "timing" - - -def test_workspace_export_signoff_preserves_exact_output_path(): - request = _parse_runtime_request( - "workspace.export_signoff", - { - "workspaceId": "ws-1", - "outputPath": "/exports/custom.tar.gz ", - }, - ) - - assert isinstance(request, WorkspaceExportSignoffRequest) - assert request.output_path == "/exports/custom.tar.gz " - - -@pytest.mark.parametrize( - "additional_files", - [ - "not-a-list", - [{"archivePath": "nested.txt"}], - [{"content": "missing path"}], - [{"archivePath": "nested.txt", "content": 42}], - ], -) -def test_workspace_export_signoff_validates_additional_files(additional_files): - with pytest.raises(RequestValidationError): - _parse_runtime_request( - "workspace.export_signoff", - { - "workspaceId": "ws-1", - "outputPath": "/exports/custom.tar.gz", - "additionalFiles": additional_files, - }, - ) - - -@pytest.mark.parametrize( - ("method", "params"), - [ - ("flow.run", {"workspaceId": "ws-1", "rerun": "false"}), - ("flow.run_step", {"workspaceId": "ws-1", "step": "Synthesis", "rerun": "true"}), - ], -) -def test_rerun_must_be_boolean(method, params): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request(method, params) - - assert exc_info.value.reason == "rerun must be a boolean" - - -@pytest.mark.parametrize( - ("method", "params"), - [ - ( - "operation.start_step", - {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": 1}, - ), - ], -) -def test_reset_dependents_must_be_boolean(method, params): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request(method, params) - - assert exc_info.value.reason == "reset_dependents must be a boolean" - - -def test_direct_flow_run_step_rejects_gui_only_reset_dependents_field(): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request( - "flow.run_step", - {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": True}, - ) - - assert exc_info.value.reason == "unknown field: reset_dependents" - - -def test_unknown_runtime_method_has_no_request_model(): - assert runtime_method_by_name("workspace.signoff") is None - assert runtime_method_by_name("db.ensure") is None diff --git a/test/runtime/test_rpc_create_parameters.py b/test/runtime/test_rpc_create_parameters.py deleted file mode 100644 index 5027b3751..000000000 --- a/test/runtime/test_rpc_create_parameters.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python - -"""RPC creation path: GUI flat parameters are effective at workspace creation.""" - -from types import SimpleNamespace - -from chipcompiler.data.parameter import load_parameter -from chipcompiler.runtime.requests import WorkspaceCreateRequest -from chipcompiler.runtime.workspace_api import WorkspaceRuntimeApi - - -def _make_api(monkeypatch): - monkeypatch.setattr( - "chipcompiler.runtime.workspace_api.build_flow_for_workspace", - lambda _workspace: SimpleNamespace(), - ) - return WorkspaceRuntimeApi() - - -def _create(api, workspace_dir, pdk_root, parameters): - tech = pdk_root / "tech.lef" - lef = pdk_root / "stdcell.lef" - liberty = pdk_root / "stdcell.lib" - for path in (tech, lef, liberty): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("VERSION 5.8 ;\n") - - return api.create_workspace( - WorkspaceCreateRequest( - directory=str(workspace_dir), - pdk="ics55", - pdk_json={ - "name": "ics55", - "root": str(pdk_root), - "tech": str(tech), - "lefs": [str(lef)], - "libs": [str(liberty)], - }, - parameters=parameters, - ) - ) - - -def test_gui_flat_parameters_are_effective_at_creation(monkeypatch, tmp_path): - api = _make_api(monkeypatch) - workspace_dir = tmp_path / "workspace" - - _create( - api, - workspace_dir, - tmp_path / "pdk", - {"frequency_max": 200, "top_module": "gcd", "design": "gcd", "clock": "clk"}, - ) - - persisted = load_parameter(workspace_dir / "home" / "params.toml").data - assert persisted["frequency_max"] == 200 - assert persisted["top_module"] == "gcd" - assert persisted["design"] == "gcd" - assert persisted["clock"] == "clk" - - -def test_gui_geometry_aliases_fold_into_subtrees(monkeypatch, tmp_path): - api = _make_api(monkeypatch) - workspace_dir = tmp_path / "workspace" - - _create( - api, - workspace_dir, - tmp_path / "pdk", - { - "design": "gcd", - "top_module": "gcd", - "clock": "clk", - "die_width": 150, - "die_height": 160, - "utilitization": 0.5, - "margin": 3, - "die_area_mode": "width_height", - }, - ) - - persisted = load_parameter(workspace_dir / "home" / "params.toml").data - assert persisted["die"]["size"] == [150, 160] - assert persisted["core"]["utilitization"] == 0.5 - assert persisted["core"]["margin"] == [3, 3] - # The GUI-only mode key never lands in the persisted configuration. - assert "die_area_mode" not in persisted - assert "die_width" not in persisted - - -def test_legacy_long_keys_in_rpc_payload_are_normalized(monkeypatch, tmp_path): - api = _make_api(monkeypatch) - workspace_dir = tmp_path / "workspace" - - _create( - api, - workspace_dir, - tmp_path / "pdk", - {"Design": "gcd", "Top module": "gcd", "Clock": "clk", "Frequency max [MHz]": 300}, - ) - - persisted = load_parameter(workspace_dir / "home" / "params.toml").data - assert persisted["frequency_max"] == 300 - assert "Frequency max [MHz]" not in persisted - - -def test_gui_geometry_reaches_floorplan_config(monkeypatch, tmp_path): - import json - - api = _make_api(monkeypatch) - workspace_dir = tmp_path / "workspace" - - _create( - api, - workspace_dir, - tmp_path / "pdk", - { - "design": "gcd", - "top_module": "gcd", - "clock": "clk", - "die_width": 150, - "die_height": 160, - "utilitization": 0.5, - "margin": 3, - "die_area_mode": "width_height", - }, - ) - - floorplan = json.loads((workspace_dir / "config" / "floorplan_ecc.json").read_text()) - die_builder = floorplan["die_builder"] - assert die_builder["mode"] == "die_size" - assert die_builder["die_size"]["width_micron"] == 150 - assert die_builder["die_size"]["height_micron"] == 160 - assert die_builder["margin"]["left_micron"] == 3 - assert die_builder["margin"]["top_micron"] == 3 - assert die_builder["die_util"]["utilization"] == 0.5 diff --git a/test/runtime/test_rpc_dispatch.py b/test/runtime/test_rpc_dispatch.py deleted file mode 100644 index 86691e15f..000000000 --- a/test/runtime/test_rpc_dispatch.py +++ /dev/null @@ -1,71 +0,0 @@ -import json - -from chipcompiler.runtime.rpc_dispatch import RpcDispatcher - - -def _dispatch(dispatcher: RpcDispatcher, payload: str) -> dict: - return json.loads(dispatcher.dispatch(payload)) - - -def test_dispatch_registered_method_returns_standard_success_response(): - dispatcher = RpcDispatcher() - dispatcher.add_method("rpc.ping", lambda: {"ok": True}) - - response = _dispatch( - dispatcher, - '{"jsonrpc":"2.0","method":"rpc.ping","id":1}', - ) - - assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 1} - - -def test_unknown_method_returns_standard_method_not_found_error(): - dispatcher = RpcDispatcher() - - response = _dispatch( - dispatcher, - '{"jsonrpc":"2.0","method":"missing","id":"req-1"}', - ) - - assert response["jsonrpc"] == "2.0" - assert response["id"] == "req-1" - assert response["error"]["code"] == -32601 - assert response["error"]["message"] == "Method not found" - - -def test_invalid_params_return_standard_invalid_params_error(): - dispatcher = RpcDispatcher() - - def needs_name(name: str) -> dict: - return {"name": name} - - dispatcher.add_method("needsName", needs_name) - - response = _dispatch( - dispatcher, - '{"jsonrpc":"2.0","method":"needsName","params":{"missing":"x"},"id":2}', - ) - - assert response["id"] == 2 - assert response["error"]["code"] == -32602 - assert response["error"]["message"] == "Invalid params" - - -def test_parse_error_uses_json_rpc_parse_error_shape(): - dispatcher = RpcDispatcher() - - response = _dispatch(dispatcher, "{") - - assert response["id"] is None - assert response["error"]["code"] == -32700 - assert response["error"]["message"] == "Parse error" - - -def test_custom_non_json_rpc_envelope_is_rejected(): - dispatcher = RpcDispatcher() - dispatcher.add_method("rpc.ping", lambda: {"ok": True}) - - response = _dispatch(dispatcher, '{"type":"request","method":"rpc.ping","id":1}') - - assert response["id"] is None - assert response["error"]["code"] == -32600 diff --git a/test/runtime/test_server.py b/test/runtime/test_server.py deleted file mode 100644 index 34393f85e..000000000 --- a/test/runtime/test_server.py +++ /dev/null @@ -1,429 +0,0 @@ -import json - -import pytest - -from chipcompiler.runtime.methods import RUNTIME_METHODS, runtime_methods -from chipcompiler.runtime.requests import ( - DbEnsureRequest, - DbReleaseRequest, - WorkspaceExportSignoffRequest, - WorkspaceInspectSignoffRequest, - WorkspaceOpenRequest, -) -from chipcompiler.runtime.server import RuntimeServer -from chipcompiler.runtime.workspace_api import RuntimeApiError - - -def _dispatch(server: RuntimeServer, payload: str) -> dict: - return json.loads(server.dispatch(payload)) - - -class CompleteFakeApi: - def create_workspace(self, _request): - raise AssertionError("unexpected create_workspace call") - - def open_workspace(self, _request): - raise AssertionError("unexpected open_workspace call") - - def close_workspace(self, _request): - raise AssertionError("unexpected close_workspace call") - - def workspace_home(self, _request): - raise AssertionError("unexpected workspace_home call") - - def workspace_info(self, _request): - raise AssertionError("unexpected workspace_info call") - - def refresh_config(self, _request): - raise AssertionError("unexpected refresh_config call") - - def sync_config(self, _request): - raise AssertionError("unexpected sync_config call") - - def reset_flow(self, _request): - raise AssertionError("unexpected reset_flow call") - - def export_signoff(self, _request): - raise AssertionError("unexpected export_signoff call") - - def inspect_signoff(self, _request): - raise AssertionError("unexpected inspect_signoff call") - - def flow_run(self, _request): - raise AssertionError("unexpected flow_run call") - - def flow_run_step(self, _request): - raise AssertionError("unexpected flow_run_step call") - - def start_flow_operation(self, _request): - raise AssertionError("unexpected start_flow_operation call") - - def start_step_operation(self, _request): - raise AssertionError("unexpected start_step_operation call") - - def operation_status(self, _request): - raise AssertionError("unexpected operation_status call") - - def cancel_operation(self, _request): - raise AssertionError("unexpected cancel_operation call") - - def acknowledge_step_rendered(self, _request): - raise AssertionError("unexpected acknowledge_step_rendered call") - - def workspace_snapshot(self, _request): - raise AssertionError("unexpected workspace_snapshot call") - - def recover_interrupted(self, _request): - raise AssertionError("unexpected recover_interrupted call") - - def db_ensure(self, _request): - raise AssertionError("unexpected db_ensure call") - - def db_release(self, _request): - raise AssertionError("unexpected db_release call") - - def layout_edit_begin(self, _request): - raise AssertionError("unexpected layout_edit_begin call") - - def layout_edit_apply(self, _request): - raise AssertionError("unexpected layout_edit_apply call") - - def layout_edit_save(self, _request): - raise AssertionError("unexpected layout_edit_save call") - - def layout_edit_discard(self, _request): - raise AssertionError("unexpected layout_edit_discard call") - - def floorplan_edit_inspect(self, _request): - raise AssertionError("unexpected floorplan_edit_inspect call") - - def floorplan_edit_run_auto(self, _request): - raise AssertionError("unexpected floorplan_edit_run_auto call") - - def floorplan_edit_validate(self, _request): - raise AssertionError("unexpected floorplan_edit_validate call") - - -def test_rpc_hello_returns_version_and_capabilities(): - server = RuntimeServer() - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello"}', - ) - - assert response["id"] == "hello" - assert response["result"]["version"] == 1 - assert response["result"]["eccVersion"] - assert "rpc.ping" in response["result"]["capabilities"] - assert "rpc.shutdown" in response["result"]["capabilities"] - assert "runtime.v2" in response["result"]["capabilities"] - assert "operation.events" in response["result"]["capabilities"] - assert "workspace.snapshot" in response["result"]["capabilities"] - assert "db.ensure" not in response["result"]["capabilities"] - assert "db.release" not in response["result"]["capabilities"] - - -def test_rpc_hello_reports_persistent_db_capabilities_when_enabled(): - server = RuntimeServer(persistent_db_enabled=True) - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello"}', - ) - - assert "db.ensure" in response["result"]["capabilities"] - assert "db.release" in response["result"]["capabilities"] - - -def test_rpc_hello_rejects_incompatible_version(): - server = RuntimeServer() - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":2},"id":1}', - ) - - assert response["id"] == 1 - assert response["error"]["code"] == -32001 - assert response["error"]["message"] == "unsupported_version" - - -def test_rpc_ping_returns_correlated_result(): - server = RuntimeServer() - - response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.ping","id":"p"}') - - assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": "p"} - - -def test_rpc_shutdown_marks_server_for_graceful_exit(): - server = RuntimeServer() - - response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.shutdown","id":3}') - - assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3} - assert server.should_exit - - -def test_rpc_shutdown_releases_runtime_sessions(): - class FakeSessions: - def __init__(self): - self.closed = False - - def close_all(self): - self.closed = True - - class FakeApi(CompleteFakeApi): - sessions = FakeSessions() - - api = FakeApi() - server = RuntimeServer(api=api) - - response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.shutdown","id":3}') - - assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3} - assert api.sessions.closed - - -def test_unknown_method_keeps_request_id(): - server = RuntimeServer() - - response = _dispatch(server, '{"jsonrpc":"2.0","method":"missing","id":"req"}') - - assert response["id"] == "req" - assert response["error"]["code"] == -32601 - - -def test_workspace_method_dispatches_typed_request_to_runtime_api(): - class FakeApi(CompleteFakeApi): - def open_workspace(self, request): - assert isinstance(request, WorkspaceOpenRequest) - return {"workspaceId": "workspace-1", "directory": request.directory} - - server = RuntimeServer(api=FakeApi()) - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"workspace.open","params":{"directory":"/ws"},"id":4}', - ) - - assert response == { - "jsonrpc": "2.0", - "result": {"workspaceId": "workspace-1", "directory": "/ws"}, - "id": 4, - } - - -def test_workspace_export_signoff_dispatches_exact_output_path(): - class FakeApi(CompleteFakeApi): - def export_signoff(self, request): - assert isinstance(request, WorkspaceExportSignoffRequest) - return {"outputPath": request.output_path} - - server = RuntimeServer(api=FakeApi()) - - response = _dispatch( - server, - ( - '{"jsonrpc":"2.0","method":"workspace.export_signoff",' - '"params":{"workspaceId":"workspace-1",' - '"outputPath":"/exports/custom.tar.gz "},"id":5}' - ), - ) - - assert response == { - "jsonrpc": "2.0", - "result": {"outputPath": "/exports/custom.tar.gz "}, - "id": 5, - } - - -def test_workspace_inspect_signoff_dispatches_typed_request(): - class FakeApi(CompleteFakeApi): - def inspect_signoff(self, request): - assert isinstance(request, WorkspaceInspectSignoffRequest) - return {"status": "ready", "groups": [], "risks": []} - - server = RuntimeServer(api=FakeApi()) - - response = _dispatch( - server, - ( - '{"jsonrpc":"2.0","method":"workspace.inspect_signoff",' - '"params":{"workspaceId":"workspace-1"},"id":6}' - ), - ) - - assert response == { - "jsonrpc": "2.0", - "result": {"status": "ready", "groups": [], "risks": []}, - "id": 6, - } - - -def test_persistent_db_methods_dispatch_typed_requests_to_runtime_api(): - seen = [] - - class FakeApi(CompleteFakeApi): - def db_ensure(self, request): - seen.append(request) - assert isinstance(request, DbEnsureRequest) - return { - "workspaceId": request.workspace_id, - "enabled": True, - "active": True, - "reused": False, - "step": request.step, - } - - def db_release(self, request): - seen.append(request) - assert isinstance(request, DbReleaseRequest) - return {"workspaceId": request.workspace_id, "released": True} - - server = RuntimeServer(api=FakeApi(), persistent_db_enabled=True) - - ensure_response = _dispatch( - server, - ( - '{"jsonrpc":"2.0","method":"db.ensure",' - '"params":{"workspaceId":"workspace-1","step":"Floorplan"},"id":8}' - ), - ) - release_response = _dispatch( - server, - ('{"jsonrpc":"2.0","method":"db.release","params":{"workspaceId":"workspace-1"},"id":9}'), - ) - - assert ensure_response["result"] == { - "workspaceId": "workspace-1", - "enabled": True, - "active": True, - "reused": False, - "step": "Floorplan", - } - assert release_response["result"] == { - "workspaceId": "workspace-1", - "released": True, - } - assert [type(request) for request in seen] == [DbEnsureRequest, DbReleaseRequest] - - -def test_persistent_db_methods_are_not_registered_by_default(): - server = RuntimeServer() - - response = _dispatch( - server, - ('{"jsonrpc":"2.0","method":"db.ensure","params":{"workspaceId":"workspace-1"},"id":10}'), - ) - - assert response["id"] == 10 - assert response["error"]["code"] == -32601 - - -def test_request_validation_errors_map_to_json_rpc_invalid_params(): - server = RuntimeServer() - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"workspace.home","params":{"directory":"/ws"},"id":5}', - ) - - assert response["id"] == 5 - assert response["error"]["code"] == -32602 - assert response["error"]["message"] == "invalid_request" - assert response["error"]["data"]["message"] == "unknown field: directory" - - -def test_workspace_session_errors_map_to_json_rpc_runtime_error(): - class FakeApi(CompleteFakeApi): - def workspace_home(self, _request): - raise RuntimeApiError( - "workspace_session_not_found", - "workspace session not found: missing", - ) - - server = RuntimeServer(api=FakeApi()) - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"workspace.home","params":{"workspaceId":"missing"},"id":6}', - ) - - assert response["id"] == 6 - assert response["error"]["code"] == -32010 - assert response["error"]["message"] == "workspace_session_not_found" - - -def test_workspace_api_user_exceptions_map_to_command_failed(): - class FakeApi(CompleteFakeApi): - def open_workspace(self, _request): - raise ValueError("PDK tech LEF is missing") - - server = RuntimeServer(api=FakeApi()) - - response = _dispatch( - server, - '{"jsonrpc":"2.0","method":"workspace.open","params":{"directory":"/ws"},"id":7}', - ) - - assert response["id"] == 7 - assert response["error"]["code"] == -32020 - assert response["error"]["message"] == "command_failed" - assert response["error"]["data"]["message"] == "PDK tech LEF is missing" - - -@pytest.mark.parametrize( - "method", - [spec.method_name for spec in RUNTIME_METHODS], -) -def test_first_slice_methods_are_registered(method): - server = RuntimeServer() - - response = _dispatch(server, f'{{"jsonrpc":"2.0","method":"{method}","id":1}}') - - assert response["error"]["code"] != -32601 - - -@pytest.mark.parametrize( - "method", - [spec.method_name for spec in runtime_methods(persistent_db_enabled=True)], -) -def test_enabled_persistent_db_runtime_methods_are_registered(method): - server = RuntimeServer(api=CompleteFakeApi(), persistent_db_enabled=True) - - response = _dispatch(server, f'{{"jsonrpc":"2.0","method":"{method}","id":1}}') - - assert response["error"]["code"] != -32601 - - -def test_runtime_server_fails_when_registered_api_handler_is_missing(monkeypatch): - from chipcompiler.runtime import methods - - missing_spec = methods.RuntimeMethodSpec( - method_name="workspace.missing_handler", - request_model=WorkspaceOpenRequest, - handler_name="missing_handler", - ) - monkeypatch.setattr(methods, "RUNTIME_METHODS", (missing_spec,)) - - with pytest.raises(TypeError, match="missing_handler"): - RuntimeServer() - - -def test_runtime_server_fails_when_registered_api_handler_is_not_callable(monkeypatch): - from chipcompiler.runtime import methods - - class FakeApi: - open_workspace = object() - - spec = methods.RuntimeMethodSpec( - method_name="workspace.open", - request_model=WorkspaceOpenRequest, - handler_name="open_workspace", - ) - monkeypatch.setattr(methods, "RUNTIME_METHODS", (spec,)) - - with pytest.raises(TypeError, match="open_workspace"): - RuntimeServer(api=FakeApi()) diff --git a/test/runtime/test_sessions.py b/test/runtime/test_sessions.py deleted file mode 100644 index 29731ed9c..000000000 --- a/test/runtime/test_sessions.py +++ /dev/null @@ -1,101 +0,0 @@ -from pathlib import Path - -import pytest - -from chipcompiler.runtime.sessions import WorkspaceSessionNotFound, WorkspaceSessionRegistry - - -def test_create_session_returns_workspace_id_and_resolved_directory(tmp_path): - registry = WorkspaceSessionRegistry() - workspace = object() - - session = registry.create_session(tmp_path / "ws", workspace=workspace) - - assert session.workspace_id.startswith("workspace-") - assert session.directory == (tmp_path / "ws").resolve() - assert session.workspace is workspace - assert session.db_handle is None - - -def test_open_reuses_existing_session_for_same_directory(tmp_path): - registry = WorkspaceSessionRegistry() - - first = registry.open_session(tmp_path / "ws", workspace="first") - second = registry.open_session(Path(tmp_path / "ws"), workspace="second") - - assert second.workspace_id == first.workspace_id - assert second.workspace == "first" - - -def test_create_replaces_existing_session_for_same_directory(tmp_path): - released = [] - registry = WorkspaceSessionRegistry(db_releaser=released.append) - - first = registry.open_session(tmp_path / "ws", workspace="first") - db_handle = object() - first.db_handle = db_handle - second = registry.create_session(Path(tmp_path / "ws"), workspace="second") - - assert second.workspace_id != first.workspace_id - assert second.directory == first.directory - assert second.workspace == "second" - assert first.db_handle is None - assert released == [db_handle] - assert registry.get_session(second.workspace_id) is second - with pytest.raises(WorkspaceSessionNotFound, match=first.workspace_id): - registry.get_session(first.workspace_id) - assert registry.open_session(tmp_path / "ws", workspace="third") is second - - -def test_get_session_rejects_unknown_and_closed_workspace_id(tmp_path): - registry = WorkspaceSessionRegistry() - session = registry.open_session(tmp_path / "ws", workspace=object()) - - registry.close_session(session.workspace_id) - - with pytest.raises(WorkspaceSessionNotFound, match=session.workspace_id): - registry.get_session(session.workspace_id) - with pytest.raises(WorkspaceSessionNotFound, match="missing"): - registry.get_session("missing") - - -def test_close_session_releases_active_db_handle_once(tmp_path): - released = [] - registry = WorkspaceSessionRegistry(db_releaser=released.append) - session = registry.open_session(tmp_path / "ws", workspace=object()) - db_handle = object() - session.db_handle = db_handle - - registry.close_session(session.workspace_id) - - assert session.db_handle is None - assert released == [db_handle] - - -def test_close_all_releases_all_active_db_handles_and_clears_directory_map(tmp_path): - released = [] - registry = WorkspaceSessionRegistry(db_releaser=released.append) - first = registry.open_session(tmp_path / "first", workspace=object()) - second = registry.open_session(tmp_path / "second", workspace=object()) - first_db = object() - second_db = object() - first.db_handle = first_db - second.db_handle = second_db - - registry.close_all() - - assert first.db_handle is None - assert second.db_handle is None - assert released == [first_db, second_db] - with pytest.raises(WorkspaceSessionNotFound, match=first.workspace_id): - registry.get_session(first.workspace_id) - reopened = registry.open_session(tmp_path / "first", workspace="new") - assert reopened.workspace == "new" - - -def test_per_session_lock_serializes_mutating_commands(tmp_path): - registry = WorkspaceSessionRegistry() - session = registry.open_session(tmp_path / "ws", workspace=object()) - - with session.mutation_lock: - assert not session.mutation_lock.acquire(blocking=False) diff --git a/test/runtime/test_signoff_export.py b/test/runtime/test_signoff_export.py deleted file mode 100644 index 0ecc11adf..000000000 --- a/test/runtime/test_signoff_export.py +++ /dev/null @@ -1,448 +0,0 @@ -import json -import queue -import threading -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from chipcompiler.runtime import signoff_export -from chipcompiler.runtime.requests import ( - WorkspaceExportSignoffRequest, - WorkspaceInspectSignoffRequest, -) -from chipcompiler.runtime.sessions import WorkspaceSessionRegistry -from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi - - -def test_workspace_export_signoff_returns_exact_output_path(monkeypatch, tmp_path): - workspace = SimpleNamespace(directory=tmp_path / "workspace") - sessions = WorkspaceSessionRegistry() - session = sessions.open_session(workspace.directory, workspace=workspace) - output_path = tmp_path / "exports" / "custom name.tar.gz" - calls = [] - - def fake_export(active_workspace, requested_output, additional_files=None): - calls.append((active_workspace, requested_output)) - return str(output_path) - - monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.export_signoff_package_archive", - fake_export, - ) - api = WorkspaceRuntimeApi(sessions=sessions) - - result = api.export_signoff( - WorkspaceExportSignoffRequest( - workspace_id=session.workspace_id, - output_path=str(output_path), - ) - ) - - assert result == {"outputPath": str(output_path)} - assert calls == [(workspace, str(output_path))] - - -def test_workspace_export_signoff_waits_for_session_mutation_lock(monkeypatch, tmp_path): - workspace = SimpleNamespace(directory=tmp_path / "workspace") - sessions = WorkspaceSessionRegistry() - session = sessions.open_session(workspace.directory, workspace=workspace) - output_path = tmp_path / "export.tar.gz" - entered = threading.Event() - results = queue.Queue() - - def fake_export(_workspace, requested_output, additional_files=None): - entered.set() - return requested_output - - monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.export_signoff_package_archive", - fake_export, - ) - api = WorkspaceRuntimeApi(sessions=sessions) - - def run_export(): - try: - results.put( - api.export_signoff( - WorkspaceExportSignoffRequest( - workspace_id=session.workspace_id, - output_path=str(output_path), - ) - ) - ) - except BaseException as error: # pragma: no cover - re-raised below - results.put(error) - - with session.mutation_lock: - worker = threading.Thread(target=run_export) - worker.start() - assert not entered.wait(0.1) - assert worker.is_alive() - - worker.join(timeout=2) - assert not worker.is_alive() - result = results.get_nowait() - if isinstance(result, BaseException): - raise result - assert result == {"outputPath": str(output_path)} - assert entered.is_set() - - -def test_inspect_signoff_package_reads_current_home_checklist(monkeypatch, tmp_path): - workspace_dir = tmp_path / "workspace" - checklist_path = workspace_dir / "home" / "checklist.json" - checklist_path.parent.mkdir(parents=True) - checklist_path.write_text( - json.dumps( - { - "schema_version": 3, - "kind": "signoff_checklist", - "status": "blocked", - "summary": {"passed": 1, "blocked": 1, "attention": 1, "unavailable": 0}, - "checklist": [ - { - "id": "quality.drc.clean", - "step": "drc", - "category": "quality_gate", - "owner": "qor", - "policy": "block", - "state": "failed", - "blocked": True, - "title": "Final DRC clean", - "summary": "drc_count=2 (required == 0)", - "source": { - "kind": "qor_gate", - "path": "drc_ecc/analysis/qor_summary.json", - "gate_id": "qor.drc.clean", - }, - "evidence": [{"kind": "feature", "path": "drc_ecc/feature/drc.step.json"}], - }, - { - "id": "report.optional.image", - "step": "workspace", - "category": "report", - "owner": "checklist", - "policy": "warn", - "state": "warning", - "blocked": False, - "title": "Optional image", - "summary": "Optional image is missing.", - "source": {"kind": "package", "path": "filler_ecc/output/gcd_filler.png"}, - "evidence": [], - }, - { - "id": "provenance.initial.rtl", - "step": "workspace", - "category": "provenance", - "owner": "checklist", - "policy": "block", - "state": "pass", - "blocked": False, - "title": "Initial RTL", - "summary": "Current output is present and non-empty.", - "source": {"kind": "provenance", "path": "origin/gcd.v"}, - "evidence": [], - }, - ], - } - ), - encoding="utf-8", - ) - - class FakeFlow: - def __init__(self, workspace): - assert workspace.directory == workspace_dir - - def collect_signoff_package(self, options): - assert options.archive is False - assert options.materialize is False - return SimpleNamespace() - - monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) - - review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir)) - - assert review["status"] == "blocked" - assert [group["id"] for group in review["groups"]] == [ - "initial", - "config", - "harden", - "final_design", - "sta", - "spef", - "reports", - ] - drc_group = next(group for group in review["groups"] if group["id"] == "final_design") - assert drc_group == { - "id": "final_design", - "label": "Final Design", - "status": "blocked", - "available": 0, - "expected": 2, - "summary": "1 blocking checklist requirements", - } - reports_group = next(group for group in review["groups"] if group["id"] == "reports") - assert reports_group["status"] == "ready" - assert [risk["severity"] for risk in review["risks"]] == ["blocked", "warning"] - blocked_risk = next(risk for risk in review["risks"] if risk["severity"] == "blocked") - assert blocked_risk["details"] == [ - { - "kind": "quality_gate", - "label": "Final DRC clean", - "location": "drc_ecc/analysis/qor_summary.json", - "reason": "drc_count=2 (required == 0)", - "owner": "qor", - "policy": "block", - "state": "failed", - "evidence": [{"kind": "feature", "path": "drc_ecc/feature/drc.step.json"}], - } - ] - - -def test_inspect_signoff_package_blocks_when_current_checklist_is_unavailable( - monkeypatch, tmp_path -): - workspace_dir = tmp_path / "workspace" - (workspace_dir / "home").mkdir(parents=True) - - class FakeFlow: - def __init__(self, workspace): - assert workspace.directory == workspace_dir - - def collect_signoff_package(self, options): - return SimpleNamespace() - - monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) - - review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir)) - - assert review["status"] == "blocked" - assert review["risks"][0]["title"] == "Signoff checklist unavailable" - - -def test_inspect_signoff_package_blocks_when_checklist_is_not_an_object(monkeypatch, tmp_path): - workspace_dir = tmp_path / "workspace" - home_dir = workspace_dir / "home" - home_dir.mkdir(parents=True) - (home_dir / "checklist.json").write_text("[]", encoding="utf-8") - - class FakeFlow: - def __init__(self, workspace): - assert workspace.directory == workspace_dir - - def collect_signoff_package(self, options): - return SimpleNamespace() - - monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) - - review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir)) - - assert review["status"] == "blocked" - assert review["risks"][0]["title"] == "Signoff checklist unavailable" - - -def test_workspace_inspect_signoff_waits_for_session_mutation_lock(monkeypatch, tmp_path): - workspace = SimpleNamespace(directory=tmp_path / "workspace") - sessions = WorkspaceSessionRegistry() - session = sessions.open_session(workspace.directory, workspace=workspace) - entered = threading.Event() - results = queue.Queue() - - def fake_inspect(active_workspace): - assert active_workspace is workspace - entered.set() - return {"status": "ready", "groups": [], "risks": []} - - monkeypatch.setattr(signoff_export, "inspect_signoff_package", fake_inspect) - api = WorkspaceRuntimeApi(sessions=sessions) - - def run_inspection(): - try: - results.put( - api.inspect_signoff( - WorkspaceInspectSignoffRequest(workspace_id=session.workspace_id) - ) - ) - except BaseException as error: # pragma: no cover - re-raised below - results.put(error) - - with session.mutation_lock: - worker = threading.Thread(target=run_inspection) - worker.start() - assert not entered.wait(0.1) - assert worker.is_alive() - - worker.join(timeout=2) - assert not worker.is_alive() - result = results.get_nowait() - if isinstance(result, BaseException): - raise result - assert result == {"status": "ready", "groups": [], "risks": []} - - -def test_export_signoff_package_archive_collects_temporarily_and_replaces_atomically( - monkeypatch, - tmp_path, -): - from chipcompiler.runtime.signoff_export import export_signoff_package_archive - - output_path = tmp_path / "nested" / "chosen.tar.gz" - captured_output_dirs = [] - - class FakeFlow: - def __init__(self, workspace): - assert workspace == "workspace" - - def collect_signoff_package(self, options): - captured_output_dirs.append(options.output_dir) - package_dir = Path(options.output_dir) / "design_signoff_package" - package_dir.mkdir(parents=True, exist_ok=True) - (package_dir / "dummy.txt").write_text("archive") - return SimpleNamespace( - ok=True, - package_dir=str(package_dir), - missing_required=[], - ) - - monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.EngineFlow", - FakeFlow, - ) - - result = export_signoff_package_archive("workspace", str(output_path)) - - assert result == str(output_path.resolve()) - import tarfile - - with tarfile.open(output_path, "r:gz") as tar: - assert tar.extractfile("design_signoff_package/dummy.txt").read() == b"archive" - assert captured_output_dirs - assert not Path(captured_output_dirs[0]).exists() - assert not list(output_path.parent.glob(f".{output_path.name}.*")) - - -@pytest.mark.parametrize("archive_path", ["/tmp/ecc-signoff-escape", "../escape.txt"]) -def test_export_signoff_package_archive_rejects_additional_file_path_escape( - monkeypatch, tmp_path, archive_path -): - output_path = tmp_path / "chosen.tar.gz" - - class FakeFlow: - def __init__(self, workspace): - pass - - def collect_signoff_package(self, options): - package_dir = Path(options.output_dir) / "design_signoff_package" - package_dir.mkdir(parents=True, exist_ok=True) - return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[]) - - monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) - - with pytest.raises(RuntimeApiError) as exc_info: - signoff_export.export_signoff_package_archive( - "workspace", - str(output_path), - [{"archivePath": archive_path, "content": "must stay inside"}], - ) - - assert exc_info.value.code == "invalid_request" - - -def test_export_signoff_package_archive_allows_nested_additional_file(monkeypatch, tmp_path): - output_path = tmp_path / "chosen.tar.gz" - - class FakeFlow: - def __init__(self, workspace): - pass - - def collect_signoff_package(self, options): - package_dir = Path(options.output_dir) / "design_signoff_package" - package_dir.mkdir(parents=True, exist_ok=True) - return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[]) - - monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) - - signoff_export.export_signoff_package_archive( - "workspace", - str(output_path), - [{"archivePath": "metadata/extra.txt", "content": "included"}], - ) - - import tarfile - - with tarfile.open(output_path, "r:gz") as tar: - assert tar.extractfile("design_signoff_package/metadata/extra.txt").read() == b"included" - - -def test_export_signoff_package_archive_preserves_existing_target_on_incomplete_result( - monkeypatch, - tmp_path, -): - from chipcompiler.runtime.signoff_export import export_signoff_package_archive - - output_path = tmp_path / "existing.tar.gz" - output_path.write_bytes(b"old") - - class FakeFlow: - def __init__(self, workspace): - pass - - def collect_signoff_package(self, options): - return SimpleNamespace( - ok=False, - package_dir=None, - missing_required=["harden/design.gds", "harden/design.lef"], - ) - - monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.EngineFlow", - FakeFlow, - ) - - with pytest.raises(RuntimeApiError) as exc_info: - export_signoff_package_archive("workspace", str(output_path)) - - assert exc_info.value.code == "command_failed" - assert "harden/design.gds" in exc_info.value.message - assert "harden/design.lef" in exc_info.value.message - assert output_path.read_bytes() == b"old" - - -def test_export_signoff_package_archive_replaces_symlink_entry_not_target( - monkeypatch, - tmp_path, -): - from chipcompiler.runtime.signoff_export import export_signoff_package_archive - - target = tmp_path / "target.tar.gz" - target.write_bytes(b"target") - output_path = tmp_path / "chosen.tar.gz" - try: - output_path.symlink_to(target.name) - except (OSError, NotImplementedError): - pytest.skip("symlinks are unavailable") - - class FakeFlow: - def __init__(self, workspace): - pass - - def collect_signoff_package(self, options): - package_dir = Path(options.output_dir) / "design_signoff_package" - package_dir.mkdir(parents=True, exist_ok=True) - (package_dir / "dummy.txt").write_text("new") - return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[]) - - monkeypatch.setattr( - "chipcompiler.runtime.signoff_export.EngineFlow", - FakeFlow, - ) - - export_signoff_package_archive("workspace", str(output_path)) - - assert not output_path.is_symlink() - import tarfile - - with tarfile.open(output_path, "r:gz") as tar: - assert tar.extractfile("design_signoff_package/dummy.txt").read() == b"new" - assert target.read_bytes() == b"target" diff --git a/test/runtime/test_stdio_server.py b/test/runtime/test_stdio_server.py deleted file mode 100644 index 7918e4af0..000000000 --- a/test/runtime/test_stdio_server.py +++ /dev/null @@ -1,241 +0,0 @@ -import io -import json -import os -import select -import subprocess -import sys -from pathlib import Path - -from chipcompiler.data import create_workspace -from chipcompiler.runtime.server import RuntimeServer -from chipcompiler.runtime.stdio_server import run_stdio_server -from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame - - -def _request(method: str, request_id, params: dict | None = None) -> bytes: - payload = {"jsonrpc": "2.0", "method": method, "id": request_id} - if params is not None: - payload["params"] = params - return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) - - -def _notification(method: str, params: dict | None = None) -> bytes: - payload = {"jsonrpc": "2.0", "method": method} - if params is not None: - payload["params"] = params - return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) - - -def _decode_output(output: bytes) -> list[dict]: - decoder = ContentLengthDecoder() - return [json.loads(message) for message in decoder.feed(output)] - - -def _read_subprocess_response(process: subprocess.Popen) -> dict: - assert process.stdout is not None - assert select.select([process.stdout], [], [], 5)[0] - length_line = process.stdout.readline() - assert length_line.startswith(b"Content-Length: ") - blank_line = process.stdout.readline() - assert blank_line == b"\r\n" - length = int(length_line.removeprefix(b"Content-Length: ").strip()) - payload = process.stdout.read(length) - return json.loads(payload) - - -def _write_subprocess_request(process: subprocess.Popen, method: str, request_id, params=None): - assert process.stdin is not None - process.stdin.write(_request(method, request_id, params)) - process.stdin.flush() - - -def _create_real_workspace(tmp_path: Path, minimal_ics55_pdk_factory) -> Path: - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - rtl_path = tmp_path / "gcd.v" - rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - workspace_dir = tmp_path / "workspace" - create_workspace( - directory=workspace_dir, - origin_def="", - origin_verilog=rtl_path, - pdk="ics55", - pdk_root=pdk_root, - parameters={ - "pdk": "ics55", - "design": "gcd", - "top_module": "gcd", - "clock": "clk", - "frequency_max": 100, - }, - ) - return workspace_dir - - -def test_stdio_server_writes_only_content_length_framed_responses(): - stdin = io.BytesIO( - _request("rpc.hello", 1, {"version": 1}) - + _request("rpc.ping", 2) - + _request("rpc.shutdown", 3) - ) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=RuntimeServer()) - - raw = stdout.getvalue() - assert rc == 0 - assert raw.startswith(b"Content-Length: ") - assert raw.count(b"Content-Length: ") == 3 - responses = _decode_output(raw) - assert [response["id"] for response in responses] == [1, 2, 3] - assert responses[0]["result"]["version"] == 1 - assert responses[1]["result"] == {"ok": True} - assert responses[2]["result"] == {"ok": True} - - -def test_stdio_server_does_not_write_response_for_notification(): - stdin = io.BytesIO(_notification("rpc.ping")) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=RuntimeServer()) - - assert rc == 0 - assert stdout.getvalue() == b"" - - -def test_stdio_server_stops_after_shutdown_notification_in_buffer(): - stdin = io.BytesIO(_notification("rpc.shutdown") + _request("rpc.ping", 1)) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=RuntimeServer()) - - assert rc == 0 - assert stdout.getvalue() == b"" - - -def test_stdio_server_redirects_print_noise_away_from_protocol_stdout(capfd): - server = RuntimeServer() - server.dispatcher.add_method("test.noisyPrint", lambda: print("tool output") or {"ok": True}) - stdin = io.BytesIO(_request("test.noisyPrint", 1)) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=server) - - captured = capfd.readouterr() - assert rc == 0 - assert captured.out == "" - assert "tool output" in captured.err - assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True} - - -def test_stdio_server_redirects_fd_stdout_noise_away_from_protocol_stdout(capfd): - server = RuntimeServer() - - def noisy_fd(): - os.write(1, b"tool output\n") - return {"ok": True} - - server.dispatcher.add_method("test.noisyFd", noisy_fd) - stdin = io.BytesIO(_request("test.noisyFd", 1)) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=server) - - captured = capfd.readouterr() - assert rc == 0 - assert captured.out == "" - assert "tool output" in captured.err - assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True} - - -def test_rpc_stdio_subprocess_smoke(): - stdin = ( - _request("rpc.hello", 1, {"version": 1}) - + _request("rpc.ping", 2) - + _request( - "rpc.shutdown", - 3, - ) - ) - - completed = subprocess.run( - [sys.executable, "-m", "chipcompiler.cli.main", "rpc", "serve", "--stdio"], - input=stdin, - cwd=os.getcwd(), - capture_output=True, - check=False, - ) - - assert completed.returncode == 0 - responses = _decode_output(completed.stdout) - assert [response["id"] for response in responses] == [1, 2, 3] - assert responses[1]["result"] == {"ok": True} - - -def test_rpc_stdio_subprocess_persistent_db_smoke(): - stdin = _request("rpc.hello", 1, {"version": 1}) + _request("rpc.shutdown", 2) - - completed = subprocess.run( - [ - sys.executable, - "-m", - "chipcompiler.cli.main", - "rpc", - "serve", - "--stdio", - "--persistent-db", - ], - input=stdin, - cwd=os.getcwd(), - capture_output=True, - check=False, - ) - - assert completed.returncode == 0 - responses = _decode_output(completed.stdout) - assert [response["id"] for response in responses] == [1, 2] - assert "db.ensure" in responses[0]["result"]["capabilities"] - assert "db.release" in responses[0]["result"]["capabilities"] - - -def test_rpc_stdio_subprocess_workspace_open_home_smoke(tmp_path, minimal_ics55_pdk_factory): - ws = _create_real_workspace(tmp_path, minimal_ics55_pdk_factory) - process = subprocess.Popen( - [sys.executable, "-m", "chipcompiler.cli.main", "rpc", "serve", "--stdio"], - cwd=os.getcwd(), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - try: - _write_subprocess_request(process, "workspace.open", 1, {"directory": str(ws)}) - open_response = _read_subprocess_response(process) - workspace_id = open_response["result"]["workspaceId"] - - _write_subprocess_request( - process, - "workspace.home", - 2, - {"workspaceId": workspace_id}, - ) - home_response = _read_subprocess_response(process) - - _write_subprocess_request(process, "rpc.shutdown", 3) - shutdown_response = _read_subprocess_response(process) - stderr = process.communicate(timeout=5)[1] - finally: - if process.poll() is None: - process.kill() - process.communicate() - - assert process.returncode == 0, stderr.decode("utf-8", errors="replace") - assert open_response["result"] == { - "workspaceId": workspace_id, - "directory": str(ws.resolve()), - } - assert home_response == { - "jsonrpc": "2.0", - "result": {"path": str(ws.resolve() / "home" / "home.json")}, - "id": 2, - } - assert shutdown_response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3} diff --git a/test/runtime/test_transport.py b/test/runtime/test_transport.py deleted file mode 100644 index 9b88db82d..000000000 --- a/test/runtime/test_transport.py +++ /dev/null @@ -1,80 +0,0 @@ -import re -from pathlib import Path - -import pytest - -from chipcompiler.runtime.transport import ( - ContentLengthDecoder, - TransportError, - encode_content_length_frame, -) - - -def test_encodes_and_decodes_one_content_length_frame(): - frame = encode_content_length_frame(b'{"jsonrpc":"2.0","id":1}') - - assert frame.startswith(b"Content-Length: 24\r\n\r\n") - decoder = ContentLengthDecoder() - assert decoder.feed(frame) == [b'{"jsonrpc":"2.0","id":1}'] - - -def test_decodes_multiple_frames_from_one_buffer(): - frame = encode_content_length_frame(b'{"id":1}') + encode_content_length_frame(b'{"id":2}') - - decoder = ContentLengthDecoder() - - assert decoder.feed(frame) == [b'{"id":1}', b'{"id":2}'] - - -def test_buffers_partial_header_and_payload_until_complete(): - frame = encode_content_length_frame(b'{"id":1}') - decoder = ContentLengthDecoder() - - assert decoder.feed(frame[:5]) == [] - assert decoder.feed(frame[5:20]) == [] - assert decoder.feed(frame[20:-1]) == [] - assert decoder.feed(frame[-1:]) == [b'{"id":1}'] - - -def test_malformed_content_length_header_is_transport_error(): - decoder = ContentLengthDecoder() - - with pytest.raises(TransportError, match="Content-Length"): - decoder.feed(b"Content-Length: nope\r\n\r\n{}") - - -def test_missing_content_length_header_is_transport_error(): - decoder = ContentLengthDecoder() - - with pytest.raises(TransportError, match="Content-Length"): - decoder.feed(b"X-Length: 2\r\n\r\n{}") - - -def test_oversize_payload_is_transport_error(): - decoder = ContentLengthDecoder(max_payload_size=4) - - with pytest.raises(TransportError, match="exceeds"): - decoder.feed(encode_content_length_frame(b"12345")) - - -def test_workspace_rpc_doc_content_lengths_match_payloads(): - source = Path("docs/rpc-guide.md").read_text(encoding="utf-8") - frames = re.findall(r"Content-Length: (\d+)\n\n({\"jsonrpc\"[^\n]+})", source) - - assert frames - for declared, payload in frames: - assert int(declared) == len(payload.encode("utf-8")) - - -def test_workspace_rpc_docs_cover_opt_in_persistent_db_surface(): - source = Path("docs/rpc-guide.md").read_text(encoding="utf-8") - cli_design = Path("docs/specification/cli-design.md").read_text(encoding="utf-8") - - for text in (source, cli_design): - assert "--persistent-db" in text - assert "db.ensure" in text - assert "db.release" in text - assert "default sidecar does not advertise or persist native DB handles" in cli_design - assert ( - "Opening or creating a workspace does not initialize persistent native DB state" in source - ) diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py deleted file mode 100644 index be63e65d4..000000000 --- a/test/runtime/test_workspace_api.py +++ /dev/null @@ -1,1616 +0,0 @@ -import json -import queue -import threading -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from chipcompiler.data import StateEnum -from chipcompiler.runtime.requests import ( - DbEnsureRequest, - DbReleaseRequest, - FlowRunRequest, - FlowRunStepRequest, - OperationStartFlowRequest, - WorkspaceCreateRequest, - WorkspaceIdRequest, - WorkspaceInfoRequest, - WorkspaceOpenRequest, - WorkspaceRecoverInterruptedRequest, - WorkspaceSyncConfigRequest, -) -from chipcompiler.runtime.sessions import WorkspaceSessionRegistry -from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi - - -class DummyEngineDB: - def __init__(self, flow): - self.flow = flow - self.initialized = False - self.close_calls = 0 - - def has_init(self): - return self.initialized - - def create_db_engine(self, step): - self.flow.init_db_engine_calls += 1 - self.flow.init_db_engine_steps.append(None if step is None else step.name) - self.flow.init_db_engine_inputs.append( - ( - None if step is None else getattr(step, "input_def", ""), - None if step is None else getattr(step, "input_verilog", ""), - ) - ) - self.flow.call_order.append(("init_db_engine",)) - self.initialized = self.flow.next_init_success - return self.initialized - - def close(self): - if not self.initialized: - return - self.close_calls += 1 - self.initialized = False - - -class DummyFlow: - instances = [] - next_run_states = [] - next_init_success = True - successful_steps = set() - workspace_step_specs = None - - def __init__(self, workspace): - self.workspace = workspace - self.added_steps = [] - self.created = False - self.prepared_for_rerun = False - self.run_steps_calls = [] - self.run_calls = [] - self.flow_init_db_engine_calls = 0 - self.init_db_engine_calls = 0 - self.init_db_engine_steps = [] - self.init_db_engine_inputs = [] - self.call_order = [] - specs = self.workspace_step_specs or ( - {"name": "Synthesis", "tool": "yosys"}, - {"name": "Floorplan", "tool": "ecc"}, - ) - self.workspace_steps = [SimpleNamespace(**spec) for spec in specs] - self.completed_steps = set() - self.engine_db = DummyEngineDB(self) - DummyFlow.instances.append(self) - - def has_init(self): - return False - - def add_step(self, step, tool, state): - self.added_steps.append((step, tool, state)) - self.workspace.flow.data.setdefault("steps", []).append( - {"name": step, "tool": tool, "state": state} - ) - - def create_step_workspaces(self): - self.created = True - - def run_steps(self, *, rerun=False): - self.run_steps_calls.append(rerun) - success = True - for workspace_step in self.workspace_steps: - self.init_db_engine() - state = self.run_step(workspace_step, rerun=rerun) - if state != StateEnum.Success: - success = False - break - return success - - def init_db_engine(self): - self.flow_init_db_engine_calls += 1 - self.call_order.append(("flow_init_db_engine",)) - if self.engine_db is None: - self.engine_db = DummyEngineDB(self) - workspace_step = self.workspace_steps[0] - for candidate in self.workspace_steps: - if candidate.name not in self.completed_steps: - workspace_step = candidate - break - return self.engine_db.create_db_engine(workspace_step) - - def run_step(self, workspace_step, *, rerun=False): - name = workspace_step if isinstance(workspace_step, str) else workspace_step.name - self.run_calls.append((name, rerun)) - self.call_order.append(("run_step", name, rerun)) - state = DummyFlow.next_run_states.pop(0) if DummyFlow.next_run_states else StateEnum.Success - if state == StateEnum.Success: - self.completed_steps.add(name) - workspace_step_object = self.get_workspace_step(name) - if getattr(workspace_step_object, "tool", "") == "sizer": - if self.engine_db is not None: - self.engine_db.close() - self.engine_db = None - return state - - def get_workspace_step(self, name): - for step in self.workspace_steps: - if step.name == name: - return step - return None - - def get_step(self, name, tool): - for step in self.workspace.flow.data.get("steps", []): - if step.get("name") == name and step.get("tool") == tool: - return step - return None - - def save(self): - return True - - def check_state(self, name, tool, state): - return getattr(state, "value", state) == StateEnum.Success.value and name in ( - self.successful_steps - ) - - -def _workspace(directory: Path): - design = SimpleNamespace( - name="gcd", - top_module="gcd", - origin_def="", - origin_verilog=directory / "origin" / "gcd.v", - input_filelist="", - ) - return SimpleNamespace( - directory=directory.resolve(), - design=design, - flow=SimpleNamespace(path=directory / "home" / "flow.json", data={"steps": []}), - home=SimpleNamespace(path=directory / "home" / "home.json"), - ) - - -def _install_runtime_mocks(monkeypatch, tmp_path, *, create_workspace_files=True): - capture = { - "create_kwargs": None, - "input_filelist_lines": [], - "loaded": [], - "workspace_entries_when_create_called": [], - } - DummyFlow.instances = [] - DummyFlow.next_run_states = [] - DummyFlow.next_init_success = True - DummyFlow.successful_steps = set() - DummyFlow.workspace_step_specs = None - - def fake_create_workspace(**kwargs): - capture["create_kwargs"] = kwargs - input_filelist = kwargs.get("input_filelist") - if input_filelist and Path(input_filelist).exists(): - capture["input_filelist_lines"] = ( - Path(input_filelist).read_text(encoding="utf-8").splitlines() - ) - workspace_dir = Path(kwargs["directory"]) - if workspace_dir.is_dir(): - capture["workspace_entries_when_create_called"] = sorted( - path.name for path in workspace_dir.iterdir() - ) - return _workspace(Path(kwargs["directory"])) - - def fake_load_workspace(directory): - capture["loaded"].append(directory) - return _workspace(Path(directory)) - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace) - monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) - monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", lambda workspace: None) - monkeypatch.setattr( - "chipcompiler.data.prepare_workspace_for_rerun", lambda ws, flow, **_kwargs: None - ) - monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - - ws = tmp_path / "workspace" - if create_workspace_files: - (ws / "home").mkdir(parents=True) - (ws / "home" / "parameters.json").write_text("{}") - (ws / "home" / "flow.json").write_text(json.dumps({"steps": []})) - (ws / "home" / "home.json").write_text("{}") - return capture, ws - - -def _assert_call_waits_for_session_lock(api, workspace_id, call, entered): - session = api.sessions.get_session(workspace_id) - result_queue = queue.Queue() - - def run_call(): - try: - result_queue.put(("result", call())) - except BaseException as exc: # pragma: no cover - re-raised in test thread - result_queue.put(("error", exc)) - - with session.mutation_lock: - worker = threading.Thread(target=run_call) - worker.start() - assert not entered.wait(0.1) - assert worker.is_alive() - - worker.join(timeout=2) - assert not worker.is_alive() - kind, payload = result_queue.get_nowait() - if kind == "error": - raise payload - assert entered.is_set() - return payload - - -def test_create_workspace_returns_plain_runtime_result_and_session(monkeypatch, tmp_path): - capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - - result = api.create_workspace( - WorkspaceCreateRequest( - directory=str(ws), - pdk="ics55", - pdk_root="/pdk", - pdk_json={"name": "ics55"}, - parameters={"design": "gcd"}, - rtl_list=["a.v"], - sdc="/constraints/top.sdc", - ) - ) - - assert set(result) == {"workspaceId", "directory"} - assert result["directory"] == str(ws.resolve()) - assert result["workspaceId"].startswith("workspace-") - assert isinstance(capture["create_kwargs"]["pdk_json"], str) - assert capture["create_kwargs"]["sdc"] == "/constraints/top.sdc" - assert DummyFlow.instances[0].created - assert api.sessions.get_session(result["workspaceId"]).directory == ws.resolve() - - -def test_create_workspace_forwards_dynamic_flow_config(monkeypatch, tmp_path): - capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - flow_config = { - "start_step": "Synthesis", - "end_step": "Harden", - "steps": ["Synthesis", "RCX", "sta", "Harden"], - } - - WorkspaceRuntimeApi().create_workspace( - WorkspaceCreateRequest(directory=str(ws), flow_config=flow_config) - ) - - assert capture["create_kwargs"]["flow_config"] == flow_config - - -def test_create_workspace_writes_rtl_list_filelist_outside_workspace( - monkeypatch, - tmp_path, -): - capture, ws = _install_runtime_mocks( - monkeypatch, - tmp_path, - create_workspace_files=False, - ) - project = tmp_path / "project" - project.mkdir() - rtl_paths = [str(project / "a.v"), str(project / "b.v")] - api = WorkspaceRuntimeApi() - - api.create_workspace( - WorkspaceCreateRequest( - directory=str(ws), - pdk="ics55", - parameters={"design": "gcd"}, - rtl_list=rtl_paths, - ) - ) - - input_filelist = Path(capture["create_kwargs"]["input_filelist"]) - assert input_filelist.name == "filelist" - assert not input_filelist.is_relative_to(ws) - assert capture["input_filelist_lines"] == rtl_paths - assert capture["workspace_entries_when_create_called"] == [] - assert not (ws / "filelist").exists() - - -def test_create_workspace_materializes_inline_pdk_json_before_data_api(monkeypatch, tmp_path): - pdk_json = {"name": "ics55", "lef": ["tech.lef"]} - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - seen = {} - - def create_workspace(**kwargs): - pdk_json_path = Path(kwargs["pdk_json"]) - seen["pdk_json"] = json.loads(pdk_json_path.read_text(encoding="utf-8")) - return _workspace(Path(kwargs["directory"])) - - monkeypatch.setattr("chipcompiler.data.create_workspace", create_workspace) - api = WorkspaceRuntimeApi() - - api.create_workspace( - WorkspaceCreateRequest( - directory=str(ws), - pdk="ics55", - pdk_json=pdk_json, - ) - ) - - assert seen["pdk_json"] == pdk_json - - -def test_create_workspace_with_inline_pdk_json_uses_real_data_api(monkeypatch, tmp_path): - pdk_root = tmp_path / "pdk" - tech = pdk_root / "tech.lef" - lef = pdk_root / "stdcell.lef" - liberty = pdk_root / "stdcell.lib" - for path in (tech, lef, liberty): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("VERSION 5.8 ;\n") - - workspace_dir = tmp_path / "workspace" - monkeypatch.setattr( - "chipcompiler.runtime.workspace_api.build_flow_for_workspace", - lambda _workspace: SimpleNamespace(), - ) - - api = WorkspaceRuntimeApi() - result = api.create_workspace( - WorkspaceCreateRequest( - directory=str(workspace_dir), - pdk="ics55", - pdk_json={ - "name": "ics55", - "root": str(pdk_root), - "tech": str(tech), - "lefs": [str(lef)], - "libs": [str(liberty)], - }, - parameters={ - "design": "gcd", - "top_module": "gcd", - "clock": "clk", - }, - ) - ) - - assert result["directory"] == str(workspace_dir.resolve()) - pdk_config_path = workspace_dir / "home" / "pdk.json" - assert pdk_config_path.is_file() - from chipcompiler.data.parameter import load_parameter - - parameters = load_parameter(workspace_dir / "home" / "params.toml").data - assert parameters["pdk_config"] == str(pdk_config_path.resolve()) - session = api.sessions.get_session(result["workspaceId"]) - assert session.workspace.pdk.tech == tech - assert session.workspace.pdk.lefs == [lef] - assert session.workspace.pdk.libs == [liberty] - assert session.workspace.pdk.buffers - assert session.directory == workspace_dir.resolve() - - -def test_open_workspace_loads_without_creating_step_workspaces(monkeypatch, tmp_path): - capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - - result = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) - - assert result == { - "workspaceId": result["workspaceId"], - "directory": str(ws.resolve()), - } - assert capture["loaded"] == [str(ws)] - assert not DummyFlow.instances[0].created - - -def test_recover_interrupted_is_marker_scoped_and_idempotent(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - session = api.sessions.get_session(workspace_id) - session.workspace.flow.data = { - "steps": [ - { - "name": "place", - "tool": "dreamplace", - "state": "Ongoing", - "info": { - "runtime_operation": { - "schema": 1, - "operation_id": "operation-1", - "runtime_instance_id": "runtime-old", - "started_at": 1.0, - } - }, - }, - {"name": "route", "tool": "ecc", "state": "Ongoing", "info": {}}, - { - "name": "Floorplan", - "tool": "ecc", - "state": "Success", - "info": { - "runtime_operation": { - "schema": 1, - "operation_id": "operation-2", - } - }, - }, - { - "name": "CTS", - "tool": "ecc", - "state": "Ongoing", - "info": { - "runtime_operation": { - "schema": 1, - "operation_id": "operation-partial", - } - }, - }, - { - "name": "STA", - "tool": "ecc", - "state": "Ongoing", - "info": { - "runtime_operation": { - "schema": 1, - "operation_id": "operation-active", - "runtime_instance_id": "runtime-current", - "started_at": 2.0, - } - }, - }, - { - "name": "DRC", - "tool": "ecc", - "state": "Ongoing", - "info": { - "runtime_operation": { - "schema": 1, - "operation_id": "operation-previous", - "runtime_instance_id": "runtime-old", - "started_at": 3.0, - } - }, - }, - ] - } - mismatch = api.recover_interrupted( - WorkspaceRecoverInterruptedRequest(workspace_id, "operation-other") - ) - assert mismatch == {"recovered": []} - - result = api.recover_interrupted( - WorkspaceRecoverInterruptedRequest(workspace_id, "operation-1") - ) - assert result == { - "recovered": [ - { - "step": "place", - "tool": "dreamplace", - "operationId": "operation-1", - "logFile": str(ws / "place_dreamplace" / "log" / "place.log"), - } - ] - } - assert session.workspace.flow.data["steps"][0]["state"] == StateEnum.Imcomplete.value - assert session.workspace.flow.data["steps"][0]["info"] == {} - assert session.workspace.flow.data["steps"][1]["state"] == "Ongoing" - assert session.workspace.flow.data["steps"][2]["state"] == "Success" - monkeypatch.setattr( - api.operations, - "is_active", - lambda operation_id: operation_id == "operation-active", - ) - previous = api.recover_interrupted(WorkspaceRecoverInterruptedRequest(workspace_id)) - assert previous == { - "recovered": [ - { - "step": "DRC", - "tool": "ecc", - "operationId": "operation-previous", - "logFile": str(ws / "DRC_ecc" / "log" / "DRC.log"), - } - ] - } - assert session.workspace.flow.data["steps"][3]["state"] == "Ongoing" - assert session.workspace.flow.data["steps"][4]["state"] == "Ongoing" - assert session.workspace.flow.data["steps"][5]["state"] == StateEnum.Imcomplete.value - assert api.recover_interrupted( - WorkspaceRecoverInterruptedRequest(workspace_id, "operation-1") - ) == {"recovered": []} - - -def test_create_workspace_replaces_existing_same_directory_session(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - - opened = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) - opened_session = api.sessions.get_session(opened["workspaceId"]) - opened_session.db_handle = object() - created = api.create_workspace(WorkspaceCreateRequest(directory=str(ws))) - - assert created["workspaceId"] != opened["workspaceId"] - assert opened_session.db_handle is None - with pytest.raises(RuntimeApiError, match="workspace session not found"): - api.workspace_home(WorkspaceIdRequest(workspace_id=opened["workspaceId"])) - created_session = api.sessions.get_session(created["workspaceId"]) - assert created_session.workspace is not opened_session.workspace - - -def test_open_workspace_reuses_existing_same_directory_session(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - - first = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) - first_session = api.sessions.get_session(first["workspaceId"]) - second = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) - - assert second["workspaceId"] == first["workspaceId"] - assert api.sessions.get_session(second["workspaceId"]).workspace is first_session.workspace - - -def test_workspace_home_and_info_use_session_id(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - monkeypatch.setattr( - "chipcompiler.tools.get_step_info", - lambda workspace, step, id: {"path": Path(workspace.directory) / "layout.png"}, - ) - api = WorkspaceRuntimeApi() - opened = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) - workspace_id = opened["workspaceId"] - - home = api.workspace_home(WorkspaceIdRequest(workspace_id=workspace_id)) - info = api.workspace_info( - WorkspaceInfoRequest(workspace_id=workspace_id, step="Synthesis", info_id="layout") - ) - - assert home == {"path": str(ws.resolve() / "home" / "home.json")} - assert info == { - "step": "Synthesis", - "id": "layout", - "info": {"path": str(ws.resolve() / "layout.png")}, - } - - -def test_refresh_sync_and_reset_flow_use_session(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - refreshed = [] - synced = [] - prepared = [] - - monkeypatch.setattr( - "chipcompiler.data.refresh_workspace_config", - lambda workspace: refreshed.append(workspace.directory), - ) - monkeypatch.setattr( - "chipcompiler.data.sync_workspace_config_to_parameters", - lambda workspace, path: synced.append((workspace.directory, path)) or True, - ) - monkeypatch.setattr( - "chipcompiler.data.prepare_workspace_for_rerun", - lambda workspace, flow, **_kwargs: prepared.append((workspace.directory, flow)), - ) - config_dir = ws / "config" - config_dir.mkdir() - config_path = config_dir / "route.json" - config_path.write_text("{}") - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - refresh = api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)) - sync = api.sync_config( - WorkspaceSyncConfigRequest( - workspace_id=workspace_id, - config_path=str(config_path), - ) - ) - reset = api.reset_flow(WorkspaceIdRequest(workspace_id=workspace_id)) - - assert refresh == {"directory": str(ws.resolve()), "refreshed": True} - assert sync == { - "directory": str(ws.resolve()), - "configPath": str(config_path.resolve()), - "parametersChanged": True, - "refreshed": True, - } - assert reset == {"directory": str(ws.resolve())} - assert refreshed == [ws.resolve(), ws.resolve()] - assert synced == [(ws.resolve(), config_path.resolve())] - assert prepared == [(ws.resolve(), DummyFlow.instances[-1])] - - -def test_refresh_config_releases_active_session_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - result = api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)) - - assert result == {"directory": str(ws.resolve()), "refreshed": True} - assert db_handle.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_sync_config_releases_active_session_db_only_when_parameters_change( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - config_dir = ws / "config" - config_dir.mkdir() - config_path = config_dir / "route.json" - config_path.write_text("{}") - changed = [False, True] - - monkeypatch.setattr( - "chipcompiler.data.sync_workspace_config_to_parameters", - lambda _workspace, _path: changed.pop(0), - ) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - unchanged = api.sync_config( - WorkspaceSyncConfigRequest(workspace_id=workspace_id, config_path=str(config_path)) - ) - assert unchanged["parametersChanged"] is False - assert unchanged["refreshed"] is False - assert db_handle.close_calls == 0 - - changed_result = api.sync_config( - WorkspaceSyncConfigRequest(workspace_id=workspace_id, config_path=str(config_path)) - ) - - assert changed_result["parametersChanged"] is True - assert changed_result["refreshed"] is True - assert db_handle.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_reset_flow_releases_active_session_db_before_prepare(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - prepared = [] - - def prepare(workspace, flow, **_kwargs): - prepared.append((workspace.directory, flow)) - assert api.sessions.get_session(workspace_id).db_handle is None - - monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - result = api.reset_flow(WorkspaceIdRequest(workspace_id=workspace_id)) - - assert result == {"directory": str(ws.resolve())} - assert db_handle.close_calls == 1 - assert prepared == [(ws.resolve(), DummyFlow.instances[-1])] - - -def test_refresh_config_waits_for_session_mutation_lock(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - entered = threading.Event() - - def refresh_config(_workspace): - entered.set() - - monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", refresh_config) - - _assert_call_waits_for_session_lock( - api=api, - workspace_id=workspace_id, - call=lambda: api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)), - entered=entered, - ) - - -def test_sync_config_waits_for_session_mutation_lock(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - config_dir = ws / "config" - config_dir.mkdir() - config_path = config_dir / "route.json" - config_path.write_text("{}") - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - entered = threading.Event() - - def sync_config(_workspace, _path): - entered.set() - return False - - monkeypatch.setattr("chipcompiler.data.sync_workspace_config_to_parameters", sync_config) - - _assert_call_waits_for_session_lock( - api=api, - workspace_id=workspace_id, - call=lambda: api.sync_config( - WorkspaceSyncConfigRequest( - workspace_id=workspace_id, - config_path=str(config_path), - ) - ), - entered=entered, - ) - - -def test_reset_flow_waits_for_session_mutation_lock(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - entered = threading.Event() - - def build_flow(_workspace): - entered.set() - return SimpleNamespace() - - monkeypatch.setattr("chipcompiler.runtime.workspace_api.build_flow_for_workspace", build_flow) - monkeypatch.setattr( - "chipcompiler.data.prepare_workspace_for_rerun", lambda _ws, _flow, **_kwargs: None - ) - - _assert_call_waits_for_session_lock( - api=api, - workspace_id=workspace_id, - call=lambda: api.reset_flow(WorkspaceIdRequest(workspace_id=workspace_id)), - entered=entered, - ) - - -def test_unknown_session_returns_structured_runtime_error(): - api = WorkspaceRuntimeApi() - - with pytest.raises(RuntimeApiError) as exc_info: - api.workspace_home(WorkspaceIdRequest(workspace_id="missing")) - - assert exc_info.value.code == "workspace_session_not_found" - - -def test_db_ensure_rejects_disabled_runtime_api(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - with pytest.raises(RuntimeApiError) as exc_info: - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id)) - - assert exc_info.value.code == "command_failed" - assert exc_info.value.message == "persistent_db_disabled" - - -def test_db_ensure_initializes_requested_step_and_stores_session_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - - flow = DummyFlow.instances[-1] - session = api.sessions.get_session(workspace_id) - assert result == { - "workspaceId": workspace_id, - "enabled": True, - "active": True, - "reused": False, - "step": "Floorplan", - } - assert flow.init_db_engine_steps == ["Floorplan"] - assert session.db_handle is flow.engine_db - assert session.db_handle.has_init() - - -def test_db_ensure_without_step_uses_flow_selection_rule(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id)) - - flow = DummyFlow.instances[-1] - assert result == { - "workspaceId": workspace_id, - "enabled": True, - "active": True, - "reused": False, - "step": "", - } - assert flow.flow_init_db_engine_calls == 1 - assert flow.init_db_engine_steps == ["Synthesis"] - assert api.sessions.get_session(workspace_id).db_handle is flow.engine_db - - -def test_db_ensure_reuses_initialized_session_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - first = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - second = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - - assert first["reused"] is False - assert second == { - "workspaceId": workspace_id, - "enabled": True, - "active": True, - "reused": True, - "step": "Floorplan", - } - assert api.sessions.get_session(workspace_id).db_handle is db_handle - - -def test_db_ensure_unknown_step_returns_runtime_error(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - with pytest.raises(RuntimeApiError) as exc_info: - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Missing")) - - assert exc_info.value.code == "command_failed" - assert exc_info.value.message == "step not found: Missing" - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_db_ensure_does_not_store_uninitialized_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - DummyFlow.next_init_success = False - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - - assert result == { - "workspaceId": workspace_id, - "enabled": True, - "active": False, - "reused": False, - "step": "Floorplan", - } - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_db_release_closes_and_clears_active_session_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - result = api.db_release(DbReleaseRequest(workspace_id=workspace_id)) - - assert result == {"workspaceId": workspace_id, "released": True} - assert db_handle.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_db_release_is_idempotent_when_no_db_is_active(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.db_release(DbReleaseRequest(workspace_id=workspace_id)) - - assert result == {"workspaceId": workspace_id, "released": False} - - -def test_db_release_closes_db_with_injected_session_registry(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi( - sessions=WorkspaceSessionRegistry(), - persistent_db_enabled=True, - ) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - result = api.db_release(DbReleaseRequest(workspace_id=workspace_id)) - - assert result == {"workspaceId": workspace_id, "released": True} - assert db_handle.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_runtime_modules_do_not_import_typer_or_click(): - for path in Path("chipcompiler/runtime").glob("*.py"): - source = path.read_text() - assert "import typer" not in source - assert "import click" not in source - - -def test_flow_run_uses_run_steps_and_prepare_on_rerun(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - prepared = [] - monkeypatch.setattr( - "chipcompiler.data.prepare_workspace_for_rerun", - lambda workspace, flow, **kwargs: prepared.append((workspace.directory, flow, kwargs)), - ) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=True)) - - flow = DummyFlow.instances[-1] - assert result == {"rerun": True} - assert prepared == [(ws.resolve(), flow, {"preserve_user_inputs": False})] - assert flow.run_steps_calls == [True] - - -def test_gui_flow_operation_rerun_preserves_current_user_inputs(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - captured = {} - - def fake_flow_run(request, *, observer=None, preserve_user_inputs=False): - captured.update( - request=request, - observer=observer, - preserve_user_inputs=preserve_user_inputs, - ) - return {"rerun": request.rerun} - - def fake_start(**kwargs): - return kwargs["runner"](None) - - monkeypatch.setattr(api, "_flow_run", fake_flow_run) - monkeypatch.setattr(api.operations, "start", fake_start) - - result = api.start_flow_operation( - OperationStartFlowRequest( - workspace_id=workspace_id, - origin="gui", - rerun=True, - idempotency_key="gui-rerun", - ) - ) - - assert result == {"rerun": True} - assert captured["request"].workspace_id == workspace_id - assert captured["request"].rerun is True - assert captured["observer"] is None - assert captured["preserve_user_inputs"] is True - - -def test_flow_run_without_active_session_db_closes_transient_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) - - flow = DummyFlow.instances[-1] - assert result == {"rerun": False} - assert not flow.engine_db.has_init() - assert flow.engine_db.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_flow_run_with_active_session_db_injects_and_captures_final_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) - - flow = DummyFlow.instances[-1] - assert result == {"rerun": False} - assert flow.engine_db is db_handle - assert api.sessions.get_session(workspace_id).db_handle is db_handle - assert db_handle.close_calls == 0 - - -def test_flow_run_rerun_releases_stale_db_and_captures_new_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - prepared = [] - - def prepare(workspace, flow, **_kwargs): - prepared.append((workspace.directory, flow)) - assert api.sessions.get_session(workspace_id).db_handle is None - - monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - stale_db = api.sessions.get_session(workspace_id).db_handle - - result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=True)) - - flow = DummyFlow.instances[-1] - assert result == {"rerun": True} - assert stale_db.close_calls == 1 - assert prepared == [(ws.resolve(), flow)] - assert api.sessions.get_session(workspace_id).db_handle is flow.engine_db - assert flow.engine_db is not stale_db - assert flow.engine_db.has_init() - - -def test_flow_run_sizer_boundary_captures_post_sizer_db(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - sizer_def = str(ws / "Timing optimization_sizer" / "output" / "sizer.def") - sizer_verilog = str(ws / "Timing optimization_sizer" / "output" / "sizer.v") - DummyFlow.workspace_step_specs = ( - {"name": "Floorplan", "tool": "ecc", "input_def": "origin.def"}, - { - "name": "Timing optimization", - "tool": "sizer", - "input_def": "floorplan.def", - "input_verilog": "floorplan.v", - "output": {"def": sizer_def, "verilog": sizer_verilog}, - }, - { - "name": "Legalization", - "tool": "ecc", - "input_def": sizer_def, - "input_verilog": sizer_verilog, - }, - ) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - pre_sizer_db = api.sessions.get_session(workspace_id).db_handle - - result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) - - flow = DummyFlow.instances[-1] - post_sizer_db = api.sessions.get_session(workspace_id).db_handle - assert result == {"rerun": False} - assert pre_sizer_db.close_calls == 1 - assert post_sizer_db is flow.engine_db - assert post_sizer_db is not pre_sizer_db - assert post_sizer_db.has_init() - assert flow.init_db_engine_steps[-1] == "Legalization" - assert flow.init_db_engine_inputs[-1] == (sizer_def, sizer_verilog) - - -def test_flow_run_sizer_boundary_failure_captures_post_sizer_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - sizer_def = str(ws / "Timing optimization_sizer" / "output" / "sizer.def") - sizer_verilog = str(ws / "Timing optimization_sizer" / "output" / "sizer.v") - DummyFlow.workspace_step_specs = ( - {"name": "Floorplan", "tool": "ecc", "input_def": "origin.def"}, - { - "name": "Timing optimization", - "tool": "sizer", - "input_def": "floorplan.def", - "input_verilog": "floorplan.v", - "output": {"def": sizer_def, "verilog": sizer_verilog}, - }, - { - "name": "Legalization", - "tool": "ecc", - "input_def": sizer_def, - "input_verilog": sizer_verilog, - }, - ) - DummyFlow.next_run_states = [ - StateEnum.Success, - StateEnum.Success, - StateEnum.Imcomplete, - ] - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - pre_sizer_db = api.sessions.get_session(workspace_id).db_handle - - with pytest.raises(RuntimeApiError) as exc_info: - api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) - - flow = DummyFlow.instances[-1] - post_sizer_db = api.sessions.get_session(workspace_id).db_handle - assert exc_info.value.code == "command_failed" - assert pre_sizer_db.close_calls == 1 - assert post_sizer_db is flow.engine_db - assert post_sizer_db is not pre_sizer_db - assert post_sizer_db.has_init() - assert flow.init_db_engine_steps[-1] == "Legalization" - - -def test_flow_run_sizer_boundary_exception_captures_post_sizer_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - DummyFlow.workspace_step_specs = ( - {"name": "Floorplan", "tool": "ecc"}, - {"name": "Timing optimization", "tool": "sizer"}, - {"name": "Legalization", "tool": "ecc"}, - ) - - def run_steps_raises_after_post_sizer_db(self, *, rerun=False): - del rerun - self.engine_db.close() - self.engine_db = DummyEngineDB(self) - self.engine_db.create_db_engine(self.workspace_steps[-1]) - raise ValueError("post-sizer failure") - - monkeypatch.setattr(DummyFlow, "run_steps", run_steps_raises_after_post_sizer_db) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - pre_sizer_db = api.sessions.get_session(workspace_id).db_handle - - with pytest.raises(ValueError, match="post-sizer failure"): - api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) - - flow = DummyFlow.instances[-1] - post_sizer_db = api.sessions.get_session(workspace_id).db_handle - assert pre_sizer_db.close_calls == 1 - assert post_sizer_db is flow.engine_db - assert post_sizer_db is not pre_sizer_db - assert post_sizer_db.has_init() - - -def test_flow_run_step_initializes_db_before_direct_step(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis", rerun=False) - ) - - flow = DummyFlow.instances[-1] - assert result == {"step": "Synthesis", "state": "Success"} - assert flow.init_db_engine_steps == ["Synthesis"] - assert flow.call_order == [ - ("init_db_engine",), - ("run_step", "Synthesis", False), - ] - assert flow.run_steps_calls == [] - assert not flow.engine_db.has_init() - assert flow.engine_db.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_flow_run_step_with_active_session_db_injects_and_captures_final_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - db_handle = api.sessions.get_session(workspace_id).db_handle - - result = api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=False) - ) - - flow = DummyFlow.instances[-1] - assert result == {"step": "Floorplan", "state": "Success"} - assert flow.engine_db is db_handle - assert api.sessions.get_session(workspace_id).db_handle is db_handle - assert db_handle.close_calls == 0 - - -def test_flow_run_step_successful_sizer_releases_active_session_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - DummyFlow.workspace_step_specs = ( - {"name": "Floorplan", "tool": "ecc"}, - {"name": "Timing optimization", "tool": "sizer"}, - ) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - pre_sizer_db = api.sessions.get_session(workspace_id).db_handle - - result = api.flow_run_step( - FlowRunStepRequest( - workspace_id=workspace_id, - step="Timing optimization", - rerun=False, - ) - ) - - flow = DummyFlow.instances[-1] - assert result == {"step": "Timing optimization", "state": "Success"} - assert flow.engine_db is None - assert pre_sizer_db.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_flow_run_step_sizer_exception_clears_closed_session_db( - monkeypatch, - tmp_path, -): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - DummyFlow.workspace_step_specs = ( - {"name": "Floorplan", "tool": "ecc"}, - {"name": "Timing optimization", "tool": "sizer"}, - ) - - def run_step_raises_after_sizer_boundary(self, workspace_step, *, rerun=False): - del workspace_step, rerun - self.engine_db.close() - self.engine_db = None - raise ValueError("sizer failure") - - monkeypatch.setattr(DummyFlow, "run_step", run_step_raises_after_sizer_boundary) - api = WorkspaceRuntimeApi(persistent_db_enabled=True) - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) - pre_sizer_db = api.sessions.get_session(workspace_id).db_handle - - with pytest.raises(ValueError, match="sizer failure"): - api.flow_run_step( - FlowRunStepRequest( - workspace_id=workspace_id, - step="Timing optimization", - rerun=False, - ) - ) - - assert pre_sizer_db.close_calls == 1 - assert api.sessions.get_session(workspace_id).db_handle is None - - -def test_flow_run_step_rerun_refreshes_before_db_init(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - refreshed = [] - - def refresh_config(workspace): - refreshed.append(workspace.directory) - DummyFlow.instances[-1].call_order.append(("refresh_config", workspace.directory)) - - monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", refresh_config) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) - ) - - flow = DummyFlow.instances[-1] - assert result == {"step": "Floorplan", "state": "Success"} - assert refreshed == [ws.resolve()] - assert flow.call_order == [ - ("refresh_config", ws.resolve()), - ("init_db_engine",), - ("run_step", "Floorplan", True), - ] - - -def test_flow_run_step_rerun_clears_step_artifacts_and_resets_step_state(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - step_dir = ws / "Floorplan_ecc" - artifact_dirs = [ - step_dir / "output", - step_dir / "data", - step_dir / "feature", - step_dir / "analysis", - step_dir / "report", - step_dir / "log", - ] - for directory in artifact_dirs: - (directory / "nested").mkdir(parents=True) - (directory / "nested" / "stale").write_text("stale") - script_dir = step_dir / "script" - script_dir.mkdir() - (script_dir / "keep.tcl").write_text("keep") - - subflow_path = step_dir / "subflow.json" - subflow_path.write_text( - json.dumps( - { - "path": str(subflow_path), - "steps": [ - { - "name": "load data", - "state": "Success", - "runtime": "0:00:03", - "peak memory (mb)": 24, - "info": {"instances": 12}, - } - ], - } - ) - ) - checklist_path = step_dir / "checklist.json" - checklist_path.write_text( - json.dumps( - { - "path": str(checklist_path), - "checklist": [{"item": "stale", "state": "Success"}], - } - ) - ) - DummyFlow.workspace_step_specs = ( - { - "name": "Floorplan", - "tool": "ecc", - "output": {"dir": step_dir / "output"}, - "data": {"dir": step_dir / "data"}, - "feature": {"dir": step_dir / "feature"}, - "analysis": {"dir": step_dir / "analysis"}, - "report": {"dir": step_dir / "report"}, - "log": {"dir": step_dir / "log"}, - "subflow": SimpleNamespace(path=subflow_path, steps=[]), - "checklist": SimpleNamespace(path=checklist_path, checklist=[]), - }, - ) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - session = api.sessions.get_session(workspace_id) - session.workspace.flow.data = { - "steps": [ - { - "name": "Floorplan", - "tool": "ecc", - "state": "Success", - "runtime": "0:00:04", - "peak memory (mb)": 30, - } - ] - } - - result = api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) - ) - - assert result == {"step": "Floorplan", "state": "Success"} - assert all(list(directory.iterdir()) == [] for directory in artifact_dirs) - assert (script_dir / "keep.tcl").read_text() == "keep" - flow_record = next( - record - for record in session.workspace.flow.data["steps"] - if record["name"] == "Floorplan" and record["tool"] == "ecc" - ) - assert flow_record == { - "name": "Floorplan", - "tool": "ecc", - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - assert json.loads(subflow_path.read_text()) == { - "path": str(subflow_path), - "steps": [ - { - "name": "load data", - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - ], - } - checklist = json.loads(checklist_path.read_text()) - assert checklist["schema_version"] == 3 - assert checklist["kind"] == "signoff_checklist" - assert checklist["checker_revision"] == "signoff-v1" - assert checklist["status"] == "ready" - assert checklist["summary"] == { - "passed": 0, - "blocked": 0, - "attention": 0, - "unavailable": 0, - } - assert checklist["checklist"] == [] - - -def test_flow_run_step_gui_rerun_resets_target_and_downstream_subflows(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - - def step_spec(name, tool): - step_dir = ws / f"{name}_{tool}" - artifact_dir = step_dir / "output" - (artifact_dir / "nested").mkdir(parents=True) - (artifact_dir / "nested" / "stale").write_text(name) - subflow_path = step_dir / "subflow.json" - subflow_path.write_text( - json.dumps( - { - "path": str(subflow_path), - "steps": [ - { - "name": "load data", - "state": "Success", - "runtime": "0:00:01", - "peak memory (mb)": 10, - "info": {"stale": name}, - }, - { - "name": "run tool", - "state": "Success", - "runtime": "0:00:02", - "peak memory (mb)": 20, - "info": {"stale": name}, - }, - ], - } - ) - ) - checklist_path = step_dir / "checklist.json" - checklist_path.write_text(json.dumps({"checklist": [{"item": name}]})) - return { - "name": name, - "tool": tool, - "output": {"dir": artifact_dir}, - "subflow": SimpleNamespace(path=subflow_path, steps=[]), - "checklist": SimpleNamespace(path=checklist_path, checklist=[]), - } - - synthesis = step_spec("Synthesis", "yosys") - floorplan = step_spec("Floorplan", "ecc") - route = step_spec("route", "ecc") - DummyFlow.workspace_step_specs = (synthesis, floorplan, route) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - session = api.sessions.get_session(workspace_id) - session.workspace.flow.data = { - "steps": [ - { - "name": spec["name"], - "tool": spec["tool"], - "state": "Success", - "runtime": "0:00:04", - "peak memory (mb)": 30, - "info": {"stale": spec["name"]}, - } - for spec in (synthesis, floorplan, route) - ] - } - - result = api._flow_run_step( - FlowRunStepRequest( - workspace_id=workspace_id, - step="Floorplan", - rerun=True, - ), - reset_dependents=True, - ) - - assert result == {"step": "Floorplan", "state": "Success"} - assert (ws / "Synthesis_yosys" / "output" / "nested" / "stale").read_text() == "Synthesis" - for spec in (floorplan, route): - assert list(spec["output"]["dir"].iterdir()) == [] - checklist = json.loads(spec["checklist"].path.read_text()) - assert checklist["schema_version"] == 3 - assert checklist["kind"] == "signoff_checklist" - assert checklist["checker_revision"] == "signoff-v1" - assert checklist["status"] == "ready" - assert checklist["summary"] == { - "passed": 0, - "blocked": 0, - "attention": 0, - "unavailable": 0, - } - assert checklist["checklist"] == [] - reset_subflow = json.loads(spec["subflow"].path.read_text()) - assert all( - step["state"] == "Unstart" - and step["runtime"] == "" - and step["peak memory (mb)"] == 0 - and step["info"] == {} - for step in reset_subflow["steps"] - ) - - records = {record["name"]: record for record in session.workspace.flow.data["steps"]} - assert any( - record["name"] == "Synthesis" and record["state"] == "Success" - for record in session.workspace.flow.data["steps"] - ) - for name in ("Floorplan", "route"): - assert records[name] == { - "name": name, - "tool": "ecc", - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - - -def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - api.sessions.get_session(workspace_id).layout_edit_session = object() - - with pytest.raises(RuntimeApiError) as exc_info: - api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) - ) - - assert exc_info.value.code == "layout_edit_active" - assert "close the rendered layout" in exc_info.value.message - assert DummyFlow.instances[-1].run_calls == [] - - -def test_flow_run_step_skips_successful_step_without_db_init(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - DummyFlow.successful_steps = {"Synthesis"} - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - result = api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis", rerun=False) - ) - - flow = DummyFlow.instances[-1] - assert result == {"step": "Synthesis", "state": "Success"} - assert flow.init_db_engine_calls == 0 - assert flow.call_order == [("run_step", "Synthesis", False)] - - -def test_flow_run_step_unknown_step_returns_runtime_error(monkeypatch, tmp_path): - _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) - api = WorkspaceRuntimeApi() - workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] - - with pytest.raises(RuntimeApiError) as exc_info: - api.flow_run_step( - FlowRunStepRequest(workspace_id=workspace_id, step="Missing", rerun=False) - ) - - assert exc_info.value.code == "command_failed" - assert "step not found" in exc_info.value.message - - -@pytest.mark.parametrize( - "db_value", - [None, "", "some/db/path"], -) -def test_build_workspace_step_for_info_forwards_db_from_any_predecessor(tmp_path, db_value): - # Regression: a Yosys (synthesis) predecessor has output.db == None on the - # base OutputPaths contract, so reconstructing the next step must not crash. - from pathlib import Path - - from chipcompiler.data import ( - EccOutput, - EccStep, - OriginDesign, - Workspace, - YosysStep, - ) - from chipcompiler.runtime.workspace_api import _build_workspace_step_for_info - - workspace = Workspace( - directory=tmp_path, - design=OriginDesign(name="gcd", top_module="gcd"), - ) - - # Yosys predecessor: db is the base default (None) -> must reconstruct cleanly. - yosys_prev = YosysStep(name="Synthesis") - ecc_step = _build_workspace_step_for_info( - workspace, {"name": "Floorplan", "tool": "ecc"}, yosys_prev - ) - assert isinstance(ecc_step, EccStep) - - # ECC/sizer predecessor: db forwarded unchanged (None / "" / a real path). - ecc_prev = EccStep( - name="place", - output=EccOutput( - def_=tmp_path / "p.def", - verilog=tmp_path / "p.v", - db=Path(db_value) if db_value else db_value, - ), - ) - next_step = _build_workspace_step_for_info(workspace, {"name": "CTS", "tool": "ecc"}, ecc_prev) - assert isinstance(next_step, EccStep) - # A real db path is forwarded into the next step's input.db; "" / None -> None. - if db_value: - assert next_step.input.db == Path(db_value) - else: - assert next_step.input.db is None diff --git a/test/test_signoff_package.py b/test/test_signoff_package.py index c2c2bfec3..1de074f14 100644 --- a/test/test_signoff_package.py +++ b/test/test_signoff_package.py @@ -301,6 +301,18 @@ def test_collect_signoff_package_requires_synthesis_verilog(tmp_path): ) +def test_read_only_signoff_collection_does_not_rewrite_home_checklist(tmp_path): + workspace_dir = _make_signoff_workspace(tmp_path) + checklist = workspace_dir / "home" / "checklist.json" + before = file_digest(checklist) + + _make_engine_flow(workspace_dir).collect_signoff_package( + SignoffPackageOptions(archive=False, materialize=False) + ) + + assert file_digest(checklist) == before + + def test_collect_signoff_package_rejects_stale_post_route_lec_proof(tmp_path): workspace_dir = _make_signoff_workspace(tmp_path) gate = workspace_dir / "lvs_ecc" / "output" / "gcd_lvs.v.gz" diff --git a/test/tools/ecc/test_qor_detail_facts.py b/test/tools/ecc/test_qor_detail_facts.py new file mode 100644 index 000000000..f4fe8537d --- /dev/null +++ b/test/tools/ecc/test_qor_detail_facts.py @@ -0,0 +1,239 @@ +import json + +from chipcompiler.data import OriginDesign, StepEnum, Workspace +from chipcompiler.tools.ecc.builder import build_step, build_step_space +from chipcompiler.tools.ecc.metrics import build_metrics_floorplan, build_metrics_lvs +from chipcompiler.tools.ecc.qor_detail_facts import database_fact_summary + + +def test_database_fact_summary_extracts_layout_and_instance_classes(tmp_path): + path = tmp_path / "Floorplan.db.json" + path.write_text( + json.dumps( + { + "Design Layout": { + "die_area": 2259.861, + "core_area": 1778.432, + "die_bounding_width": 47.538, + "die_bounding_height": 47.538, + "die_usage": 0.34, + "core_usage": 0.42, + }, + "Design Statis": { + "num_iopins": 58, + "num_instances": 615, + "num_nets": 361, + }, + "Instances": { + "total": {"area": 1200.0, "num": 615}, + "clock": {"area": 20.0, "num": 10}, + "iopads": {"area": 10.0, "num": 2}, + "logic": {"area": 709.25, "num": 286}, + "macros": {"area": 401.5, "num": 3}, + }, + } + ), + encoding="utf-8", + ) + + summary = database_fact_summary(path) + + assert summary is not None + assert summary["layout"]["core_area"] == 1778.432 + assert summary["instance_total"]["count"] == 615 + assert {item["kind"] for item in summary["instance_classes"]} == { + "clock", + "iopads", + "logic", + "macros", + } + + +def test_floorplan_qor_metrics_include_instance_classes_and_database_facts(tmp_path): + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + ) + step = build_step( + workspace=workspace, + step_name=StepEnum.FLOORPLAN.value, + input_def=tmp_path / "input.def", + input_verilog=tmp_path / "input.v", + ) + build_step_space(step) + assert step.feature.db is not None + step.feature.db.write_text( + json.dumps( + { + "Design Layout": { + "die_area": 2259.861, + "core_area": 1778.432, + "die_bounding_width": 47.538, + "die_bounding_height": 47.538, + "die_usage": 0.34, + "core_usage": 0.42, + }, + "Design Statis": { + "num_iopins": 58, + "num_instances": 615, + "num_nets": 361, + }, + "Instances": { + "total": {"area": 1200.0, "num": 615}, + "clock": {"area": 20.0, "num": 10}, + "iopads": {"area": 10.0, "num": 2}, + "logic": {"area": 709.25, "num": 286}, + "macros": {"area": 401.5, "num": 3}, + }, + } + ), + encoding="utf-8", + ) + + metrics = build_metrics_floorplan(workspace, step) + + assert metrics is not None + assert step.analysis.qor_metrics is not None + assert step.analysis.qor_metrics.exists() + qor_metrics = json.loads(step.analysis.qor_metrics.read_text(encoding="utf-8")) + assert qor_metrics["schema_version"] == 3 + assert qor_metrics["tool"] == "ecc" + assert qor_metrics["step"] == StepEnum.FLOORPLAN.value + assert qor_metrics["design"] == "gcd" + + records = {record["id"]: record for record in qor_metrics["metrics"]} + assert records["core_utilization"]["value"] == 0.42 + assert records["core_utilization"]["direction"] == "target_range" + assert records["core_area"]["value"] == 1778.432 + assert records["die_area"]["unit"] == "um^2" + assert { + metric_id: ( + records[metric_id]["value"], + records[metric_id]["source"]["selector"], + records[metric_id]["rating"]["score"], + ) + for metric_id in ( + "macro_count", + "macro_area", + "std_cell_count", + "std_cell_area", + "clock_count", + "clock_area", + "io_pad_count", + "io_pad_area", + "instance_area", + ) + } == { + "macro_count": (3, "/Instances/macros/num", False), + "macro_area": (401.5, "/Instances/macros/area", False), + "std_cell_count": (286, "/Instances/logic/num", False), + "std_cell_area": (709.25, "/Instances/logic/area", False), + "clock_count": (10, "/Instances/clock/num", False), + "clock_area": (20, "/Instances/clock/area", False), + "io_pad_count": (2, "/Instances/iopads/num", False), + "io_pad_area": (10, "/Instances/iopads/area", False), + "instance_area": (1200, "/Instances/total/area", False), + } + details = {detail["id"]: detail["summary"] for detail in qor_metrics["details"]} + assert details["database_facts"]["layout"]["core_area"] == 1778.432 + assert details["database_facts"]["instance_total"]["count"] == 615 + assert {item["kind"] for item in details["database_facts"]["instance_classes"]} == { + "clock", + "iopads", + "logic", + "macros", + } + + +def test_lvs_qor_metrics_include_connectivity_summary(tmp_path): + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + ) + step = build_step( + workspace=workspace, + step_name=StepEnum.LVS.value, + input_def=tmp_path / "input.def", + input_verilog=tmp_path / "input.v", + ) + build_step_space(step) + assert step.feature.step is not None + step.feature.step.write_text( + json.dumps( + { + "entity": [{"entity": "nets", "netlist": 10, "def": 9, "difference": 1}], + "connectivity": [ + {"connectivity": "signals", "open": 1, "short": 0, "connected": 9, "total": 10} + ], + "violations": [ + {"type": "open", "net": ["n1"], "terminals": ["A", "B"]}, + {"type": "short", "net": "n2", "components": ["u1", "u2"]}, + ], + } + ), + encoding="utf-8", + ) + + metrics = build_metrics_lvs(workspace, step) + + assert metrics is not None + assert metrics.data["lvs_count"] == 2 + assert step.analysis.qor_summary is not None + summary = json.loads(step.analysis.qor_summary.read_text(encoding="utf-8")) + assert summary["quality_status"] == "blocked" + assert summary["gates"] == [ + { + "id": "qor.lvs.clean", + "title": "Final LVS clean", + "state": "failed", + "blocking": True, + "metrics": [ + { + "id": "lvs_count", + "actual": 2, + "operator": "==", + "expected": 0, + "source": { + "kind": "feature", + "path": "feature/lvs.step.json", + "selector": "/violations", + }, + } + ], + "evidence": [ + { + "kind": "feature", + "path": "feature/lvs.step.json", + "selector": "/violations", + } + ], + } + ] + assert step.analysis.qor_metrics is not None + details = { + detail["id"]: detail + for detail in json.loads(step.analysis.qor_metrics.read_text(encoding="utf-8"))["details"] + } + assert details["lvs_connectivity_summary"]["summary"] == { + "schema_version": 1, + "entities": [{"entity": "nets", "netlist": 10, "def": 9, "difference": 1}], + "connectivity": [ + {"connectivity": "signals", "open": 1, "short": 0, "connected": 9, "total": 10} + ], + "violations": [ + { + "type": "open", + "net": "n1", + "instance": "", + "terminals": "A, B", + "components": "", + }, + { + "type": "short", + "net": "n2", + "instance": "", + "terminals": "", + "components": "u1, u2", + }, + ], + } diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index 4def6b45b..b28adbd88 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -867,6 +867,50 @@ def test_run_sta_uses_matched_report_and_feature_corner_directories(tmp_path, mo ] +def test_run_sta_returns_false_when_sdc_is_missing(tmp_path, monkeypatch): + config_dir = tmp_path / "config" + config_dir.mkdir() + max_lib = tmp_path / "pdk" / "max.lib" + spef = tmp_path / "RCX_ecc" / "output" / "gcd_RCworst_125C.spef" + for path in (max_lib, spef): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + sta_config = config_dir / "sta_ecc.json" + sta_config.write_text( + json.dumps( + { + "liberty": [{"corner": "MAX", "temperature": 125, "path": [str(max_lib)]}], + "signoff": [{"MAX": ["RCworst"]}], + } + ), + encoding="utf-8", + ) + rcx_config = config_dir / "rcx_ecc.json" + rcx_config.write_text( + json.dumps({"output": str(tmp_path / "RCX_ecc" / "data")}), + encoding="utf-8", + ) + logger = FakeLogger() + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + pdk=PDK(libs=[max_lib], sdc=None), + config={StepEnum.STA.value: sta_config, StepEnum.RCX.value: rcx_config}, + logger=logger, + ) + step = EccStep( + name=StepEnum.STA.value, + data=EccData(steps={StepEnum.STA.value: tmp_path / "sta_ecc" / "data" / "sta"}), + report=EccReport(dir=tmp_path / "sta_ecc" / "report"), + feature=EccFeature(dir=tmp_path / "sta_ecc" / "feature"), + ) + monkeypatch.setattr(ecc_runner, "EccSubFlow", FakeSubFlow) + monkeypatch.setattr(ecc_runner, "get_eda_instance", lambda **kwargs: FakeSynthesisStaModule()) + + assert ecc_runner.run_sta(workspace, step) is False + assert logger.errors[0][0] == "STA SDC does not exist: %s" + + def test_rcx_checklist_strips_top_module_from_spef_corner(tmp_path): checklist = EccRcxChecklist.__new__(EccRcxChecklist) checklist.workspace = Workspace( diff --git a/test/tools/yosys/test_metrics.py b/test/tools/yosys/test_metrics.py index f9185a230..5e0900ef3 100644 --- a/test/tools/yosys/test_metrics.py +++ b/test/tools/yosys/test_metrics.py @@ -98,7 +98,7 @@ def test_synthesis_metrics_write_v2_qor_files_without_legacy_metrics(tmp_path): "display_name": "Synthesis Dynamic Power", "value": 62.7968, "unit": "uW", - "category": "power", + "category": "power_integrity", "direction": "trend_only", "scope": "synthesis", "corner": None, diff --git a/uv.lock b/uv.lock index aedf194bc..80d403d09 100644 --- a/uv.lock +++ b/uv.lock @@ -42,37 +42,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - [[package]] name = "auditwheel" version = "6.6.0" @@ -450,15 +419,12 @@ source = { editable = "." } dependencies = [ { name = "ecc-dreamplace" }, { name = "ecc-tools-bin" }, - { name = "fastapi" }, - { name = "jsonrpcserver" }, { name = "klayout" }, { name = "matplotlib" }, { name = "numpy" }, { name = "pandas" }, { name = "pip" }, { name = "pyarrow" }, - { name = "pydantic" }, { name = "pyjson5" }, { name = "pyyaml" }, { name = "rosettakit" }, @@ -468,7 +434,6 @@ dependencies = [ { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, { name = "tqdm" }, { name = "typer" }, - { name = "uvicorn" }, ] [package.dev-dependencies] @@ -492,15 +457,12 @@ dev = [ requires-dist = [ { name = "ecc-dreamplace", editable = "chipcompiler/thirdparty/ecc-dreamplace" }, { name = "ecc-tools-bin", editable = "chipcompiler/thirdparty/ecc-tools" }, - { name = "fastapi", specifier = ">=0.109" }, - { name = "jsonrpcserver", specifier = ">=5.0.9" }, { name = "klayout", specifier = ">=0.30.2" }, { name = "matplotlib", specifier = ">=3.4" }, { name = "numpy", specifier = ">=1.21" }, { name = "pandas", specifier = ">=1.3" }, { name = "pip", specifier = ">=25.0.1" }, { name = "pyarrow", specifier = ">=15" }, - { name = "pydantic", specifier = ">=2.5" }, { name = "pyjson5", specifier = ">=1.6" }, { name = "pyyaml", specifier = ">=6" }, { name = "rosettakit", specifier = "==0.2.0" }, @@ -509,7 +471,6 @@ requires-dist = [ { name = "torch", specifier = ">=1.6.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "tqdm", specifier = ">=4.67.1" }, { name = "typer", specifier = ">=0.12" }, - { name = "uvicorn", specifier = ">=0.27" }, ] [package.metadata.requires-dev] @@ -625,21 +586,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/ee/84c8990b08efa0265bd10fc8781ef26e3157715bf0dfa47ee3c056b513d4/entrypoint2-1.1-py2.py3-none-any.whl", hash = "sha256:eeb8c327bdb65cdd1668c023a6b110b7e3d1a046fb05e043861ebd9264b3a257", size = 9864, upload-time = "2022-06-11T06:28:19.529Z" }, ] -[[package]] -name = "fastapi" -version = "0.128.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, -] - [[package]] name = "filelock" version = "3.25.2" @@ -707,24 +653,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -755,43 +683,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] -[[package]] -name = "jsonrpcserver" -version = "5.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "oslash" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e3/3b/8d4d4fe8c59a1a4d1e6edd6126ec118b989510fadf262950a5c4f4bca664/jsonrpcserver-5.0.9.tar.gz", hash = "sha256:a71fb2cfa18541c80935f60987f92755d94d74141248c7438847b96eee5c4482", size = 14506, upload-time = "2022-09-15T02:28:24.004Z" } - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - [[package]] name = "kiwisolver" version = "1.4.9" @@ -1369,18 +1260,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, ] -[[package]] -name = "oslash" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/b2/54ea4a7c6f768469a4c6a2f27f5c7cf572d63e9fd7f7618fca89c30966b3/OSlash-0.6.3.tar.gz", hash = "sha256:868aeb58a656f2ed3b73d9dd6abe387b20b74fc9413d3e8653b615b15bf728f3", size = 35228, upload-time = "2020-10-12T20:50:14.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/c3/77d40a6e20fdfbf92b086d2c47e3cc82731e179e3f44bdc8e60b7306bcc3/OSlash-0.6.3-py3-none-any.whl", hash = "sha256:89b978443b7db3ac2666106bdc3680add3c886a6d8fcdd02fd062af86d29494f", size = 26943, upload-time = "2020-10-12T20:50:13.06Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1682,118 +1561,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, -] - [[package]] name = "pydoe2" version = "1.3.0" @@ -2156,20 +1923,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -2192,129 +1945,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e1/716c06dc81db6065db98325fbba92631198de9c288ad57d50231cee6277f/rosettakit-0.2.0-py3-none-any.whl", hash = "sha256:b91438a445a1d14f5e70442b4152ea4fd398641a0b62955fc0f156ce7771b1de", size = 15725, upload-time = "2026-06-22T08:12:50.669Z" }, ] -[[package]] -name = "rpds-py" -version = "2026.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, - { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, - { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, - { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, - { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, -] - [[package]] name = "ruff" version = "0.14.13" @@ -2681,19 +2311,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/81/9ef641ff4e12cbcca30e54e72fb0951a2ba195d0cda0ba4100e532d929db/slicer-0.0.8-py3-none-any.whl", hash = "sha256:6c206258543aecd010d497dc2eca9d2805860a0b3758673903456b7df7934dc3", size = 15251, upload-time = "2024-03-09T07:03:07.708Z" }, ] -[[package]] -name = "starlette" -version = "0.50.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, -] - [[package]] name = "statsmodels" version = "0.14.6" @@ -2948,18 +2565,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - [[package]] name = "tzdata" version = "2025.3" @@ -2995,19 +2600,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b9/e581dd6a90ba08f0518c70276aa9896eed6efb0d5b480ef85acd7c665722/uv_build-0.10.12-py3-none-win_arm64.whl", hash = "sha256:e4b618934f177f31a930ea18398ae1cb40f35e2d3aca4d09b73afad5cd86b326", size = 1413755, upload-time = "2026-03-19T21:50:51.134Z" }, ] -[[package]] -name = "uvicorn" -version = "0.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, -] - [[package]] name = "xgboost" version = "3.2.0"