divbase-lib 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev5__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.
divbase_lib/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.0.dev2"
1
+ __version__ = "0.1.0.dev5"
@@ -0,0 +1,18 @@
1
+ """
2
+ Schemas for announcements.
3
+ """
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class AnnouncementResponse(BaseModel):
9
+ """Response model for an announcement returned by the API."""
10
+
11
+ heading: str = Field(..., description="Title of the announcement.")
12
+ message: str | None = Field(None, description="Detailed message of the announcement.")
13
+ level: str = Field(
14
+ ...,
15
+ description="The announcement level, which can control styling of announcement. Possible values are: info, success, warning, danger.",
16
+ )
17
+
18
+ model_config = ConfigDict(from_attributes=True)
@@ -4,6 +4,8 @@ Schemas for project versioning routes.
4
4
  Project versions are the state of all files in a project's storage bucket at a given time point.
5
5
  """
6
6
 
7
+ from typing import TypedDict
8
+
7
9
  from pydantic import BaseModel, Field
8
10
 
9
11
 
@@ -22,6 +24,12 @@ class DeleteVersionRequest(BaseModel):
22
24
  version_name: str = Field(..., description="Name of the version to delete")
23
25
 
24
26
 
27
+ class UpdateVersionRequest(BaseModel):
28
+ version_name: str = Field(..., description="Name of the version to update")
29
+ new_name: str | None = Field(None, description="New name for the version")
30
+ new_description: str | None = Field(None, description="New description for the version")
31
+
32
+
25
33
  # Response Models
26
34
  class ProjectBasicInfo(BaseModel):
27
35
  """Base model for describing a single project version, not for direct use in an endpoint."""
@@ -43,12 +51,23 @@ class ProjectVersionInfo(ProjectBasicInfo):
43
51
  is_deleted: bool = Field(..., description="Whether this version has been soft-deleted")
44
52
 
45
53
 
54
+ # Typed dict used as this is used in the db model to define the contents of the JSONB
55
+ class FileDetails(TypedDict):
56
+ """Details about a single file in a project version, ETag (is checksum) and size is in bytes."""
57
+
58
+ version_id: str
59
+ etag: str
60
+ size: int
61
+
62
+
46
63
  class ProjectVersionDetailResponse(ProjectBasicInfo):
47
64
  """Full information about a single project version, including the files at that version."""
48
65
 
49
66
  created_at: str = Field(..., description="ISO timestamp when version was created")
50
67
  is_deleted: bool = Field(..., description="Whether this version has been soft-deleted")
51
- files: dict[str, str] = Field(..., description="Mapping of file names to their version IDs")
68
+ files: dict[str, FileDetails] = Field(
69
+ ..., description="Info about all files at this version, including: version IDs, etags and file sizes"
70
+ )
52
71
 
53
72
 
54
73
  class DeleteVersionResponse(BaseModel):
@@ -60,3 +79,9 @@ class DeleteVersionResponse(BaseModel):
60
79
  description="Whether the version was already soft-deleted before this request",
61
80
  )
62
81
  date_deleted: str = Field(..., description="ISO timestamp of when the version was soft-deleted")
82
+
83
+
84
+ class UpdateVersionResponse(ProjectBasicInfo):
85
+ """Response model for updating a version."""
86
+
87
+ created_at: str = Field(..., description="ISO timestamp when version was created")
@@ -45,6 +45,7 @@ class BcftoolsQueryKwargs(BaseModel):
45
45
  project_id: int
46
46
  project_name: str
47
47
  user_id: int
48
+ job_id: int
48
49
 
49
50
 
50
51
  class SampleMetadataQueryTaskResult(BaseModel):
@@ -54,6 +55,7 @@ class SampleMetadataQueryTaskResult(BaseModel):
54
55
  unique_sample_ids: list[str]
55
56
  unique_filenames: list[str]
56
57
  query_message: str
58
+ warnings: list[str] = []
57
59
  status: Optional[str] = None
58
60
 
59
61
 
@@ -1,12 +1,81 @@
1
1
  """
2
2
  Schemas for DivBase's S3 API routes.
