Skip to content

SDKs

Python SDK

Convert spreadsheets, CSV and JSON into SQL from Python with the intosql package - upload, wait, download, webhooks and errors.

View as Markdown

intosql wraps the IntoSQL API for Python, one of the official SDKs. One call uploads a file, starts the conversion and waits for the result. The package depends only on httpx and is fully typed.

Pro: The API is part of IntoSQL Pro. Make an API key in Settings, API keys and keep it out of your source code.

Install

Terminal
pip install intosql

The package runs on Python 3.9 or newer. The client is synchronous.

Create a client

Python
import os
from intosql import IntoSQL

intosql = IntoSQL(os.environ["INTOSQL_API_KEY"])

The constructor raises ValueError if api_key is an empty string.

Option Type Default What it does
api_key str required An API key from Settings, API keys. Sent as Authorization: Bearer <key>. The only positional argument.
base_url str https://intosql.com/api/v1 The API's base address. A trailing slash is removed.
max_retries int 2 Attempts after the first one for rate limits, server errors and lost connections. 0 turns retries off.
timeout float 60.0 Seconds each HTTP request may take, the upload included.
http_client httpx.Client a new httpx.Client The client used for every call, the upload included. Useful for proxies, custom TLS and tests (MockTransport).
sleep Callable[[float], None] time.sleep How the client pauses between retries and while waiting. Tests pass a function that returns at once.

The client never sends your key to another origin. If a pagination link points somewhere other than base_url's origin, the call raises instead.

The client keeps connections open. Close it with intosql.close(), or use it as a context manager: with IntoSQL(key) as intosql:. A http_client you pass is left open.

Convert a file

convert(file, name=..., spec=...) does the whole flow and returns the finished conversion.

Python
import os
from intosql import IntoSQL, spec

intosql = IntoSQL(os.environ["INTOSQL_API_KEY"])

conversion = intosql.convert(
    "ventas.xlsx",
    name="ventas.xlsx",
    spec=spec("postgresql", table="ventas"),
)

print(conversion["report"]["rows"]["converted"], conversion["output"]["url"])

What happens, in order:

  1. Open. POST /conversions with name, the file's size in bytes and spec. The answer has an upload address.
  2. Upload. The file is sent with a PUT to that address, with the headers the API gave, as it is. A path or file object is streamed in chunks, not read into memory first.
  3. Start. POST /conversions/{id}/start.
  4. Wait. The conversion is read until its status is done or failed.

file is an Upload: bytes, bytearray or memoryview, a path (str or pathlib.Path), or a binary file object open for reading. The size is read from the bytes, the path or by seeking the file object; for a stream that can't seek (a socket, a pipe, a download), pass size= with its exact size in bytes, since the conversion only starts once the whole file has arrived.

Python
from urllib.request import urlopen

with urlopen("https://example.com/exports/pedidos.json") as response:
    conversion = intosql.convert(
        response,
        name="pedidos.json",
        spec=spec("sqlite"),
        size=int(response.headers["Content-Length"]),
    )

name helps tell the file's format, and the downloaded result is named after it: ventas.xlsx downloads as ventas.sql. Set table in the spec to choose the table's name.

Database dumps

A MySQL or MariaDB dump (.sql, .sql.gz) converts to PostgreSQL like any file. Its report lists each table in tables, with its own columns, fixes and problems, where the dump came from in source, and the views, triggers, routines and events it did not carry in not_converted. Files that are not dumps leave these fields out.

Python
conversion = intosql.convert("shop.sql.gz", name="shop.sql.gz", spec=spec("postgresql"))

for table in conversion["report"].get("tables", []):
    print(table["name"], table["rows"])

How it waits

convert, convert_url and conversions.wait read the conversion again and again with growing pauses: half a second, then 1, 2 and 4 seconds, then every 5 seconds. They return when the status is done, raise ConversionFailedError when it is failed, and ConversionCanceledError when it was canceled.

Wait option Type Default What it does
timeout float 3600.0 (1 h) Gives up after this many seconds and raises TimeoutError saying the conversion is still queued or running.
interval float | None growing pauses Seconds between reads, instead of the growing pauses.

Giving up only stops the waiting. The conversion keeps running on IntoSQL's servers, and you can read it later by its id.

Python
conversion = intosql.convert(
    "clientes_2024.xlsx",
    name="clientes_2024.xlsx",
    spec=spec("mysql"),
    timeout=10 * 60,
)

Convert a file by URL

convert_url(url, spec=...) asks IntoSQL to download the file from a public HTTPS address, then waits the same way. There is no upload step.

Python
conversion = intosql.convert_url(
    "https://example.com/exports/ventas.csv",
    spec=spec("mysql"),
)

