diff --git a/src/buildcompiler/adapters/__init__.py b/src/buildcompiler/adapters/__init__.py index 5e19692..3390756 100644 --- a/src/buildcompiler/adapters/__init__.py +++ b/src/buildcompiler/adapters/__init__.py @@ -1 +1,5 @@ -"""Package scaffolding for clean architecture.""" +"""Adapter package exports without optional dependency side effects.""" + +from .protocols import ProtocolArtifact, maybe_write_protocol_artifacts + +__all__ = ["ProtocolArtifact", "maybe_write_protocol_artifacts"] diff --git a/src/buildcompiler/adapters/opentrons/__init__.py b/src/buildcompiler/adapters/opentrons/__init__.py index 5e19692..48db553 100644 --- a/src/buildcompiler/adapters/opentrons/__init__.py +++ b/src/buildcompiler/adapters/opentrons/__init__.py @@ -1 +1,13 @@ -"""Package scaffolding for clean architecture.""" +"""Optional Opentrons adapter exports.""" + +from .simulation import ( + OpentronsSimulationAdapter, + OptionalAutomationDependencyError, + SimulationResult, +) + +__all__ = [ + "OpentronsSimulationAdapter", + "OptionalAutomationDependencyError", + "SimulationResult", +] diff --git a/src/buildcompiler/adapters/opentrons/simulation.py b/src/buildcompiler/adapters/opentrons/simulation.py new file mode 100644 index 0000000..a8c8e17 --- /dev/null +++ b/src/buildcompiler/adapters/opentrons/simulation.py @@ -0,0 +1,44 @@ +"""Optional Opentrons simulation boundary adapter.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from buildcompiler.api import ProtocolOptions + + +class OptionalAutomationDependencyError(ImportError): + """Raised when optional automation dependency is unavailable.""" + + +@dataclass +class SimulationResult: + ran: bool + logs: list[str] = field(default_factory=list) + metadata: dict[str, object] = field(default_factory=dict) + + +class OpentronsSimulationAdapter: + def simulate( + self, protocol_source: str | Path, *, options: ProtocolOptions + ) -> SimulationResult: + if not options.simulate: + return SimulationResult( + ran=False, + logs=["Simulation skipped: ProtocolOptions.simulate is False."], + metadata={"protocol_source": str(protocol_source)}, + ) + + try: + __import__("opentrons") + except ImportError as exc: + raise OptionalAutomationDependencyError( + "Install synbio-buildcompiler[automation] to use Opentrons simulation." + ) from exc + + return SimulationResult( + ran=True, + logs=["Simulation dependency check passed."], + metadata={"protocol_source": str(protocol_source)}, + ) diff --git a/src/buildcompiler/adapters/protocols.py b/src/buildcompiler/adapters/protocols.py new file mode 100644 index 0000000..92c8e7b --- /dev/null +++ b/src/buildcompiler/adapters/protocols.py @@ -0,0 +1,55 @@ +"""Protocol artifact boundaries for optional file output.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from buildcompiler.api import ProtocolMode, ProtocolOptions + + +@dataclass +class ProtocolArtifact: + kind: str + path: Path | None = None + content: str | dict[str, object] | list[dict[str, object]] | None = None + metadata: dict[str, object] = field(default_factory=dict) + + +def _artifact_filename(*, basename: str, kind: str) -> str: + safe_kind = kind.replace(" ", "_").lower() + return f"{basename}_{safe_kind}.json" + + +def maybe_write_protocol_artifacts( + *, + payloads: dict[str, object], + options: ProtocolOptions, + basename: str = "buildcompiler_protocol", +) -> dict[str, ProtocolArtifact]: + """Return in-memory protocol payloads and optionally write them to disk.""" + + should_write = ( + options.mode in {ProtocolMode.MANUAL, ProtocolMode.AUTOMATED} + and options.results_dir is not None + ) + output_dir = Path(options.results_dir) if should_write else None + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + + artifacts: dict[str, ProtocolArtifact] = {} + for kind, payload in payloads.items(): + artifact = ProtocolArtifact(kind=kind, content=payload) + if output_dir is not None: + path = output_dir / _artifact_filename(basename=basename, kind=kind) + path.write_text( + json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8" + ) + artifact.path = path + artifact.metadata["written"] = True + else: + artifact.metadata["written"] = False + artifacts[kind] = artifact + + return artifacts diff --git a/src/buildcompiler/adapters/pudu/__init__.py b/src/buildcompiler/adapters/pudu/__init__.py index a31fbde..50838bf 100644 --- a/src/buildcompiler/adapters/pudu/__init__.py +++ b/src/buildcompiler/adapters/pudu/__init__.py @@ -1,5 +1,16 @@ """PUDU adapter exports.""" from .assembly_json import assembly_route_to_pudu_json, assembly_routes_to_pudu_json +from .plating_json import plating_to_pudu_json +from .transformation_json import ( + transformation_to_pudu_json, + transformations_to_pudu_json, +) -__all__ = ["assembly_route_to_pudu_json", "assembly_routes_to_pudu_json"] +__all__ = [ + "assembly_route_to_pudu_json", + "assembly_routes_to_pudu_json", + "transformation_to_pudu_json", + "transformations_to_pudu_json", + "plating_to_pudu_json", +] diff --git a/src/buildcompiler/adapters/pudu/plating_json.py b/src/buildcompiler/adapters/pudu/plating_json.py new file mode 100644 index 0000000..3648e20 --- /dev/null +++ b/src/buildcompiler/adapters/pudu/plating_json.py @@ -0,0 +1,22 @@ +"""In-memory adapter for compiler-level PUDU plating JSON payloads.""" + +from collections import OrderedDict +from collections.abc import Mapping +from typing import Any + + +def plating_to_pudu_json( + *, + bacterium_locations: Mapping[str, str], + advanced_parameters: Mapping[str, object] | None = None, +) -> dict[str, object]: + """Adapt plating records into deterministic legacy-compatible PUDU JSON keys.""" + + stable_locations = OrderedDict( + sorted(bacterium_locations.items(), key=lambda kv: kv[0]) + ) + payload: dict[str, Any] = { + "bacterium_locations": dict(stable_locations), + "advanced_parameters": dict(advanced_parameters or {}), + } + return payload diff --git a/src/buildcompiler/adapters/pudu/transformation_json.py b/src/buildcompiler/adapters/pudu/transformation_json.py new file mode 100644 index 0000000..207a561 --- /dev/null +++ b/src/buildcompiler/adapters/pudu/transformation_json.py @@ -0,0 +1,53 @@ +"""In-memory adapter for compiler-level PUDU transformation JSON payloads.""" + +from collections.abc import Sequence + +from buildcompiler.domain import IndexedPlasmid + + +def _stable_identifier(identity: str, display_id: str | None) -> str: + return identity or display_id or "" + + +def _plasmid_identifier(plasmid: IndexedPlasmid | str) -> str: + if isinstance(plasmid, str): + return plasmid + return _stable_identifier(identity=plasmid.identity, display_id=plasmid.display_id) + + +def transformation_to_pudu_json( + *, + strain_identity: str, + chassis_identity: str, + plasmids: Sequence[IndexedPlasmid | str], +) -> dict[str, object]: + """Adapt a transformation record into legacy-compatible PUDU JSON keys.""" + + return { + "Strain": strain_identity, + "Chassis": chassis_identity, + "Plasmids": [_plasmid_identifier(plasmid) for plasmid in plasmids], + } + + +def transformations_to_pudu_json( + *, + strain_identities: Sequence[str], + chassis_identities: Sequence[str], + plasmid_sets: Sequence[Sequence[IndexedPlasmid | str]], +) -> list[dict[str, object]]: + """Batch helper for deterministic in-memory transformation JSON payloads.""" + + return [ + transformation_to_pudu_json( + strain_identity=strain_identity, + chassis_identity=chassis_identity, + plasmids=plasmids, + ) + for strain_identity, chassis_identity, plasmids in zip( + strain_identities, + chassis_identities, + plasmid_sets, + strict=True, + ) + ] diff --git a/tests/unit/adapters/opentrons/test_simulation_boundary.py b/tests/unit/adapters/opentrons/test_simulation_boundary.py new file mode 100644 index 0000000..53fbf2c --- /dev/null +++ b/tests/unit/adapters/opentrons/test_simulation_boundary.py @@ -0,0 +1,38 @@ +import builtins +import sys + +import pytest + +from buildcompiler.adapters.opentrons import ( + OpentronsSimulationAdapter, + OptionalAutomationDependencyError, +) +from buildcompiler.api import ProtocolOptions + + +def test_opentrons_import_is_lazy(): + assert "opentrons" not in sys.modules + + +def test_simulate_false_does_not_import_opentrons(): + adapter = OpentronsSimulationAdapter() + + result = adapter.simulate("protocol.py", options=ProtocolOptions(simulate=False)) + + assert result.ran is False + assert "opentrons" not in sys.modules + + +def test_simulate_true_missing_dependency_raises(monkeypatch): + adapter = OpentronsSimulationAdapter() + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "opentrons": + raise ImportError("forced missing dependency") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(OptionalAutomationDependencyError): + adapter.simulate("protocol.py", options=ProtocolOptions(simulate=True)) diff --git a/tests/unit/adapters/pudu/test_plating_json.py b/tests/unit/adapters/pudu/test_plating_json.py new file mode 100644 index 0000000..aa075e5 --- /dev/null +++ b/tests/unit/adapters/pudu/test_plating_json.py @@ -0,0 +1,19 @@ +from buildcompiler.adapters.pudu import plating_to_pudu_json + + +def test_plating_to_pudu_json_shape_and_values(): + payload = plating_to_pudu_json( + bacterium_locations={"strain_b": "B2", "strain_a": "A1"}, + advanced_parameters={"replicates": 2}, + ) + + assert payload == { + "bacterium_locations": {"strain_a": "A1", "strain_b": "B2"}, + "advanced_parameters": {"replicates": 2}, + } + + +def test_plating_to_pudu_json_defaults_advanced_parameters(): + payload = plating_to_pudu_json(bacterium_locations={"strain_a": "A1"}) + + assert payload["advanced_parameters"] == {} diff --git a/tests/unit/adapters/pudu/test_transformation_json.py b/tests/unit/adapters/pudu/test_transformation_json.py new file mode 100644 index 0000000..d18f7ab --- /dev/null +++ b/tests/unit/adapters/pudu/test_transformation_json.py @@ -0,0 +1,38 @@ +from buildcompiler.adapters.pudu import ( + transformation_to_pudu_json, + transformations_to_pudu_json, +) +from buildcompiler.domain import IndexedPlasmid + + +def test_transformation_to_pudu_json_shape_and_values(): + payload = transformation_to_pudu_json( + strain_identity="https://example.org/strain/s1", + chassis_identity="https://example.org/chassis/c1", + plasmids=[ + IndexedPlasmid(identity="https://example.org/plasmids/p1"), + "https://example.org/plasmids/p2", + ], + ) + + assert payload == { + "Strain": "https://example.org/strain/s1", + "Chassis": "https://example.org/chassis/c1", + "Plasmids": [ + "https://example.org/plasmids/p1", + "https://example.org/plasmids/p2", + ], + } + + +def test_transformations_to_pudu_json_batch_helper_is_deterministic(): + payloads = transformations_to_pudu_json( + strain_identities=["s1", "s2"], + chassis_identities=["c1", "c2"], + plasmid_sets=[["p1"], ["p2", "p3"]], + ) + + assert payloads == [ + {"Strain": "s1", "Chassis": "c1", "Plasmids": ["p1"]}, + {"Strain": "s2", "Chassis": "c2", "Plasmids": ["p2", "p3"]}, + ] diff --git a/tests/unit/adapters/test_protocol_boundaries.py b/tests/unit/adapters/test_protocol_boundaries.py new file mode 100644 index 0000000..266dbf6 --- /dev/null +++ b/tests/unit/adapters/test_protocol_boundaries.py @@ -0,0 +1,38 @@ +from buildcompiler.adapters import maybe_write_protocol_artifacts +from buildcompiler.api import ProtocolMode, ProtocolOptions + + +def test_protocol_mode_none_returns_in_memory_only(tmp_path): + artifacts = maybe_write_protocol_artifacts( + payloads={"assembly": {"k": "v"}}, + options=ProtocolOptions(mode=ProtocolMode.NONE, results_dir=tmp_path), + basename="artifact", + ) + + assert artifacts["assembly"].path is None + assert artifacts["assembly"].content == {"k": "v"} + assert artifacts["assembly"].metadata["written"] is False + assert not any(tmp_path.iterdir()) + + +def test_protocol_mode_manual_writes_when_results_dir_set(tmp_path): + artifacts = maybe_write_protocol_artifacts( + payloads={"assembly": {"k": "v"}}, + options=ProtocolOptions(mode=ProtocolMode.MANUAL, results_dir=tmp_path), + basename="artifact", + ) + + path = artifacts["assembly"].path + assert path is not None + assert path.name == "artifact_assembly.json" + assert path.exists() + + +def test_protocol_mode_automated_with_no_results_dir_writes_nothing(): + artifacts = maybe_write_protocol_artifacts( + payloads={"plating": {"k": "v"}}, + options=ProtocolOptions(mode=ProtocolMode.AUTOMATED, results_dir=None), + ) + + assert artifacts["plating"].path is None + assert artifacts["plating"].metadata["written"] is False diff --git a/tests/unit/test_core_imports.py b/tests/unit/test_core_imports.py new file mode 100644 index 0000000..f5440d5 --- /dev/null +++ b/tests/unit/test_core_imports.py @@ -0,0 +1,21 @@ +import sys + + +def test_core_imports_do_not_load_optional_automation_dependencies(): + import buildcompiler + from buildcompiler.adapters.opentrons import OpentronsSimulationAdapter + from buildcompiler.adapters.pudu import ( + plating_to_pudu_json, + transformation_to_pudu_json, + ) + from buildcompiler.api import BuildOptions + + assert buildcompiler + assert BuildOptions + assert OpentronsSimulationAdapter + assert transformation_to_pudu_json + assert plating_to_pudu_json + + assert "pudupy" not in sys.modules + assert "opentrons" not in sys.modules + assert "SBOLInventory" not in sys.modules