API Reference

This API reference describes each component of the AQIS staging library.

cli.py

Overview

cli.py provides the command-line entry point for the streaming staging workflow.

  • it loads workflow configuration from an INI file
  • optionally retrieves secrets from Infisical
  • creates source and destination storage adaptors
  • builds a source file manifest
  • and streams files directly from the source backend to the destination backend.

The workflow supports local filesystem, S3, iRODS, and SCP backends. Transfers are executed in parallel using worker processes with each file streamed in chunks. The workflow validates transfers using file size checks but also provides the option to perform full checksum validation.

Module Constants

ADAPTOR_MAP

ADAPTOR_MAP = {
    "FS": FSAdaptor,
    "S3": S3Adaptor,
    "IRODS": IRODSAdaptor,
    "SCP": SCPAdaptor,
}

Maps configured storage backend names to their corresponding adaptor classes.

Supported values:

Type Adaptor
FS FSAdaptor
S3 S3Adaptor
IRODS IRODSAdaptor
SCP SCPAdaptor

Used to instantiate source and destination adaptors based on SOURCE_TYPE and DEST_TYPE.


AdaptorClass

AdaptorClass = TypeVar("AdaptorClass", bound=StorageAdaptor)

Type variable representing a concrete storage adaptor class derived from StorageAdaptor.


ManifestType

ManifestType = tuple[str, int]

Represents a single manifest entry.

The tuple contains:

Index Type Description
0 str Relative file path
1 int Expected file size in bytes

Example:

("example.json", 2048)

_worker_runner

_worker_runner: "StreamingFileRunner | None" = None

Global worker-local StreamingFileRunner instance.

Each worker process initialises its own runner using init_worker().


_stream_chunk_size

_stream_chunk_size = 64 * 1024 * 1024

Global stream chunk size used by worker processes.

Default value is 64 MiB.

This can be configured through:

STREAM_CHUNK_SIZE_MB=64

Functions

init_worker()

def init_worker(
    source_path: str,
    upload_dest_base: str,
    source_adaptor_cls: Type[AdaptorClass],
    dest_adaptor_cls: Type[AdaptorClass],
    stream_chunk_size: int,
    checksum_strategy: str,
    checksum_algorithm: str,
):

Initialises a worker process for streaming transfers.

This function is passed to ProcessPoolExecutor as the worker initializer. It creates a worker-local StreamingFileRunner and stores it in the global _worker_runner.

Name Type Description
source_path str Base source path used to resolve manifest entries
upload_dest_base str Base destination path used for uploads
source_adaptor_cls Type[StorageAdaptor] Source adaptor class
dest_adaptor_cls Type[StorageAdaptor] Destination adaptor class
stream_chunk_size int Chunk size in bytes
checksum_strategy str Checksum strategy, usually SIZE or FULL
checksum_algorithm str Hashing algorithm, for example md5

Behaviour

Creates adaptor instances using the configured prefixes:

source_adaptor_cls("SRC")
dest_adaptor_cls("DST")

The prefixes are used by the adaptors to read environment variables such as:

SRC_S3_ENDPOINT_URL
DST_IRODS_HOST

process_item()

def process_item(item: ManifestType, free_indices: Queue):

Processes a single manifest item in a worker process.

This function obtains a progress-bar position from the shared queue, streams the file using the worker-local StreamingFileRunner, and returns the position to the queue when complete.

Name Type Description
item ManifestType Manifest entry containing relative path and expected size
free_indices Queue Shared queue of available tqdm progress-bar positions

Returns

Returns the manifest item after successful transfer.

Raises

May raise any exception raised by:

_worker_runner.stream_single_file()

Typical failures include:

Error Cause
RuntimeError Size mismatch
RuntimeError Checksum mismatch
Storage adaptor exception Read/write failure
Network exception Backend connection failure

dest_path_check()

def dest_path_check(source_path: str, dest_path: str, single_file: bool) -> str:

Determines the final base destination path.

For directory transfers, if the source folder name does not match the destination folder name, the source folder name is appended to the destination path.

Name Type Description
source_path str Source file or directory path
dest_path str Configured destination path
single_file bool Whether the transfer contains a single source file

Returns

The resolved destination base path.

Example

dest_path_check(
    source_path="/data/my_folder",
    dest_path="/tempZone/home/rods",
    single_file=False,
)

Returns:

/tempZone/home/rods/my_folder

If single_file=True, the original dest_path is returned unchanged.


ini_has_section()

def ini_has_section(path: str, section: str) -> bool:
Name Type Description
path str Path to the INI file
section str Section name to check

Returns

True if the section exists, otherwise False.

Example

ini_has_section("config.ini", "creds")

parse_args()

def parse_args() -> argparse.Namespace:

Parses command-line arguments for the staging workflow.

Supported Arguments

Argument Required Default Description
--config No config.ini Main workflow INI file
--infisical-src-config No None Optional Infisical config for source secrets
--infisical-dst-config No None Optional Infisical config for destination secrets

Returns

An argparse.Namespace containing the parsed command-line arguments.

Example

python -m staging_workflow.cli \
  --config configs/workflows/s3_to_irods.ini \
  --infisical-src-config configs/infisical/src.ini \
  --infisical-dst-config configs/infisical/dst.ini

load_configuration()

def load_configuration(args: argparse.Namespace) -> None:

Loads workflow configuration into environment variables.

The function always loads the [env] section from the main configuration file. It then either loads secrets from Infisical or falls back to the [creds] section of the main configuration file.

Name Type Description
args argparse.Namespace Parsed command-line arguments

Behaviour

The configuration loading order is:

  • Load [env] from args.config.
  • If Infisical source or destination configs are provided:
  • Load secrets from Infisical.
  • Do not load [creds] from the main config.
  • If no Infisical config is provided:
  • Load [creds] from the main config if it exists.

Example

args = parse_args()
load_configuration(args)

main()

def main():

Main entry point for the streaming staging workflow.

This function coordinates the complete transfer lifecycle.

Workflow Steps

  1. Parse command-line arguments.
  2. Load configuration and secrets.
  3. Read required environment variables.
  4. Validate source and destination backend types.
  5. Create source adaptor for manifest generation.
  6. Run source compliance checks.
  7. Determine destination path layout.
  8. Create a pool of worker processes.
  9. Stream files in parallel.
  10. Validate file sizes and optional checksums.
  11. Report bandwidth and transfer result.

Required Environment Variables

Variable Description
SOURCE_TYPE Source backend type: FS, S3, IRODS, or SCP
DEST_TYPE Destination backend type: FS, S3, IRODS, or SCP
SOURCE_PATH Source path to transfer
DEST_PATH Destination path

Optional Environment Variables

Variable Default Description
MAX_FILE_SIZE_MB Unlimited Maximum allowed file size in MiB
CONCURRENCY 10 Number of parallel worker processes
STREAM_CHUNK_SIZE_MB 64 Streaming chunk size in MiB
CHECKSUM_STRATEGY SIZE Validation strategy
CHECKSUM_ALGORITHM md5 Hashing algorithm for full checksums

Checksum Strategies

Strategy Description
SIZE Validate expected byte count and destination size
FULL Hash source stream and compare with destination checksum

Note

The manifest compliance check currently uses:

IntegrityManager("SIZE", max_file_size_mb)

This means the manifest is built using size-based validation and file size limits. Full checksum validation is handled later during streaming by StreamingFileRunner.


Classes

ProgressReader

class ProgressReader:

File-like wrapper used to track upload progress and optionally update a checksum while a stream is being read.

This is mainly used for destination adaptors such as S3Adaptor, where uploads may consume a file-like object directly.

Constructor

def __init__(self, raw, pbar, hasher=None):
Name Type Description
raw file-like object Underlying readable stream
pbar tqdm Progress bar updated as bytes are read
hasher hash object or None Optional hashlib object used for checksum updates

Attributes

Attribute Description
raw Wrapped input stream
pbar Progress bar instance
hasher Optional checksum hasher
bytes_read Total number of bytes read

Methods

read

def read(self, size=-1):

Reads bytes from the wrapped stream.

Updates:

  • progress bar
  • byte counter
  • checksum hasher, if enabled

Returns the chunk read from the underlying stream.

close

def close(self):

Closes the wrapped stream.

__enter__()

def __enter__(self):

Context manager entry method.

__exit__()

def __exit__(self, exc_type, exc, tb):

Context manager exit method. Closes the wrapped stream.


StreamingFileRunner

@dataclasses.dataclass
class StreamingFileRunner:

Handles streaming transfer of individual files from a source adaptor to a destination adaptor.

Each worker process owns one StreamingFileRunner instance.

