Skip to content

fix(deposition): normalise raw-read file extensions to the validated set - #7265

Open
corneliusroemer-agent wants to merge 19 commits into
mainfrom
raw-reads-ext-normalisation
Open

fix(deposition): normalise raw-read file extensions to the validated set#7265
corneliusroemer-agent wants to merge 19 commits into
mainfrom
raw-reads-ext-normalisation

Conversation

@corneliusroemer-agent

@corneliusroemer-agent corneliusroemer-agent commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Make ENA submission work with what prior raw reads validation has checked and guarantees:
file ending is one of (".fastq.gz", ".fq.gz", ".fastq", ".fq") with arbitrary casing.

Fix double compression bug

download_fastq_files decided whether to compress with file_path.endswith((".gz", ".bz2")), which is case-sensitive. The upstream check in raw-reads-processing is not: _has_extension lower-cases before comparing, so READS.FASTQ.GZ is a perfectly legal submission today. It reached this code, failed the .gz test, and got gzipped a second time.

Make extensions lowercase so that webin is happy

ENA submission needs to lowercase extensions so that webin-cli accepts it (it compares suffixes with String.endsWith, so r.FASTQ.GZ is rejected outright with "Invalid FASTQ file suffix".

Misnamed extension should be rejected in raw reads validation not here

A genuinely gzipped file named reads.fastq still gets compressed twice. The right place to catch it is at submission: separate PR #7266

Worth deciding separately

If raw-reads-processing were narrowed to gzip-only the code would be simplified a lot. We would also save on storage, raw reads on Loculus/PPX would be more uniform and faster to download for everyone (and make upload for submitter faster). The trade-off is that submitters would have to create gzipped raw reads - but that's probably not an issue (and ENA requires gzip (or bz2) anyways so it's a normal requirement.

🚀 Preview: Add preview label to enable

anna-parker and others added 12 commits September 8, 2026 10:37
… way bioproject and biosample accession being handled is managed
`has_raw_reads_changed` compares a `name -> fileId` mapping, so renaming
a raw read file while keeping the same `fileId` counts as a change to
the data. The revision then re-uploads byte-identical FASTQs, mints a
new ERR/ERX, and puts the old ERR on the list of run accessions that
need a suppression email to ENA. For paired reads that is gigabytes of
upload and a manual step at ENA, all to record a cosmetic rename.

The filename is the wrong thing to key on because it barely reaches ENA
at all: `download_fastq_files` names the uploaded file after the
`fileId`, so only the extension survives into the manifest, and the stem
is discarded. The `fileId` is the faithful identity, and the docstring
already excluded the URL for being a short-lived presigned S3 link that
changes even when the file does not — this extends the same reasoning to
the name.

The ids are sorted so that reordering the same two files is not a change
either. The reads manifest has no R1/R2 designation, so order carries no
meaning to ENA; pairing is inferred from the read names inside the
files.

https://claude.ai/code/session_01HoLNKkpF4B6EZRynPotieW

🚀 Preview: Add `preview` label to enable

---------

Co-authored-by: Cornelius Roemer <cornelius.roemer@pathoplexus.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cornelius Roemer <cornelius.roemer@gmail.com>
download_fastq_files derived the local filename with
`"".join(Path(name).suffixes)` and decided whether to compress with
`endswith((".gz", ".bz2"))`. Both were more general than they needed to
be, and the second was case-sensitive while the upstream check is not.

raw-reads-processing already restricts raw reads to
{.fastq, .fq, .fastq.gz, .fq.gz}, matched case-insensitively, and
preprocessing blocks anything else at submission time. So `.bz2` was
unreachable and the suffix-chain handling was dead generality.

The case mismatch was a live bug: `READS.FASTQ.GZ` is accepted upstream
but `.GZ` != `.gz`, so it was gzipped a second time. Double-gzipped input
passes webin-cli's manifest validation (only the outer layer is
decompressed) and fails later during submit, after the upload has begun.

