opencloning-db 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. opencloning_db/__init__.py +3 -0
  2. opencloning_db/api.py +60 -0
  3. opencloning_db/apimodels.py +335 -0
  4. opencloning_db/auth/__init__.py +1 -0
  5. opencloning_db/auth/security.py +39 -0
  6. opencloning_db/combined.py +28 -0
  7. opencloning_db/config.py +92 -0
  8. opencloning_db/context.py +15 -0
  9. opencloning_db/db.py +149 -0
  10. opencloning_db/deps.py +47 -0
  11. opencloning_db/init_db/cre_lox_recombination.json +145 -0
  12. opencloning_db/init_db/crispr_hdr.json +152 -0
  13. opencloning_db/init_db/example_sequencing.json +31 -0
  14. opencloning_db/init_db/gateway.json +198 -0
  15. opencloning_db/init_db/gibson_assembly.json +188 -0
  16. opencloning_db/init_db/golden_gate.json +313 -0
  17. opencloning_db/init_db/homologous_recombination.json +141 -0
  18. opencloning_db/init_db/restriction_ligation_assembly.json +139 -0
  19. opencloning_db/init_db/restriction_then_ligation.json +195 -0
  20. opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008.ab1 +0 -0
  21. opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008.gbk +226 -0
  22. opencloning_db/init_db/sequencing_data/TN9W63_1_pREX0008_mutations_added.fasta +71 -0
  23. opencloning_db/init_db/templateless_PCR.json +71 -0
  24. opencloning_db/init_db.py +199 -0
  25. opencloning_db/models.py +831 -0
  26. opencloning_db/routers/__init__.py +1 -0
  27. opencloning_db/routers/auth.py +90 -0
  28. opencloning_db/routers/lines.py +261 -0
  29. opencloning_db/routers/primers.py +347 -0
  30. opencloning_db/routers/sequence_samples.py +116 -0
  31. opencloning_db/routers/sequences.py +823 -0
  32. opencloning_db/routers/tags.py +246 -0
  33. opencloning_db/routers/template_sequences.py +28 -0
  34. opencloning_db/routers/test_tools.py +43 -0
  35. opencloning_db/routers/workspaces.py +102 -0
  36. opencloning_db/utils.py +22 -0
  37. opencloning_db/workspace_auth.py +41 -0
  38. opencloning_db/workspace_deps.py +161 -0
  39. opencloning_db-1.4.0.dist-info/METADATA +91 -0
  40. opencloning_db-1.4.0.dist-info/RECORD +41 -0
  41. opencloning_db-1.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,3 @@
