Metadata-Version: 2.4
Name: omtx
Version: 2.0.20
Summary: Official Python SDK for the Om API
Author-email: Om <hello@omtx.ai>
License: MIT
Project-URL: Homepage, https://omtx.ai
Project-URL: Documentation, https://omtx.ai/docs
Project-URL: Source, https://github.com/omtx-ai/vibe-discovery
Project-URL: Issues, https://github.com/omtx-ai/vibe-discovery/issues
Project-URL: LULA-2 Weights, https://huggingface.co/omtx/lula-2
Project-URL: LULA-2 Quickstart, https://huggingface.co/omtx/lula-2/blob/main/notebooks/lula2_quickstart.ipynb
Project-URL: LULA-1.1 Weights, https://huggingface.co/omtx/lula-1.1
Keywords: om,omtx,bioinformatics,drug-discovery,protein-design
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Requires-Dist: polars>=1.17
Requires-Dist: rdkit>=2023.9.5
Provides-Extra: lula
Requires-Dist: huggingface_hub<2.0.0,>=0.24.0; extra == "lula"
Requires-Dist: numpy>=1.24; extra == "lula"
Requires-Dist: rdkit>=2023.9.1; extra == "lula"
Requires-Dist: torch>=2.4; extra == "lula"
Requires-Dist: transformers<5.0.0,>=4.35.0; extra == "lula"
Requires-Dist: safetensors>=0.4.3; extra == "lula"
Dynamic: license-file

# Om Python SDK (`omtx`)

Official Python SDK for the Om API.

The SDK talks to the public Om API `/v2/*` surface for Om-owned workflows and
to RCSB public file URLs for exact PDB structure downloads. It covers:
- Diligence workflows and job polling
- Active public Hub workflows through `client.hub.submit(...)` and selected typed helpers
- Hosted LULA-1 and LULA-2 protein-sequence plus SMILES scoring
- Artifact upload for artifact-backed Hub jobs
- Entitlement-scoped dataset catalog, shard exports, and Polars-backed `OmData` loaders
- Private data-generation order helpers for subscription quota and invoice-backed orders
- Om Accessible Space molecule availability, quote, Wallet Credits order, and order status helpers
- Public RCSB PDB/mmCIF structure download helpers
- Health and Wallet Credits helpers

Public docs: `https://omtx.ai/docs`

## Installation

```bash
pip install omtx
```

## Compatibility

For best compatibility:
- Linux: modern Ubuntu on x86_64 or arm64
- macOS: Apple Silicon with a native `arm64` Python interpreter
- macOS Python distribution: Miniforge or Mambaforge recommended

The dataframe helpers in `omtx` use `polars`:
- `load_binders(...)`
- `load_nonbinders(...)`
- `load_data(...)`

If you are on Apple Silicon, use a native `arm64` shell and Python. Avoid
Rosetta / `x86_64` Python for dataframe-backed workflows.

If you are on older `x86_64` hardware, or if the default `polars` runtime fails
with CPU-feature errors, install the compatibility runtime:

```bash
pip install "polars[rtcompat]"
```

Or install both in one step:

```bash
pip install omtx "polars[rtcompat]"
```

JSON-based SDK methods may still work without `rtcompat`, but dataframe helpers
are not guaranteed on older `x86_64` CPUs unless the compatibility runtime is
installed.

## Setup

```bash
export OMTX_API_KEY="your-api-key"
```

The SDK targets `https://api.omtx.ai`.

## Quick Start

```python
from omtx import OmClient

with OmClient() as client:
    print(client.status())

    job = client.diligence.deep_diligence(
        query="CRISPR applications in cancer therapy",
        preset="quick",
    )

    result = client.jobs.wait(
        job["job_id"],
        result_endpoint="/v2/jobs/deep-diligence/{job_id}",
    )
    print(result.get("result", {}).get("total_claims"))
```

## Hub Quick Start

```python
from omtx import OmClient

with OmClient() as client:
    artifact = client.artifacts.upload("target.cif")

    job = client.hub.boltzgen(
        protocol="protein_anything",
        target_cif_artifact_id=artifact["artifact_id"],
        target_chain_id="A",
        binder_length_min=90,
        binder_length_max=110,
        idempotency_key="boltzgen-demo-20260325",
    )

    status = client.jobs.wait(job["job_id"], poll_interval=5, timeout=3600)
    print(status["status"])
```