Replace both with a lookup against the accepted set, returning the
extension lower-cased; compress iff it is not already `.gz`.

Also make the integration mock name files as production does
(<fileId><canonical ext>) rather than basename(name), so these paths are
exercised, and add unit tests for download_fastq_files, which was mocked
everywhere and had no direct coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wgBKxVSaukBXBazE6uGTQ
@claude claude Bot added the deposition related to ENA/INSDC deposition label Sep 8, 2026
@corneliusroemer
corneliusroemer marked this pull request as ready for review September 8, 2026 17:39
Copilot AI lite review requested due to automatic review settings September 8, 2026 17:39
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @corneliusroemer's task in 2m 24s —— View job


Review

The core change is right, and it fixes more than the PR description claims. The old "".join(pathlib.Path(name).suffixes) was broken for any dotted basename, not just casing: my.sample.reads.fastq.gz produced the extension .sample.reads.fastq.gz, so the file landed on disk as <fileId>.sample.reads.fastq.gz. Matching against a fixed accepted set removes that whole class of problem. Deriving the extension from name while the basename comes from fileId also keeps the existing path-traversal protection intact.

Checks I made:

  • ACCEPTED_FASTQ_EXTENSIONS here matches the upstream set in raw-reads-processing/src/raw_reads_processing/file_format_validation.py:21 exactly, and the lower-case-then-endswith matching mirrors _has_extension there, so nothing that passes submission validation is rejected here.
  • Tuple ordering isn't load-bearing ("r.fastq.gz".endswith(".fastq") is false), so the tuple-vs-set difference between the two files is harmless.
  • Dropping .bz2 from the compression check is correct — no accepted extension is .bz2, so the old branch was unreachable for it.
  • RuntimeError matches how create_raw_reads.py:115's caller already handles failures from this function.

Three inline comments, all minor: hoisting the extension check above the download loop, one test that doesn't assert what its name claims, and an unspecced mock.Mock() config.

Two things not worth an inline comment:

  • The duplicated constant has a comment here pointing at file_format_validation.py, but not the reverse. A one-line comment there pointing back would make drift visible to whoever edits the upstream set — that's the direction the drift will come from.
  • Worth confirming webin-cli accepts .fq.gz and not only .fastq.gz. If it's the latter, reads.fq would need normalising to .fastq.gz rather than .fq.gz. Low risk, but test_uncompressed_fq_is_gzipped bakes the assumption in, so it's cheap to verify once.

I could not run the test suite — micromamba run -n loculus-ena-submission python -m pytest isn't in this job's allowed tools, so the new DownloadFastqFilesTests are unverified locally.

  • Read PR diff
  • Read call_loculus.py in full context
  • Cross-check upstream file_format_validation.py and the caller
  • Review tests and integration-test mock
  • Post review
    · branch raw-reads-ext-normalisation

Comment thread ena-submission/test/test_ena_submission.py Outdated
Comment thread ena-submission/test/test_ena_submission.py Outdated
Comment thread ena-submission/src/ena_deposition/call_loculus.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new unit-test mock context manager can inadvertently suppress exceptions, and one updated comment is misleading relative to the enforced extension contract.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes ENA raw-read submission edge cases by normalizing FASTQ filename extensions to the same validated, case-insensitive set used upstream, preventing double-gzip for uppercase .GZ and ensuring webin-cli accepts the resulting filenames.

Changes:

  • Introduce canonical_fastq_extension() and a local ACCEPTED_FASTQ_EXTENSIONS contract to lower-case and validate raw-read FASTQ suffixes before download/compression.
  • Update download_fastq_files() to decide gzip behavior based on the canonicalized extension (avoiding double compression for e.g. READS.FASTQ.GZ).
  • Improve unit/integration tests so mocks mirror production naming and so download/compression behavior is actually exercised.