1
+ """opencloning_db package."""
2
+
3
+ """OpenCloning DB package namespace."""
opencloning_db/api.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ OpenCloning API - main FastAPI application.
3
+ """
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi_pagination import add_pagination
8
+ import os
9
+ from starlette.types import ASGIApp
10
+
11
+ from opencloning.app_settings import settings as opencloning_settings
12
+ from opencloning_db.config import parse_bool
13
+ from opencloning_db.routers import (
14
+ auth,
15
+ lines,
16
+ primers,
17
+ sequence_samples,
18
+ sequences,
19
+ template_sequences,
20
+ tags,
21
+ test_tools,
22
+ workspaces,
23
+ )
24
+
25
+
26
+ def create_fastapi_app() -> FastAPI:
27
+ app = FastAPI(title='OpenCloningDB API')
28
+
29
+ app.include_router(auth.router)
30
+ app.include_router(workspaces.router)
31
+ app.include_router(tags.router)
32
+ app.include_router(primers.router)
33
+ app.include_router(sequences.router)
34
+ app.include_router(template_sequences.router)
35
+ app.include_router(lines.router)
36
+ app.include_router(sequence_samples.router)
37
+ if parse_bool(os.getenv('OPENCLONING_TESTING', False)):
38
+ app.include_router(test_tools.router)
39
+
40
+ # Register routes first so Page[...] endpoints get pagination_ctx.
41
+ add_pagination(app)
42
+ return app
43
+
44
+
45
+ def wrap_with_cors(app: ASGIApp) -> ASGIApp:
46
+ return CORSMiddleware(
47
+ app,
48
+ allow_origins=opencloning_settings.ALLOWED_ORIGINS,
49
+ allow_credentials=True,
50
+ allow_methods=['*'],
51
+ allow_headers=['*'],
52
+ )
53
+
54
+
55
+ def create_app() -> ASGIApp:
56
+ return wrap_with_cors(create_fastapi_app())
57
+
58
+
59
+ fastapi_app = create_fastapi_app()
60
+ app = wrap_with_cors(fastapi_app)
@@ -0,0 +1,335 @@
1
+ """Shared Pydantic request/response models for the API."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
6
+
7
+ import opencloning_linkml.datamodel.models as opencloning_models
8
+ from opencloning_db.models import BaseSequence, SequenceType, Sequence, Primer, Line, SequenceInLine
9
+
10
+
11
+ class ApiModel(BaseModel):
12
+ """Reject unknown JSON keys on all API request/response models in this module."""
13
+
14
+ model_config = ConfigDict(extra='forbid')
15
+
16
+
17
+ # --- Auth (OAuth2 password + JWT) ---
18
+ class Token(ApiModel):
19
+ access_token: str
20
+ token_type: str = 'bearer'
21
+
22
+
23
+ class UserPublic(ApiModel):
24
+ id: int
25
+ email: str
26
+ display_name: str | None
27
+ is_instance_admin: bool
28
+
29
+
30
+ class UserRef(ApiModel):
31
+ """Minimal user reference for embedding in resource responses."""
32
+
33
+ id: int
34
+ display_name: str | None
35
+
36
+
37
+ class WorkspaceRef(ApiModel):
38
+ id: int
39
+ name: str
40
+ role: str
41
+
42
+
43
+ class WorkspaceCreate(ApiModel):
44
+ name: str = Field(min_length=1)
45
+
46
+
47
+ class WorkspaceRename(ApiModel):
48
+ name: str = Field(min_length=1)
49
+
50
+
51
+ class RegisterBody(ApiModel):
52
+ email: EmailStr
53
+ password: str = Field(min_length=1)
54
+ display_name: str | None = None
55
+
56
+
57
+ # --- Sequence sample ---
58
+ class SequenceSampleCreate(ApiModel):
59
+ uid: str
60
+ sequence_id: int
61
+
62
+
63
+ class SequenceSampleUpdate(ApiModel):
64
+ sequence_id: int
65
+
66
+
67
+ class SequenceSampleRead(ApiModel):
68
+ id: int
69
+ uid: str
70
+ sequence_id: int
71
+
72
+
73
+ class SequenceSampleCreated(ApiModel):
74
+ id: int
75
+ uid: str
76
+
77
+
78
+ # --- Tags ---
79
+ class TagCreate(ApiModel):
80
+ name: str = Field(min_length=1)
81
+
82
+ @field_validator('name', mode='before')
83
+ @classmethod
84
+ def strip_tag_name(cls, v: object) -> object:
85
+ # We do it before to strip before counting the length of the string
86
+ if isinstance(v, str):
87
+ return v.strip()
88
+ return v
89
+
90
+
91
+ class TagRead(ApiModel):
92
+ id: int
93
+ name: str
94
+
95
+
96
+ class EntityTagAttach(ApiModel):
97
+ tag_id: int
98
+
99
+
100
+ # --- Entity refs ---
101
+ class InputEntityRef(ApiModel):
102
+ id: int
103
+ type: str
104
+ name: str | None
105
+
106
+
107
+ class SequencingFileRef(ApiModel):
108
+ id: int
109
+ original_name: str
110
+
111
+
112
+ # --- Common responses ---
113
+ class IdResponse(ApiModel):
114
+ id: int
115
+
116
+
117
+ class RemovedResponse(ApiModel):
118
+ removed: int
119
+
120
+
121
+ class DeletedResponse(ApiModel):
122
+ deleted: int
123
+ data: dict | None = None
124
+
125
+
126
+ # --- Cloning strategy ---
127
+ class CloningStrategyIdMapping(ApiModel):
128
+ localId: int
129
+ databaseId: int
130
+
131
+
132
+ class CloningStrategyResponse(ApiModel):
133
+ id: int
134
+ mappings: list[CloningStrategyIdMapping]
135
+
136
+
137
+ # --- Sequence / primer refs ---
138
+ class SequenceRef(ApiModel):
139
+ id: int
140
+ type: str
141
+ name: str | None
142
+ sequence_type: SequenceType
143
+ tags: list[TagRead] = []
144
+ sample_uids: list[str] = []
145
+ seguid: str | None = None
146
+ created_at: datetime
147
+ created_by: UserRef
148
+
149
+
150
+ class SequenceUpdate(ApiModel):
151
+ name: str | None = None
152
+ sequence_type: SequenceType | None = None
153
+
154
+
155
+ class TemplateSequenceCreate(ApiModel):
156
+ name: str = Field(min_length=1)
157
+ sequence_type: SequenceType
158
+
159
+ @field_validator('name', mode='before')
160
+ @classmethod
161
+ def strip_name(cls, v: object) -> object:
162
+ if isinstance(v, str):
163
+ return v.strip()
164
+ return v
165
+
166
+
167
+ class PrimerUpdate(ApiModel):
168
+ name: str | None = None
169
+ uid: str | None = None
170
+
171
+ @field_validator('uid', mode='before')
172
+ @classmethod
173
+ def strip_uid(cls, v: object) -> object:
174
+ if isinstance(v, str):
175
+ return v.strip()
176
+ return v
177
+
178
+ @field_validator('name', mode='before')
179
+ @classmethod
180
+ def strip_name(cls, v: object) -> object:
181
+ if isinstance(v, str):
182
+ stripped_name = v.strip()
183
+ if len(stripped_name) < 2:
184
+ raise ValueError('Primer name must be at least 2 characters long')
185
+ return stripped_name
186
+ return v
187
+
188
+
189
+ class PrimerCreate(ApiModel):
190
+ name: str
191
+ uid: str | None = None
192
+ sequence: str = Field(min_length=2, pattern=r'^[ACGTacgt]+$')
193
+
194
+
195
+ class PrimerBulkSubmission(ApiModel):
196
+ name: str
197
+ uid: str | None = None
198
+ sequence: str
199
+
200
+
201
+ class PrimerBulkRow(PrimerBulkSubmission):
202
+ sequence_invalid: bool
203
+ name_exists: bool
204
+ sequence_exists: bool
205
+ uid_exists: bool
206
+ name_duplicated: bool
207
+ sequence_duplicated: bool
208
+ uid_duplicated: bool
209
+
210
+
211
+ class PrimerRef(ApiModel):
212
+ id: int
213
+ name: str | None
214
+ sequence: str
215
+ uid: str | None = None
216
+ tags: list[TagRead] = []
217
+ created_at: datetime
218
+ created_by: UserRef
219
+
220
+
221
+ class SequenceSampleWithSequence(ApiModel):
222
+ id: int
223
+ uid: str
224
+ sequence_id: int
225
+ sequence: SequenceRef
226
+
227
+
228
+ # --- Line ---
229
+ class SequenceInLineRef(ApiModel):
230
+ """Sequence in a line, including the SequenceInLine instance id."""
231
+
232
+ id: int
233
+ sequence: SequenceRef
234
+
235
+
236
+ class LineRef(ApiModel):
237
+ id: int
238
+ uid: str
239
+ sequences_in_line: list[SequenceInLineRef]
240
+ parent_ids: list[int]
241
+ tags: list[TagRead]
242
+ created_at: datetime
243
+ created_by: UserRef
244
+
245
+
246
+ class LineCreate(ApiModel):
247
+ uid: str
248
+ allele_ids: list[int] = []
249
+ plasmid_ids: list[int] = []
250
+ parent_ids: list[int] = []
251
+
252
+
253
+ class LineUpdate(ApiModel):
254
+ uid: str | None = None
255
+ allele_ids: list[int] | None = None
256
+ plasmid_ids: list[int] | None = None
257
+ parent_ids: list[int] | None = None
258
+
259
+
260
+ def _user_ref(user) -> UserRef | None:
261
+ if user is None:
262
+ return None
263
+ return UserRef(id=user.id, display_name=user.display_name)
264
+
265
+
266
+ def sequence_ref(sequence: BaseSequence) -> SequenceRef:
267
+ return SequenceRef(
268
+ id=sequence.id,
269
+ type=sequence.type,
270
+ name=sequence.name,
271
+ sequence_type=sequence.sequence_type,
272
+ tags=[TagRead(id=t.id, name=t.name) for t in sequence.tags],
273
+ sample_uids=sequence.sample_uids,
274
+ seguid=sequence.seguid if isinstance(sequence, Sequence) else None,
275
+ created_at=sequence.created_at,
276
+ created_by=_user_ref(sequence.created_by),
277
+ )
278
+
279
+
280
+ def primer_ref(primer: Primer) -> PrimerRef:
281
+ return PrimerRef(
282
+ id=primer.id,
283
+ name=primer.name,
284
+ sequence=primer.sequence,
285
+ uid=primer.uid,
286
+ tags=[TagRead(id=t.id, name=t.name) for t in primer.tags],
287
+ created_at=primer.created_at,
288
+ created_by=_user_ref(primer.created_by),
289
+ )
290
+
291
+
292
+ def sequence_in_line_ref(sil: SequenceInLine) -> SequenceInLineRef:
293
+ """Build a SequenceInLineRef from a SequenceInLine ORM instance."""
294
+ seq = sil.sequence
295
+ return SequenceInLineRef(
296
+ id=sil.id,
297
+ sequence=sequence_ref(seq),
298
+ )
299
+
300
+
301
+ def line_ref(line: Line) -> LineRef:
302
+ return LineRef(
303
+ id=line.id,
304
+ uid=line.uid,
305
+ sequences_in_line=[sequence_in_line_ref(sil) for sil in line.sequences_in_line],
306
+ parent_ids=line.parent_ids,
307
+ tags=[TagRead(id=tag.id, name=tag.name) for tag in line.tags],
308
+ created_at=line.created_at,
309
+ created_by=_user_ref(line.created_by),
310
+ )
311
+
312
+
313
+ class SequenceSearchResult(ApiModel):
314
+ sequence_ref: SequenceRef
315
+ sequence: opencloning_models.TextFileSequence
316
+ shift: int = 0
317
+ reverse_complemented: bool = False
318
+
319
+
320
+ class SequenceValidationRow(ApiModel):
321
+ file_name: str
322
+ reading_error: bool
323
+
324
+ name: str | None = None
325
+ length: int | None = None
326
+ circular: bool | None = None
327
+ seguid: str | None = None
328
+ circularised_seguid: str | None = None
329
+
330
+ sequence_exists: bool | None = None
331
+ sequence_circularised_exists: bool | None = None
332
+ name_exists: bool | None = None
333
+
334
+ duplicated_seguid: bool | None = None
335
+ duplicated_name: bool | None = None
@@ -0,0 +1 @@
1
+ """Authentication helpers (password hashing, JWT)."""
@@ -0,0 +1,39 @@
1
+ """Password hashing (pwdlib) and JWT access tokens (PyJWT)."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any
5
+
6
+ import jwt
7
+ from pwdlib import PasswordHash
8
+
9
+ from opencloning_db.config import Config
10
+
11
+ password_hasher = PasswordHash.recommended()
12
+
13
+
14
+ def get_password_hash(password: str) -> str:
15
+ return password_hasher.hash(password)
16
+
17
+
18
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
19
+ return password_hasher.verify(plain_password, hashed_password)
20
+
21
+
22
+ def create_access_token(data: dict[str, Any], config: Config, expires_delta: timedelta) -> str:
23
+ to_encode = data.copy()
24
+ expire = datetime.now(timezone.utc) + expires_delta
25
+ to_encode.update({'exp': expire})
26
+ encoded_jwt = jwt.encode(
27
+ to_encode,
28
+ config.jwt_secret,
29
+ algorithm=config.jwt_algorithm,
30
+ )
31
+ return encoded_jwt
32
+
33
+
34
+ def decode_access_token(token: str, config: Config) -> dict[str, Any]:
35
+ return jwt.decode(
36
+ token,
37
+ config.jwt_secret,
38
+ algorithms=[config.jwt_algorithm],
39
+ )
@@ -0,0 +1,28 @@
1
+ """Parent application that serves OpenCloning and opencloning-db under separate roots."""
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from opencloning.main import create_app as create_cloning_app
6
+
7
+ from opencloning_db.api import create_app as create_db_app
8
+
9
+
10
+ app = FastAPI(
11
+ title='OpenCloning Combined API',
12
+ docs_url=None,
13
+ redoc_url=None,
14
+ openapi_url=None,
15
+ )
16
+
17
+ app.mount('/cloning', create_cloning_app())
18
+ app.mount('/db', create_db_app())
19
+
20
+
21
+ @app.get('/')
22
+ async def root() -> dict[str, str]:
23
+ return {
24
+ 'cloning': '/cloning',
25
+ 'cloning_docs': '/cloning/docs',
26
+ 'db': '/db',
27
+ 'db_docs': '/db/docs',
28
+ }
@@ -0,0 +1,92 @@
1
+ """
2
+ Application configuration.
3
+
4
+ Runtime config is loaded lazily from environment variables via ``get_config()``.
5
+ Tests and other callers can still instantiate ``Config`` directly.
6
+ """
7
+
8
+ import os
9
+
10
+ from pydantic import BaseModel, Field, field_validator
11
+ from sqlalchemy.engine import make_url
12
+
13
+ _ENV_TO_FIELD = {
14
+ 'OPENCLONING_DATABASE_URL': 'database_url',
15
+ 'OPENCLONING_SEQUENCE_FILES_DIR': 'sequence_files_dir',
16
+ 'OPENCLONING_SEQUENCING_FILES_DIR': 'sequencing_files_dir',
17
+ 'OPENCLONING_JWT_SECRET': 'jwt_secret',
18
+ }
19
+
20
+
21
+ def parse_bool(value: str | bool) -> bool:
22
+ return value in {'1', 'TRUE', 'true', 'True', True}
23
+
24
+
25
+ def _load_config_from_env() -> 'Config':
26
+ missing_vars = [env_name for env_name in _ENV_TO_FIELD if not os.environ.get(env_name)]
27
+ if missing_vars:
28
+ missing = ', '.join(missing_vars)
29
+ raise RuntimeError(
30
+ 'Missing required OpenCloning environment variables: ' f'{missing}. For local development load .env.dev'
31
+ )
32
+
33
+ return Config(
34
+ database_url=os.environ['OPENCLONING_DATABASE_URL'],
35
+ sequence_files_dir=os.environ['OPENCLONING_SEQUENCE_FILES_DIR'],
36
+ sequencing_files_dir=os.environ['OPENCLONING_SEQUENCING_FILES_DIR'],
37
+ jwt_secret=os.environ['OPENCLONING_JWT_SECRET'],
38
+ )
39
+
40
+
41
+ class Config(BaseModel):
42
+ """OpenCloning database configuration."""
43
+
44
+ @field_validator('database_url')
45
+ @classmethod
46
+ def _validate_database_url(cls, value: str) -> str:
47
+ url = make_url(value)
48
+ if url.get_backend_name() != 'postgresql':
49
+ raise ValueError('Only PostgreSQL database URLs are supported.')
50
+ if url.drivername != 'postgresql+psycopg':
51
+ raise ValueError(
52
+ 'Only PostgreSQL database URLs using the psycopg driver are supported ' '(postgresql+psycopg://...).'
53
+ )
54
+ return value
55
+
56
+ database_url: str = Field(
57
+ description='SQLAlchemy PostgreSQL URL using the psycopg (v3) driver (postgresql+psycopg://...)',
58
+ )
59
+ sequence_files_dir: str = Field(
60
+ description='Directory for storing sequence GenBank files',
61
+ )
62
+ sequencing_files_dir: str = Field(
63
+ description='Directory for storing uploaded sequencing files (ab1, fasta, etc.)',
64
+ )
65
+ jwt_secret: str = Field(
66
+ description='HS256 signing key for JWT access tokens',
67
+ )
68
+ jwt_algorithm: str = Field(default='HS256', description='JWT signing algorithm')
69
+ access_token_expire_minutes: int = Field(
70
+ default=60,
71
+ ge=1,
72
+ description='Access token lifetime in minutes',
73
+ )
74
+
75
+
76
+ config: Config | None = None
77
+
78
+
79
+ def _peek_config() -> Config | None:
80
+ return config
81
+
82
+
83
+ def get_config() -> Config:
84
+ global config
85
+ if config is None:
86
+ config = _load_config_from_env()
87
+ return config
88
+
89
+
90
+ def set_config(new_config: Config | None) -> None:
91
+ global config
92
+ config = new_config
@@ -0,0 +1,15 @@
1
+ """Lightweight value objects shared between the auth, persistence, and model layers."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from opencloning_db.models import User
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class WriteContext:
12
+ """Identity for any write/create operation: who is acting, in which workspace."""
13
+
14
+ user: 'User'
15
+ workspace_id: int