Skip to main content

rustodbc

Motor ODBC async en Rust para IBM DB2 for i (iSeries / AS400), expuesto como extensión de Python (PyO3 + maturin).

Ver AGENTS.md para el diseño completo y el estado real de cada módulo.

Estado del proyecto: API async (Db2iEngine) y síncrona (BlockingEngine vía rustodbc.blocking) implementadas, CI verde (cargo fmt + clippy -D warnings + build + smoke import en Windows y Linux). Streaming con prefetch en ambas.

Instalación

Una vez publicado en PyPI:

pip install rustodbc-mi

El paquete de PyPI se llama rustodbc-mi (así está registrado el trusted publisher en pypi.org), pero el import en Python es rustodbc.

El wheel solo no alcanza en runtime. DB2 for i se habla a través del driver IBM i Access ODBC Driver, que no viene con el wheel (ver "Requisito de runtime" más abajo).

Build local

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo build --release
maturin develop --release
python -c "import rustodbc; print(rustodbc.__version__)"

Requiere: Rust estable (target x86_64-pc-windows-msvc en Windows), maturin, y el toolchain de C++ de Visual Studio (link.exe, odbc32.lib) en Windows o unixodbc-dev/unixODBC-devel en Linux.


Conexión: dos caminos

rustodbc no asume nunca una única forma de conectar. Hay dos caminos independientes, y podés mezclarlos según el caso.

1. Convención de entorno (Credentials.from_env)

Pensada para multi-cliente/multi-entorno: las credenciales viven en variables de entorno con el patrón <VAR>_<CLIENTE>_<ENTORNO>, nunca en código ni en config versionada.

import rustodbc

# Lee DB_SYSTEM_ACME_PROD, DB_USER_ACME_PROD, DB_PASSWORD_ACME_PROD
creds = rustodbc.Credentials.from_env("ACME", "PROD")

Variables que lee (ver también .env.example):

Variable Requerida Descripción
DB_SYSTEM_<CLIENTE>_<ENTORNO> Sí Hostname/IP del AS/400
DB_USER_<CLIENTE>_<ENTORNO> Sí Usuario
DB_PASSWORD_<CLIENTE>_<ENTORNO> Sí Password
APP_ENV No <ENTORNO> default si no se pasa environment= (default "dev")
DB_DRIVER No Nombre exacto del driver ODBC; si falta, se autodetecta con SQLDrivers (ver config.rs::PREFERRED_DRIVERS)

<CLIENTE> y <ENTORNO> se normalizan siempre a mayúsculas. Si falta cualquiera de las tres variables requeridas, se levanta ConfigurationError nombrando todas las que faltan (nunca se conecta con SYSTEM=None;UID=None; en silencio):

try:
    creds = rustodbc.Credentials.from_env("ACME", "PROD")
except rustodbc.ConfigurationError as e:
    print(e)  # "faltan variables de entorno requeridas: DB_SYSTEM_ACME_PROD, DB_PASSWORD_ACME_PROD"

rustodbc nunca carga un .env implícitamente. Si tu app usa archivos .env, cargalo vos explícitamente antes:

rustodbc.load_dotenv()            # busca .env en el directorio actual/padres
rustodbc.load_dotenv("/ruta/a/.env")  # o una ruta explícita

2. Connection string directo

Para quien no quiere (o no puede) usar la convención de entorno: tests locales, un DSN de un solo uso, un secret manager propio, etc. Hay tres variantes, de más estructurada a más cruda:

a) Constructor directo — cuando ya tenés los campos por separado:

creds = rustodbc.Credentials(
    system="10.0.0.5",
    user="MIUSER",
    password="secreto",
    driver="IBM i Access ODBC Driver",  # opcional; se autodetecta si se omite
)

b) Credentials.from_dsn — parsea un DSN de los 4 keywords conocidos (DRIVER=, SYSTEM=, UID=, PWD=) y te deja .system/.user/.driver disponibles después:

creds = rustodbc.Credentials.from_dsn(
    "DRIVER={IBM i Access ODBC Driver};SYSTEM=10.0.0.5;UID=MIUSER;PWD=secreto;"
)
creds.system  # "10.0.0.5"

Cualquier keyword que no sea uno de esos 4 se descarta — si tu connection string trae PORT=, CCSID=, u otras opciones propias del driver, usá la variante (c).

c) Credentials.from_connection_string — guarda el string tal cual, sin parsear ni reconstruir. Es el escape hatch total: nada se pierde, pero .system/.user/.driver quedan en None porque no se intenta adivinarlos:

creds = rustodbc.Credentials.from_connection_string(
    "DRIVER={IBM i Access ODBC Driver};SYSTEM=10.0.0.5;PORT=8471;"
    "UID=MIUSER;PWD=secreto;CCSID=37;"
)

En los tres casos, repr(creds) nunca expone el password (ni el DSN crudo completo en el caso (c), justamente porque puede contener PWD=).


EngineOptions

Tunables del engine — pool, batching, paralelismo, formato de datos. Todos tienen default y son ajustables por keyword o por variable de entorno.

opts = rustodbc.EngineOptions(pool_size=8, batch_size=2000)
# o
opts = rustodbc.EngineOptions.from_env()
Campo Default Env var Descripción
pool_size 4 RUSTODBC_POOL_SIZE Conexiones simultáneas en el pool
login_timeout 0 (sin timeout) — Timeout de SQLConnect, en segundos
query_timeout 0 (sin timeout) — Timeout de ejecución de statement, en segundos
batch_size 1000 BATCH_SIZE Filas por lote en executebatch/inserts masivos
max_workers 4 MAX_WORKERS Jobs paralelos máximos (I/O-bound, no cpu-aware — ver AGENTS.md ss4)
min_rows_per_worker 500 MIN_ROWS_PER_WORKER Umbral para decidir cuántos workers usar
merge_chunk_size 7000 MERGE_CHUNK_SIZE Filas por chunk en el motor MERGE (TableSync)
merge_max_workers 3 MERGE_MAX_WORKERS Jobs paralelos máximos para MERGE
stream_batch_size 5000 — Filas por lote al iterar con stream/stream_batches
prefetch_batches 2 — Lotes prefetcheados por delante durante streaming
decimal_mode "decimal" — "decimal" (siempre Decimal exacto) / "str" / "float"
strip_char_padding True — Recorta el relleno de espacios de columnas CHAR/GRAPHIC

EngineOptions.from_env() solo lee las variables que tienen equivalente en la tabla; un valor no numérico levanta ConfigurationError nombrando la variable y el valor inválido (no un ValueError opaco).

Nota sobre decimal_mode: rustodbc bindea DECIMAL/NUMERIC/DECFLOAT como texto crudo del driver y lo pasa a decimal.Decimal(...) — nunca float. Es una regla dura del proyecto (ver AGENTS.md ss4), no solo un default conveniente.


Árbol de excepciones

Todas heredan de rustodbc.RustOdbcError (que a su vez es Exception). Ningún mensaje de error llega a Python sin pasar por un scrub que redacta PWD=... — nunca vas a ver una contraseña en un traceback.

RustOdbcError
├── ConfigurationError       # credenciales/opciones faltantes o inválidas
├── ConnectError             # fallo de SQLConnect
│   └── PoolTimeout          # se agotó el tiempo esperando una conexión libre del pool
├── InterfaceError           # uso inválido de la API (p.ej. conexión ya cerrada)
├── QueryError               # error de ejecución de SQL
│   ├── SqlSyntaxError       # SQLSTATE 42xxx
│   ├── IntegrityError       # SQLSTATE 23xxx (p.ej. violación de PK/FK)
│   ├── DataError            # SQLSTATE 22xxx (p.ej. overflow, conversión inválida)
│   └── OperationTimeout     # SQLSTATE HYT00 / HYT01
├── ParameterError           # parámetro Python no representable en un tipo ODBC
├── BulkFailure              # uno o más statements de un batch fallaron
├── MergeFailure             # el motor MERGE (TableSync) falló
├── CatalogError             # no se pudo leer el catálogo (PK, columnas, tipos)
└── FeatureUnavailable       # feature no compilada en este wheel (p.ej. arrow)

Notar: la cancelación nunca entra en este árbol — se propaga como asyncio.CancelledError nativo, no como una excepción de rustodbc.

try:
    ...
except rustodbc.IntegrityError:
    ...  # violación de PK/FK -- no reintentar
except rustodbc.QueryError as e:
    print(e.sqlstate, e.native_code, e.message)

Uso async

La API es async (basada en asyncio + un runtime tokio interno). Todo el ciclo de vida del engine debe correr dentro de un solo event loop — usá un único asyncio.run(main()) o un único loop.run_until_complete(...), nunca asyncio.run() por llamada (ver "Errores comunes" más abajo).

import asyncio
import rustodbc