Field Type Description
source_path str Base source path
upload_dest_base str Base destination path
source_adaptor StorageAdaptor Source storage adaptor
dest_adaptor StorageAdaptor Destination storage adaptor
checksum_strategy str Validation strategy, default SIZE
checksum_algorithm str Hashing algorithm, default md5

StreamingFileRunner._make_hasher()

def _make_hasher(self):

Creates a checksum hasher when full checksum validation is enabled.

Returns

Condition Return Value
checksum_strategy != "FULL" None
checksum_strategy == "FULL" hashlib hasher instance

Example

self.checksum_strategy = "FULL"
self.checksum_algorithm = "md5"

hasher = self._make_hasher()

StreamingFileRunner._normalise_checksum()

def _normalise_checksum(self, value: str | None) -> str | None:

Normalises checksum strings before comparison.

Parameters

Name Type Description
value str | None Raw checksum value

Returns

A cleaned checksum string, or None.

Behaviour

The function:

  • converts the value to string
  • strips whitespace
  • converts to lowercase
  • removes surrounding quotes
  • removes algorithm prefixes such as md5: or sha2:

Example

_normalise_checksum("md5:ABC123")

Returns:

abc123

StreamingFileRunner._checksum_by_reading()

def _checksum_by_reading(self, adaptor: StorageAdaptor, path: str) -> str:

Calculates a checksum by reading a file from a storage adaptor.

This is used when adaptor-native checksums are unavailable or when the native checksum format does not match the configured checksum algorithm.

Parameters

Name Type Description
adaptor StorageAdaptor Storage adaptor to read from
path str File path to checksum

Returns

Checksum digest as a hexadecimal string.

Example

checksum = self._checksum_by_reading(self.dest_adaptor, "/target/file.json")

StreamingFileRunner._get_destination_checksum()

def _get_destination_checksum(self, dest_file_path: str) -> str:

Returns the destination checksum using the same algorithm as the source checksum.

Parameters

Name Type Description
dest_file_path str Destination file path

Returns

Destination checksum string.

Important Behaviour

For md5, the destination file is read back and hashed locally:

if self.checksum_algorithm.lower() == "md5":
    return self._checksum_by_reading(self.dest_adaptor, dest_file_path)

This avoids comparing MD5 against backend-native checksum formats such as iRODS SHA-256/base64 checksums.

For non-MD5 algorithms, the method may attempt to use adaptor-native checksums through:

self.dest_adaptor.get_remote_checksum(dest_file_path)

If that is unavailable, it falls back to reading the destination and hashing it locally.

Raises

Error Cause
RuntimeError Destination cannot provide or calculate a checksum

StreamingFileRunner.stream_single_file()

def stream_single_file(self, source_file_str: ManifestType, position: int = 1):

Streams a single file from the source adaptor to the destination adaptor.

Parameters

Name Type Description
source_file_str ManifestType Manifest entry containing relative path and expected file size
position int tqdm progress-bar position

Returns

The original manifest item after successful transfer.

Behaviour

The method:

  1. Resolves the source file path.
  2. Resolves the destination file path.
  3. Optionally prepares the destination for streaming.
  4. Opens the source file for reading.
  5. Streams the file to the destination.
  6. Updates a progress bar.
  7. Tracks the number of bytes written.
  8. Optionally calculates source checksum while streaming.
  9. Validates byte count.
  10. Validates destination size if supported.
  11. Optionally validates destination checksum.

Source Path Resolution

source_file_path = Path(self.source_path) / source_file

Destination Path Resolution

dest_file_path = Path(self.upload_dest_base) / source_file

Destination Preparation

If the destination adaptor supports:

prepare_for_stream_write()

then the method calls:

self.dest_adaptor.prepare_for_stream_write(
    str(dest_file_path),
    expected_size=expected_size,
)

if this returns True, the file is skipped because a complete destination file already exists.

S3 Upload Behaviour

If the destination adaptor is S3Adaptor, the method uses:

