Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/buildcompiler/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
14 changes: 13 additions & 1 deletion src/buildcompiler/adapters/opentrons/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
"""Package scaffolding for clean architecture."""
"""Optional Opentrons adapter exports."""

from .simulation import (
OpentronsSimulationAdapter,
OptionalAutomationDependencyError,
SimulationResult,
)

__all__ = [
"OpentronsSimulationAdapter",
"OptionalAutomationDependencyError",
"SimulationResult",
]
44 changes: 44 additions & 0 deletions src/buildcompiler/adapters/opentrons/simulation.py
Original file line number Diff line number Diff line change
@@ -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)},
)
55 changes: 55 additions & 0 deletions src/buildcompiler/adapters/protocols.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion src/buildcompiler/adapters/pudu/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
22 changes: 22 additions & 0 deletions src/buildcompiler/adapters/pudu/plating_json.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions src/buildcompiler/adapters/pudu/transformation_json.py
Original file line number Diff line number Diff line change
@@ -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,
)
]
38 changes: 38 additions & 0 deletions tests/unit/adapters/opentrons/test_simulation_boundary.py
Original file line number Diff line number Diff line change
@@ -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))
19 changes: 19 additions & 0 deletions tests/unit/adapters/pudu/test_plating_json.py
Original file line number Diff line number Diff line change
@@ -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"] == {}
38 changes: 38 additions & 0 deletions tests/unit/adapters/pudu/test_transformation_json.py
Original file line number Diff line number Diff line change
@@ -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"]},
]
38 changes: 38 additions & 0 deletions tests/unit/adapters/test_protocol_boundaries.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions tests/unit/test_core_imports.py
Original file line number Diff line number Diff line change
@@ -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
Loading