async def main():
    async with rustodbc.Db2iEngine.from_env("ACME", "PROD") as engine:
        # ejecutar sin traer datos -> rowcount
        n = await engine.execute("UPDATE SCHEMA.TABLA SET x = ? WHERE id = ?", [1, 123])

        # traer todo -> list[dict]
        rows = await engine.fetch_all("SELECT * FROM SCHEMA.TABLA WHERE id = ?", [123])
        # rows[0] == {"ID": 123, "NOMBRE": "..."}

        # una fila -> dict | None
        row = await engine.fetch_one("SELECT * FROM SCHEMA.TABLA WHERE id = ?", [123])

        # un solo valor / una columna
        total = await engine.fetch_value("SELECT COUNT(*) FROM SCHEMA.TABLA")
        ids = await engine.fetch_column("SELECT id FROM SCHEMA.TABLA")

        # streaming por lotes (no carga todo en memoria) -> list[Row] por batch.
        # Prefetch: mientras consumís un lote, la tarea ya pidió el siguiente
        # (EngineOptions.prefetch_batches, default 2) -- RAM acotada a unos
        # pocos lotes, no a toda la tabla.
        async for batch in engine.stream_batches("SELECT * FROM SCHEMA.HUGE_TABLE", batch_size=5000):
            for r in batch:
                ...

asyncio.run(main())

Conexión explícita (en vez de async with):

engine = await rustodbc.Db2iEngine.connect(creds)   # o .from_env("ACME", "PROD")
try:
    rows = await engine.fetch_all(sql)
finally:
    engine.close()   # idempotente

Parámetros

Posicionales (list/tuple o None). Tipos: str, int, float, bool, decimal.Decimal, bytes/bytearray, date/time/datetime, None. DECIMAL/NUMERIC/DECFLOAT llegan siempre como Decimal exacto (nunca float), con decimal_mode="decimal" por defecto.

from decimal import Decimal
from datetime import date

await engine.execute(
    "INSERT INTO T (importe, fecha, ok) VALUES (?, ?, ?)",
    [Decimal("123.45"), date(2026, 8, 22), True],
)

Escritura masiva (executebatch)

Reescribe INSERT ... VALUES (?,...) a un VALUES multi-fila (el driver IBM i no soporta SQL_ATTR_PARAMSET_SIZE). Un solo lease para todo el batch (no uno por lote) y halve-and-retry de chunk size: si un lote excede el límite de statement/parámetros de DB2 for i (SQL0101/SQL54001), se reduce a la mitad y se reintenta; el tamaño que funcionó queda cacheado por engine y se usa solo a partir de entonces.

report = await engine.executebatch(
    "INSERT INTO SCHEMA.TABLA (a, b) VALUES (?,?)",
    [[1, "x"], [2, "y"], [3, "z"]],
)
print(report.rows_affected, report.batches)

Escritura masiva en paralelo (batch_execute / parallel_execute)

Reparten la carga en varias conexiones del pool (workers), cada una con su propio lease. Sin éxito parcial silencioso: se juntan todos los errores antes de devolver el reporte. fail_fast=True (opt-in) cancela el resto al primer error.

# batch_execute: un solo INSERT, filas partidas en workers
report = await engine.batch_execute(
    "INSERT INTO SCHEMA.TABLA (a, b) VALUES (?,?)",
    [[1, "x"], [2, "y"], [3, "z"], [4, "w"]],
    max_workers=4,      # default: EngineOptions.max_workers
    fail_fast=False,    # default
)
print(report.rows_affected, report.failures)

# parallel_execute: varias (sql, rows) independientes en paralelo
report = await engine.parallel_execute([
    ("INSERT INTO SCHEMA.A (id) VALUES (?)", [[1], [2]]),
    ("INSERT INTO SCHEMA.B (id) VALUES (?)", [[3], [4]]),
], max_workers=2)
print(report.rows_affected, report.failures)

Procedimientos (call_proc)

Recibe un dict {nombre_parametro: valor}. Los parámetros OUT no necesitan venir en el dict — se bindean como NULL de entrada y el procedimiento igual se ejecuta; el resultado sale en out_params:

result = await engine.call_proc("SCHEMA", "MI_PROC", {"IN_PARAM": 5})
# result.result_sets -> list[list[Row]]
# result.out_params  -> {"OUT_PARAM": <valor>, ...}  # OUT/INOUT

MERGE/upsert (TableSync)

Por composición, nunca herencia:

class TransferRepository:
    def __init__(self, ori, dest):
        self.sync = dest.table_sync(source=ori)

sync = dest_engine.table_sync(source=ori_engine)
report = await sync.merge(
    "SCHEMA", "TABLA",
    [{"ID": 1, "VALOR": "a"}, {"ID": 2, "VALOR": "b"}],
    # primary_key=["ID"],   # opcional; si se omite, lo saca del catálogo
)
print(report.used_merge, report.rows_affected, report.warning)

Sin PK → hace INSERT simple con warning (nunca crashea ni hace MERGE silencioso — regla dura de AGENTS.md ss4).

Copiar tabla desde otro engine (transfer)

Lee schema.table desde el engine source en streaming (RAM acotada por lote) y la mergea/inserta en dest. select_sql opcional permite un SELECT con filtro (debe devolver las mismas columnas que la tabla destino):

sync = dest_engine.table_sync(source=ori_engine)   # source es OBLIGATORIO para transfer

# copia SCHEMA.TABLA de ori_engine a dest_engine (mismo esquema/tabla en ambos)
report = await sync.transfer("SCHEMA", "TABLA")

# con filtro
report = await sync.transfer("SCHEMA", "TABLA", select_sql="SELECT * FROM SCHEMA.TABLA WHERE activo = 1")

print(report.rows_affected, report.used_merge, report.warning)

En la fachada síncrona (BlockingEngine), usá merge_sync/transfer_sync (mismo comportamiento, síncrono).

Errores comunes

  • no running event loop al conectar/consultar: usaste dos asyncio.run() separados (uno para connect y otro para fetch_all). Cada asyncio.run() crea y cierra su propio event loop, y el engine no sobrevive al cierre. Usá un solo asyncio.run(main()) con todo adentro.
  • Type "Db2iEngine" is not awaitable (warning de Pyright/Pylance): era un bug del stub .pyi; connect/from_env ya están tipados como async def.

Síncrono (BlockingEngine)

Fachada síncrona en Rust (no un wrapper de asyncio.run): misma API que Db2iEngine pero bloqueante, con un runtime tokio propio. Pensada para call-sites que hoy envuelven ISeriesConnection en asyncio.to_thread(...) desde código síncrono (crons de arq, parsers).

from rustodbc.blocking import BlockingEngine

engine = BlockingEngine.from_env("ACME", "PROD")   # o .connect(creds)
rows = engine.fetch_all("SELECT * FROM SCHEMA.TABLA WHERE id = ?", [123])
report = engine.executebatch("INSERT INTO T (a) VALUES (?)", [[1], [2]])
result = engine.call_proc("SCHEMA", "MI_PROC", {"IN": 1})
engine.close()

Streaming síncrono (reemplaza iter_dict_chunks):

for batch in engine.stream("SELECT * FROM SCHEMA.HUGE_TABLE", batch_size=5000):
    for row in batch:
        ...

MERGE síncrono:

sync = engine.table_sync()
report = sync.merge_sync("SCHEMA", "TABLA", [{"ID": 1, "VALOR": "a"}])

Escritura en paralelo síncrona:

report = engine.batch_execute("INSERT INTO T (a) VALUES (?)", [[1], [2]], max_workers=4)
report = engine.parallel_execute([("INSERT INTO A (id) VALUES (?)", [[1]]), ("INSERT INTO B (id) VALUES (?)", [[2]])])

Copiar tabla síncrona (requiere crear el sync con source):

sync = dest_engine.table_sync(source=ori_engine)
report = sync.transfer_sync("SCHEMA", "TABLA")

Regla importante: BlockingEngine levanta InterfaceError si se llama desde un hilo con un event loop de asyncio corriendo — para que nadie termine bloqueando el loop de arq. Si estás dentro de un loop, usá la API async (Db2iEngine).


Requisito de runtime (despliegue)

El wheel de rustodbc no trae el driver ODBC de IBM. En runtime la imagen/host necesita:

  • El driver IBM i Access ODBC Driver (ibm-iaccess, repo apt ibmi-acs-1.1.0) instalado.
  • unixODBC (Linux) o el subsistema ODBC de Windows (ya presente en Windows).

En Linux, el wheel se buildea con libodbc.so.* excluido explícitamente (auditwheel --exclude) — nunca vendoreado — porque cargar el driver de IBM contra una versión de libodbc distinta a la del sistema puede corromper buffers SQLWCHAR/CCSID. Ver AGENTS.md ss2 y ss9 para el detalle completo.


CI/CD

  • .github/workflows/ci.yml — en cada push/PR: cargo fmt + clippy --all-targets --all-features -- -D warnings + lint mecánico del GIL, y cargo check + maturin build + smoke import del wheel, en Windows y Linux.
  • .github/workflows/release.yml — workflow_dispatch manual: bump de versión + tag, build de sdist + wheels. Windows con maturin-action; Linux (manylinux_2_28 y musllinux_1_2 x86_64) con cibuildwheel usando las imágenes oficiales de PyPA y auditwheel --exclude libodbc.so.*. Crea un GitHub Release con todos los assets.
  • .github/workflows/publish.yml — workflow_dispatch manual: baja los assets del release y los publica a PyPI (rustodbc-mi) vía Trusted Publishing (OIDC, sin token de larga vida). Requiere el trusted publisher configurado una vez en pypi.org para el proyecto rustodbc-mi (workflow publish.yml, environment pypi).

No hay wheels de macOS ni de Linux aarch64 hoy: no hay evidencia de que el driver IBM i Access ODBC exista para esas plataformas.

Release files for rustodbc-mi 0.5.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 rustodbc-mi 0.5.0
File Size Uploaded
rustodbc_mi-0.5.0.tar.gz 78.1 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for rustodbc-mi 0.5.0
File Interpreter ABI Platform
rustodbc_mi-0.5.0-cp312-abi3-win_amd64.whl CPython 3.12 abi3 Windows x86-64 Details
rustodbc_mi-0.5.0-cp312-abi3-musllinux_1_2_x86_64.whl CPython 3.12 abi3 Linux musl 1.2+ x86-64 Details
rustodbc_mi-0.5.0-cp312-abi3-manylinux_2_28_x86_64.whl CPython 3.12 abi3 Linux glibc 2.28+ x86-64 Details

Total release size: 2.7 MB

Release files / rustodbc_mi-0.5.0.tar.gz

Download URL rustodbc_mi-0.5.0.tar.gz
Size 78.1 kB
Tags Source
SHA-256 checksum
How to use checksums
9634b0e59ce867375d2458c7e5071c0197bc05399a9d20de6def9b777148b41f
BLAKE2b-256 checksum
How to use checksums
7788e800e914b764b5d7b6c912659e746f23e52e1e03b27adfd6219aec878771
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 22, 2026.

Transparency log

Release files / rustodbc_mi-0.5.0-cp312-abi3-win_amd64.whl

Download URL rustodbc_mi-0.5.0-cp312-abi3-win_amd64.whl
Size 827.5 kB
Tags CPython 3.12 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
bf7077e329d86a5ee1520fe3e0618ba6bdb0fcc850259dc73739a4a98f132837
BLAKE2b-256 checksum
How to use checksums
895f551961c236e3d3002aff225bdd114a969e860bd98965e69cd8b6d8a70c68
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 22, 2026.

Transparency log

Release files / rustodbc_mi-0.5.0-cp312-abi3-musllinux_1_2_x86_64.whl

Download URL rustodbc_mi-0.5.0-cp312-abi3-musllinux_1_2_x86_64.whl
Size 927.1 kB
Tags CPython 3.12 Linux musl 1.2+ x86-64 abi3
SHA-256 checksum
How to use checksums
c46bd851e0898199fc43d61ef1aea1a7718138cf29f37968d40db39253563efc
BLAKE2b-256 checksum
How to use checksums
fc8fa611be260dca0d5a8135b7d1f81b9f8254d7bf200db8ac9fb493ac0f3136
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 22, 2026.

Transparency log

Release files / rustodbc_mi-0.5.0-cp312-abi3-manylinux_2_28_x86_64.whl

Download URL rustodbc_mi-0.5.0-cp312-abi3-manylinux_2_28_x86_64.whl
Size 846.4 kB
Tags CPython 3.12 Linux glibc 2.28+ x86-64 abi3
SHA-256 checksum
How to use checksums
cd383a14d16fa4b8f621ec0e5f5715fd1de02654069fc9e1f77b7df6298d7c0c
BLAKE2b-256 checksum
How to use checksums
ad8f1d4016a962919c1a4dd9ff9c472994982465b83febccbffe986524feb4a4
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 22, 2026.

Transparency log

Release history Release notifications | RSS feed

0.9.3

4 release files

0.9.2

4 release files

0.9.1

4 release files

0.9.0

4 release files

0.8.1

4 release files

0.8.0

4 release files

0.7.2

4 release files

0.7.1

4 release files

0.7.0

4 release files

0.6.2

4 release files

0.6.1

4 release files

0.6.0

4 release files

0.5.1

4 release files

This release

0.5.0 This release

4 release files

0.4.0

4 release files

0.3.0

4 release files

0.2.0

4 release files

0.1.0

4 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