Skip to content

Fix slow downloads and read-only config exports - #307

Merged
GavinHuttley merged 6 commits into
cogent3:developfrom
genomematt:develop
Mar 28, 2026
Merged

Fix slow downloads and read-only config exports#307
GavinHuttley merged 6 commits into
cogent3:developfrom
genomematt:develop

Conversation

@genomematt

@genomematt genomematt commented Mar 11, 2026

Copy link
Copy Markdown

Fix slow downloads and read-only config exports

Summary

Two independent bug fixes, each a small targeted change.


Fix 1: Use HTTPS instead of FTP for file downloads

File: src/ensembl_tui/_ftp_download.py

Problem

File downloads via eti download were extremely slow compared to
downloading the same files directly via a browser. A genome FASTA file
that downloaded in ~4 minutes via HTTPS took hours via eti download.

The root cause is that _copy_to_local uses Python's ftplib.FTP
raw FTP protocol — while browsers use HTTPS. EBI exposes an HTTPS
gateway at the same hostname (ftp.ebi.ac.uk) serving identical
content. Switching to HTTPS resolved the performance issue.

The listdir function (used for directory listings) and
download_species_table (used for the species TSV) retain FTP — these
are small, fast operations where the slowness was not observed.
Note that _download.py already uses HTTPS for tree downloads via
cogent3.load_tree, establishing the pattern.

Change

Replace the ftplib.retrbinary transfer in _copy_to_local with an
httpx.stream GET request against the HTTPS endpoint. httpx is
already a transitive dependency of ensembl_tui.

# before
ftp = configured_ftp(host=host)
with eti_util.atomic_write(dest, mode="wb") as outfile:
    ftp.retrbinary(f"RETR {src}", outfile.write)
ftp.close()

# after
url = f"https://{host}/{str(src).lstrip('/')}"
with httpx.stream("GET", url, follow_redirects=True, timeout=300.0) as response:
    response.raise_for_status()
    with eti_util.atomic_write(dest, mode="wb") as outfile:
        for chunk in response.iter_bytes(chunk_size=1024 * 1024):
            outfile.write(chunk)

Fix 2: demo-config exports read-only files when installed via Nix

File: src/ensembl_tui/cli.py

Problem

demo_config calls shutil.copytree to copy package resource files
(including sample.cfg) out of the installed package into the user's
chosen output directory. copytree defaults to shutil.copy2 as its
copy function, which preserves source file permissions including the
read-only 0444 mode set on all files in the Nix store. The exported
config files are then uneditable without a manual chmod.

Requiring a manual chmod after every demo-config invocation
complicates automated setup workflows and is likely to produce
confusing errors for users who are unaware of the Nix store's
read-only semantics.

Change

Pass copy_function=shutil.copyfile to copy content only, without
preserving permissions. The destination files receive permissions set
by the user's umask (typically 0644).

# before
shutil.copytree(eti_util.ENSEMBLDBRC, outpath, dirs_exist_ok=True)

# after
shutil.copytree(eti_util.ENSEMBLDBRC, outpath, dirs_exist_ok=True,
                copy_function=shutil.copyfile)

shutil.copyfile matches the copy_function signature and is the
standard library's content-only copy primitive.

Summary by Sourcery

Switch downloads to use HTTPS streaming and adjust demo-config exports to produce writable files, while adding the HTTP client dependency.

Bug Fixes:

  • Speed up large file downloads by replacing FTP-based transfers with HTTPS streaming in the download helper.
  • Ensure demo-config exports writable config files even when the installed package source is read-only (e.g. under Nix).

Build:

  • Declare httpx as an explicit project dependency for HTTPS downloads.

with ftp downloads repeated cases of multi-hour downloads of files that could be https downloaded in minutes were occurring. This is likely due to ftp throttling. By using https this change aims to improve reliability and speed.
shutil.copytree defaults to copy2 which preserves source permissions.
Package resources in the Nix store are read-only (0444), so exported
config files were uneditable without a manual chmod, complicating
automated workflows.

Pass copy_function=shutil.copyfile to copy content only, allowing the
destination permissions to be set by the user's umask (typically 0644).
@sourcery-ai

sourcery-ai Bot commented Mar 11, 2026

Copy link
Copy Markdown
Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Replaces FTP-based bulk file downloads with streaming HTTPS using httpx to fix slow transfers, and updates demo-config export logic to use a content-only copy so configs are writable even when installed from a read-only Nix store, adding httpx as an explicit dependency.

Sequence diagram for HTTPS-based file download in _copy_to_local