For active public Hub models without a dedicated typed helper, use
`client.hub.submit(job_type="hub.<model>", payload=...)`.

## Hosted LULA Scoring

```python
from omtx import OmClient

with OmClient() as client:
    job = client.lula2.score(
        protein_sequence="MEEPQSDPSV",
        smiles=["CCO", "c1ccccc1"],
    )
    print(job["status"], job["job_ids"])
```

Hosted `client.lula1.score(...)` and `client.lula2.score(...)` submit async
LULA scoring jobs for explicit SMILES or fixed-price Om Accessible Space tiers.
For Om space scoring, pass `source="om"`, a wallet-credit `tier`, and `n`. The
SDK does not expose `mode`; LULA-1 and LULA-2 are separate product namespaces.
Legacy async `client.hub.lula1(...)` remains available for current Hub job
compatibility.

```python
with OmClient() as client:
    job = client.lula2.score(
        protein_sequence=sequence,
        source="om",
        tier=50,
        n=100000,
    )
    print(job["batch_id"], job["job_count"], job["status"])
```

Hosted score calls return a launch envelope with `batch_id`, `job_ids`,
`job_count`, `total_molecules`, and `status`. Completed LULA score records
include `score`, `rank`, and `top_percentile_in_batch`. `score` is a bounded
0-1 model score intended for ranking and enrichment, not a calibrated binding
probability. Rank and percentile are numeric values computed
within the submitted batch.

## Local LULA Open-Weight Scoring

Install the optional local scorer only when you want to run public LULA weights
on your own machine:

```bash
pip install "omtx[lula]>=2.0.20"
hf auth login
omtx lula download --model lula1.1
omtx lula verify
```

Model cards, weights, licenses, and notebooks:
[`huggingface.co/omtx/lula-1`](https://huggingface.co/omtx/lula-1),
[`huggingface.co/omtx/lula-1.1`](https://huggingface.co/omtx/lula-1.1), and
[`huggingface.co/omtx/lula-2`](https://huggingface.co/omtx/lula-2).
LULA open-weight releases are public gated on Hugging Face. By downloading,
accessing, or using LULA weights, you agree to the applicable
[Om LULA Community License 1.3](https://huggingface.co/omtx/lula-2/blob/main/LICENSE).
Commercial use requires a separate Om commercial license; email `dmc@omtx.ai`
for commercial licensing.
This includes commercial hosted inference, paid API/SaaS access, resale, paid
support/deployment, bundling model access into a paid product, and competing
model services.

Use `--model lula1`, `--model lula1.1`, or `--model lula2`. In Python, pass the
same public selector to `load_model(...)`. LULA-2 uses the epoch3656
cross-attention checkpoint package with `model/best.pt`, `model_config.json`,
and `inference_config.json`.

Hugging Face login is required for gated LULA weights. In Colab, `omtx>=2.0.20`
does not pin NumPy below 2.

Score a real target:

```python
from omtx.lula import load_model

CA2 = (
    "MSHHWGYGKHNGPEHWHKDFPIAKGERQSPVDIDTHTAKYDPSLKPLSVSYDQATSLRIL"
    "NNGHAFNVEFDDSQDKAVLKGGPLDGTYRLIQFHFHWGSLDGQGSEHTVDKKKYAAELHL"
    "VHWNTKYGDFGKAVQQPDGLAVLGIFLKVGSAKPGLQKVVDVLDSIKTKGKSADFTNFDP"
    "RGLLPESLDYWTYPGSLTTPPLLECVTWIVLKEPISVSSEQVLKFRKLNFNGEGEPEELM"
    "VDNWRPAQPLKNRQIKASFK"
)
mols = [
    "CC(=O)Nc1nnc(s1)S(N)(=O)=O",
    "Cc1ccc(cc1)S(=O)(=O)N",
    "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
    "CCN(CC)CCNC(=O)c1ccc(N)cc1",
    "c1ccc(cc1)C(=O)O",
    "CCO",
]

model = load_model("lula1.1")
for row in sorted(model.score(protein_sequence=CA2, smiles=mols), key=lambda r: r["rank"]):
    print(row["rank"], round(row["score"], 4), row["smiles"])
```

Local scoring uses downloaded weights and cached encoder assets. It does not
contact Om or Hugging Face during explicit-SMILES scoring unless you explicitly
run a download. Local scoring over Om Accessible Space requires an authenticated
`OmClient` so the SDK can fetch orderable Om rows with source metadata before
running the local model.
The local scorer returns the same customer-facing score fields as hosted LULA:
`score`, `rank`, and `top_percentile_in_batch`. The canonical `score` remains the
0-1 model score; rank and percentile are batch-ranking aids. Do not interpret
any LULA score field as an experimental binding probability unless you have
calibrated it against your own assay data.
If you pass `--encoder-cache-dir` to `omtx lula download`, pass the same value to
`omtx lula score`.

```python
from omtx import OmClient
from omtx.lula import load_model

with OmClient(api_key="omtx_...") as client:
    model = load_model("lula1.1")
    scores = model.score(
        protein_sequence=CA2,
        source="om",
        tier=50,
        n=50000,
        client=client,
    )
```

## Local LULA Fine-Tuning

Fine-tuning uses only the CSV you provide. The SDK freezes ESM2 and ChemBERTa,
precomputes embeddings locally, and evaluates the `test` split after each
epoch. For LULA-1/LULA-1.1 it trains the protein and ligand projectors. For
LULA-2 it trains residual cross-attention adapters plus
attention-pooling/scoring layers while keeping the encoders and base
cross-attention backbone frozen. There is no hidden replay of Om training data
and no upload of proteins, SMILES, labels, scores, or checkpoints.

```csv
target_id,protein_sequence,smiles,label,split
STAT6,MEEPQSDPSV,CCOc1ccc(CCN)cc1,1,train
STAT6,MEEPQSDPSV,CN1CCN(CC1)c1ccccc1,0,train
STAT6,MEEPQSDPSV,CC(=O)Nc1ccccc1,1,test
STAT6,MEEPQSDPSV,CCN(CC)CC,0,test
```

```bash
omtx lula finetune customer.csv --out stat6_lula
```

For LULA-2:

```bash
omtx lula finetune customer.csv --model lula2 --out stat6_lula2
```

The default base is `lula1.1`, epochs default to `10`, and intermediate
checkpoints are saved every epoch. The low-data learning-rate default is
model-specific: `1e-6` for LULA-1/LULA-1.1 projectors and `1e-5` for the
LULA-2 adapter/pooling/head surface. Use `--save-every 0` to write only the
final checkpoint. Continue from a prior derived checkpoint with:

```bash
omtx lula finetune customer_round2.csv --base stat6_lula/final --out stat6_lula_round2
```

Outputs:

```text
stat6_lula/
  checkpoints/
  final/
  training_curve.csv
  metrics.json
  split_manifest.json
  training_config.json
  provenance.json
```

Synthetic analog augmentation is not automatic. If you add analog rows to the
CSV, mark and split them in your own data pipeline so `test` remains a real
holdout for the question you intend to measure.

## Molecule Fulfillment

```python
from uuid import uuid4

from omtx import OmClient
from omtx.lula import load_model

protein_sequence = "MEEPQSDPSV"

with OmClient() as client:
    model = load_model("lula1.1")
    scores = model.score(
        protein_sequence=protein_sequence,
        source="om",
        tier=50,
        n=50000,
        client=client,
    )
    selected = sorted(scores, key=lambda row: row["score"], reverse=True)[:96]

    addresses = client.molecules.shipping_addresses()
    shipping_address_id = addresses["default_shipping_address_id"]

    order = client.molecules.order(
        items=selected,
        shipping_address_id=shipping_address_id,
        idempotency_key=f"molecule-order-{uuid4()}",
    )
```

The molecule namespace is a thin Om API wrapper for Wallet Credits-funded
fulfillment. Om Accessible Space score rows include source metadata that lets Om
route procurement internally after the customer orders. Customers see fixed
Wallet Credit cost per SMILES and do not select a vendor. Search and quote
helpers remain available for direct explicit-SMILES compatibility, while the Om
tier path is just `score(...)` then `order(...)`. Shipping address reads
call `/v2/molecules/fulfillment/shipping-addresses`; order creation calls
`/v2/molecules/fulfillment/orders`. Orders require an `Idempotency-Key`.

## Data-Generation Orders

```python
from omtx import OmClient

with OmClient() as client:
    sequences = [{"name": "target", "sequence": "M" * 120}]
    quota_order = client.data_generation.quota_order(
        sequences=sequences,
        idempotency_key="dg-quota-target-001",
    )
    invoice_order = client.data_generation.invoice_order(
        sequences=sequences,
        idempotency_key="dg-invoice-target-001",
    )
```

Public API data-generation order creation is private by default. Extra
data-generation orders are invoice-only through the SDK/API/MCP surface; card
checkout remains a frontend/internal flow.

## Data Access

Browse published protein-specific models with `client.models.catalog()`.
Generated and covered raw-data access is entitlement-scoped; fetch a `protein_uuid` from
`client.datasets.catalog()` or `client.datasets.generated_protein_uuids()` for
the authenticated account before loading generated or otherwise covered data.

Primary training flow (single call):

```python
loaded = client.load_data(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
    binders=50000,          # required
    nonbinder_multiplier=5, # optional, default 5x background negatives
    # nonbinders=200000,    # optional explicit override (wins over multiplier)
    sample_seed=42,         # optional: deterministic sampling
)

binders = loaded["binders"]
nonbinders = loaded["nonbinders"]
print(binders.shape, nonbinders.shape)
binders.show(top_n=24)  # defaults: smiles_col="smiles", sort_by="binding_score"
binders.show(top_n=24, sort_by="selectivity_score")
# show() renders inline in notebooks; no extra display() wrapper needed.
```

`load_data(...)` samples non-binders from the Gateway-provided non-binder
source. New binder-only vintages use canonical NEGATIVE-control binder shards;
the SDK adaptively reads a bounded randomized NEGATIVE window distributed across
returned shard URLs, excludes molecules already seen as target binders, and
reads up to the full NEGATIVE pool only when needed. Requested non-binders are
capped at `20x` requested binders; if the
anti-joined NEGATIVE pool is smaller than the requested count, the SDK returns
the available non-overlapping rows.
Canonical NEGATIVE responses must include explicit source metadata
(`source_protein_uuid` and `source_vintage_id`); missing metadata is treated as
a contract error. Legacy target-specific non-binder shards remain supported.

Explicit per-pool loading (advanced control):

```python
binders = client.load_binders(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
    n=1000,          # optional: random sample size
    sample_seed=42,  # optional: deterministic sampling
)
nonbinders = client.load_nonbinders(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
    n=10000,         # optional: random sample size
    sample_seed=42,  # optional: deterministic sampling
)
print(binders.shape, nonbinders.shape)

# Omit n (or set n=None) to load the full pool.
# binders = client.load_binders(protein_uuid="...")
# nonbinders = client.load_nonbinders(protein_uuid="...")  # legacy or derived pools
```

Manual shard export URLs (advanced use):

```python
urls = client.binders.urls(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
)
print("Binder shard URLs:", len(urls["binder_urls"]))
print("Non-binder shard URLs:", len(urls["non_binder_urls"]))
print("First binder URL:", urls["binder_urls"][0] if urls["binder_urls"] else None)
```

Generated proteins available now:

```python
protein_uuids = client.datasets.generated_protein_uuids()
print("Generated protein UUIDs:", protein_uuids[:5])
```

Module-level convenience:

```python
import omtx as om

loaded = om.load_data(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
    binders=50000,
    nonbinder_multiplier=5,
    sample_seed=42,
)
print(loaded["binders"].shape, loaded["nonbinders"].shape)

binders = om.load_binders(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
    n=1000,
    sample_seed=42,
)
nonbinders = om.load_nonbinders(
    protein_uuid="YOUR_GENERATED_PROTEIN_UUID",
    n=10000,
    sample_seed=42,
)
print(binders.shape, nonbinders.shape)
```

## Idempotency

- Every non-GET call gets an idempotency key automatically.
- Auto-generated keys are per-call convenience and are not retry-stable.
- For retry dedupe, pass and reuse your own `idempotency_key` (logical operation ID).

Example (retry-safe launch):

```python
request_key = "search-protein-x-20260303-001"

job = client.diligence.search(
    query="MKNK2 inhibitor landscape",
    idempotency_key=request_key,
)

# If you retry the same logical launch, reuse the same idempotency key.
# retried = client.diligence.search(query="MKNK2 inhibitor landscape", idempotency_key=request_key)
```

## Wallet Funding

Use `client.wallet.topup(...)` to explicitly fund Wallet Credits by saved card
or invoice. Saved-card top-ups are capped at `$500` per request; invoice
top-ups can be larger. Wallet funding requires an explicit retry-stable
`idempotency_key` and exact user approval text.

```python
confirmation = client.wallet.expected_topup_confirmation(
    amount_cents=50_000,
    payment_mode="saved_card",
)

topup = client.wallet.topup(
    amount_cents=50_000,
    payment_mode="saved_card",
    user_approval_confirmation=confirmation,
    idempotency_key="wallet-topup-target-x-001",
)
```

## Helper Surface

- `diligence.deep_diligence(query, preset=None, idempotency_key=None)`
- `diligence.synthesize_report(gene_key, idempotency_key=None)`
- `diligence.search(query, idempotency_key=None)`
- `diligence.gather(query, preset=None, idempotency_key=None)`
- `diligence.crawl(url, preset=None, idempotency_key=None)`
- `diligence.list_gene_keys()`
- `artifacts.upload(file_path, content_type=None)`, `artifacts.upload_bytes(...)`, `artifacts.get(artifact_id)`
- `hub.submit(job_type, payload, idempotency_key=None)` for the full active public Hub route set
- selected typed `hub.<model>(...)` helpers for `boltz2`, `boltzgen`, `rosettafold3`, `chai1`, `rfd3`, `bindcraft`, `alphafold`, `proteinttt`, `diffdock`, `flowdock`, `neuralplexer`, `openfold3`, `lula1`, `protein_specific_models`
- `lula1.score(protein_sequence, smiles=None, source=None, tier=50, n=None, threshold=0.3, top_k=None, seed=42, job_name=None, protein_uuid=None, idempotency_key=None)`
- `lula2.score(protein_sequence, smiles=None, source=None, tier=50, n=None, threshold=0.3, top_k=None, seed=42, job_name=None, protein_uuid=None, idempotency_key=None)`
- optional local LULA open-weight helpers through `omtx.lula.load_model(...)`
  after installing `omtx[lula]`
- optional CLI commands installed by the `omtx` package: `omtx lula download`,
  `omtx lula verify`, `omtx lula score`, and `omtx lula finetune`
- `data_generation.quota_order(sequences, ...)`
- `data_generation.invoice_order(sequences, ...)`
- `molecules.pricing()`
- `molecules.search(smiles_list, ...)`
- `molecules.quote(items, ...)`
- `molecules.shipping_addresses()`
- `molecules.order(items, shipping_address_id, idempotency_key)`
- `molecules.orders(limit=20)`
- `molecules.status(order_number)`
- `models.catalog(protein_uuid=None, q=None, limit=100, offset=0)`
- `structures.pdb_download(pdb_id, format="pdb")`
- `structures.pdb_save(pdb_id, output_dir=".", format="pdb", overwrite=False)`
- `wallet.expected_topup_confirmation(amount_cents, payment_mode)`
- `wallet.topup(amount_cents, payment_mode, user_approval_confirmation, idempotency_key)`
- `vibe_video.submit(music_prompt, visual_prompt=None, artist_name=None, title_hint=None, duration_seconds=890, credit_artist=True, idempotency_key=None)`
- `vibe_video.list(limit=20)`
- `vibe_video.status(submission_id)`
- `jobs.history(...)`, `jobs.status(job_id)`, `jobs.wait(job_id, ...)`
- `binders.get_shards(...)`
- `binders.urls(...)`
- `load_binders(...)`
- `load_nonbinders(...)`
- `load_data(...)` (combined binder/non-binder load)
- `datasets.catalog()`
- `datasets.generated_protein_uuids()`
- `status()`
- `users.profile()`

Visualization column contract:
- `OmData.show(...)` is strict (no column fallback aliases).
- Default columns are `smiles` and `binding_score`.
- For selectivity views, pass `sort_by="selectivity_score"`.

Route policy:
- `/v2/diligence/getTargetDiligenceReport` remains an alias route and is not a separate SDK helper.
- `/v2/rag/search` is intentionally not exposed in the SDK.
- Public Hub coverage follows the active public model set in the canonical
  gateway route inventory.
- `hub.submit(...)` covers the full active public model set.
- Typed helpers are the selected subset listed above.
- Molecule fulfillment helpers call only the canonical `/v2/molecules/*`
  Gateway routes. Public molecule orders are invoice-only.
- `models.catalog(...)` calls `/v2/models/catalog` for published protein-specific
  model discovery.
- `structures.pdb_download(...)` fetches exact public structure files from
  `https://files.rcsb.org/download/{PDB_ID}.{format}`. It accepts only
  canonical `pdb` or `cif` formats.
- Data-generation order helpers call only the canonical
  `/v2/data-generation/*` Gateway routes. Public extra orders are invoice-only;
  quota orders consume active subscription capacity.
- Vibe video helpers call only the canonical `/v2/vibe-video/*` Gateway routes.

## Hub and Artifacts

```python
artifact = client.artifacts.upload("target.pdb")

job = client.hub.diffdock(
    protein_artifact_id=artifact["artifact_id"],
    ligand_smiles="CCO",
    idempotency_key="diffdock-demo-20260316",
)

status = client.jobs.wait(job["job_id"], poll_interval=5, timeout=1800)
history = client.jobs.history(limit=20)
```

Notes:

- Artifact-backed Hub workflows upload via `client.artifacts.*` first, then pass
  artifact IDs into canonical `client.hub.*` request fields.
- `client.hub.submit(...)` is the generic escape hatch for active Hub models
  using canonical `job_type="hub.<model>"`.
- Active public models without a dedicated helper, such as `neuralplexer`, are
  launched through `client.hub.submit(...)`.
- `jobs.history(...)` returns recent user jobs newest-first and paginates with
  `limit` and `cursor`.

## Migration

Breaking changes in `2.0.0`:
- `OMTXClient` removed.
- `OmClient` is now the only supported client class.
- Legacy pricing helpers removed from SDK surface.
- Legacy binder batch-cost helper removed from SDK surface.
- Shard access now resolves latest accessible dataset by `protein_uuid`.
- Generated data access is granted by qualifying covered data-generation
  sequences for the matching `protein_uuid`, including legacy-public snapshots
  where the account has covered access.
- `client.status()` is the primary health helper.
- `load_data(...)` is the primary training-set helper; it samples target binders and background negatives from binder pools.
- `load_binders(...)` remains the primary binder loader; `load_nonbinders(...)` remains available for legacy target non-binders and canonical NEGATIVE-control derived pools.
- Flat shard URL aliases are available as `binder_urls` / `non_binder_urls`.
- Core SDK runtime includes `polars` + `rdkit`.

Migration mapping (`1.x` -> `2.x`):
- `from omtx import OMTXClient` -> `from omtx import OmClient`
- `OMTXClient(...)` -> `OmClient(...)`

Breaking changes in `1.0.0`:
- `binders.get(...)` removed from core SDK.
- `binders.iter(...)` removed from core SDK.
- `pandas` removed from required dependencies.

Migration mapping (`0.x` -> `1.x`):
- `binders.get(...)` -> `client.load_binders(...)` / `client.load_nonbinders(...)` or `binders.get_shards(...)`
- `binders.iter(...)` -> `binders.get_shards(...)` + application-level streaming
- `pip install omtx` (with pandas) -> `pip install omtx` (with polars + rdkit)

Full details: see [`MIGRATION.md`](MIGRATION.md).

## Requirements

- Python `>=3.9`
- OMTX API key

## License

MIT. See `LICENSE`.
