Skip to main content

opteryx-upload

Python client SDK for the Opteryx Upload Service.

Install

pip install opteryx-upload

Parts are compressed before upload. gzip comes from the standard library, so that works out of the box; installing the zstd extra adds a denser and faster codec, which the SDK then selects automatically:

pip install "opteryx-upload[zstd]"

The command line

Installing the package puts opteryx-upload on your PATH.

export OPTERYX_CLIENT_ID="<client id>"
export OPTERYX_CLIENT_SECRET="<pat secret>"
opteryx-upload push findings.csv --to acme.security.findings

You are not asked where the schema comes from, because the destination answers it. A dataset that already declares its columns supplies them, and the only question left is whether these rows are added or replace what is there. A dataset that does not exist yet has its types read from your data and shows them to you before anything is written:

  findings.csv  686.5 KB

  acme.security.findings  new, types read from your data

  column     sample                type
  cve_id     CVE-2026-00001        VARCHAR
  published  2026-08-02T04:22:07Z  VARCHAR
  source_ip  10.1.7.13             VARCHAR
  hosts      1                     INT64

  these types were read from your data
  accept [enter]   change column=TYPE   drop -column   stop q
  > published=TIMESTAMP source_ip=IPV4

Nothing is uploaded until that is settled. published and source_ip are the reason: a CSV cannot say that a column of dotted quads is an address, and once it is catalogued as VARCHAR no amount of reading the data back will tell you it was wrong.

In a pipeline

There is no terminal to show the table to, so inference has to be authorised in advance - --yes accepts what was read from the data, and --declare says the types outright. A push with neither is refused rather than guessed at.

opteryx-upload push data/*.parquet --to acme.security.findings \
    --type published=TIMESTAMP --type source_ip=IPV4 \
    --message "nightly load" --yes

plan does the same negotiation, prints the table and abandons the contract, so it uploads nothing and leaves nothing behind:

opteryx-upload plan data/*.parquet --to acme.security.findings --json

Exit codes are part of the interface, because a pipeline that has to grep stderr will eventually retry the wrong thing:

code meaning retrying
0 committed -
2 bad arguments, missing file, no credentials no
3 the service refused it: a value that will not cast, files that disagree no
4 the target moved after the contract was agreed yes
5 not signed in, or not permitted to write here no
6 the service could not be reached yes
130 interrupted -

Options

--to WORKSPACE.COLLECTION.DATASET where the rows go (required)
--append / --overwrite for a dataset that exists; asked if you are at a terminal
--type COLUMN=TYPE correct one type without a prompt; repeatable
--ignore COLUMN read this column and do not write it; repeatable
--infer / --use-dataset / --declare COLUMN:TYPE override the destination's answer
-y, --yes accept inferred types unasked; required off a terminal
--json the contract as the service sent it

Credentials

Set OPTERYX_CLIENT_ID to your access token username and OPTERYX_CLIENT_SECRET to the access token. OPTERYX_TOKEN takes a bearer JWT instead; an access token in the environment wins over one, and --token wins over both.

The service comes from OPTERYX_UPLOAD_URL and the authenticate service from OPTERYX_AUTH_URL. Each has a flag if you would rather pass it.

The full-screen version

Run it with no arguments at a terminal and this is what you get - typing the name and nothing else means you want to upload something, not to read a list of subcommands. Off a terminal, no arguments prints the usage instead.

opteryx-upload                                             # a is add, t is target
opteryx-upload tui findings.csv --to acme.security.findings  # or start it filled in

Same contract, same calls - what it adds is that the table stays put. At a scrolling prompt the plan goes past once and correcting a type means retyping the whole command; here the cursor moves down it and e changes the type of the row under the cursor.

 opteryx upload                                              http://upload.opteryx.app

 FILES
   findings.csv       686.5 KB
   findings_more.csv  457.7 KB

 TO
   acme.security.findings

 PLAN   a new dataset; these types were read from your data
   column     sample                type
   cve_id     CVE-2026-00001        VARCHAR
   published  2026-08-02T04:22:07Z  TIMESTAMP[us]   was VARCHAR, converted
 › source_ip  10.1.7.13             IPV4            was VARCHAR, converted
   hosts      1                     INT64
   score      0.5                   FLOAT64         read and not written

 these types were read from your data - nothing is written until you accept
 ↑↓ column  e retype  x ignore  ⏎ accept  u upload  r re-plan  q quit

h lists every key. c signs in, a adds files, t sets the destination, n negotiates, x drops a column, u uploads and commits.

a opens a file browser:

 ADD FILES  ~/exports/2026-08
    ..
 ›  part-0000.parquet   412.9 MB
  ✓ part-0001.parquet   398.1 MB
  ✓ part-0002.parquet   401.7 MB
    SHA256SUMS               64 B

 2 to add
 ↑↓ move  ⏎ open  ← up  space tag  a all here  g type a path  . hidden  esc back

The cursor always starts on the top row, so a sequence of keys means the same thing in every directory. Space tags, a tags every readable file in the directory, and tagging survives walking into another one, so an upload can gather from several places. Files the service cannot read are listed and dimmed rather than hidden - an empty directory is the one answer that sends you looking in the wrong place. Anything already on the upload list shows as tagged and is not offered twice.

g types a path, a folder or a glob instead, which is still the fastest way in when the path is already on your clipboard.

Both front ends use the Alucard palette, defined once in opteryx_upload/cli/render.py. Truecolor when the terminal advertises it, the nearest xterm-256 index when it does not, and the basic eight underneath that. NO_COLOR turns it all off.

Starting it with no credentials opens the screen rather than refusing: press c and it asks for your access token username, then the access token, which is masked as you type. It exchanges them straight away, so a mistyped token is a line on the status bar rather than a 401 half way through negotiating. It never asks for a bearer JWT. Neither is written anywhere - they are held for the session; set them in the environment to skip the prompt. Requests run on a worker thread and the screen keeps redrawing while they do, so a multi-gigabyte write shows a byte counter rather than a frozen terminal. Quitting with a contract still open abandons it - nothing written was ever readable, so there is nothing to undo.

It needs curses, which is in the standard library everywhere except Windows; there, pip install windows-curses, or use push.

Usage

from opteryx_upload import UploadClient, Target, ConflictResolution

client = UploadClient(token="<jwt>")  # or token=lambda: fetch_fresh_token()

session = client.create_session()
session.upload_file("findings.parquet")
session.upload_file("more_findings.csv")  # compressed, and auto-split if still too big

result = session.inspect()
if result.has_issues:
    raise SystemExit(result.issues)

commit = session.commit(
    Target(workspace="acme", collection="security", dataset="findings"),
    snapshot_message="Initial load",
    conflict_resolution=ConflictResolution.APPEND,
)
print(commit.table, commit.commit_id, commit.rows_written)

Or in one call:

client.upload_and_commit(
    ["findings.parquet"],
    Target("acme", "security", "findings"),
    snapshot_message="Initial load",
)

Authenticating with an access token

If you have an access token (a username + the token itself) instead of a ready-made JWT, use PATAuthenticator to exchange it for a short-lived assertion. It caches the token and transparently re-authenticates before it expires, so you can pass it straight through as token=:

from opteryx_upload import UploadClient, PATAuthenticator

auth = PATAuthenticator(client_id="<username>", client_secret="<access token>")
client = UploadClient(token=auth)

This exchanges the access token via POST {auth_url}/token with grant_type=client_credentials (default auth_url is https://authenticate.opteryx.app), the same flow used by the opteryx-sqlalchemy driver. If the API ever rejects a token as expired/invalid, call auth.invalidate() and retry to force a fresh exchange.

Examples

Each UploadSession maps directly onto the service's REST flow: create a session, stage one or more parts, inspect them, then commit. See the service README for the underlying HTTP API these calls wrap.

End-to-end: upload and commit a dataset

from opteryx_upload import UploadClient, Target, ConflictResolution

client = UploadClient(token="<jwt>")

session = client.create_session()
print(session.info.session_id, session.info.expires_at)  # sessions expire after 6 hours

session.upload_file("findings.parquet")
session.upload_file("more_findings.parquet")

result = session.inspect()
print(result.rows_estimate, result.schema)
if result.has_issues:
    for issue in result.issues:
        print(f"part {issue.part}: {issue.issue}")
    raise SystemExit("fix the reported issues before committing")

commit = session.commit(
    Target(workspace="acme", collection="security", dataset="findings"),
    snapshot_message="Initial load of findings",
    conflict_resolution=ConflictResolution.FAIL,  # default: error if the dataset already exists
)
print(f"committed {commit.rows_written} rows across {commit.files_created} files as {commit.commit_id}")

Choosing a conflict resolution strategy

  • ConflictResolution.FAIL (default) — reject the commit if the dataset already exists.
  • ConflictResolution.APPEND — add the new rows to the existing dataset (schemas must match).
  • ConflictResolution.OVERWRITE — replace the existing dataset's contents entirely.
session.commit(
    Target("acme", "security", "findings"),
    conflict_resolution=ConflictResolution.OVERWRITE,
)

Uploading many files, then deciding what to commit

Parts can be staged incrementally (e.g. from multiple upload jobs) before a single commit, and a bad part can be removed before it's committed:

session = client.create_session()
part_numbers = []
for path in ("2026-01.parquet", "2026-02.parquet", "2026-03.parquet"):
    part_numbers += session.upload_file(path)

result = session.inspect()
if result.has_issues:
    bad_part = result.issues[0].part
    session.delete_part(bad_part)
    result = session.inspect()

session.commit(Target("acme", "security", "findings"))

Handling errors

from opteryx_upload import (
    UploadClient,
    ConflictError,
    SessionExpiredError,
    UnprocessableEntityError,
)

client = UploadClient(token="<jwt>")
session = client.create_session()

try:
    session.upload_file("findings.csv")
    session.commit(Target("acme", "security", "findings"))
except UnprocessableEntityError as exc:
    print(f"file rejected: {exc}")
except ConflictError as exc:
    print(f"commit conflict, consider ConflictResolution.APPEND/OVERWRITE: {exc}")
except SessionExpiredError:
    session = client.create_session()  # start over with a fresh session

One-shot upload

For simple jobs where you just want to push files straight into a table:

client.upload_and_commit(
    ["findings.parquet"],
    Target("acme", "security", "findings"),
    snapshot_message="Initial load",
)

Authenticating with an access token end-to-end

from opteryx_upload import UploadClient, PATAuthenticator, Target

client = UploadClient(
    token=PATAuthenticator(client_id="acme-etl", client_secret="opt_XXXXXXXX_01"),
)
client.upload_and_commit(["findings.parquet"], Target("acme", "security", "findings"))

Notes

  • Files are auto-typed from their extension (.parquet, .csv, .ndjson/.jsonl).

  • CSV and NDJSON files larger than the part size limit are automatically split into multiple parts (CSV chunks repeat the header row). Parquet is a binary format and cannot be split this way — write multiple smaller parquet files and upload each as a separate part if a single export is too large.

  • CSV and NDJSON parts are compressed before upload and sent with Content-Encoding. compression="auto" (the default) uses zstd when zstandard is installed and gzip otherwise; pass "gzip", "zstd" or None to choose explicitly. Parquet is never compressed — it already is, internally.

    This matters more than bandwidth: the server's 30MB part limit applies to the compressed bytes, so a compressed part carries far more rows and a large file needs far fewer parts. A 55MB NDJSON export goes from 2 parts to 1 at ~7x. Parts are also bounded by max_source_bytes (default 190MB), because the server decodes at most 200MB per part.

  • Errors map to typed exceptions (AuthenticationError, SessionExpiredError, ConflictError, UnprocessableEntityError, etc.) so callers can catch specific failure modes instead of parsing HTTP status codes.

  • token may be a plain string or a zero-arg callable, so short-lived JWTs can be refreshed transparently between requests.

Development

pip install -e ".[dev]"
pytest tests/

Release files for opteryx-upload 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for opteryx-upload 0.4.0
File Size Uploaded
opteryx_upload-0.4.0.tar.gz 80.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for opteryx-upload 0.4.0
File Interpreter ABI Platform
opteryx_upload-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size:143.6 kB

Release files / opteryx_upload-0.4.0.tar.gz

Download URL opteryx_upload-0.4.0.tar.gz
Size 80.6 kB
Tags Source
SHA-256 checksum
How to use checksums
afb9149ceaa87a054b5e90ef62b24f48bc235a0745537439dce03d0196777c31
BLAKE2b-256 checksum
How to use checksums
2f7baa8fa3eb43fa081e57b5acdc31d0bb75370aa1f5fc19160e939fc52713c6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.

Transparency log

Release files / opteryx_upload-0.4.0-py3-none-any.whl

Download URL opteryx_upload-0.4.0-py3-none-any.whl
Size 62.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4a5fc93e226c3b0f1ca0b51cf0ebb341789b678e7fd09b851a10346cc623a314
BLAKE2b-256 checksum
How to use checksums
2f59c12825b37ddd27fab653f604372181e71ff38403e67dad4234d7e7a889f2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.0

2 release files

0.5.0

2 release files

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page