sequenceDiagram
    actor User
    participant CLI as cli_download_command
    participant Downloader as _copy_to_local
    participant HTTPClient as httpx_client
    participant EBIServer as ftp_ebi_ac_uk_https
    participant FS as local_filesystem

    User->>CLI: run_eti_download
    CLI->>Downloader: _copy_to_local(src, dest, host)
    Downloader->>FS: check_dest_exists(dest)
    FS-->>Downloader: dest_missing
    Downloader->>HTTPClient: stream_get(url=https://host/src, follow_redirects=True, timeout=300.0)
    HTTPClient->>EBIServer: HTTPS_GET_stream(url)
    EBIServer-->>HTTPClient: streaming_response
    HTTPClient-->>Downloader: response_handle
    Downloader->>HTTPClient: response_raise_for_status()
    loop for_each_chunk
        Downloader->>HTTPClient: response_iter_bytes(chunk_size=1048576)
        HTTPClient-->>Downloader: chunk_bytes
        Downloader->>FS: atomic_write_chunk(dest, chunk_bytes)
    end
    Downloader-->>CLI: return_dest_path(dest)
    CLI-->>User: download_complete
Loading

Flow diagram for _copy_to_local HTTPS streaming logic

flowchart TD
    A["_copy_to_local called"] --> B["Check if dest exists"]
    B -->|exists| C["Return existing dest path"]
    B -->|does_not_exist| D["Build HTTPS URL from host and src"]
    D --> E["Open httpx streaming GET with follow_redirects=True timeout=300.0"]
    E --> F["response.raise_for_status()"]
    F --> G["Open atomic_write(dest, mode='wb')"]
    G --> H{"More chunks from response.iter_bytes(chunk_size=1048576)?"}
    H -->|yes| I["Read next chunk bytes"]
    I --> J["Write chunk to outfile"]
    J --> H
    H -->|no| K["Close response and outfile"]
    K --> L["Return dest path"]
Loading

File-Level Changes

Change Details Files
Switch bulk file download implementation from FTP to streaming HTTPS for performance and reliability.
  • Replace ftplib FTP retrbinary-based file transfer in the _copy_to_local helper with an httpx.stream GET request to the HTTPS endpoint.
  • Stream response data in 1 MiB chunks into eti_util.atomic_write to preserve atomic file writes while avoiding large in-memory buffers.
  • Raise for non-2xx HTTP responses via response.raise_for_status() to surface download errors cleanly.
  • Construct HTTPS URLs from the existing host and src path, stripping leading slashes from src to avoid malformed URLs.
src/ensembl_tui/_ftp_download.py
pyproject.toml
Ensure demo_config exports writable configuration files even when the package is installed from a read-only Nix store.
  • Change shutil.copytree invocation in demo_config to use copy_function=shutil.copyfile so destination file permissions follow the user’s umask instead of the source’s read-only mode.
  • Retain existing behavior of recreating the output directory (removing it first if present) and then globbing files for post-copy processing.
src/ensembl_tui/cli.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@GavinHuttley GavinHuttley left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I understand the motivation and am happy to add this in. Moving to https opens another improvement opportunity here if you are interested and have the time. The approach is to use threading and the ability to download a large file in chunks, requesting a range of bytes and writing those into the correct location. Pasting some demo code from another project below

def _fetch_range(
    url: str,
    dest: pathlib.Path,
    start: int,
    end: int,
    progress: rich.progress.Progress,
    task: rich.progress.TaskID,
) -> None:
    """Fetch a byte range of *url* and write it into *dest* at the correct offset."""
    req = urllib.request.Request(url)  # noqa: S310
    req.add_header("Range", f"bytes={start}-{end}")
    resp = urllib.request.urlopen(req)  # noqa: S310
    with open(dest, "r+b") as f:
        f.seek(start)
        while data := resp.read(1024 * 64):
            f.write(data)
            progress.update(task, advance=len(data))


def _download_parallel(
    name: str, url: str, dest: pathlib.Path, total: int, connections: int
) -> None:
    """Download a URL using parallel byte-range requests."""
    # pre-allocate the output file
    with open(dest, "wb") as f:
        f.truncate(total)

    chunk_size = total // connections
    ranges = []
    for i in range(connections):
        start = i * chunk_size
        end = total - 1 if i == connections - 1 else (i + 1) * chunk_size - 1
        ranges.append((start, end))

    with _make_progress(name) as progress:
        task = progress.add_task("download", total=total)

        with ThreadPoolExecutor(max_workers=connections) as pool:
            futures = [
                pool.submit(_fetch_range, url, dest, s, e, progress, task)
                for s, e in ranges
            ]
            for future in as_completed(futures):
                future.result()

Comment thread src/ensembl_tui/_ftp_download.py Outdated
Comment thread src/ensembl_tui/cli.py
Comment thread pyproject.toml Outdated

@GavinHuttley GavinHuttley left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Awesome, thanks @genomematt !

@coveralls

Copy link
Copy Markdown
Collaborator

Pull Request Test Coverage Report for Build 23123677479

Details

  • 10 of 15 (66.67%) changed or added relevant lines in 2 files are covered.
  • 1 unchanged line in 1 file lost coverage.
  • Overall coverage decreased (-0.2%) to 88.913%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/ensembl_tui/_ftp_download.py 9 14 64.29%
Files with Coverage Reduction New Missed Lines %
src/ensembl_tui/_ftp_download.py 1 74.36%
Totals Coverage Status
Change from base Build 22605105073: -0.2%
Covered Lines: 2887
Relevant Lines: 3247

💛 - Coveralls

@GavinHuttley
GavinHuttley merged commit 13e7bb9 into cogent3:develop Mar 28, 2026
9 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants