Skip to content

Commit 532f9dd

Browse files
authored
Add recombinase endpoint (#412)
* add recombinase endpoint * fix test * complete tests
1 parent 2e130b8 commit 532f9dd

File tree

4 files changed

+190
-11
lines changed

4 files changed

+190
-11
lines changed

poetry.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ primer3-py = "^2.3"
2929
biopython = {git = "https://github.com/manulera/biopython", rev = "2b4e11f0d48ef593f18cba9bf0fe8e99bb6e5bcf"}
3030
packaging = "^25.0"
3131
pairwise-alignments-to-msa = "^0.1.1"
32-
pydna = {git = "https://github.com/pydna-group/pydna", rev = "1ef52e4ba398bf4ea05d5ad4b8c496ef65fe7be0"}
32+
pydna = {git = "https://github.com/pydna-group/pydna", rev = "b567985784744768579f6c1fad20bd4b70ee29ba"}
3333

3434
[tool.poetry.group.dev.dependencies]
3535
autopep8 = "^2.0.4"

src/opencloning/endpoints/assembly.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from fastapi import Query, HTTPException
22
from typing import Union
3+
import copy
34
from pydna.dseqrecord import Dseqrecord
45
from pydna.primer import Primer as PydnaPrimer
56
from pydantic import create_model, Field
@@ -25,6 +26,7 @@
2526
GatewaySource,
2627
Primer as PrimerModel,
2728
TextFileSequence,
29+
RecombinaseSource,
2830
)
2931

3032
from pydna.assembly2 import (
@@ -40,10 +42,13 @@
4042
crispr_integration as _crispr_integration,
4143
cre_lox_integration as _cre_lox_integration,
4244
cre_lox_excision as _cre_lox_excision,
45+
recombinase_integration as _recombinase_integration,
46+
recombinase_excision as _recombinase_excision,
4347
)
4448
from pydna.cre_lox import annotate_loxP_sites
4549

4650
from pydna.gateway import annotate_gateway_sites
51+
from pydna.recombinase import RecombinaseCollection, Recombinase
4752
from ..get_router import get_router
4853

4954
router = get_router()
@@ -377,3 +382,48 @@ async def cre_lox_recombination(
377382
source.output_name,
378383
no_products_error_message='No compatible Cre/Lox recombination was found.',
379384
)
385+
386+
387+
@router.post(
388+
'/recombinase',
389+
response_model=create_model(
390+
'RecombinaseResponse',
391+
sources=(list[RecombinaseSource], ...),
392+
sequences=(list[TextFileSequence], ...),
393+
),
394+
)
395+
async def recombinase(
396+
source: RecombinaseSource,
397+
sequences: Annotated[list[TextFileSequence], Field(min_length=1)],
398+
reverse_recombinase: bool = Query(False, description='Whether to use the reverse reaction of the recombinase.'),
399+
):
400+
fragments = [read_dsrecord_from_json(seq) for seq in sequences]
401+
completed_source = source if is_assembly_complete(source) else None
402+
try:
403+
collection = RecombinaseCollection([Recombinase(**r.model_dump()) for r in source.recombinases])
404+
except ValueError as e:
405+
raise HTTPException(422, *e.args)
406+
407+
reverse_collection = copy.deepcopy(collection)
408+
reverse_collection.recombinases.extend([r.get_reverse_recombinase() for r in reverse_collection.recombinases])
409+
if reverse_recombinase:
410+
collection = reverse_collection
411+
412+
if len(fragments) == 1:
413+
products = _recombinase_excision(fragments[0], collection)
414+
else:
415+
products = []
416+
if not fragments[0].circular:
417+
products.extend(_recombinase_integration(fragments[0], fragments[1:], collection))
418+
if not fragments[1].circular:
419+
products.extend(_recombinase_integration(fragments[1], fragments[:1], collection))
420+
421+
products = [reverse_collection.annotate(p) for p in products]
422+
423+
return format_products(
424+
source.id,
425+
products,
426+
completed_source,
427+
source.output_name,
428+
no_products_error_message='No compatible reaction was found with the provided recombinases.',
429+
)

tests/test_endpoints_assembly.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
CRISPRSource,
2222
GatewaySource,
2323
CreLoxRecombinationSource,
24+
RecombinaseSource,
25+
Recombinase,
2426
)
2527

2628

@@ -1271,7 +1273,7 @@ def test_gateway_source(self):
12711273
payload = response.json()
12721274
self.assertIn('Inputs are not compatible for LR reaction', payload['detail'])
12731275
self.assertIn('fragment 1: attB1', payload['detail'])
1274-
self.assertTrue(payload['detail'].endswith('fragment 2: attB1, attL1, attR1, attP1'))
1276+
self.assertTrue(payload['detail'].endswith('fragment 2: attB1, attP1, attL1, attR1'))
12751277