File summaries
File Description
ena-submission/src/ena_deposition/call_loculus.py Adds canonical FASTQ extension validation/normalization and updates gzip decision logic.
ena-submission/test/test_ena_submission.py Adds unit tests covering download_fastq_files() naming and compression behaviors.
ena-submission/test/test_ena_submission_integration.py Aligns the integration mock’s output filenames with production behavior using canonicalized extensions.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ena-submission/test/test_ena_submission.py
Comment thread ena-submission/src/ena_deposition/call_loculus.py Outdated
corneliusroemer-agent added a commit that referenced this pull request Sep 8, 2026
- Only check FASTQ. BAM is BGZF, i.e. a valid gzip stream, so a .bam
  would have been rejected as "gzip-compressed but not named .gz".
  Unreachable while ACCEPTED_FORMATS is FASTQ-only, but it would have
  bitten whoever enables BAM.
- Catch EOFError and zlib.error alongside OSError. gzip raises EOFError
  on a truncated file and zlib.error on corrupt deflate data; neither is
  an OSError, so both escaped as an unhandled 500 instead of the
  submitter-facing annotation. Both new tests fail without the wider
  except.
- Drop the error text from that message: some OSErrors carry the local
  temp path, which should not reach submitters.
- Note on the sync pointer that the mirror arrives with #7265; it is not
  on main yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wgBKxVSaukBXBazE6uGTQ
Comment thread ena-submission/src/ena_deposition/call_loculus.py
Base automatically changed from raw_reads_ena to main September 9, 2026 16:02
@anna-parker

Copy link
Copy Markdown
Contributor

@claude review

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @anna-parker's task in 1m 37s —— View job


Review

  • Read current state of call_loculus.py and tests
  • Check diff against origin/main
  • Verify whether prior review comments were addressed
  • Post review

All the previously raised review comments (mine, Copilot's, and the discussion with @anna-parker) have been addressed in the current state of the branch (commit 124f7f2):

  • Validation hoisted above the download loop (call_loculus.py:282): extensions are now computed for all files up front via a list comprehension and zipped with files (strict=True), so an unaccepted extension on file N is now rejected before any of the preceding files are downloaded — matches what test_unaccepted_extension_raises_before_download claims.
  • Test now proves "before download" (test_ena_submission.py:820-832): patches requests.get and asserts get.assert_not_called().
  • Mock hardened (test_ena_submission.py:773): self.config = mock.Mock(spec=Config). Confirmed raw_reads_metadata_field and s3_request_timeout_seconds are real fields on Config (config.py:137, :170), so this will correctly fail if a test starts relying on an attribute that doesn't exist.
  • Context manager no longer swallows exceptions (test_ena_submission.py:787): response.__exit__.return_value = False is set explicitly.
  • .bz2 comment reworded (call_loculus.py:295-296) to distinguish what webin-cli accepts from what upstream validation actually allows through, so the mention of .bz2 no longer implies it's a supported input extension. .bz2 support was discussed and intentionally left out per @anna-parker's comment.

Cross-checked against upstream again: ACCEPTED_FASTQ_EXTENSIONS in call_loculus.py:224 still matches raw-reads-processing/src/raw_reads_processing/file_format_validation.py:21 exactly, and canonical_fastq_extension's lower-then-endswith matching still mirrors _has_extension there (file_format_validation.py:49-52). No remaining correctness issues found.

One very minor, non-blocking nit: the comment at call_loculus.py:295-296 is now accurate but reads as a single run-on sentence ("webin-cli rejects any FASTQ whose name does not end in .gz or .bz2, upstream only accepts .gz, so gzip..."). Splitting it into two sentences would make the two separate facts (what webin-cli accepts vs. what upstream lets through) easier to parse at a glance. Not worth blocking on.

I could not run the test suite locally — the loculus-ena-submission micromamba environment isn't set up in this sandbox — so DownloadFastqFilesTests remains unverified by me beyond static reading. Given the fixes align with every specific concern raised in earlier reviews, this looks ready to merge from a correctness standpoint.
· branch raw-reads-ext-normalisation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deposition related to ENA/INSDC deposition

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants