Skip to content

Commit 4df1b68

Browse files
authored
Merge branch 'main' into chore/bump-uv
2 parents dc61656 + b0929cf commit 4df1b68

24 files changed

Lines changed: 1508 additions & 90 deletions

File tree

packages/grz-cli/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ dependencies = [
2323
"pydantic-settings >=2.11,<3",
2424
"platformdirs >=4.3.6,<5",
2525
"grz-pydantic-models >=2.5,<3",
26-
"pysam ==0.23.*",
2726
"rich ==13.*",
2827
"requests >=2.32.3,<3",
2928
"grz-common >=1.5.0,<2",

packages/grz-common/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ dependencies = [
2525
"platformdirs >=4.3.6,<5",
2626
"grz-pydantic-models >=2.2.0",
2727
"grz-check >=0.2.1",
28-
"pysam ==0.23.*",
2928
"rich ==13.*",
3029
"requests >=2.32.3,<3",
3130
]

packages/grz-common/src/grz_common/progress/states.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class State(TypedDict, total=False):
1111
"""
1212

1313
errors: list[str]
14+
submission_id: str
1415

1516

1617
class ValidationState(State):

packages/grz-common/src/grz_common/workers/download.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def download_file(
164164
s3_object_id: str,
165165
progress_logger: FileProgressLogger[DownloadState],
166166
file_metadata: SubmissionFileMetadata,
167+
submission_id: str,
167168
):
168169
"""
169170
Download a single file from S3 to the specified local_file_path.
@@ -179,7 +180,11 @@ def download_file(
179180
self._download_with_progress(str(local_file_path), s3_object_id)
180181

181182
self.__log.info(f"Download complete for {str(local_file_path)}.")
182-
progress_logger.set_state(local_file_path, file_metadata, state=DownloadState(download_successful=True))
183+
progress_logger.set_state(
184+
local_file_path,
185+
file_metadata,
186+
state=DownloadState(download_successful=True, submission_id=submission_id),
187+
)
183188

184189
except botocore.exceptions.ClientError as e:
185190
if e.response.get("Error", {}).get("Code") == "404":
@@ -190,13 +195,17 @@ def download_file(
190195
exc = e # type: ignore[assignment]
191196
self.__log.error(error_msg)
192197
progress_logger.set_state(
193-
local_file_path, file_metadata, state=DownloadState(download_successful=False, errors=[str(exc)])
198+
local_file_path,
199+
file_metadata,
200+
state=DownloadState(download_successful=False, errors=[str(exc)], submission_id=submission_id),
194201
)
195202
raise exc from e
196203
except Exception as e:
197204
self.__log.error("Download failed for '%s': %s", str(local_file_path), e)
198205
progress_logger.set_state(
199-
local_file_path, file_metadata, state=DownloadState(download_successful=False, errors=[str(e)])
206+
local_file_path,
207+
file_metadata,
208+
state=DownloadState(download_successful=False, errors=[str(e)], submission_id=submission_id),
200209
)
201210
raise e
202211

@@ -217,7 +226,11 @@ def download(self, submission_id: str, encrypted_submission: EncryptedSubmission
217226
file_key = f"{submission_id}/files/{relative_encrypted_path}"
218227

219228
logged_state = progress_logger.get_state(local_file_path, file_metadata)
220-
if logged_state and logged_state.get("download_successful"):
229+
if (
230+
logged_state
231+
and logged_state.get("download_successful")
232+
and logged_state.get("submission_id") == submission_id
233+
):
221234
self.__log.info(
222235
"File '%s' already downloaded (at '%s'), skipping.",
223236
file_key,
@@ -226,7 +239,7 @@ def download(self, submission_id: str, encrypted_submission: EncryptedSubmission
226239
continue
227240

228241
self.__log.info("Downloading file: '%s' -> '%s'", file_key, str(local_file_path))
229-
self.download_file(local_file_path, file_key, progress_logger, file_metadata)
242+
self.download_file(local_file_path, file_key, progress_logger, file_metadata, submission_id)
230243

231244

232245
class InboxSubmissionState(enum.StrEnum):

packages/grz-common/src/grz_common/workers/submission.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ def __init__(self, metadata_dir: str | PathLike, files_dir: str | PathLike):
259259

260260
self.metadata = SubmissionMetadata(self.metadata_dir / "metadata.json")
261261

262+
@property
263+
def submission_id(self) -> str:
264+
return self.metadata.content.submission_id
265+
262266
@property
263267
def files(self) -> dict[Path, SubmissionFileMetadata]:
264268
"""
@@ -312,14 +316,19 @@ def validate_files( # noqa: C901, PLR0912, PLR0915
312316
tasks = []
313317

314318
def should_check_file(file_path: Path, file_metadata: SubmissionFileMetadata) -> bool:
315-
# Check against both logs. If either is missing a "pass", re-check.
316319
checksum_state = checksum_progress_logger.get_state(file_path, file_metadata)
317320
seq_data_state = seq_data_progress_logger.get_state(file_path, file_metadata)
318321
is_seq_file = file_metadata.file_type in ("fastq", "bam")
319-
320-
checksum_passed = checksum_state and checksum_state.get("validation_passed")
321-
seq_data_passed = seq_data_state and seq_data_state.get("validation_passed")
322-
322+
checksum_passed = (
323+
checksum_state
324+
and checksum_state.get("validation_passed")
325+
and checksum_state.get("submission_id") == self.submission_id
326+
)
327+
seq_data_passed = (
328+
seq_data_state
329+
and seq_data_state.get("validation_passed")
330+
and seq_data_state.get("submission_id") == self.submission_id
331+
)
323332
if is_seq_file:
324333
return not (checksum_passed and seq_data_passed)
325334
return not checksum_passed
@@ -448,11 +457,15 @@ def _execute_task(task_type, paths, metas, kwargs, pbar):
448457
checksum_issues.append("File not found for size check.")
449458

450459
checksum_passed = not checksum_issues
451-
checksum_state = ValidationState(errors=checksum_issues, validation_passed=checksum_passed)
460+
checksum_state = ValidationState(
461+
errors=checksum_issues, validation_passed=checksum_passed, submission_id=self.submission_id
462+
)
452463
checksum_progress_logger.set_state(file_path, file_metadata, checksum_state)
453464

454465
if file_metadata.file_type in ("fastq", "bam"):
455-
seq_data_state = ValidationState(errors=report.errors, validation_passed=report.is_valid)
466+
seq_data_state = ValidationState(
467+
errors=report.errors, validation_passed=report.is_valid, submission_id=self.submission_id
468+
)
456469
seq_data_progress_logger.set_state(file_path, file_metadata, seq_data_state)
457470

458471
if not no_mmap:
@@ -524,6 +537,7 @@ def encrypt(
524537
(logged_state is None)
525538
or not logged_state.get("encryption_successful", False)
526539
or not encrypted_file_path.is_file()
540+
or logged_state.get("submission_id") != self.submission_id
527541
):
528542
self.__log.info(
529543
"Encrypting file: '%s' -> '%s'",
@@ -543,15 +557,17 @@ def encrypt(
543557
progress_logger.set_state(
544558
file_path,
545559
file_metadata,
546-
state=EncryptionState(encryption_successful=True),
560+
state=EncryptionState(encryption_successful=True, submission_id=self.submission_id),
547561
)
548562
except Exception as e:
549563
self.__log.error("Encryption failed for '%s'", str(file_path))
550564

551565
progress_logger.set_state(
552566
file_path,
553567
file_metadata,
554-
state=EncryptionState(encryption_successful=False, errors=[str(e)]),
568+
state=EncryptionState(
569+
encryption_successful=False, errors=[str(e)], submission_id=self.submission_id
570+
),
555571
)
556572

557573
raise e
@@ -704,6 +720,7 @@ def decrypt(
704720
if (
705721
(logged_state is None)
706722
or not logged_state.get("decryption_successful", False)
723+
or logged_state.get("submission_id") != self.submission_id
707724
or not decrypted_file_path.is_file()
708725
):
709726
self.__log.info(
@@ -719,15 +736,17 @@ def decrypt(
719736
progress_logger.set_state(
720737
encrypted_file_path,
721738
file_metadata,
722-
state=DecryptionState(decryption_successful=True),
739+
state=DecryptionState(decryption_successful=True, submission_id=self.submission_id),
723740
)
724741
except Exception as e:
725742
self.__log.error("Decryption failed for '%s'", str(encrypted_file_path))
726743

727744
progress_logger.set_state(
728745
encrypted_file_path,
729746
file_metadata,
730-
state=DecryptionState(decryption_successful=False, errors=[str(e)]),
747+
state=DecryptionState(
748+
decryption_successful=False, errors=[str(e)], submission_id=self.submission_id
749+
),
731750
)
732751

733752
raise e

packages/grz-common/src/grz_common/workers/upload.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,11 @@ def _upload_logged_files(self, encrypted_submission, progress_logger, files_to_u
168168
self.__log.debug("state for %s: %s", file_path, logged_state)
169169

170170
s3_object_id = files_to_upload[file_path]
171-
172-
if (logged_state is None) or not logged_state.get("upload_successful", False):
171+
if (
172+
(logged_state is None)
173+
or not logged_state.get("upload_successful", False)
174+
or logged_state.get("submission_id") != encrypted_submission.submission_id
175+
):
173176
self.__log.info(
174177
"Uploading file: '%s' -> '%s'",
175178
str(file_path),
@@ -183,22 +186,25 @@ def _upload_logged_files(self, encrypted_submission, progress_logger, files_to_u
183186
progress_logger.set_state(
184187
file_path,
185188
file_metadata,
186-
state=UploadState(upload_successful=True),
189+
state=UploadState(upload_successful=True, submission_id=encrypted_submission.submission_id),
187190
)
188191
except Exception as e:
189192
self.__log.error("Upload failed for '%s'", str(file_path))
190193

191194
progress_logger.set_state(
192195
file_path,
193196
file_metadata,
194-
state=UploadState(upload_successful=False, errors=[str(e)]),
197+
state=UploadState(
198+
upload_successful=False, errors=[str(e)], submission_id=encrypted_submission.submission_id
199+
),
195200
)
196201

197202
raise e
198203
else:
199204
self.__log.info(
200-
"File '%s' already uploaded (at '%s')",
205+
"File '%s' already uploaded for submission '%s' (at '%s')",
201206
str(file_path),
207+
encrypted_submission.submission_id,
202208
str(s3_object_id),
203209
)
204210

packages/grz-common/src/grz_common/workers/worker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,13 +370,13 @@ def populate(self, s3_options: S3Options, db: SubmissionDb, submission_id: str,
370370
submission_diff, donors_diff = db.diff(submission_id, metadata, submission_date)
371371

372372
# check for destructive changes
373-
if not force and (len(submission_diff.deleted) + len(submission_diff.updated)) > 0:
373+
if not force and submission_diff.has_pending_destructive:
374374
raise RuntimeError(
375375
f"Would update/delete existing submission data in the database, but `force` not set. "
376376
f"submission_id={submission_id!r}, submission_diff={submission_diff!r}"
377377
)
378378

379-
if not force and (len(donors_diff.deleted) + len(donors_diff.updated)) > 0:
379+
if not force and donors_diff.has_pending_destructive:
380380
raise RuntimeError(
381381
f"Would update/delete existing donors in the database, but `force` not set. "
382382
f"submission_id={submission_id!r}, donors_diff={donors_diff!r}"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""add grzctl_version and qc_workflow_version columns
2+
3+
Revision ID: c5f6a8b9d2e1
4+
Revises: 834bd50b8734
5+
Create Date: 2026-05-06 00:00:00.000000
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision: str = "c5f6a8b9d2e1"
14+
down_revision: str | Sequence[str] | None = "834bd50b8734"
15+
branch_labels: str | Sequence[str] | None = None
16+
depends_on: str | Sequence[str] | None = None
17+
18+
19+
def upgrade() -> None:
20+
# Add grzctl_versions to submission_states
21+
op.add_column(
22+
"submission_states",
23+
sa.Column("grzctl_versions", sa.JSON(), nullable=True),
24+
)
25+
26+
# Add qc_workflow_version to detailed_qc_results
27+
op.add_column(
28+
"detailed_qc_results",
29+
sa.Column("qc_workflow_version", sa.String(length=64), nullable=True),
30+
)
31+
32+
33+
def downgrade() -> None:
34+
"""Downgrade schema."""
35+
raise RuntimeError("Downgrades not supported.")

packages/grz-db/src/grz_db/models/submission/__init__.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ class SubmissionStateLogBase(SQLModel):
257257

258258
state: SubmissionStateEnum
259259
data: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
260+
grzctl_versions: dict[str, str] | None = Field(
261+
default=None,
262+
description="grzctl versions that created this state log (nullable for backward compatibility with old state logs)",
263+
sa_column=Column(JSON),
264+
)
260265
timestamp: datetime.datetime = Field(
261266
default_factory=lambda: datetime.datetime.now(datetime.UTC),
262267
sa_column=Column(DateTime(timezone=True), nullable=False),
@@ -465,6 +470,11 @@ class DetailedQCResult(SQLModel, table=True):
465470
targeted_regions_above_min_coverage: float
466471
targeted_regions_above_min_coverage_passed_qc: bool
467472
targeted_regions_above_min_coverage_percent_deviation: float
473+
qc_workflow_version: str | None = Field(
474+
default=None,
475+
sa_column=Column(sa.String(length=64), nullable=True),
476+
description="QC workflow version (nullable for backward compatibility with old results)",
477+
)
468478

469479
model_config = ConfigDict( # type: ignore
470480
populate_by_name=True,
@@ -737,6 +747,7 @@ def update_submission_state(
737747
submission_id: str,
738748
state: SubmissionStateEnum,
739749
data: dict | None = None,
750+
grzctl_versions: dict[str, str] | None = None,
740751
) -> SubmissionStateLog:
741752
"""
742753
Updates a submission's state to the specified state.
@@ -745,6 +756,7 @@ def update_submission_state(
745756
submission_id: Submission ID of the submission to update.
746757
state: New state of the submission.
747758
data: Optional data to attach to the update.
759+
grzctl_versions: Optional dictionary of grzctl dependency versions.
748760
749761
Returns:
750762
An instance of SubmissionStateLog.
@@ -757,7 +769,11 @@ def update_submission_state(
757769
raise ValueError("No author defined")
758770

759771
state_log_payload = SubmissionStateLogPayload(
760-
submission_id=submission_id, author_name=self._author.name, state=state, data=data
772+
submission_id=submission_id,
773+
author_name=self._author.name,
774+
state=state,
775+
data=data,
776+
grzctl_versions=grzctl_versions,
761777
)
762778
signature = state_log_payload.sign(self._author.private_key())
763779

@@ -919,6 +935,24 @@ def get_submission(self, submission_id: str) -> Submission | None:
919935
submission = session.exec(statement).first()
920936
return submission
921937

938+
def get_submissions(self, submission_ids: Sequence[str]) -> list[Submission | None]:
939+
"""Fetch a specific set of submissions by their IDs in a single query.
940+
941+
:param submission_ids: IDs to fetch.
942+
:returns: A list of the same length as *submission_ids*, where each element is the
943+
matching :class:`Submission` or ``None`` when the ID does not exist.
944+
"""
945+
if not submission_ids:
946+
return []
947+
with self._get_session() as session:
948+
statement = (
949+
select(Submission)
950+
.where(Submission.id.in_(submission_ids)) # type: ignore[attr-defined]
951+
.options(selectinload(Submission.states)) # type: ignore[arg-type]
952+
)
953+
found = {s.id: s for s in session.exec(statement).all()}
954+
return [found.get(sid) for sid in submission_ids]
955+
922956
def list_submissions(
923957
self,
924958
limit: int | None,

0 commit comments

Comments
 (0)