It takes spec, an optional name (otherwise the name comes from the server's Content-Disposition header or the address), and the wait options. The address must be HTTPS and reachable from the internet; anything else is refused with invalid_url. If the download fails, the conversion ends as failed.

Download the result

When status is done, output["url"] downloads the result. The address works for ten minutes. After that, read the conversion again to get a fresh one.

Python
import httpx

fresh = intosql.conversions.retrieve(conversion["id"])

with httpx.stream("GET", fresh["output"]["url"]) as download, open("ventas.sql", "wb") as file:
    for chunk in download.iter_bytes():
        file.write(chunk)

Send no Authorization header to output["url"]; the address carries its own signature. Files are deleted 24 hours after the conversion finishes, or right away with conversions.delete.

Build a spec

spec(dialect, ...) returns a full JobSpec with IntoSQL's defaults. Only dialect is required.

Python
from intosql import spec

request = spec(
    "sql_server",
    table="clientes",
    schema="ventas",
    sheet="Clientes 2024",
    batch_rows=1000,
)
Option Type Default Spec field
dialect 'postgresql' | 'mysql' | 'mariadb' | 'sqlite' | 'sql_server' | 'oracle' required output.dialect
format 'sql' | 'csv' | 'json' 'sql' output.format
table str | None chosen by the engine output.table
schema str | None none output.schema
sheet str | None first sheet input.sheet
create_table bool True output.create_table
batch_rows int 500 output.batch_rows

dialect is positional; the rest are keyword arguments. The rest is fixed by the helper: format, encoding, delimiter and header row detected from the file; column names in snake_case; INSERT statements (style: 'inserts'); types inferred from the first 10,000 rows; date order detected; leading zeros kept; no per-column overrides. For anything else (bulk output, a date order, a locale, columns renamed, kept as text or left out), change the dict it returns or write a JobSpec yourself. Every field is in Conversion spec.

Python
request = spec("postgresql", table="pedidos")

request["output"]["style"] = "bulk"
request["inference"]["date_order"] = "day_first"
request["columns"] = [{"index": 0, "as_text": True}]

Conversions

intosql.conversions exposes each step on its own, for when you want to upload from somewhere else, start later, or not wait.

Method Request Returns
create(name=, size=, spec=) POST /conversions Conversion with upload
upload(conversion, file, size=None) PUT to conversion["upload"]["url"] None
start(id) POST /conversions/{id}/start Conversion, queued
create_from_url(url=, spec=, name=None) POST /conversions with url Conversion, queued
retrieve(id) GET /conversions/{id} Conversion
wait(id, timeout=3600.0, interval=None) GET /conversions/{id}, repeated Conversion, done
list(limit=None, status=None) GET /conversions, every page Iterator[Conversion]
cancel(id) POST /conversions/{id}/cancel Conversion, canceled
delete(id) DELETE /conversions/{id} None
create_case(id, consent=True, share=, email=, note=) POST /conversions/{id}/cases Case

create takes the file's size as size and sends it as the API's bytes field.

Step by step

Python
import os

opened = intosql.conversions.create(
    name="ventas.xlsx",
    size=os.path.getsize("ventas.xlsx"),
    spec=spec("mariadb"),
)

intosql.conversions.upload(opened, "ventas.xlsx")
intosql.conversions.start(opened["id"])

# Later, or from a webhook handler:
conversion = intosql.conversions.retrieve(opened["id"])

The upload address from create works for an hour. upload raises ValueError if the conversion has no upload address (one read with retrieve never does), and raises an IntoSQLError with code upload_failed if storage doesn't answer with a 2xx.

List

list() yields every conversion on the account, newest first. It fetches a page at a time (50, or limit from 1 to 100) and follows links.next until there are no more pages. status keeps only conversions in that status.

Python
for conversion in intosql.conversions.list(status="failed"):
    print(conversion["id"], conversion["error"]["code"], conversion["name"])

Pages are fetched as the loop reads them. Stop early with break; no further pages are fetched.

Cancel

cancel(id) stops a conversion that hasn't ended and deletes its files. It stays listed as canceled and doesn't count toward the month. Canceling it again answers the same; canceling one that already ended raises IntoSQLError with code already_finished.

Python
intosql.conversions.cancel(conversion["id"])

Send us a conversion

When a file didn't convert well, or at all, create_case sends the conversion to IntoSQL so support for it gets added. Pass consent=True only when the person agreed to send it. share="file" sends the file as well as the report; a failed conversion keeps its upload until it expires, so the file is still there to send.

Python
intosql.conversions.create_case(
    conversion["id"],
    consent=True,
    share="file",
    note="Exported from our ERP; the dates come out wrong.",
)

Cases are private, kept 90 days, and then deleted. The privacy policy says how they are handled.

Delete

delete(id) removes the conversion's input and output files now instead of after 24 hours. Reading it afterwards answers not_found.

Python
intosql.conversions.delete(conversion["id"])

Usage

usage() returns the bytes the plan includes this month, used and left.

Python
usage = intosql.usage()

print(usage["limit"], usage["used"], usage["left"], usage["period_start"])

Failed conversions don't count toward used.

Verify webhooks

With a webhook address saved in Settings, API keys, IntoSQL sends a signed POST when a conversion is done, failed or canceled. parse_webhook checks the IntoSQL-Signature header against the raw body and returns the typed event; it raises WebhookSignatureError when the signature doesn't match.

Python
import os

from flask import Flask, request
from intosql import WebhookSignatureError, parse_webhook

app = Flask(__name__)


@app.post("/hooks/intosql")
def intosql_webhook():
    try:
        event = parse_webhook(
            request.get_data(),
            request.headers.get("IntoSQL-Signature", ""),
            os.environ["INTOSQL_WEBHOOK_SECRET"],
        )
    except WebhookSignatureError:
        return "", 400

    if event["type"] == "conversion.done":
        # event["data"] is the conversion; event["id"] is the same on every retry.
        pass

    return "", 204

verify_webhook(body, header, secret) does the check alone and returns True or False. Both refuse a call when the header is malformed, the signature doesn't match, or the timestamp is more than 300 seconds from now. The keyword argument tolerance changes that window and now (Unix seconds) fixes the current time in tests. Pass the body exactly as received, as bytes or str, before any JSON parsing. The signature is compared in constant time. The payload and retries are in Webhooks.

Errors

The SDK raises these exceptions.

  • IntoSQLError: the API answered with an error. It has status (the HTTP status), code (stable between versions), message (for people), fields (per field messages when the code is invalid_request, otherwise {}) and request_id, to quote when you write to us. If the answer has no error body, code is http_error.
  • ConversionFailedError: the conversion ended as failed. It has id, code (stable: ours, such as timed_out, or the engine's, such as encrypted_file), problems (what the engine found) and the explanation as its message.
  • ConversionCanceledError: the conversion was canceled while it was awaited. It has id.
  • WebhookSignatureError: a webhook call didn't check out. It is a ValueError.

A connection that keeps failing after the retries raises the httpx error (an httpx.TransportError such as httpx.ConnectError). Waiting past timeout raises TimeoutError.

Python
from intosql import ConversionFailedError, IntoSQLError

try:
    intosql.convert("ventas.xlsx", name="ventas.xlsx", spec=spec("oracle"))
except ConversionFailedError as error:
    print(f"Conversion {error.id} failed ({error.code}): {error.message}")
except IntoSQLError as error:
    if error.code == "over_quota":
        print("This month's conversions are used up.")
    else:
        print(error.status, error.code, error.message, error.fields)

Every code, its status and how to fix it is in Errors.

Retries and idempotency

The client retries a request when the connection fails, when the API answers rate_limited, too_many_active or request_in_progress, on any 5xx, and on 408 and 425 from the network in between, up to max_retries times (2 by default). It waits for Retry-After when the answer has it, and otherwise half a second, then 1 second, doubling up to 8 seconds.

Every POST gets an Idempotency-Key header with a random UUID, and its retries reuse the same key. If a first attempt reached the API but its answer was lost, the retry gets that first answer back instead of opening a second conversion.

Not retried: the file upload to storage (a failed PUT raises upload_failed), already_started (the conversion is on its way; read it instead), and any other 4xx such as invalid_spec or over_quota.

Types

Answers are the API's JSON as plain dicts, typed with TypedDict so editors and mypy check the keys. Fields and values the server adds later pass through untouched. The package exports:

Type What it is
Conversion A conversion as the API answers it: id, status, name, bytes, spec, report, error, output, upload, created_at, started_at, finished_at, expires_at.
ConversionFailure A failed conversion's error: {code, message, problems}.
Case A conversion sent to IntoSQL: id, conversion_id, share, status, created_at, expires_at.
WebhookEvent What parse_webhook returns: {id, type, created_at, data}.
ConversionStatus Literal['awaiting_upload', 'queued', 'running', 'done', 'failed', 'canceled']. Conversion["status"] is a str, so a status added later doesn't break your code.
Upload bytes | bytearray | memoryview | str | os.PathLike[str] | IO[bytes].
Usage {limit, used, left, period_start}.
Page {data: list[Conversion], links: {next}, meta: {per_page, next_cursor, prev_cursor}}.
JobSpec and its parts InputSpec, OutputSpec, InferenceSpec, ColumnSpec, Dialect, OutputFormat, OutputStyle, ColumnNames, DateOrder, InputFormat.
JobReport and its parts ColumnReport, ColumnKind, RowCounts, Fix, FixCode, Problem, ErrorCode, Severity, DetectedInput, Timings, SourceReport, TableReport, NotConverted, ObjectKind.

The JobSpec and JobReport types follow the engine's contract, so they match what the API accepts and returns. report is described in Conversions, and fixes in Fixes.