diff --git a/README.md b/README.md index addf87a..edfe75d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,34 @@ result = full_build( `BuildCompiler.__init__` should stay lightweight and dependency-injected. Automatic SynBioHub collection indexing belongs in `BuildCompiler.from_synbiohub(...)`. +### SynBioHub repository authentication + +`BuildCompiler.from_synbiohub(...)` supports anonymous pulls, auth-token reuse, or login via email/password: + +```python +from buildcompiler.api import BuildCompiler + +# Anonymous/public access +public_compiler = BuildCompiler.from_synbiohub( + repository_url="https://synbiohub.org", + collections=["https://synbiohub.org/public/igem"], +) + +# Existing auth token +token_compiler = BuildCompiler.from_synbiohub( + repository_url="https://synbiohub.org", + auth_token="", + collections=["https://synbiohub.org/user/private_collection"], +) + +# Email/password login (token kept in-memory) +login_compiler = BuildCompiler.from_synbiohub( + repository_url="https://synbiohub.org", + email="user@example.org", + password="", +) +``` + ## Local development Recommended local workflow: diff --git a/notebooks/buildcompiler_synbiohub_tutorial.ipynb b/notebooks/buildcompiler_synbiohub_tutorial.ipynb new file mode 100644 index 0000000..2fe0591 --- /dev/null +++ b/notebooks/buildcompiler_synbiohub_tutorial.ipynb @@ -0,0 +1,105 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# BuildCompiler SynBioHub Tutorial\n", + "\n", + "This notebook shows how to authenticate to SynBioHub, load private collections into BuildCompiler, and run a full-build workflow using an abstract design URI.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from getpass import getpass\n", + "import sbol2\n", + "\n", + "from buildcompiler.api import BuildCompiler\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "repository_url = \"https://synbiohub.org\"\n", + "user = \"user\"\n", + "password = getpass(\"SynBioHub password: \")\n", + "\n", + "abstract_design_uri = \"https://synbiohub.org/user/Gon/abstract_design/standard_GFP/1\"\n", + "collections = [\n", + " \"https://synbiohub.org/user/Gon/impl_test/impl_test_collection/1\",\n", + " \"https://synbiohub.org/user/Gon/Enzyme_Implementations/Enzyme_Implementations_collection/1\",\n", + "]\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sbol_doc = sbol2.Document()\n", + "\n", + "compiler = BuildCompiler.from_synbiohub(\n", + " repository_url=repository_url,\n", + " email=user,\n", + " password=password,\n", + " collections=collections,\n", + " sbol_doc=sbol_doc,\n", + ")\n", + "\n", + "print(f\"Loaded SBOL objects: {len(list(sbol_doc))}\")\n", + "print(f\"Indexed plasmids: {len(getattr(compiler.inventory, 'plasmids', [])) if compiler.inventory else 'n/a'}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Pull/resolve the abstract design into the shared document.\n", + "if compiler.repository_client is not None:\n", + " compiler.repository_client.pull_identity(abstract_design_uri)\n", + "\n", + "abstract_design = sbol_doc.find(abstract_design_uri)\n", + "if abstract_design is None:\n", + " raise LookupError(f\"Could not resolve abstract design: {abstract_design_uri}\")\n", + "\n", + "plan = compiler.plan([abstract_design])\n", + "result = compiler.execute(plan)\n", + "result\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Notes\n", + "\n", + "- BuildCompiler reuses a single authenticated `sbol2.PartShop` client for collection pulls and identity fallback pulls.\n", + "- Tokens returned by SynBioHub login are kept in memory only; they are not serialized by default.\n", + "- Assembly uses Golden Gate digestion/ligation simulation and links reagent usages + generated products through a stage activity in SBOL.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/src/buildcompiler/api/compiler.py b/src/buildcompiler/api/compiler.py index 2a82db0..a75614a 100644 --- a/src/buildcompiler/api/compiler.py +++ b/src/buildcompiler/api/compiler.py @@ -6,6 +6,7 @@ from typing import Any from buildcompiler.planning import FullBuildPlanner +from buildcompiler.sbol import PartShopRepositoryClient from .options import BuildOptions @@ -17,6 +18,7 @@ class BuildCompiler: planner: Any = None executor: Any = None adapters: Any = None + repository_client: PartShopRepositoryClient | None = None options: BuildOptions = field(default_factory=BuildOptions) @classmethod @@ -24,17 +26,47 @@ def from_synbiohub( cls, *, collections: list[str] | None = None, + repository_url: str | None = None, sbh_registry: str | None = None, auth_token: str | None = None, + email: str | None = None, + password: str | None = None, sbol_doc: Any = None, options: BuildOptions | None = None, **kwargs: Any, ) -> "BuildCompiler": - if collections: - raise NotImplementedError( - "Automatic SynBioHub collection loading/indexing is not implemented yet. Inject inventory dependencies directly for now." + resolved_repository_url = repository_url or sbh_registry + if auth_token and password: + raise ValueError("auth_token cannot be combined with password") + + requires_repository = bool( + collections or resolved_repository_url or auth_token or email or password + ) + if requires_repository and not resolved_repository_url: + raise ValueError("repository_url (or sbh_registry) is required") + + document = sbol_doc + repository_client = None + if resolved_repository_url: + import sbol2 + + document = document or sbol2.Document() + repository_client = PartShopRepositoryClient( + repository_url=resolved_repository_url, + document=document, + auth_token=auth_token, + email=email, + password=password, ) - return cls(sbol_document=sbol_doc, options=options or BuildOptions(), **kwargs) + for collection_uri in collections or []: + repository_client.pull_collection(collection_uri) + + return cls( + sbol_document=document, + repository_client=repository_client, + options=options or BuildOptions(), + **kwargs, + ) def plan(self, abstract_designs: Any, options: BuildOptions | None = None) -> Any: effective_options = options or self.options @@ -60,6 +92,11 @@ def execute(self, plan: Any, options: BuildOptions | None = None) -> Any: sbol_document=self.sbol_document, options=effective_options, adapters=self.adapters, + pull_client=( + self.repository_client.pull_identity + if self.repository_client is not None + else None + ), ) return executor.execute(plan, options=effective_options) @@ -80,8 +117,11 @@ def full_build( adapters: Any = None, options: BuildOptions | None = None, collections: list[str] | None = None, + repository_url: str | None = None, sbh_registry: str | None = None, auth_token: str | None = None, + email: str | None = None, + password: str | None = None, sbol_doc: Any = None, **kwargs: Any, ) -> Any: @@ -89,13 +129,19 @@ def full_build( if ( collections is not None or sbh_registry is not None + or repository_url is not None or auth_token is not None + or email is not None + or password is not None or sbol_doc is not None ): compiler = BuildCompiler.from_synbiohub( collections=collections, + repository_url=repository_url, sbh_registry=sbh_registry, auth_token=auth_token, + email=email, + password=password, sbol_doc=sbol_doc, options=compiler_options, inventory=inventory, diff --git a/src/buildcompiler/execution/executor.py b/src/buildcompiler/execution/executor.py index 4a1a064..758c8bf 100644 --- a/src/buildcompiler/execution/executor.py +++ b/src/buildcompiler/execution/executor.py @@ -61,11 +61,12 @@ def from_dependencies( sbol_document: Any, options: BuildOptions, adapters: Any = None, + pull_client: Any = None, graph: Any = None, logger: Any = None, **stage_overrides: Any, ) -> "FullBuildExecutor": - resolver = SbolResolver(sbol_document) + resolver = SbolResolver(sbol_document, pull_client=pull_client) return cls( context=BuildContext( sbol=resolver, diff --git a/src/buildcompiler/sbol/__init__.py b/src/buildcompiler/sbol/__init__.py index 7539882..de1c9c9 100644 --- a/src/buildcompiler/sbol/__init__.py +++ b/src/buildcompiler/sbol/__init__.py @@ -2,6 +2,7 @@ from .assembly import AssemblyJob, AssemblySbolResult, AssemblyService from .domestication import DomesticationJob, DomesticationSbolResult, DomesticationService +from .repository import PartShopRepositoryClient from .resolver import PullPolicy, SbolResolver __all__ = [ @@ -11,6 +12,7 @@ "DomesticationJob", "DomesticationSbolResult", "DomesticationService", + "PartShopRepositoryClient", "PullPolicy", "SbolResolver", ] diff --git a/src/buildcompiler/sbol/assembly.py b/src/buildcompiler/sbol/assembly.py index fa877d9..f0acf5d 100644 --- a/src/buildcompiler/sbol/assembly.py +++ b/src/buildcompiler/sbol/assembly.py @@ -3,6 +3,8 @@ from dataclasses import dataclass, field import sbol2 +from Bio import Restriction +from pydna.dseqrecord import Dseqrecord from buildcompiler.domain import ( BuildStage, @@ -74,6 +76,13 @@ def run(self, job: AssemblyJob) -> AssemblySbolResult: ) ligase_impl = self._implementation_from_record(job.ligase, job.source_document) + self._simulate_golden_gate( + part_components=[p.plasmid_definition for p in legacy_parts], + backbone_component=legacy_backbone.plasmid_definition, + restriction_impl=restriction_impl, + source_document=job.source_document, + ) + composite_prefix = job.product_display_id or job.product_identity.split("/")[-1] legacy_assembly = Assembly( part_plasmids=legacy_parts, @@ -104,6 +113,63 @@ def run(self, job: AssemblyJob) -> AssemblySbolResult: logs=logs, ) + def _simulate_golden_gate( + self, + *, + part_components: list[sbol2.ComponentDefinition], + backbone_component: sbol2.ComponentDefinition, + restriction_impl: sbol2.Implementation, + source_document: sbol2.Document, + ) -> None: + enzyme_definition = source_document.find(restriction_impl.built) + enzyme_name = getattr(enzyme_definition, "displayId", None) or getattr( + enzyme_definition, "name", None + ) + if not enzyme_name or not hasattr(Restriction, enzyme_name): + return + enzyme = getattr(Restriction, enzyme_name) + + part_inserts = [] + for component in part_components: + fragments = self._digest_component(component, enzyme, source_document) + if len(fragments) != 2: + raise ValueError( + f"Part plasmid {component.displayId} digestion with {enzyme_name} produced {len(fragments)} fragments; expected 2" + ) + part_inserts.append(min(fragments, key=len)) + + backbone_fragments = self._digest_component( + backbone_component, enzyme, source_document + ) + if len(backbone_fragments) != 2: + raise ValueError( + f"Backbone {backbone_component.displayId} digestion with {enzyme_name} produced {len(backbone_fragments)} fragments; expected 2" + ) + open_backbone = max(backbone_fragments, key=len) + + assembled = open_backbone + for part_insert in part_inserts: + assembled = assembled + part_insert + ligated = assembled.looped() + if ligated is None or len(ligated) == 0: + raise ValueError("Golden Gate ligation failed: expected one circular product") + + def _digest_component( + self, + component: sbol2.ComponentDefinition, + enzyme: Restriction.RestrictionType, + source_document: sbol2.Document, + ) -> list[Dseqrecord]: + if len(component.sequences) != 1: + raise ValueError( + f"Component {component.displayId} must have exactly one sequence for digestion simulation" + ) + sequence_obj = source_document.find(component.sequences[0]) + if not isinstance(sequence_obj, sbol2.Sequence): + raise ValueError(f"Missing sequence for component {component.displayId}") + ds_record = Dseqrecord(sequence_obj.elements, circular=True) + return ds_record.cut([enzyme]) + def _record_to_legacy_plasmid( self, record: IndexedPlasmid, diff --git a/src/buildcompiler/sbol/repository.py b/src/buildcompiler/sbol/repository.py new file mode 100644 index 0000000..04a2e1f --- /dev/null +++ b/src/buildcompiler/sbol/repository.py @@ -0,0 +1,60 @@ +"""SynBioHub repository adapter backed by ``sbol2.PartShop``.""" + +from __future__ import annotations + +from typing import Any + +import sbol2 + + +class PartShopRepositoryClient: + """Authenticated/anonymous repository pull client. + + Secrets are intentionally not exposed in ``repr``. + """ + + def __init__( + self, + repository_url: str, + document: sbol2.Document, + *, + auth_token: str | None = None, + email: str | None = None, + password: str | None = None, + part_shop: sbol2.PartShop | None = None, + ) -> None: + if not repository_url: + raise ValueError("repository_url is required") + if auth_token and (email or password): + raise ValueError("Provide either auth_token or email/password, not both") + if (email and not password) or (password and not email): + raise ValueError("Both email and password are required together") + + self.repository_url = repository_url + self.document = document + self.part_shop = part_shop or sbol2.PartShop(repository_url) + self._auth_token: str | None = None + + if auth_token: + self.part_shop.key = auth_token + self._auth_token = auth_token + elif email and password: + self.part_shop.login(email, password) + self._auth_token = self.part_shop.getKey() + + @property + def auth_token(self) -> str | None: + return self._auth_token + + def pull_identity(self, identity: str) -> Any | None: + self.part_shop.pull(identity, self.document) + return self.document.find(identity) + + def pull_collection(self, collection_uri: str) -> None: + self.part_shop.pull(collection_uri, self.document) + + def __repr__(self) -> str: + return ( + "PartShopRepositoryClient(" + f"repository_url={self.repository_url!r}, has_auth={self._auth_token is not None})" + ) diff --git a/tests/unit/api/test_compiler_api.py b/tests/unit/api/test_compiler_api.py index 648d61e..323570a 100644 --- a/tests/unit/api/test_compiler_api.py +++ b/tests/unit/api/test_compiler_api.py @@ -1,6 +1,8 @@ import sys +from unittest.mock import patch import pytest +import sbol2 from buildcompiler.api import BuildCompiler, BuildOptions, full_build @@ -75,9 +77,34 @@ def test_from_synbiohub_placeholder_without_collection_loading(): assert isinstance(compiler, BuildCompiler) -def test_from_synbiohub_raises_when_collection_loading_is_requested(): - with pytest.raises(NotImplementedError, match="collection loading/indexing"): - BuildCompiler.from_synbiohub(collections=["https://example.org/collection"]) +def test_from_synbiohub_pulls_collections_when_requested(): + doc = sbol2.Document() + + with patch("buildcompiler.api.compiler.PartShopRepositoryClient") as client_cls: + client = client_cls.return_value + BuildCompiler.from_synbiohub( + collections=["https://example.org/collection"], + repository_url="https://example.org", + auth_token="token", + sbol_doc=doc, + ) + + client_cls.assert_called_once() + client.pull_collection.assert_called_once_with("https://example.org/collection") + + +def test_from_synbiohub_requires_repository_when_credentials_present(): + with pytest.raises(ValueError, match="repository_url"): + BuildCompiler.from_synbiohub(auth_token="token") + + +def test_from_synbiohub_rejects_auth_token_and_password_together(): + with pytest.raises(ValueError, match="auth_token"): + BuildCompiler.from_synbiohub( + repository_url="https://example.org", + auth_token="token", + password="secret", + ) def test_execute_raises_clear_error_without_dependencies(): diff --git a/tests/unit/sbol/test_repository.py b/tests/unit/sbol/test_repository.py new file mode 100644 index 0000000..0a0a9cf --- /dev/null +++ b/tests/unit/sbol/test_repository.py @@ -0,0 +1,70 @@ + +from buildcompiler.sbol import PartShopRepositoryClient + + +class FakePartShop: + def __init__(self): + self.key = None + self.login_calls = [] + self.pull_calls = [] + + def login(self, email, password): + self.login_calls.append((email, password)) + self.key = "fake-session-token" + + def getKey(self): + return self.key + + def pull(self, identity, document): + self.pull_calls.append(identity) + document.objects[identity] = {"identity": identity} + + +class FakeDocument: + def __init__(self): + self.objects = {} + + def find(self, identity): + return self.objects.get(identity) + + +def test_repository_client_anonymous_pull(): + doc = FakeDocument() + fake = FakePartShop() + client = PartShopRepositoryClient("https://example.org", doc, part_shop=fake) + + resolved = client.pull_identity("https://example.org/ComponentDefinition/component/1") + + assert resolved is not None + assert fake.login_calls == [] + + +def test_repository_client_auth_token_does_not_login(): + doc = FakeDocument() + fake = FakePartShop() + client = PartShopRepositoryClient( + "https://example.org", doc, auth_token="token", part_shop=fake + ) + + assert client.auth_token == "token" + assert fake.key == "token" + assert fake.login_calls == [] + assert "token" not in repr(client) + + +def test_repository_client_email_password_login_and_key_reuse(): + doc = FakeDocument() + fake = FakePartShop() + client = PartShopRepositoryClient( + "https://example.org", + doc, + email="user@example.org", + password="secret", + part_shop=fake, + ) + + client.pull_identity("https://example.org/ComponentDefinition/component/1") + + assert fake.login_calls == [("user@example.org", "secret")] + assert client.auth_token == "fake-session-token" + assert fake.pull_calls == ["https://example.org/ComponentDefinition/component/1"]