self.dest_adaptor.upload_stream(
    ProgressReader(src, pbar, source_hasher),
    str(dest_file_path),

This allows the S3 adaptor to consume a file-like stream directly.

Generic Upload Behaviour

For non-S3 destinations, the method opens a destination write stream:

with self.dest_adaptor.open_write(str(dest_file_path), overwrite=True) as dst:

Then copies chunks from source to destination:

chunk = src.read(_stream_chunk_size)
dst.write(chunk)

Size Validation

After streaming, the method checks that the number of bytes written matches the expected manifest size:

if bytes_written != expected_size:
    raise RuntimeError(...)

If the destination adaptor supports get_size(), the destination size is also checked.

Checksum Validation

If:

checksum_strategy == "FULL"

the method compares:

source_checksum

against:

dest_checksum

A mismatch raises a RuntimeError.

Raises

Error Cause
RuntimeError Streamed byte count does not match expected size
RuntimeError Destination size does not match expected size
RuntimeError Source and destination checksums do not match
Backend-specific exception Source read or destination write failed

Configuration Reference

Main Workflow INI

Example:

[env]
SOURCE_TYPE=S3
DEST_TYPE=IRODS
SOURCE_PATH=/path/to/example_data
DEST_PATH=/tempZone/home/rods/example_data

MAX_FILE_SIZE_MB=5000
CONCURRENCY=10
STREAM_CHUNK_SIZE_MB=64

CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5

[creds]
SRC_S3_ENDPOINT_URL=https://example-minio:9000
SRC_S3_ACCESS_KEY_ID=...
SRC_S3_SECRET_ACCESS_KEY=...
SRC_S3_REGION=us-east-1
SRC_S3_BUCKET=wp6-health

DST_IRODS_HOST=example-irods-host
DST_IRODS_PORT=1247
DST_IRODS_USER=rods
DST_IRODS_PASSWORD=...
DST_IRODS_ZONE=tempZone

[env]

The [env] section defines workflow behaviour.

Key Required Description
SOURCE_TYPE Yes Source backend type
DEST_TYPE Yes Destination backend type
SOURCE_PATH Yes Source file or directory
DEST_PATH Yes Destination file or directory
MAX_FILE_SIZE_MB No Maximum file size limit
CONCURRENCY No Number of parallel workers
STREAM_CHUNK_SIZE_MB No Stream chunk size in MiB
CHECKSUM_STRATEGY No SIZE or FULL
CHECKSUM_ALGORITHM No Hash algorithm, for example md5

[creds]

The [creds] section stores backend credentials.

It is only loaded when no Infisical configuration is provided.

If either of these arguments is used:

--infisical-src-config
--infisical-dst-config

then [creds] is skipped.


Command-Line Usage

Basic Usage

python -m staging_workflow.cli --config config.ini

With Infisical Secrets

python -m staging_workflow.cli \
  --config configs/workflows/s3_to_irods.ini \
  --infisical-src-config configs/infisical/source.ini \
  --infisical-dst-config configs/infisical/destination.ini

Execution Model

The workflow uses a process pool:

ProcessPoolExecutor(max_workers=concurrency_value)

Each worker process receives:

  • source path
  • destination path
  • source adaptor class
  • destination adaptor class
  • stream chunk size
  • checksum strategy
  • checksum algorithm

Files are submitted as individual tasks:

pool.submit(process_item, item, free_slots)

Each task streams one file.


Validation Behaviour

Size Validation

Always performed.

The workflow checks:

  1. bytes read/written during streaming
  2. destination size, if the adaptor supports get_size()

Full Checksum Validation

Enabled with:

CHECKSUM_STRATEGY=FULL

The workflow:

  1. Creates a hasher using hashlib.
  2. Updates the hasher while reading the source stream.
  3. Calculates or retrieves the destination checksum.
  4. Compares source and destination checksums.

Recommended setting:

CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5

For MD5, the destination is read back and hashed locally to avoid comparing against backend-native checksum formats such as iRODS SHA-256/base64.


Error Handling

Worker failures are captured per file.

If a worker task fails:

logger.exception("Worker task failed")
failed_items.append(futures[completed_future])

The workflow continues processing remaining files.

At the end:

Condition Result
No failed files Logs success
One or more failed files Logs failure and lists failed manifest entries

Backend Requirements

Each adaptor should implement the methods needed for the workflow path being used.

Required for Source Adaptors

Method Purpose
open_read(path) Open source file as readable stream
Manifest/listing support Used by IntegrityManager.check_compliance()
Required for Destination Adaptors

Required for Destination Adaptors

Method Purpose
open_write(path, overwrite=True) Open destination stream for writing
upload_stream(stream, path) Used for S3-style streaming uploads
get_size(path) Optional destination size validation
prepare_for_stream_write(path, expected_size) Optional skip/resume preparation
get_remote_checksum(path) Optional backend-native checksum retrieval
open_read(path) Required when destination checksum is calculated by reading back

Example Transfer Flow

For an S3 to iRODS transfer:

SOURCE_TYPE=S3
DEST_TYPE=IRODS
SOURCE_PATH=/path/to/example
DEST_PATH=/tempZone/home/rods
CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5
CONCURRENCY=4
STREAM_CHUNK_SIZE_MB=64

The workflow will:

  1. Build a manifest from the S3 source.
  2. Resolve the destination path.
  3. Start four worker processes.
  4. Stream each S3 object to iRODS.
  5. Track per-file progress.
  6. Verify byte counts.
  7. Read the iRODS object back and calculate MD5.
  8. Compare source MD5 with destination MD5.
  9. Report failed files, if any.

Adaptors

base.py

Overview

base.py defines the common adaptor interface used by the staging workflow. It provides the abstract StorageAdaptor base class that all storage backend adaptors must implement, such as filesystem, S3, iRODS, and SCP adaptors.

The module also defines TQDMHolder, a small helper class used to manage tqdm progress bars for transfer operations.

The current version is designed primarily around streaming transfers, where files are read from a source backend and written directly to a destination backend without first downloading the full file to a local temporary directory.


Imports

import dataclasses
import hashlib
from abc import ABC, abstractmethod
from typing import BinaryIO, Generator, Tuple

from tqdm import tqdm
Import Purpose
dataclasses Used to define TQDMHolder as a dataclass
hashlib Previously used for local checksum calculation in the legacy API
ABC, abstractmethod Used to define the abstract adaptor interface
BinaryIO Type hint for readable/writable binary streams
Generator Type hint for object listing generators
Tuple Type hint for manifest entries
tqdm Progress bar support

Class: StorageAdaptor

class StorageAdaptor(ABC):

Abstract base class for all storage backend adaptors.

Concrete adaptors such as FSAdaptor, S3Adaptor, IRODSAdaptor, and SCPAdaptor should inherit from this class and implement the required methods.

The adaptor interface is designed around three main operations:

Listing source objects. Opening source objects as readable streams. Writing or uploading streams to a destination backend.


Constructor

def __init__(self, prefix):

Initialises the adaptor with an environment-variable prefix.

Parameters

Name Type Description
prefix str Prefix used to identify source or destination environment variables

Typical values are:

SRC
DST

For example, an S3 adaptor may use this prefix to read variables such as:

SRC_S3_ENDPOINT_URL
SRC_S3_BUCKET
DST_S3_ENDPOINT_URL
DST_S3_BUCKET

Example

source_adaptor = S3Adaptor("SRC")
dest_adaptor = IRODSAdaptor("DST")

Attribute: prefix

self.prefix = prefix

Stores the adaptor prefix.

This is used by concrete adaptors to load the correct environment variables for source or destination configuration.


Abstract Methods

list_objects()

@abstractmethod
def list_objects(self, path: str) -> Generator[Tuple[str, int], None, None]:

Lists files or objects under a given source path.

This method is used during manifest generation. Each returned item represents one transferable file.

Parameters

Name Type Description
path str Source directory, prefix, collection, or file path

Yields

A generator of tuples:

(relative_path, size_in_bytes)
Tuple Item Type Description
relative_path str File path relative to the source base path
size_in_bytes int File size in bytes

Example Return Values

yield ("file.json", 2048)
yield ("nested/data/file.csv", 1048576)

Expected Behaviour

Implementations should recursively list files where appropriate.

For example:

Backend Expected Listing Behaviour
Filesystem Walk local directories
S3 List objects under a bucket prefix
iRODS List data objects under a collection
SCP List files on a remote host

open_read()

@abstractmethod
def open_read(self, source_path: str) -> BinaryIO:

Opens a source file or object as a readable binary stream.

This method is used by the streaming workflow to read source data in chunks.

Name Type Description
source_path str Path to the source file or object

Returns

A readable binary stream.

The returned object should support:

read(size)
close()

and ideally context manager usage:

with adaptor.open_read(path) as src:
    chunk = src.read(8192)

Example

with source_adaptor.open_read("/data/file.csv") as src:
    data = src.read(1024)

get_size()

@abstractmethod
def get_size(self, path: str) -> int:

Returns the size of a file or object in bytes.

This is used for compliance checks and destination validation.

Parameters

Name Type Description
path str File, object, or collection path

Returns

File size in bytes.

Example

size = adaptor.get_size("/data/file.csv")

Expected Return

1048576

Optional Streaming Methods

open_write()

def open_write(self, dest_path: str, overwrite: bool = True) -> BinaryIO:

Opens a destination file or object as a writable binary stream.

Backends that support normal writable file handles should override this method.

For example:

Backend Suitable for open_write()
Local filesystem Yes
SCP/SFTP Yes
iRODS Yes, depending on implementation
S3 Usually no

Parameters

Name Type Default Description
dest_path str Required Destination file path
overwrite bool True Whether to overwrite an existing destination file

Returns

A writable binary stream.

The returned object should support:

write(bytes)
close()

and ideally context manager usage:

with adaptor.open_write(path, overwrite=True) as dst:
    dst.write(chunk)

Default Behaviour

The base class raises:

NotImplementedError

Raises

NotImplementedError

if the concrete adaptor does not support writable streams.


upload_stream()

def upload_stream(self, stream: BinaryIO, dest_path: str, extra_args=None) -> None:

Uploads a readable stream to the destination backend.

This method is mainly intended for object stores such as S3, where a normal writable file handle is not usually available.

Parameters

Name Type Default Description
stream BinaryIO Required Readable binary stream to upload
dest_path str Required Destination object path
extra_args dict \| None None Optional backend-specific upload arguments

Returns

None

Example

with source_adaptor.open_read("/source/file.csv") as src:
    s3_adaptor.upload_stream(src, "/target/file.csv")

Default Behaviour

The base class raises:

NotImplementedError

Raises

NotImplementedError

if the concrete adaptor does not support stream uploads.


prepare_for_stream_write()

def prepare_for_stream_write(
    self,
    dest_path: str,
    expected_size: int | None = None,
) -> bool:

Optional helper used before streaming a file to the destination.

This can be overridden by adaptors that are able to check whether a destination object already exists and whether it is complete.

Parameters

Name Type Default Description
dest_path str Required Destination file or object path
expected_size int \| None None Expected file size in bytes

Returns

Return Value Meaning
True Destination already exists and should be skipped
False Transfer should proceed

Default Behaviour

return False

The base implementation always tells the workflow to continue with the transfer.

Example Use Case

An adaptor may implement:

def prepare_for_stream_write(self, dest_path, expected_size=None):
    if self.exists(dest_path):
        if expected_size is not None and self.get_size(dest_path) == expected_size:
            return True

    return False

This allows the workflow to skip files that already exist with the correct size.


checksum_remote()

def checksum_remote(self, path: str):

Optional backend-native checksum helper.

This method is intended to delegate to a backend-specific checksum implementation.

Parameters

Name Type Description
path str Path to the remote or local file/object

Returns

A backend-native checksum value.

The returned format may depend on the backend.

Examples:

Backend Possible Checksum Format
Filesystem MD5 hex
S3 ETag or metadata checksum
iRODS SHA-256/base64
SCP Locally calculated checksum after reading

Class: TQDMHolder

@dataclasses.dataclass
class TQDMHolder:

Helper class for managing a tqdm progress bar during transfer operations.

This class is mainly useful for adaptor-specific callbacks, such as S3 or SCP progress callbacks.


Fields

filename: str
pbar: tqdm = None
bytes_to_mb = 1e6
amount_transferred: int = 0
Field Type Default Description
filename str Required File name displayed in the progress bar
pbar tqdm \| None None Progress bar instance
bytes_to_mb float 1e6 Conversion factor from bytes to megabytes
amount_transferred int 0 Number of bytes transferred so far

init_tqdm()

def init_tqdm(self, total_in_mb, position=1):

Parameters

Name Type Default Description
total_in_mb int \| float Required Total transfer size in MB
position int 1 Terminal row position for the progress bar

Behaviour

Creates a tqdm progress bar with:

self.pbar = tqdm(
    desc=self.filename,
    total=total_in_mb,
    unit="MB",
    unit_scale=True,
    position=position,
    leave=False,
)

Example

holder = TQDMHolder("file.csv")
holder.init_tqdm(total_in_mb=100)

callback_s3()

def callback_s3(self, chunk_bytes: int):

Progress callback intended for S3 transfers.

Boto-style callbacks report the size of the most recent chunk, not the cumulative number of bytes transferred. This method accumulates the total internally.

Parameters

Name Type Description
chunk_bytes int Number of bytes transferred in the latest callback

Behaviour

  1. Ensures the progress bar exists.
  2. Adds chunk_bytes to amount_transferred.
  3. Updates the progress bar.

callback_scp()

def callback_scp(self, current_bytes: int, total_bytes: int):

Progress callback intended for SCP/SFTP transfers.

SCP-style callbacks usually report the current cumulative byte count and total byte count.

Parameters

Name Type Description
current_bytes int Current number of bytes transferred
total_bytes int Total file size in bytes

Behaviour

If the progress bar has not been initialised, it creates one using the total file size.

Then it updates the progress bar to the current byte count.


update()

def update(self, current_bytes: int):

Updates the progress bar to reflect the current transferred byte count.

Parameters

Name Type Description
current_bytes int Current number of transferred bytes

Behaviour

Converts bytes to megabytes:

current_mb = int(current_bytes // self.bytes_to_mb)

Then updates the progress bar position:

self.pbar.n = current_mb
self.pbar.refresh()

__enter__()

def __enter__(self):

Context manager entry method.

Returns

The current TQDMHolder instance.

Example

with TQDMHolder("file.csv") as progress:
    progress.init_tqdm(100)

__exit__()

def __exit__(self, exc_type, exc_val, exc_tb):

Context manager exit method.

Closes the progress bar.

Parameters

Name Description
exc_type Exception type, if raised
exc_val Exception value, if raised
exc_tb Exception traceback, if raised

Behaviour

self.pbar.close()

irods_adaptor.py

Overview

irods_adaptor.py implements the iRODS backend for the staging workflow. It defines IRODSAdaptor, a concrete implementation of StorageAdaptor, allowing the workflow to list, read, write, validate, skip, delete, and checksum iRODS data objects.

The adaptor supports the streaming workflow by exposing open_read() and open_write() methods. This allows data to be streamed directly between iRODS and another backend without first staging full files in a temporary local directory.


Imports

import os

from irods.session import iRODSSession

from .base import StorageAdaptor
Import Purpose
os Reads iRODS connection settings from environment variables and handles path manipulation
iRODSSession Creates an authenticated iRODS session
StorageAdaptor Base adaptor interface implemented by IRODSAdaptor

Class: IRODSAdaptor

class IRODSAdaptor(StorageAdaptor):

Concrete storage adaptor for iRODS.

This class provides iRODS-specific implementations for:

  • listing collections and data objects
  • opening data objects for reading
  • opening data objects for writing
  • checking object sizes
  • checking whether paths exist as data objects or collections
  • deleting data objects
  • preparing destinations for streaming writes
  • retrieving native iRODS checksums

Constructor

__init__()

def __init__(self, prefix):

Parameters

Name Type Description
prefix str Environment variable prefix, usually SRC or DST

Environment Variables

The adaptor expects the following variables:

Variable Pattern Example Description
{prefix}_IRODS_HOST SRC_IRODS_HOST iRODS server hostname
{prefix}_IRODS_PORT SRC_IRODS_PORT iRODS server port
{prefix}_IRODS_USER SRC_IRODS_USER iRODS username
{prefix}_IRODS_PASSWORD SRC_IRODS_PASSWORD iRODS password
{prefix}_IRODS_ZONE SRC_IRODS_ZONE iRODS zone

Example

source_adaptor = IRODSAdaptor("SRC")
dest_adaptor = IRODSAdaptor("DST")

For IRODSAdaptor("SRC"), the adaptor reads:

SRC_IRODS_HOST
SRC_IRODS_PORT
SRC_IRODS_USER
SRC_IRODS_PASSWORD
SRC_IRODS_ZONE

Behaviour

initialises:

self.session = iRODSSession(...)

The session is reused by all methods on the adaptor instance.


Path Helper Methods

_sanitize_path()

def _sanitize_path(self, path):

Normalises an iRODS path.

Parameters

Name Type Description
path str Raw iRODS path

Returns

A cleaned absolute iRODS path.

Behaviour

The method:

  1. Removes an irods:// prefix if present.
  2. Converts backslashes to forward slashes.
  3. Ensures the path starts with /.
  4. Removes trailing slashes.

Example

self._sanitize_path("irods://tempZone/home/rods/data/")

Returns

/tempZone/home/rods/data

_get_parent()

def _get_parent(self, path):

Returns the parent collection path for an iRODS path.

Parameters

Name Type Description
path str iRODS path

Returns

The parent path.

Example

self._get_parent("/tempZone/home/rods/data/file.txt")

Returns

/tempZone/home/rods/data

_ensure_collection_exists()

def _ensure_collection_exists(self, collection_path):

Ensures that an iRODS collection exists, creating parent collections recursively if needed.

Parameters

Name Type Description
collection_path str iRODS collection path

Returns

None

Behaviour

The method:

  1. Sanitises the collection path.
  2. Checks whether the collection already exists.
  3. Recursively creates missing parent collections.
  4. Creates the requested collection.

Example

self._ensure_collection_exists("/tempZone/home/rods/output/subdir")

If needed, this creates:

/tempZone/home/rods/output
/tempZone/home/rods/output/subdir

Listing Methods

list_objects()

def list_objects(self, path):

Lists iRODS data objects under a collection or returns a single data object.

This method implements the StorageAdaptor.list_objects() interface.

Parameters

Name Type Description
path str iRODS collection path or data object path

Yields

Tuples of:

(relative_path, size_in_bytes)
Item Type Description
relative_path str File path relative to the listed root
size_in_bytes int Data object size in bytes

Behaviour

The method first tries to treat path as a collection.

If it is a collection, it recursively yields all data objects under it.

If it is not a collection, it tries to treat path as a single data object.

If neither lookup succeeds, it prints an error.

Collection Example

Given:

/tempZone/home/rods/data/
├── a.csv
└── nested/
    └── b.csv

Calling:

list(adaptor.list_objects("/tempZone/home/rods/data"))

May return:

[
    ("a.csv", 1234),
    ("nested/b.csv", 5678),
]

Single File Example

Calling:

list(adaptor.list_objects("/tempZone/home/rods/data/a.csv"))

May return:

[
    ("a.csv", 1234),
]

Streaming Read/Write Methods

open_read()

def open_read(self, source_path):

Opens an iRODS data object as a readable binary stream.

This method is used when iRODS is the source backend.

**Parameters

Name Type Description
source_path str Path to an iRODS data object

Returns

A readable stream from the iRODS data object.

Behaviour

The method:

  1. Sanitises the source path.
  2. Checks whether the path is a collection.
  3. Raises IsADirectoryError if the path is a collection.
  4. Opens the data object in read mode.

Example

with adaptor.open_read("/tempZone/home/rods/data/file.csv") as src:
    chunk = src.read(1024)

Raises

Error Cause
IsADirectoryError Path points to an iRODS collection
iRODS exception Data object does not exist or cannot be opened

open_write()

def open_write(self, dest_file_path, overwrite=True):

Opens an iRODS data object as a writable stream.

This method is used when iRODS is the destination backend.

Parameters

Name Type Default Description
dest_file_path str Required Destination iRODS data object path
overwrite bool True Whether to overwrite an existing data object

Returns

A writable stream for the iRODS data object.

Behaviour

The method:

  1. Sanitises the destination path.
  2. Determines the parent collection.
  3. Creates missing parent collections.
  4. Checks whether the destination path already exists as a collection.
  5. Deletes an existing data object if overwrite=True.
  6. Raises FileExistsError if the object exists and overwrite=False.
  7. Creates a new iRODS data object.
  8. Opens it in write mode.

Example

with adaptor.open_write("/tempZone/home/rods/output/file.csv") as dst:
    dst.write(b"example data")

Raises

Error Cause
IsADirectoryError Destination path exists as a collection
FileExistsError Destination object exists and overwrite=False
iRODS exception Object cannot be created or opened

Size and Existence Methods

get_size()

def get_size(self, path):

Returns the size of an iRODS data object in bytes.

This method implements the StorageAdaptor.get_size() interface.

Parameters

Name Type Description
path str iRODS data object path

Returns

The data object size in bytes.

Example

size = adaptor.get_size("/tempZone/home/rods/data/file.csv")

Returns

1048576

Raises

An iRODS exception if the object does not exist or cannot be accessed.


exists_as_data_object()

def exists_as_data_object(self, path):

Checks whether a path exists as an iRODS data object.

Parameters

Name Type Description
path str iRODS path

Returns

Return Value Meaning
True Path exists as a data object
False Path does not exist as a data object

Example

adaptor.exists_as_data_object("/tempZone/home/rods/data/file.csv")

exists_as_collection()

def exists_as_collection(self, path):

Checks whether a path exists as an iRODS collection.

Parameters

Name Type Description
path str iRODS path

Returns

Return Value Meaning
True Path exists as a collection
False Path does not exist as a collection

Example

adaptor.exists_as_collection("/tempZone/home/rods/data")

Delete and Resume Methods

delete_data_object()

def delete_data_object(self, path):

Deletes an iRODS data object if it exists.

Parameters

Name Type Description
path str iRODS data object path

Returns

None

Behaviour

The method:

  1. Sanitises the path.
  2. Checks whether it exists as a data object.
  3. Deletes it using force=True if it exists.

Example

adaptor.delete_data_object("/tempZone/home/rods/output/file.csv")

prepare_for_stream_write()

def prepare_for_stream_write(self, dest_file_path, expected_size=None):

Prepares an iRODS destination path before streaming a file.

This method supports file-level resume/skip behaviour.

Parameters

Name Type Default Description
dest_file_path str Required Destination iRODS data object path
expected_size int \| None None Expected file size in bytes

Returns

Return Value Meaning
True Existing destination object has the expected size and can be skipped
False Transfer should proceed

Behaviour

The method:

  1. Sanitises the destination path.
  2. Raises an error if the destination exists as a collection.
  3. Returns False if no data object exists at the destination.
  4. If a data object exists and its size matches expected_size, returns True.
  5. If a data object exists but its size differs or size is unknown, deletes it and returns False.

Example

should_skip = adaptor.prepare_for_stream_write(
    "/tempZone/home/rods/output/file.csv",
    expected_size=1048576,
)

if should_skip:
    print("File already exists and is complete")

Raises

Error Cause
IsADirectoryError Destination exists as an iRODS collection

Checksum Methods

get_remote_checksum()

def get_remote_checksum(self, path):

Returns the native iRODS checksum for a data object.

Parameters

Name Type Description
path str iRODS data object path

Returns

A checksum string, or None if the checksum cannot be retrieved.

Behaviour

The method:

  1. Sanitises the path.
  2. Retrieves the iRODS data object.
  3. Uses the existing registered checksum if present.
  4. Otherwise asks iRODS to calculate one with chksum().
  5. Removes a checksum algorithm prefix if present.

For example, if iRODS returns:

sha2:abc123

the method returns:

abc123

Example

checksum = adaptor.get_remote_checksum("/tempZone/home/rods/output/file.csv")

Error Handling

If anything fails, the method returns:

None

instead of raising the exception.


Destructor**

__del__()

def __del__(self):

Cleans up the iRODS session when the adaptor instance is garbage collected.

Behaviour

If the adaptor has a session attribute, the method calls:

self.session.cleanup()

Environment Configuration Example

[env]
SOURCE_TYPE=IRODS
DEST_TYPE=FS
SOURCE_PATH=/tempZone/home/rods/input
DEST_PATH=/tmp/output

[creds]
SRC_IRODS_HOST=vm-10-195-10-192.cloud.mwn.de
SRC_IRODS_PORT=1247
SRC_IRODS_USER=rods
SRC_IRODS_PASSWORD=your-password
SRC_IRODS_ZONE=tempZone

For iRODS as a destination:

[env]
SOURCE_TYPE=S3
DEST_TYPE=IRODS
SOURCE_PATH=/Cleaned_Data_EDD
DEST_PATH=/tempZone/home/rods/output

[creds]
DST_IRODS_HOST=vm-10-195-10-192.cloud.mwn.de
DST_IRODS_PORT=1247
DST_IRODS_USER=rods
DST_IRODS_PASSWORD=your-password
DST_IRODS_ZONE=tempZone

Example Usage

iRODS as Source

adaptor = IRODSAdaptor("SRC")

for rel_path, size in adaptor.list_objects("/tempZone/home/rods/input"):
    print(rel_path, size)

with adaptor.open_read("/tempZone/home/rods/input/file.csv") as src:
    data = src.read(1024)

iRODS as Destination

adaptor = IRODSAdaptor("DST")

with adaptor.open_write("/tempZone/home/rods/output/file.csv") as dst:
    dst.write(b"example data")

size = adaptor.get_size("/tempZone/home/rods/output/file.csv")

s3_adaptor.py

Overview

s3_adaptor.py implements the S3-compatible backend for the staging workflow. It defines S3Adaptor, a concrete implementation of StorageAdaptor, allowing the workflow to list, read, upload, size-check, skip, delete, and checksum objects in S3-compatible object storage.

The adaptor is designed for streaming transfers. It can stream data from S3 using open_read() and stream data to S3 using upload_stream(). Since S3 does not expose normal writable file handles, open_write() is intentionally unsupported.

This adaptor works with AWS S3 and S3-compatible services such as MinIO, provided the correct endpoint and credentials are configured.


Imports

import logging
import os
from pathlib import Path

import boto3
import botocore
from boto3.s3.transfer import TransferConfig
from botocore.client import Config
from types_boto3_s3.client import S3Client

from .base import StorageAdaptor, TQDMHolder
Import Purpose
logging Logs S3 operations
os Reads S3 configuration from environment variables
boto3 Creates the S3 client
botocore Handles S3 client errors
TransferConfig Configures multipart upload behaviour
Config Configures S3 client options
S3Client Type hint for the boto3 S3 client
StorageAdaptor Base adaptor interface
Path Imported but not currently used
TQDMHolder Imported but not currently used in active code

Logger

logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

Initialises module-level logging for S3 adaptor operations.

The adaptor logs actions such as:

  • opening S3 objects for reading
  • streaming uploads to S3
  • skipping existing objects
  • deleting incomplete or mismatched objects

Class: S3Adaptor

class S3Adaptor(StorageAdaptor):

Concrete storage adaptor for S3-compatible object storage.

This class implements the streaming interface required by the staging workflow.

Supported operations include:

  • listing objects under a prefix
  • opening an object as a readable stream
  • uploading a readable stream to S3
  • retrieving object size
  • checking existing destination objects
  • retrieving MD5 metadata or safe ETag checksums

Constructor

__init__()

def __init__(
    self,
    prefix,
    transfer_config: TransferConfig = None,
    position: int = 1,
):

Initialises an S3 adaptor using environment variables and creates a boto3 S3 client.

Parameters

Name Type Default Description
prefix str Required Environment variable prefix, usually SRC or DST
transfer_config TransferConfig \| None None Optional boto3 transfer configuration
position int 1 Progress-bar position used by worker processes

Environment Variables

The adaptor expects the following variables:

Variable Pattern Example Required Description
{prefix}_S3_BUCKET SRC_S3_BUCKET Yes S3 bucket name
{prefix}_S3_ENDPOINT_URL SRC_S3_ENDPOINT_URL Yes S3 or MinIO endpoint URL
{prefix}_S3_ACCESS_KEY_ID SRC_S3_ACCESS_KEY_ID Yes S3 access key
{prefix}_S3_SECRET_ACCESS_KEY SRC_S3_SECRET_ACCESS_KEY Yes S3 secret key
{prefix}_S3_REGION SRC_S3_REGION No S3 region, defaults to us-east-1
{prefix}_S3_VERIFY_SSL SRC_S3_VERIFY_SSL No Whether to verify SSL certificates, defaults to true

Example

source_adaptor = S3Adaptor("SRC")
dest_adaptor = S3Adaptor("DST")

For S3Adaptor("SRC"), the adaptor reads:

SRC_S3_BUCKET
SRC_S3_ENDPOINT_URL
SRC_S3_ACCESS_KEY_ID
SRC_S3_SECRET_ACCESS_KEY
SRC_S3_REGION
SRC_S3_VERIFY_SSL

S3 Client Configuration

The adaptor creates a boto3 client with:

s3_config = Config(
    signature_version="s3v4",
    s3={"addressing_style": "path"},
    connect_timeout=60,
    read_timeout=300,
    retries={"mode": "adaptive", "total_max_attempts": 10},
)

Behaviour

Setting Purpose
signature_version="s3v4" Uses AWS Signature Version 4
addressing_style="path" Uses path-style bucket addressing, useful for MinIO
connect_timeout=60 Allows up to 60 seconds for connection establishment
read_timeout=300 Allows up to 300 seconds for read operations
retries={"mode": "adaptive", "total_max_attempts": 10} Enables adaptive retries for transient failures

SSL Verification

verify_env = os.getenv(f"{prefix}_S3_VERIFY_SSL", "true").strip().lower()
verify_ssl = verify_env not in {"0", "false", "no"}

SSL verification is enabled by default.

To disable SSL verification for testing or internal MinIO endpoints:

SRC_S3_VERIFY_SSL=false

or:

DST_S3_VERIFY_SSL=false

Transfer Configuration

If no custom TransferConfig is supplied, the adaptor uses:

Setting Value Description
multipart_threshold 64 MiB Objects larger than this use multipart upload
multipart_chunksize 1 GiB Size of each multipart upload part
max_concurrency 2 Number of S3 upload threads per transfer
use_threads True Enables threaded multipart uploads

Path Helper Methods

_sanitize_path()

def _sanitize_path(self, path):

Normalises an S3 path into an object key.

Parameters

Name Type Description
path str Raw S3 path, object key, or s3:// URI

Returns

A clean S3 object key without a leading slash or bucket prefix.

Behaviour

The method:

  1. Removes an s3:// prefix if present.
  2. Removes the bucket name from the start of the path if present.
  3. Converts backslashes to forward slashes.
  4. Removes leading slashes.

Examples

self._sanitize_path("s3://my-bucket/data/file.csv")

Returns:

data/file.csv
self._sanitize_path("/data/file.csv")

Returns:

data/file.csv
self._sanitize_path("data\\file.csv")

Returns

data/file.csv

Listing Methods

list_objects()

def list_objects(self, path):

Lists S3 objects under a prefix or returns a single object if the path points directly to an object.

This method implements the StorageAdaptor.list_objects() interface.

Parameters

Name Type Description
path str S3 prefix, object key, or s3:// URI

Yields

Tuples of:

(relative_path, size_in_bytes)
Item Type Description
relative_path str Object path relative to the requested prefix
size_in_bytes int Object size in bytes

Behaviour

The method first checks whether path is a single object:

response = self.client.head_object(Bucket=self.bucket, Key=prefix)

If that succeeds, it yields one manifest item:

yield os.path.basename(prefix), response["ContentLength"]

If the object does not exist, it treats the path as a prefix and paginates through matching objects:

paginator = self.client.get_paginator("list_objects_v2")

Each listed object is returned relative to the prefix.


Single Object Example

Given:

s3://my-bucket/data/file.csv

Calling:

list(adaptor.list_objects("data/file.csv"))

May return:

[
    ("file.csv", 1048576),
]

Prefix Example

Given:

s3://my-bucket/data/
├── a.csv
└── nested/b.csv

Calling:

list(adaptor.list_objects("data"))

May return:

[
    ("a.csv", 1234),
    ("nested/b.csv", 5678),
]

Empty Prefix Example

Calling:

list(adaptor.list_objects(""))

lists objects from the bucket root.


Error Handling

If head_object() fails with one of these codes, the method treats the path as a prefix:

404
NoSuchKey
NotFound

Other errors are re-raised.


Streaming Read/Write Methods

open_read()

def open_read(self, source_path):

Opens an S3 object as a readable binary stream.

This method is used when S3 is the source backend. Parameters

Name Type Description
source_path str S3 object key or path

Returns

A readable streaming body returned by boto3:

response["Body"]

This object supports:

read(size)
close()

Example

with adaptor.open_read("data/file.csv") as src:
    chunk = src.read(8192)

Behaviour

The method:

  1. Sanitises the source path.
  2. Logs the object key.
  3. Calls get_object().
  4. Returns the response body stream.
response = self.client.get_object(
    Bucket=self.bucket,
    Key=key,
)

Raises

Boto3 or botocore exceptions if the object cannot be opened.


open_write()

def open_write(self, dest_file_path, overwrite=True):

S3 does not support normal writable file handles.

This method always raises NotImplementedError.

Parameters

Name Type Default Description
dest_file_path str Required Destination object path
overwrite bool True Included for interface compatibility

Raises

NotImplementedError

Message

S3 does not support open_write(). Use upload_stream() instead.

upload_stream()

def upload_stream(self, stream, dest_file_path, extra_args=None):

Uploads a readable binary stream directly to S3.

This method is used when S3 is the destination backend.

Parameters

Name Type Default Description
stream file-like object Required Readable binary stream
dest_file_path str Required Destination S3 object key or path
extra_args dict \| None None Optional boto3 upload arguments

Returns

None

Behaviour

The method:

  1. Sanitises the destination path.
  2. Logs the target key.
  3. Calls upload_fileobj().
self.client.upload_fileobj(
    stream,
    self.bucket,
    key,
    ExtraArgs=extra_args or {},
    Config=self.transfer_config,
)

Example

with source_adaptor.open_read("/source/file.csv") as src:
    s3_adaptor.upload_stream(src, "output/file.csv")

Raises

Boto3 or botocore exceptions if the upload fails.


Size and Resume Methods

get_size()

Returns the size of an S3 object in bytes.

This method implements the StorageAdaptor.get_size() interface.

Parameters

Name Type Description
path str S3 object key or path

Returns

Object size in bytes.

Behaviour

The method:

  1. Sanitises the path.
  2. Calls head_object().
  3. Returns ContentLength.
response = self.client.head_object(
    Bucket=self.bucket,
    Key=key,
)

return response["ContentLength"]

Example

size = adaptor.get_size("data/file.csv")

Returns:

1048576

Raises

Boto3 or botocore exceptions if the object does not exist or cannot be accessed.


prepare_for_stream_write()

def prepare_for_stream_write(self, dest_file_path, expected_size=None):

Prepares an S3 destination before streaming upload.

This method supports file-level resume/skip behaviour.

Parameters

Name Type Default Description
dest_file_path str Required Destination S3 object key or path
expected_size int \| None None Expected object size in bytes

Returns

Return Value Meaning
True Existing object has the expected size and can be skipped
False Upload should proceed

Behaviour

The method:

  1. Sanitises the destination key.
  2. Checks if the destination object exists with head_object().
  3. If the object exists and its size matches expected_size, returns True.
  4. If the object exists but size differs, deletes it and returns False.
  5. If the object does not exist, returns False.
  6. Re-raises unexpected S3 errors.

Matching Existing Object

if expected_size is not None and existing_size == expected_size:
    logger.info("[S3] Existing object has matching size, skipping: %s", key)
    return True

Mismatched Existing Object

self.client.delete_object(
    Bucket=self.bucket,
    Key=key,
)

Not Found Codes

The following errors are treated as “object does not exist”:

404
NoSuchKey
NotFound

Example

should_skip = adaptor.prepare_for_stream_write(
    "output/file.csv",
    expected_size=1048576,
)

if not should_skip:
    adaptor.upload_stream(src, "output/file.csv")

Checksum Methods

def get_remote_checksum(self, remote_path):

Returns an S3 object checksum if one can be safely determined.

Parameters

Name Type Description
remote_path str S3 object key or path

Returns

A checksum string, or None.

Behaviour

The method:

  1. Sanitises the object key.
  2. Calls head_object().
  3. Checks object metadata for an md5 value.
  4. If metadata contains md5, returns that value.
  5. Otherwise, reads the object ETag.
  6. Returns the ETag only if it does not contain -.
  7. Returns None for multipart ETags or on failure.
metadata = response.get("Metadata", {})

if "md5" in metadata:
    return metadata["md5"]

etag = response.get("ETag", "").replace('"', "")

return etag if "-" not in etag else None

Example Environment Configuration

S3 as Source

[env]
SOURCE_TYPE=S3
DEST_TYPE=IRODS
SOURCE_PATH=/Cleaned_Data_EDD
DEST_PATH=/tempZone/home/rods/Cleaned_Data_EDD

[creds]
SRC_S3_ENDPOINT_URL=https://minio.example.org:9000
SRC_S3_ACCESS_KEY_ID=your-access-key
SRC_S3_SECRET_ACCESS_KEY=your-secret-key
SRC_S3_REGION=us-east-1
SRC_S3_BUCKET=wp6-health
SRC_S3_VERIFY_SSL=false

S3 as Destination

[env]
SOURCE_TYPE=IRODS
DEST_TYPE=S3
SOURCE_PATH=/tempZone/home/rods/Cleaned_Data_EDD
DEST_PATH=/backup/Cleaned_Data_EDD

[creds]
DST_S3_ENDPOINT_URL=https://minio.example.org:9000
DST_S3_ACCESS_KEY_ID=your-access-key
DST_S3_SECRET_ACCESS_KEY=your-secret-key
DST_S3_REGION=us-east-1
DST_S3_BUCKET=backup-bucket
DST_S3_VERIFY_SSL=false

Example Usage

S3 as Source

adaptor = S3Adaptor("SRC")

for rel_path, size in adaptor.list_objects("Cleaned_Data_EDD"):
    print(rel_path, size)

with adaptor.open_read("Cleaned_Data_EDD/population_malta.json") as src:
    chunk = src.read(1024)

S3 as Destination

adaptor = S3Adaptor("DST")

with open("population_malta.json", "rb") as src:
    adaptor.upload_stream(src, "backup/population_malta.json")

size = adaptor.get_size("backup/population_malta.json")

fs_adaptor.py

Overview

fs_adaptor.py implements the local filesystem backend for the staging workflow. It defines FSAdaptor, a concrete implementation of StorageAdaptor, allowing the workflow to list, read, write, validate, skip, and checksum files on a local or mounted filesystem.

The adaptor is used when either the source or destination type is configured as:

SOURCE_TYPE=FS

or:

DEST_TYPE=FS

It supports the streaming workflow by exposing normal file-based open_read() and open_write() methods. This makes it suitable for transfers such as:

FS -> S3
FS -> iRODS
S3 -> FS
iRODS -> FS
SCP -> FS

Class: FSAdaptor

class FSAdaptor(StorageAdaptor):

Concrete storage adaptor for local filesystem paths.

The adaptor provides filesystem-specific implementations for:

  • listing files under a directory
  • opening files for binary reading
  • opening files for binary writing
  • checking file sizes
  • creating destination directories
  • skipping already-complete destination files
  • calculating local checksums

Constructor

__init__()

def __init__(self, prefix):

Initialises the filesystem adaptor.

Parameter

Name Type Description
prefix str Environment variable prefix, usually SRC or DST

Behaviour

The filesystem adaptor usually does not require credentials, but it still accepts a prefix for consistency with the other adaptors.

Example:

source_adaptor = FSAdaptor("SRC")
dest_adaptor = FSAdaptor("DST")

Unlike S3, iRODS, or SCP, the filesystem adaptor typically does not need variables such as usernames, passwords, hosts, or buckets.


Path Handling

Filesystem paths are handled as local paths.

Examples:

/data/input/file.csv
/tmp/staging/output
/dss/dsshome1/05/di38jim2/data

The adaptor should preserve normal filesystem paths and use Python’s pathlib.Path or os.path utilities to resolve directories, parents, and file sizes.


Listing Methods

list_objects()

def list_objects(self, path: str):

Lists files under a local directory or returns a single file.

This method implements the StorageAdaptor.list_objects() interface.

Parameters

Name Type Description
path str Local file or directory path

Yields

Tuples of:

(relative_path, size_in_bytes)
Item Type Description
relative_path str File path relative to the source root
size_in_bytes int File size in bytes

Behaviour

If path is a file, the method yields a single item:

("file.csv", 1048576)

If path is a directory, the method recursively walks the directory and yields all files under it.

For example, given:

/data/input/
├── a.csv
└── nested/
    └── b.csv

Calling:

list(adaptor.list_objects("/data/input"))

should return:

[
    ("a.csv", 1234),
    ("nested/b.csv", 5678),
]

Expected Implementation Pattern

def list_objects(self, path: str):
    root = Path(path)

    if root.is_file():
        yield root.name, root.stat().st_size
        return

    for file_path in root.rglob("*"):
        if file_path.is_file():
            rel_path = file_path.relative_to(root)
            yield str(rel_path), file_path.stat().st_size

Raises

Error Cause
FileNotFoundError Source path does not exist
NotADirectoryError Path is invalid for directory listing
PermissionError User cannot access the file or directory

Streaming Read/Write Methods

open_read()

def open_read(self, source_path: str):

Opens a local file as a readable binary stream.

This method is used when the filesystem is the source backend.

Parameters

Name Type Description
source_path str Local source file path

Returns

A readable binary file object.

Example

with adaptor.open_read("/data/input/file.csv") as src:
    chunk = src.read(1024)

Expected Implementation

def open_read(self, source_path: str):
    return open(source_path, "rb")

Raises

Error Cause
FileNotFoundError Source file does not exist
IsADirectoryError Source path is a directory
PermissionError File cannot be read

open_write()

def open_write(self, dest_path: str, overwrite: bool = True):

Opens a local destination file as a writable binary stream.

This method is used when the filesystem is the destination backend.

Parameters

Name Type Default Description
dest_path str Required Local destination file path
overwrite bool True Whether to overwrite an existing file

Returns

A writable binary file object.

Behaviour

The method should:

  1. Resolve the destination path.
  2. Create missing parent directories.
  3. Open the file in binary write mode.
  4. Respect the overwrite setting.

Example

with adaptor.open_write("/tmp/output/file.csv", overwrite=True) as dst:
    dst.write(b"example data")

Raises

Error Cause
FileExistsError Destination exists and overwrite=False
IsADirectoryError Destination path points to a directory
PermissionError Destination cannot be written

Size Methods

get_size()

def get_size(self, path: str) -> int:

Returns the size of a local file in bytes.

This method implements the StorageAdaptor.get_size() interface.

Parameters

Name Type Description
path str Local file path

Returns

File size in bytes.

Example

size = adaptor.get_size("/tmp/output/file.csv")

Returns:

1048576

Raises

Error Cause
FileNotFoundError File does not exist
IsADirectoryError Path points to a directory
PermissionError File metadata cannot be accessed

Resume and Skip Methods

prepare_for_stream_write()

def prepare_for_stream_write(
    self,
    dest_path: str,
    expected_size: int | None = None,
) -> bool:

Prepares a local filesystem destination before streaming a file.

This method supports file-level resume/skip behaviour.

Parameters

Return Value Meaning
True Existing file has the expected size and can be skipped
False Transfer should proceed

Returns

Return Value Meaning
True Existing file has the expected size and can be skipped
False Transfer should proceed

Behaviour

The method should:

  1. Check whether the destination exists.
  2. If it does not exist, return False.
  3. If it exists as a directory, raise IsADirectoryError.
  4. If it exists and size matches expected_size, return True.
  5. If it exists and size differs, delete the file and return False.

Example

should_skip = adaptor.prepare_for_stream_write(
    "/tmp/output/file.csv",
    expected_size=1048576,
)

if should_skip:
    print("File already exists and is complete")

Checksum Methods

get_remote_checksum()

def get_remote_checksum(self, path: str):

Returns the checksum of a local file.

Although the name says “remote”, for the filesystem adaptor this refers to the local file at the given path.

Parameters

Name Type Description
path str Local file path

Returns

Checksum as a string, usually MD5.

Example

checksum = adaptor.get_remote_checksum("/tmp/output/file.csv")

Returns

8747a4309a6e27e05334b71ff2c9894e

Example Environment Configuration

Filesystem as Source

[env]
SOURCE_TYPE=FS
DEST_TYPE=S3
SOURCE_PATH=/dss/dsshome1/05/di38jim2/input
DEST_PATH=/backup/input

CONCURRENCY=4
STREAM_CHUNK_SIZE_MB=64
CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5

No [creds] entries are required for the filesystem source.


Filesystem as Destination

[env]
SOURCE_TYPE=S3
DEST_TYPE=FS
SOURCE_PATH=/Cleaned_Data_EDD
DEST_PATH=/dss/dsshome1/05/di38jim2/output

CONCURRENCY=4
STREAM_CHUNK_SIZE_MB=64
CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5

Example Usage

List Local Files

adaptor = FSAdaptor("SRC")

for rel_path, size in adaptor.list_objects("/data/input"):
    print(rel_path, size)

Example output:

a.csv 1234
nested/b.csv 5678

Read a Local File

adaptor = FSAdaptor("SRC")

with adaptor.open_read("/data/input/a.csv") as src:
    chunk = src.read(1024)

Write a Local File

adaptor = FSAdaptor("DST")

with adaptor.open_write("/tmp/output/a.csv", overwrite=True) as dst:
    dst.write(b"example data")

Check Destination Size

adaptor = FSAdaptor("DST")

size = adaptor.get_size("/tmp/output/a.csv")
print(size)

Skip Existing Complete File

adaptor = FSAdaptor("DST")

should_skip = adaptor.prepare_for_stream_write(
    "/tmp/output/a.csv",
    expected_size=1234,
)

if should_skip:
    print("Skipping existing complete file")

scp_adaptor.py

Overview

scp_adaptor.py implements the SCP/SFTP backend for the staging workflow. It defines SCPAdaptor, a concrete implementation of StorageAdaptor, using paramiko to connect to a remote host over SSH and transfer files through SFTP streams.

The adaptor supports the streaming workflow by exposing:

open_read()
open_write()
list_objects()
get_size()
prepare_for_stream_write()
get_remote_checksum()

This allows the workflow to stream files:

SCP -> FS
SCP -> S3
SCP -> iRODS
FS -> SCP
S3 -> SCP
iRODS -> SCP

Imports

import logging
import os
import stat

import paramiko

from .base import StorageAdaptor, TQDMHolder
Import Purpose
logging Logs SCP/SFTP operations and errors
os Reads environment variables and handles path operations
stat Checks whether remote paths are files or directories
paramiko Provides SSH and SFTP functionality
StorageAdaptor Base adaptor interface
TQDMHolder Imported but not currently used in active code

Logger

logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

Initialises module-level logging.

Used for messages such as:

  • missing remote paths
  • skipped existing files
  • deleting incomplete or mismatched files

Class: SCPAdaptor

class SCPAdaptor(StorageAdaptor):

Concrete storage adaptor for remote SSH/SFTP-accessible filesystems.

The adaptor provides methods for:

  • connecting to a remote host
  • listing files recursively
  • opening remote files for reading
  • opening remote files for writing
  • checking remote file sizes
  • skipping already-complete destination files
  • deleting incomplete destination files
  • calculating remote MD5 checksums using md5sum
  • closing SSH/SFTP connections

Constructor

__init__()

Creates an SSH connection and opens an SFTP session.

Parameters

Name Type Description
prefix str Environment variable prefix, usually SRC or DST

Environment Variables

The adaptor expects the following variables:

Variable Pattern Example Required Description
{prefix}_SCP_HOST SRC_SCP_HOST Yes Remote SSH hostname or IP address
{prefix}_SCP_PORT SRC_SCP_PORT No SSH port, defaults to 22
{prefix}_SCP_USER SRC_SCP_USER Yes SSH username
{prefix}_SCP_PASSWORD SRC_SCP_PASSWORD Yes SSH password

Example

source_adaptor = SCPAdaptor("SRC")
dest_adaptor = SCPAdaptor("DST")

For SCPAdaptor("SRC"), the adaptor reads:

SRC_SCP_HOST
SRC_SCP_PORT
SRC_SCP_USER
SRC_SCP_PASSWORD

SSH Setup

self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

The adaptor creates a Paramiko SSH client and automatically accepts missing host keys.

Connection

self.ssh.connect(
    self.host,
    port=self.port,
    username=self.user,
    password=self.password,
)
self.sftp = self.ssh.open_sftp()

If the connection fails, the constructor raises:

ConnectionError

with the message:

SCP Connection Failed: ...

Listing Methods

list_objects()

def list_objects(self, path):

Lists files under a remote path.

This method implements the StorageAdaptor.list_objects() interface.

Parameters

Name Type Description
path str Remote file or directory path

Yields

Tuples of:

(relative_path, size_in_bytes)
Item Type Description
relative_path str File path relative to the listed root
size_in_bytes int Remote file size in bytes

Behaviour

The method first normalises the path:

path = path.replace("\\", "/")

Then it checks the remote path type:

attr = self.sftp.stat(path)

If the path is a regular file:

yield os.path.basename(path), attr.st_size

If the path is a directory, it recursively walks through the directory using:

self.sftp.listdir_attr(base_path)

and yields all files below it.


Single File Example

Given this remote file:

/home/user/data/file.csv

Calling:

list(adaptor.list_objects("/home/user/data/file.csv"))

May return:

[
    ("file.csv", 1048576),
]

Directory Example

Given this remote directory:

/home/user/data/
├── a.csv
└── nested/
    └── b.csv

Calling:

list(adaptor.list_objects("/home/user/data"))

May return:

[
    ("a.csv", 1234),
    ("nested/b.csv", 5678),
]

Error Handling

If the path does not exist, the method logs:

[SCP] Path not found: <path>

and returns without yielding anything.


Streaming Read/Write Methods

open_read()

def open_read(self, source_path):

Opens a remote SCP/SFTP file as a readable binary stream.

This method is used when SCP/SFTP is the source backend.

Parameters

Name Type Description
source_path str Remote source file path

Returns

A Paramiko SFTP file object opened in binary read mode.

self.sftp.open(remote_path, "rb")

Behavior

The method:

  1. Normalises path separators.
  2. Checks the remote path with sftp.stat().
  3. Verifies that the path is a regular file.
  4. Opens the remote file for binary reading.

Example

with adaptor.open_read("/home/user/data/file.csv") as src:
    chunk = src.read(1024)

Raises

Error Cause
IsADirectoryError Source path is not a regular file
FileNotFoundError Source file does not exist
Paramiko/SFTP exception Remote file cannot be accessed

open_write()

def open_write(self, dest_file_path, overwrite=True):

Opens a remote SCP/SFTP file as a writable binary stream.

This method is used when SCP/SFTP is the destination backend.

Parameters

Name Type Default Description
dest_file_path str Required Remote destination file path
overwrite bool True Whether to overwrite an existing file

Returns

A Paramiko SFTP file object opened in binary write mode.

self.sftp.open(remote_path, "wb")

Behaviour

The method:

  1. Normalises path separators.
  2. Determines the parent directory.
  3. Creates the parent directory if needed.
  4. Checks whether the destination already exists.
  5. Raises an error if the destination is a directory.
  6. Raises an error if the destination exists and overwrite=False.
  7. Removes the existing file if overwrite=True.
  8. Opens the destination file for binary writing.

Example

with adaptor.open_write("/home/user/output/file.csv", overwrite=True) as dst:
    dst.write(b"example data")

Raises

Error Cause
IsADirectoryError Destination exists as a directory
FileExistsError Destination exists and overwrite=False
FileNotFoundError Parent path cannot be created or accessed
Paramiko/SFTP exception Remote file cannot be opened

Size Methods

get_size()

def get_size(self, path):

Returns the size of a remote SCP/SFTP file in bytes.

This method implements the StorageAdaptor.get_size() interface.

Parameters

Name Type Description
path str Remote file path

Returns

Remote file size in bytes.

return self.sftp.stat(remote_path).st_size

Example

size = adaptor.get_size("/home/user/output/file.csv")

Returns

1048576

Raises

Error Cause
FileNotFoundError Remote file does not exist
Paramiko/SFTP exception Remote metadata cannot be read

Resume and Skip Methods

prepare_for_stream_write()

Prepares a remote SCP/SFTP destination path before streaming upload.

This method supports file-level resume/skip behaviour.

Parameters

Name Type Default Description
dest_file_path str Required Remote destination file path
expected_size int \| None None Expected file size in bytes

Returns

Return Value Meaning
True Existing destination file has the expected size and can be skipped
False Transfer should proceed

Behaviour

The method:

  1. Normalises the destination path.
  2. Checks whether the destination exists.
  3. Raises an error if the destination is a directory.
  4. If the existing file size matches expected_size, returns True.
  5. If the existing file size differs, deletes the file and returns False.
  6. If the file does not exist, returns False.

Matching Existing File

if expected_size is not None and existing_size == expected_size:
    logger.info("[SCP] Existing file has matching size, skipping: %s", remote_path)
    return True

Mismatched Existing File

self.sftp.remove(remote_path)
return False

Example

should_skip = adaptor.prepare_for_stream_write(
    "/home/user/output/file.csv",
    expected_size=1048576,
)

if should_skip:
    print("Skipping existing complete file")

Destructor

__del__()

def __del__(self):

Closes the SFTP and SSH connections when the adaptor object is garbage collected.

Behaviour

If self.sftp exists, the method attempts to close it:

self.sftp.close()

If self.ssh exists, the method attempts to close it:

self.ssh.close()

OSError exceptions are ignored during cleanup.


Environment Configuration Example

SCP/SFTP as Source

[env]
SOURCE_TYPE=SCP
DEST_TYPE=S3
SOURCE_PATH=/home/user/input
DEST_PATH=/backup/input

CONCURRENCY=4
STREAM_CHUNK_SIZE_MB=64
CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5

[creds]
SRC_SCP_HOST=remote.example.org
SRC_SCP_PORT=22
SRC_SCP_USER=myuser
SRC_SCP_PASSWORD=mypassword

SCP/SFTP as Destination

[env]
SOURCE_TYPE=S3
DEST_TYPE=SCP
SOURCE_PATH=/Data
DEST_PATH=/home/user/output/Data

CONCURRENCY=4
STREAM_CHUNK_SIZE_MB=64
CHECKSUM_STRATEGY=FULL
CHECKSUM_ALGORITHM=md5

[creds]
DST_SCP_HOST=remote.example.org
DST_SCP_PORT=22
DST_SCP_USER=myuser
DST_SCP_PASSWORD=mypassword

Example Usage

SCP/SFTP as Source

adaptor = SCPAdaptor("SRC")

for rel_path, size in adaptor.list_objects("/home/user/input"):
    print(rel_path, size)

with adaptor.open_read("/home/user/input/file.csv") as src:
    chunk = src.read(1024)

SCP/SFTP as Destination

adaptor = SCPAdaptor("DST")

with adaptor.open_write("/home/user/output/file.csv", overwrite=True) as dst:
    dst.write(b"example data")

size = adaptor.get_size("/home/user/output/file.csv")

Skip Existing Complete File

should_skip = adaptor.prepare_for_stream_write(
    "/home/user/output/file.csv",
    expected_size=2048,
)

if should_skip:
    print("Remote file already exists with matching size")