Skip to content

oxedi in a pipeline

A pipeline step that receives an 835, checks it and hands it on needs four moves: parse the bytes, look at the diagnostics, keep or reject, and write the result out.

The step

import oxedi
from pathlib import Path


def process(data: bytes, out_dir: Path, name: str) -> list:
    """Parse one file; write its tables as Parquet; return its findings."""
    try:
        result = oxedi.parse(data)
    except oxedi.ParseError as error:  # not an 835 at all
        return [str(error)]

    findings = [str(d) for d in result.diagnostics]
    if findings:
        return findings  # leave the file for a person; write nothing

    out_dir.mkdir(parents=True, exist_ok=True)
    for table in result.tables.keys():
        result.tables[table].to_polars().write_parquet(out_dir / f"{name}-{table}.parquet")
    return []


problems = process(Path("remittance.835").read_bytes(), Path("out"), "remittance")
print("clean" if not problems else problems)

parse never raises on questionable data, only on a file that is not an 835. Diagnostics are the signal. To triage, filter on level (the SNIP level) or rule. See Diagnostics.

The step has no global state and no I/O of its own beyond what you pass in, so it runs the same in a script, a task or a consumer.

Keep the original bytes

A parsed file writes back exactly as it came in. Archive that alongside the tables, so the source of every number is one call away:

result = oxedi.parse_file("remittance.835")
assert result.document.write() == Path("remittance.835").read_bytes()

Airflow

Wrap process in a task. Pass paths, not bytes, between tasks. Not executed here, because it needs an Airflow installation:

from airflow.sdk import task  # Airflow 2: from airflow.decorators import task

@task
def load_remittance(path: str) -> list:
    return process(Path(path).read_bytes(), Path("/data/out"), Path(path).stem)

Dagster

An asset with the findings as metadata. Not executed here, because it needs Dagster:

from dagster import Config, Output, asset

class RemitConfig(Config):
    path: str

@asset
def remittance_tables(context, config: RemitConfig):
    path = config.path
    findings = process(Path(path).read_bytes(), Path("/data/out"), Path(path).stem)
    return Output(None, metadata={"findings": len(findings)})

Kafka

Treat each message as one file: call process on the message value and send a message with findings to a dead-letter topic when the list is not empty. Commit the offset only after the output is written, so a crash re-processes the message rather than losing it. oxedi.stream(data) yields one transaction at a time when a single message is large.

Alongside SQL

If the files land in object storage, the same checks run in DuckDB without Python. See a Parquet lakehouse.