12761278
def test_only_multi_site(self):
12771279
attB1 = self.attB1
@@ -1437,3 +1439,130 @@ def test_no_results(self):
14371439
self.assertEqual(response.status_code, 400)
14381440
payload = response.json()
14391441
self.assertIn('No compatible Cre/Lox', payload['detail'])
1442+
1443+
1444+
class RecombinaseTest(unittest.TestCase):
1445+
1446+
def test_recombinase(self):
1447+
site1 = 'ATGCCCTAAaaCT'
1448+
site2 = 'CAaaTTTTTTTCCCT'
1449+
1450+
genome = Dseqrecord(f"cccccc{site1.upper()}aaaaa")
1451+
insert = Dseqrecord(f"{site2.upper()}bbbbb", circular=True)
1452+
fragments = [format_sequence_genbank(genome), format_sequence_genbank(insert)]
1453+
fragments[0].id = 1
1454+
fragments[1].id = 2
1455+
rec = Recombinase(
1456+
name='blah',
1457+
site1=site1,
1458+
site2=site2,
1459+
)
1460+
1461+
source = RecombinaseSource(id=0, recombinases=[rec])
1462+
data = {
1463+
'source': source.model_dump(),
1464+
'sequences': [f.model_dump() for f in fragments],
1465+
}
1466+
response = client.post('/recombinase', json=data)
1467+
self.assertEqual(response.status_code, 200)
1468+
payload = response.json()
1469+
self.assertEqual(len(payload['sources']), 1)
1470+
1471+
# We can do the reverse reaction
1472+
data = {
1473+
'source': source.model_dump(),
1474+
'sequences': payload['sequences'],
1475+
}
1476+
response = client.post('/recombinase', json=data, params={'reverse_recombinase': True})
1477+
self.assertEqual(response.status_code, 200)
1478+
payload = response.json()
1479+
self.assertEqual(len(payload['sources']), 2)
1480+
1481+
def test_recombinase_integration_two_site_pairs(self):
1482+
site1 = 'AAaaTTC'
1483+
site2 = 'CCaaGC'
1484+
site3 = 'GAccACC'
1485+
site4 = 'TCccAAC'
1486+
rec1 = Recombinase(
1487+
site1=site1,
1488+
site2=site2,
1489+
site1_name='s1',
1490+
site2_name='s2',
1491+
)
1492+
rec2 = Recombinase(
1493+
site1=site3,
1494+
site2=site4,
1495+
site1_name='s1',
1496+
site2_name='s2',
1497+
)
1498+
source = RecombinaseSource(id=0, recombinases=[rec1, rec2])
1499+
seq = Dseqrecord(f"ggg{site1}aaa{site3}ttt")
1500+
seq2 = Dseqrecord(f"ccc{site2}ttt{site4}aaa")
1501+
fragments = [format_sequence_genbank(seq), format_sequence_genbank(seq2)]
1502+
fragments[0].id = 1
1503+
fragments[1].id = 2
1504+
data = {
1505+
'source': source.model_dump(),
1506+
'sequences': [f.model_dump() for f in fragments],
1507+
}
1508+
response = client.post('/recombinase', json=data)
1509+
self.assertEqual(response.status_code, 200)
1510+
payload = response.json()
1511+
resulting_sequences = [
1512+
read_dsrecord_from_json(TextFileSequence.model_validate(s)) for s in payload['sequences']
1513+
]
1514+
self.assertEqual(len(resulting_sequences), 2)
1515+
self.assertEqual(str(resulting_sequences[0].seq).upper(), 'GGGAAAAGCTTTTCCCACCTTT')
1516+
self.assertEqual(str(resulting_sequences[1].seq).upper(), 'CCCCCAATTCAAAGACCAACAAA')
1517+
# The number of recombinases returned is the same:
1518+
source_recombinases = [Recombinase.model_validate(s) for s in payload['sources'][0]['recombinases']]
1519+
self.assertEqual(len(source_recombinases), 2)
1520+
self.assertEqual(source_recombinases[0], rec1)
1521+
self.assertEqual(source_recombinases[1], rec2)
1522+
1523+
# The same reaction again does not work
1524+
data = {
1525+
'source': source.model_dump(),
1526+
'sequences': payload['sequences'],
1527+
}
1528+
response2 = client.post('/recombinase', json=data)
1529+
self.assertEqual(response2.status_code, 400)
1530+
payload2 = response2.json()
1531+
self.assertIn('No compatible reaction was found with the provided recombinases.', payload2['detail'])
1532+
1533+
# The reverse does, and regenerates the original sequences
1534+
data = {
1535+
'source': source.model_dump(),
1536+
'sequences': payload['sequences'],
1537+
}
1538+
response = client.post('/recombinase', json=data, params={'reverse_recombinase': True})
1539+
self.assertEqual(response.status_code, 200)
1540+
payload = response.json()
1541+
resulting_sequences = [
1542+
read_dsrecord_from_json(TextFileSequence.model_validate(s)) for s in payload['sequences']
1543+
]
1544+
self.assertEqual(len(resulting_sequences), 2)
1545+
self.assertEqual(str(resulting_sequences[0].seq).upper(), str(seq.seq).upper())
1546+
self.assertEqual(str(resulting_sequences[1].seq).upper(), str(seq2.seq).upper())
1547+
1548+
def test_recombinase_validation(self):
1549+
site1 = 'AAaaTTC'
1550+
site2 = 'CCggGC'
1551+
1552+
data = {
1553+
'source': {'id': 0, 'recombinases': [{'site1': site1, 'site2': site2}]},
1554+
'sequences': [format_sequence_genbank(Dseqrecord(f"ggg{site1}aaa")).model_dump()],
1555+
}
1556+
response = client.post('/recombinase', json=data)
1557+
self.assertEqual(response.status_code, 422)
1558+
payload = response.json()
1559+
self.assertIn('Recombinase recognition sites do not have matching homology cores', payload['detail'])
1560+
1561+
site1 = 'AAAAAAA'
1562+
1563+
data = {
1564+
'source': {'id': 0, 'recombinases': [{'site1': site1, 'site2': site2}]},
1565+
'sequences': [format_sequence_genbank(Dseqrecord(f"ggg{site1}aaa")).model_dump()],
1566+
}
1567+
response = client.post('/recombinase', json=data)
1568+
self.assertEqual(response.status_code, 422)

0 commit comments

Comments
 (0)