3
+
4
+ Pre-signed download URLs do not need to account for single vs multipart as this can be controlled by the client
5
+ using the HTTP range header when downloading (so you only need 1 pre-signed URL per object for download).
6
+
7
+ Pre-signed upload URLs need to account for single vs multipart uploads hence all the extra schemas below.
3
8
  """
4
9
 
10
+ from datetime import datetime
11
+
5
12
  from pydantic import BaseModel, Field
6
13
 
14
+ from divbase_lib.divbase_constants import S3_MULTIPART_CHUNK_SIZE
15
+
16
+ MB = 1024 * 1024
17
+
18
+
19
+ ## list objects models ##
20
+ class ListObjectsRequest(BaseModel):
21
+ """Request model for listing objects in an S3 bucket."""
22
+
23
+ prefix: str | None = Field(None, description="Optional prefix to filter objects by name.")
24
+ next_token: str | None = Field(
25
+ None, description="Token to continue listing files from the end of a previous request."
26
+ )
27
+
28
+
29
+ class ObjectDetails(BaseModel):
30
+ """Details about a single object in an S3 bucket."""
31
+
32
+ name: str = Field(..., description="The name of the object in the bucket.")
33
+ size: int = Field(..., description="The size of the object in bytes.")
34
+ last_modified: datetime = Field(..., description="The date and time the object was last modified.")
35
+ etag: str = Field(..., description="The ETag of the object, which is the MD5 checksum.")
36
+
37
+
38
+ class ListObjectsResponse(BaseModel):
39
+ """Response model for listing objects in an S3 bucket."""
40
+
41
+ objects: list[ObjectDetails] = Field(
42
+ ..., description="A list of objects in the bucket.", min_length=0, max_length=1000
43
+ )
44
+ next_token: str | None = Field(
45
+ None, description="Token for fetching the next page of results. If None, no more results."
46
+ )
47
+
7
48
 
49
+ ## list soft-deleted objects models ##
50
+ class SoftDeletedObjectDetails(BaseModel):
51
+ """Details about a single soft-deleted object in an S3 bucket."""
52
+
53
+ name: str = Field(..., description="The name of the object in the bucket.")
54
+ last_modified: datetime = Field(..., description="The date and time the object was deleted.")
55
+
56
+
57
+ ## file info models ##
58
+ class ObjectVersionInfo(BaseModel):
59
+ """Detailed information about a single version of an S3 object."""
60
+
61
+ version_id: str = Field(..., description="The version ID of the object.")
62
+ last_modified: datetime = Field(..., description="The date and time the object version was last modified.")
63
+ size: int = Field(..., description="The size of the object in bytes.")
64
+ etag: str = Field(..., description="The ETag of the object, which is the MD5 checksum.")
65
+ is_latest: bool = Field(..., description="Indicates if this is the latest version of the object.")
66
+
67
+
68
+ class ObjectInfoResponse(BaseModel):
69
+ """Response model for detailed information about all versions of a single object stored in S3."""
70
+
71
+ object_name: str = Field(..., description="The name of the object.")
72
+ is_currently_deleted: bool = Field(..., description="True if the latest version of the object is a delete marker.")
73
+ versions: list[ObjectVersionInfo] = Field(..., description="A list of all versions of the object.")
74
+
75
+
76
+ ## download models ##
8
77
  class DownloadObjectRequest(BaseModel):
9
- """Request model to upload a single object using a pre-signed URL."""
78
+ """Request model to download a single object using a pre-signed URL."""
10
79
 
11
80
  name: str = Field(..., description="Name of the object to be downloaded")
12
81
  version_id: str | None = Field(..., description="Version ID of the object, None if latest version")
@@ -20,32 +89,126 @@ class PreSignedDownloadResponse(BaseModel):
20
89
  version_id: str | None = Field(..., description="Version ID of the object, None if latest version")
21
90
 
22
91
 
23
- class UploadObjectRequest(BaseModel):
24
- """Request model to upload a single object using a pre-signed URL."""
92
+ ### Single-part upload models ###
93
+ class UploadSinglePartObjectRequest(BaseModel):
94
+ """Request model to upload a single object as a single part using a pre-signed URL."""
25
95
 
26
96
  name: str = Field(..., description="Name of the object to be uploaded")
27
97
  content_length: int = Field(..., description="Size of the file in bytes")
28
98
  md5_hash: str | None = Field(None, description="Optional MD5 hash of the object for integrity check")
29
99
 
30
100
 
31
- class PreSignedUploadResponse(BaseModel):
32
- """Response model to upload a single object using the pre-signed URL using PUT."""
101
+ class PreSignedSinglePartUploadResponse(BaseModel):
102
+ """Response model to upload a single object as a single part using the pre-signed URL using PUT."""
33
103
 
34
104
  name: str = Field(..., description="Name of the object to be uploaded")
35
105
  pre_signed_url: str = Field(..., description="Pre-signed URL to which the file should be uploaded")
36
106
  put_headers: dict[str, str] = Field(..., description="Headers to be included in the PUT request")
37
107
 
38
108
 
39
- class CheckFileExistsRequest(BaseModel):
40
- """Request model to check if a file already exists in the bucket (using the checksum)"""
109
+ ### Multipart upload models ###
110
+ class CreateMultipartUploadRequest(BaseModel):
111
+ """Request model to create a multipart upload using pre-signed URLs."""
112
+
113
+ name: str = Field(..., description="Name of the object to be uploaded")
114
+ content_length: int = Field(..., description="Size of the file in bytes")
115
+
116
+
117
+ class CreateMultipartUploadResponse(BaseModel):
118
+ """Response model to create a multipart upload using pre-signed URLs."""
119
+
120
+ name: str = Field(..., description="Name of the object to be uploaded")
121
+ upload_id: str = Field(..., description="Upload ID for the multipart upload")
122
+ number_of_parts: int = Field(..., description="Total number of parts required for the upload", ge=1, le=10000)
123
+ part_size: int = Field(
124
+ S3_MULTIPART_CHUNK_SIZE, description="Size of each part in bytes (the last part may be smaller)."
125
+ )
126
+
127
+
128
+ class GetPresignedPartUrlsRequest(BaseModel):
129
+ """
130
+ Request model to get pre-signed URLs for multiple parts of a presigned multipart upload.
131
+
132
+ You can request up to 100 parts at a time.
133
+ Part number indexing is 1-based (with max allowed range: 1 to 10000).
134
+ """
135
+
136
+ name: str = Field(..., description="Name of the object to be uploaded")
137
+ upload_id: str = Field(..., description="Upload ID for the multipart upload")
138
+ parts_range_start: int = Field(..., description="Starting part number", ge=1, le=10000)
139
+ parts_range_end: int = Field(..., description="Ending part number", ge=1, le=10000)
140
+ md5_checksums: list[str] | None = Field(
141
+ None, description="Optional list of MD5 checksums for each part to be uploaded"
142
+ )
143
+
144
+
145
+ class PresignedUploadPartUrlResponse(BaseModel):
146
+ """Response model for a pre-signed URL for a single part of a multipart upload."""
147
+
148
+ part_number: int = Field(..., description="Part number", ge=1, le=10000)
149
+ pre_signed_url: str = Field(..., description="Pre-signed URL for uploading this part")
150
+ headers: dict[str, str] = Field(..., description="Headers to be included in the PUT request for this part")
151
+
152
+
153
+ class UploadedPart(BaseModel):
154
+ """Model representing a part of an object that has been uploaded via multi-part upload."""
155
+
156
+ part_number: int = Field(..., description="Part number", ge=1, le=10000)
157
+ etag: str = Field(description="ETag returned by S3 after uploading the part")
158
+
159
+
160
+ class CompleteMultipartUploadRequest(BaseModel):
161
+ """Request model to complete a multipart upload using pre-signed URLs."""
162
+
163
+ name: str = Field(..., description="Name of the object to be uploaded")
164
+ upload_id: str = Field(..., description="Upload ID for the multipart upload")
165
+ parts: list[UploadedPart] = Field(..., description="List of parts that have been uploaded")
166
+
167
+
168
+ class CompleteMultipartUploadResponse(BaseModel):
169
+ """Response model to complete a multipart upload using pre-signed URLs."""
170
+
171
+ name: str = Field(..., description="Name of the object that was uploaded")
172
+ version_id: str = Field(..., description="Version ID of the uploaded object")
173
+ md5_hash: str = Field(..., description="MD5 hash of the uploaded object")
174
+
175
+
176
+ class AbortMultipartUploadRequest(BaseModel):
177
+ """Request model to abort a multipart upload and clean up parts."""
178
+
179
+ name: str = Field(..., description="Name of the object being uploaded")
180
+ upload_id: str = Field(..., description="Upload ID for the multipart upload to be aborted")
181
+
182
+
183
+ class AbortMultipartUploadResponse(BaseModel):
184
+ """Response model to abort a multipart upload."""
185
+
186
+ name: str = Field(..., description="Name of the object being uploaded")
187
+ upload_id: str = Field(..., description="Upload ID for the multipart upload that was aborted")
188
+
189
+
190
+ class RestoreObjectsResponse(BaseModel):
191
+ """Response model for restoring soft-deleted objects in a bucket."""
41
192
 
42
- object_name: str
43
- md5_checksum: str
193
+ restored: list[str] = Field(
194
+ ...,
195
+ description="List of object names that were successfully restored, this includes objects that were already live",
196
+ )
197
+ not_restored: list[str] = Field(
198
+ ...,
199
+ description=(
200
+ "List of object names that could not be processed.\n"
201
+ "This could be due to several reasons:\n"
202
+ "1. The object does not exist in the bucket (e.g., a typo in the name).\n"
203
+ "2. The object was hard-deleted and is unrecoverable.\n"
204
+ "3. An unexpected server error occurred during the restore attempt."
205
+ ),
206
+ )
44
207
 
45
208
 
46
- class ExistingFileResponse(BaseModel):
47
- """Response model for reporting a file that already exists in the bucket (using it's checksum)"""
209
+ ## checksum models ##
210
+ class FileChecksumResponse(BaseModel):
211
+ """Response model for reporting a file's checksum in the bucket."""
48
212
 
49
- object_name: str
50
- md5_checksum: str
51
- matching_object_name: str | None
213
+ object_name: str = Field(..., description="Name of the object in the bucket")
214
+ md5_checksum: str = Field(..., description="MD5 checksum of the object in the bucket")
@@ -40,3 +40,15 @@ class DimensionsShowResult(BaseModel):
40
40
  vcf_files: list[dict]
41
41
  skipped_file_count: int
42
42
  skipped_files: list[dict]
43
+
44
+
45
+ class DimensionsSamplesResult(BaseModel):
46
+ """Result model for showing unique samples across project VCFs."""
47
+
48
+ unique_samples: list[str] # Already sorted, by the CRUD function get_unique_samples_by_project_async()
49
+
50
+
51
+ class DimensionsScaffoldsResult(BaseModel):
52
+ """Result model for showing unique scaffolds across project VCFs."""
53
+
54
+ unique_scaffolds: list[str] # Already sorted, by the CRUD function get_unique_scaffolds_by_project_async()
@@ -0,0 +1,53 @@
1
+ """
2
+ Constants that both divbase-api and divbase-cli need to agree on.
3
+ """
4
+
5
+ ONE_MiB = 1024 * 1024
6
+
7
+ # When you download a file that has been uploaded in parts, you have
8
+ # to know the part/chunk size used in order to correctly calculate the composite checksum
9
+ S3_MULTIPART_CHUNK_SIZE = 32 * ONE_MiB
10
+
11
+ # At what point you swap from single part to multipart upload to S3.
12
+ # If server and client used the same threshold then makes life easier
13
+ # when validating the checksums of files in s3 as single part and multipart uploads use different ETag formats.
14
+ # (No benefit in constraining the download threshold, so not done here)
15
+ S3_MULTIPART_UPLOAD_THRESHOLD = 96 * ONE_MiB
16
+
17
+ # Max number of items that can be processed in a single API call to divbase-api's S3 routes
18
+ # covers e.g pre-signed urls for upload/download, soft delete and checksum comparisons
19
+ # client has to batch requests if exceeding this limit
20
+ MAX_S3_API_BATCH_SIZE = 100
21
+
22
+ # How long the pre-signed URLs divbase-api creates are valid for
23
+ SINGLE_PART_UPLOAD_URL_EXPIRATION_SECONDS = 3600 # 1 hour
24
+ MULTI_PART_UPLOAD_URL_EXPIRATION_SECONDS = 36000 # 10 hours
25
+ DOWNLOAD_URL_EXPIRATION_SECONDS = 36000 # 10 hours
26
+
27
+ # (Not used anywhere, just making it explicit)
28
+ # This is limited by our fixing of the chunk size and S3's limit to the number of chunks allowed (10,000)
29
+ # 320 GiB if using 32 MiB chunks
30
+ LARGEST_FILE_UPLOADABLE_TO_DIVBASE_BYTES = 10_000 * S3_MULTIPART_CHUNK_SIZE
31
+
32
+ # File types that DivBase supports
33
+ # Whilst we can't realistically limit what file types a user actually uploads,
34
+ # this is here to say what we know should work in DivBase.
35
+ SUPPORTED_DIVBASE_FILE_TYPES = (".tsv", ".vcf.gz", ".csi", ".tbi")
36
+
37
+ # Characters that are not allowed in file names uploaded to DivBase
38
+ # This is to prevent issues when users try to filter/query files on DivBase using these characters
39
+ # or when downloading files (e.g. ":" is used to specify file versions when downloading files
40
+ UNSUPPORTED_CHARACTERS_IN_FILENAMES = (":", "*", "?", "<", ">", "|", "\\")
41
+
42
+ # This prefix is used for all *.vcf.gz results files from a query job/task.
43
+ # After the prefix comes the job id which is a rolling integer.
44
+ # E.g. format: result_of_job_<job-id>.vcf.gz , where <job-id> = 1 and is auto-incremented for every new job.
45
+ # NOTE: If you update this, you should also update the S3 lifecycle policies associated with this.
46
+ QUERY_RESULTS_FILE_PREFIX = "result_of_job_"
47
+
48
+ # CLI includes this in every request to the server,
49
+ # and server uses it to determine if it should:
50
+ # 1. Reject the request as the version is too outdated
51
+ # 2. Add an announcement about a new CLI version being available when the user logs in.
52
+ # 3. Do Nothing as the user's CLI version is up to date.
53
+ CLI_VERSION_HEADER_KEY = "X-CLI-Version"
divbase_lib/exceptions.py CHANGED
@@ -10,20 +10,6 @@ we ensure that when you manually raise a specific exception the error message lo
10
10
  from pathlib import Path
11
11
 
12
12
 
13
- class ObjectDoesNotExistError(FileNotFoundError):
14
- """Raised when an S3 object/key does not exist in the bucket."""
15
-
16
- def __init__(self, key: str, bucket_name: str):
17
- error_message = f"The file/object '{key}' does not exist in the bucket '{bucket_name}'. "
18
- super().__init__(error_message)
19
- self.key = key
20
- self.bucket = bucket_name
21
- self.error_message = error_message
22
-
23
- def __str__(self):
24
- return self.error_message
25
-
26
-
27
13
  class BcftoolsEnvironmentError(Exception):
28
14
  """Raised when there's an issue with the execution environment (Docker, etc.)."""
29
15
 
@@ -115,12 +101,47 @@ class SidecarColumnNotFoundError(Exception):
115
101
  pass
116
102
 
117
103
 
104
+ class SidecarSampleIDError(Exception):
105
+ """Raised when a Sample_ID is invalid or duplicated when loading sidecar data."""
106
+
107
+ pass
108
+
109
+
110
+ class SidecarMetadataFormatError(Exception):
111
+ """Raised when the sidecar metadata TSV file has formatting issues (duplicate columns, empty columns, commas in values, etc.)."""
112
+
113
+ pass
114
+
115
+
116
+ class TaskUserError(Exception):
117
+ """
118
+ Raised in Celery tasks when an error needs to propagate back to the CLI user.
119
+
120
+ This is intentionally kept as a simple Exception subclass (no custom __init__)
121
+ to avoid UnpicklableExceptionWrapper when passing through Celery's JSON serialization/deserialization.
122
+ Complex exception types such as those inheriting from DivBaseAPIException seem to trigger UnpicklableExceptionWrapper.
123
+
124
+ This class is essentially a wrapper to allow to use more complex exceptions in Celery tasks and catch them
125
+ in the API route handlers to return user-friendly error messages to the CLI.
126
+ In the Celery task use it like:
127
+ raise TaskUserError(str(SomeComplexError(...))) from None
128
+ """
129
+
130
+ pass
131
+
132
+
118
133
  class NoVCFFilesFoundError(Exception):
119
134
  """Raised when no VCF files are found in the project bucket."""
120
135
 
121
136
  pass
122
137
 
123
138
 
139
+ class DimensionsNotUpToDateWithBucketError(Exception):
140
+ """Raised when VCF files in a bucket are missing or outdated in the dimensions index."""
141
+
142
+ pass
143
+
144
+
124
145
  class ChecksumVerificationError(Exception):
125
146
  """Raised when a calculated file's checksum does not match the expected value."""
126
147
 
@@ -128,5 +149,8 @@ class ChecksumVerificationError(Exception):
128
149
  self.expected_checksum = expected_checksum
129
150
  self.calculated_checksum = calculated_checksum
130
151
 
131
- message = f"Checksum verification failed. Expected: {expected_checksum}, Calculated: {calculated_checksum}"
152
+ message = (
153
+ f"Checksum verification failed. Expected: {expected_checksum}, Calculated: {calculated_checksum}"
154
+ f" The file has been deleted to avoid accidental use of a corrupted file."
155
+ )
132
156
  super().__init__(message)