import sbol2
import itertools
import re
from typing import Dict, List
from .constants import (
ANTIBIOTIC_MAP,
ENGINEERED_PLASMID,
FUSION_SITES,
ANTIBIOTIC_RESISTANCE,
RESTRICTION_ENZYME_ASSEMBLY_SCAR,
)
[docs]
class Plasmid:
def __init__(
self,
definition: sbol2.ComponentDefinition,
strain_definition: sbol2.ModuleDefinition,
doc: sbol2.document,
):
self.definition = definition
self.strain_definition = strain_definition
self.fusion_sites = self._match_fusion_sites(doc)
self.name = definition.displayId + "".join(f"_{s}" for s in self.fusion_sites)
self.antibiotic_resistance = self._get_antibiotic_resistance(doc)
def _match_fusion_sites(self, doc: sbol2.document) -> List[str]:
fusion_site_definitions = extract_fusion_sites(self.definition, doc)
fusion_sites = []
for site in fusion_site_definitions:
sequence_obj = doc.getSequence(site.sequences[0])
sequence = sequence_obj.elements
for key, seq in FUSION_SITES.items():
if seq == sequence.upper():
fusion_sites.append(key)
fusion_sites.sort()
return fusion_sites
def _get_antibiotic_resistance(self, doc: sbol2.Document) -> str:
for component in (
self.definition.components
): # go a level deeper, within the backbone core component
definition = doc.get(component.definition)
for subcomponent in definition.components:
subcomponent_def = doc.get(subcomponent.definition)
if ANTIBIOTIC_RESISTANCE in subcomponent_def.roles:
match = re.search(
r"\b(" + "|".join(ANTIBIOTIC_MAP) + r")_",
subcomponent_def.displayId,
re.IGNORECASE,
)
if match:
return ANTIBIOTIC_MAP[match.group(1).lower()]
return "Unknown"
return None
def __repr__(self) -> str:
return (
f"Plasmid:\n"
f" Name: {self.name}\n"
f" Definition: {self.definition.identity}\n"
f" Strain: {getattr(self.strain_definition, 'identity', 'None')}\n"
f" Fusion Sites: {self.fusion_sites or 'Not found'}"
f" Antibiotic Resistance: {self.antibiotic_resistance}\n"
)
def __eq__(self, other):
if not isinstance(other, Plasmid):
return False
return self.definition == other.definition
def __hash__(self):
return hash(self.definition)
[docs]
def copy_sequences(component_definition, target_doc, collection_doc):
"""Copy all sequences referenced by a ComponentDefinition into target_doc."""
subdefinitions = extract_design_parts(component_definition, collection_doc)
for seq_uri in component_definition.sequences:
seq_obj = component_definition.doc.find(seq_uri)
if seq_obj is not None:
seq_obj.copy(target_doc)
for subdefinition in subdefinitions:
print(subdefinition.displayId)
subdefinition.copy(target_doc)
for seq_uri in subdefinition.sequences:
seq_obj = component_definition.doc.find(seq_uri)
if seq_obj is not None:
seq_obj.copy(target_doc)
[docs]
def get_or_pull(
doc: sbol2.Document, sbh: sbol2.PartShop, uri: str, server_mode: bool = False
):
"""
Get an SBOL object from a Document.
If missing, pull it from SynBioHub and retry.
"""
try:
return doc.get(uri)
except Exception as e:
pull_uri = uri
if server_mode:
canonical_resource = sbh.resource.replace("://api.", "://")
pull_uri = uri.replace(canonical_resource, sbh.resource)
sbh.pull(pull_uri, doc)
try:
return doc.get(uri)
except Exception:
raise e
[docs]
def enumerate_design_variants(component_dict):
"""
Given a dict mapping variable component identities to lists of ComponentDefinitions,
generate all possible design combinations as lists of ComponentDefinitions
(in consistent order of keys).
"""
keys = list(component_dict.keys())
variant_lists = [component_dict[k] for k in keys]
# Cartesian product across all variant lists
all_variants = list(itertools.product(*variant_lists))
all_variants = [list(combo) for combo in all_variants]
return all_variants
[docs]
def construct_plasmid_dict(
part_list: List[sbol2.ComponentDefinition], plasmid_collection: sbol2.Document
) -> Dict[str, List[Plasmid]]:
"""
Builds a mapping from part display IDs to lists of compatible Plasmid objects.
For each part in the given list, this function searches the provided plasmid
collection for plasmids that contain the part as a component.
Each matching plasmid is wrapped in a `Plasmid` object and added to the
dictionary under the part's display ID.
Args:
part_list:
List of :class:`sbol2.ComponentDefinition` objects representing
the parts to match.
plasmid_collection:
The :class:`sbol2.Document` containing plasmids to search through.
Returns:
Dict[str, List[Plasmid]]:
A dictionary mapping each part display ID to a list of corresponding
`Plasmid` objects found in the collection.
"""
plasmid_dict = {}
for part in part_list:
for plasmid in plasmid_collection.componentDefinitions:
if ENGINEERED_PLASMID in plasmid.roles:
for component in plasmid.components:
if (
component.definition == str(part)
): # TODO make sure this is not a composite plasmid, i.e. plasmid just contains singular part of interest
fusion_sites = [
site.name
for site in extract_fusion_sites(
plasmid, plasmid_collection
)
]
print(
f"found: {component.definition} in {plasmid} with {fusion_sites}"
) # TODO switch to logger for backend tracing?
plasmid_dict.setdefault(part.displayId, [])
componentName = plasmid_collection.getComponentDefinition(
component.definition
).name
plasmid_dict[part.displayId].append(
Plasmid(componentName, plasmid, plasmid_collection)
)
return plasmid_dict
[docs]
def get_compatible_plasmids(
plasmid_dict: Dict[str, List[Plasmid]], backbone: Plasmid
) -> List[Plasmid]:
"""
Returns a list of Plasmid objects that can form a compatible assembly
with the given backbone plasmid. The function selects one plasmid from each
entry in the dictionary, ensuring that adjacent plasmids have matching MoClo fusion sites,
and that the first and last plasmids are compatible with the backbone.
Args:
plasmid_dict: A dictionary mapping assembly positions or categories to lists
of Plasmid objects.
backbone: The backbone Plasmid whose fusion sites define compatibility.
Returns:
A list of compatible Plasmid objects forming a sequential assembly.
"""
selected_plasmids = []
match_to = backbone
match_idx = 0
for i, key in enumerate(plasmid_dict):
found = False
for plasmid in plasmid_dict[key]:
if (
i == len(plasmid_dict) - 1
and plasmid.fusion_sites[0] == match_to.fusion_sites[match_idx]
and plasmid.fusion_sites[1] == backbone.fusion_sites[1]
):
selected_plasmids.append(plasmid)
found = True
break
elif (
i < len(plasmid_dict) - 1
and plasmid.fusion_sites[0] == match_to.fusion_sites[match_idx]
):
selected_plasmids.append(plasmid)
found = True
match_to = plasmid
match_idx = 1
break
# TODO edge case where second fusion site does not match terminator fusion site will not be caught by current logic
if not found:
raise ValueError(f"No compatible plasmid found for part {key}")
return selected_plasmids
[docs]
def translate_abstract_to_plasmids(
abstract_design_doc: sbol2.Document,
plasmid_collection: sbol2.Document,
backbone_doc: sbol2.Document,
) -> List[Plasmid]:
"""
Translates an abstract SBOLCanvas design into a set of compatible MoClo plasmid assemblies.
Takes an abstract design, identifies the appropriate component
definitions and combinatorial derivations, and produces all possible plasmid
combinations that can be assembled using the provided backbone and plasmid
collection.
Args:
abstract_design_doc:
The :class:`sbol2.Document` representing the abstract genetic design.
May include either a single component definition (generic design) or
one or more combinatorial derivations (combinatorial design).
plasmid_collection:
The :class:`sbol2.Document` containing the available MoClo plasmid
components used for matching and assembly.
backbone_doc:
The :class:`sbol2.Document` defining the backbone plasmid into which
parts are assembled.
Returns:
List[Plasmid]:
- For combinatorial designs: a list of unique compatible plasmids
(`Plasmid` objects) representing all enumerated design variants.
- For generic designs: a list of compatible plasmids for the single
design instance.
"""
backbone_def = extract_toplevel_definition(backbone_doc)
backbone_plasmid = Plasmid(backbone_def.displayId, backbone_def, backbone_doc)
# combinatorial design
if len(abstract_design_doc.combinatorialderivations) > 0:
abstract_design_def = abstract_design_doc.getComponentDefinition(
abstract_design_doc.combinatorialderivations[0].masterTemplate
)
combinatorial_part_dict = extract_combinatorial_design_parts(
abstract_design_def, abstract_design_doc, plasmid_collection
)
enumerated_part_list = enumerate_design_variants(combinatorial_part_dict)
seen = set()
ordered_unique_plasmids = []
for design in enumerated_part_list:
plasmid_dict = construct_plasmid_dict(design, plasmid_collection)
compatible_plasmids = get_compatible_plasmids(
plasmid_dict, backbone_plasmid
)
for plasmid in compatible_plasmids:
if plasmid not in seen:
seen.add(plasmid)
ordered_unique_plasmids.append(plasmid)
return ordered_unique_plasmids
# generic design
else:
abstract_design_def = extract_toplevel_definition(abstract_design_doc)
ordered_part_definitions = extract_design_parts(
abstract_design_def, abstract_design_doc
)
plasmid_dict = construct_plasmid_dict(
ordered_part_definitions, plasmid_collection
)
return get_compatible_plasmids(plasmid_dict, backbone_plasmid)