intosql 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
intosql/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """Convert spreadsheets, CSV and JSON into SQL with the IntoSQL API."""
2
+
3
+ from ._client import DEFAULT_BASE_URL, Conversions, IntoSQL, Upload
4
+ from ._contract import (
5
+ CONTRACT_VERSION,
6
+ ColumnKind,
7
+ ColumnNames,
8
+ ColumnReport,
9
+ ColumnSpec,
10
+ DateOrder,
11
+ DetectedInput,
12
+ Dialect,
13
+ ErrorCode,
14
+ Fix,
15
+ FixCode,
16
+ InferenceSpec,
17
+ InputFormat,
18
+ InputSpec,
19
+ JobReport,
20
+ JobSpec,
21
+ OutputFormat,
22
+ OutputSpec,
23
+ OutputStyle,
24
+ Problem,
25
+ RowCounts,
26
+ Severity,
27
+ Timings,
28
+ )
29
+ from ._errors import (
30
+ ConversionCanceledError,
31
+ ConversionFailedError,
32
+ IntoSQLError,
33
+ WebhookSignatureError,
34
+ )
35
+ from ._spec import spec
36
+ from ._types import (
37
+ Case,
38
+ Conversion,
39
+ ConversionErrorCode,
40
+ ConversionFailure,
41
+ ConversionOutput,
42
+ ConversionStatus,
43
+ ConversionUpload,
44
+ Page,
45
+ PageLinks,
46
+ PageMeta,
47
+ PingData,
48
+ Usage,
49
+ WebhookEvent,
50
+ )
51
+ from ._version import __version__
52
+ from ._webhooks import parse_webhook, verify_webhook
53
+
54
+ __all__ = [
55
+ "CONTRACT_VERSION",
56
+ "DEFAULT_BASE_URL",
57
+ "Case",
58
+ "ColumnKind",
59
+ "ColumnNames",
60
+ "ColumnReport",
61
+ "ColumnSpec",
62
+ "Conversion",
63
+ "ConversionCanceledError",
64
+ "ConversionErrorCode",
65
+ "ConversionFailedError",
66
+ "ConversionFailure",
67
+ "ConversionOutput",
68
+ "ConversionStatus",
69
+ "ConversionUpload",
70
+ "Conversions",
71
+ "DateOrder",
72
+ "DetectedInput",
73
+ "Dialect",
74
+ "ErrorCode",
75
+ "Fix",
76
+ "FixCode",
77
+ "InferenceSpec",
78
+ "InputFormat",
79
+ "InputSpec",
80
+ "IntoSQL",
81
+ "IntoSQLError",
82
+ "JobReport",
83
+ "JobSpec",
84
+ "OutputFormat",
85
+ "OutputSpec",
86
+ "OutputStyle",
87
+ "Page",
88
+ "PageLinks",
89
+ "PageMeta",
90
+ "PingData",
91
+ "Problem",
92
+ "RowCounts",
93
+ "Severity",
94
+ "Timings",
95
+ "Upload",
96
+ "Usage",
97
+ "WebhookEvent",
98
+ "WebhookSignatureError",
99
+ "__version__",
100
+ "parse_webhook",
101
+ "spec",
102
+ "verify_webhook",
103
+ ]
intosql/_client.py ADDED
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ import uuid
6
+ from collections.abc import Iterator
7
+ from types import TracebackType
8
+ from typing import (
9
+ IO,
10
+ Any,
11
+ Callable,
12
+ Literal,
13
+ Union,
14
+ cast,
15
+ )
16
+ from urllib.parse import quote, urlencode, urlsplit
17
+
18
+ import httpx
19
+
20
+ from ._contract import JobSpec
21
+ from ._errors import ConversionCanceledError, ConversionFailedError, IntoSQLError
22
+ from ._types import Case, Conversion, Page, Usage
23
+ from ._version import __version__
24
+
25
+ DEFAULT_BASE_URL = "https://intosql.com/api/v1"
26
+
27
+ Upload = Union[bytes, bytearray, memoryview, str, "os.PathLike[str]", IO[bytes]]
28
+ """A file to upload: its bytes, a path to it, or a binary file object open for reading."""
29
+
30
+ _RETRYABLE_CODES = frozenset({"rate_limited", "too_many_active", "request_in_progress"})
31
+ """Codes worth sending again: waiting fixes them. Never `already_started`: it is on its way."""
32
+
33
+ _RETRYABLE_STATUSES = frozenset({408, 425})
34
+ """Statuses from the network in between, which carry no code of ours. Every 5xx is retried too."""
35
+
36
+
37
+ class IntoSQL:
38
+ """The IntoSQL API: convert spreadsheets, CSV and JSON into SQL on IntoSQL's machines."""
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str,
43
+ *,
44
+ base_url: str = DEFAULT_BASE_URL,
45
+ max_retries: int = 2,
46
+ timeout: float = 60.0,
47
+ http_client: httpx.Client | None = None,
48
+ sleep: Callable[[float], None] = time.sleep,
49
+ ) -> None:
50
+ if api_key == "":
51
+ raise ValueError("An API key is required: make one in Settings, API keys.")
52
+
53
+ self._api_key = api_key
54
+ self._base_url = base_url[:-1] if base_url.endswith("/") else base_url
55
+ self._max_retries = max_retries
56
+ self._timeout = timeout
57
+ self._owns_http = http_client is None
58
+ self._http = http_client if http_client is not None else httpx.Client()
59
+ self._sleep = sleep
60
+ self.conversions = Conversions(self)
61
+
62
+ def close(self) -> None:
63
+ """Closes the HTTP client, unless it was passed in."""
64
+ if self._owns_http:
65
+ self._http.close()
66
+
67
+ def __enter__(self) -> IntoSQL:
68
+ return self
69
+
70
+ def __exit__(
71
+ self,
72
+ exc_type: type[BaseException] | None,
73
+ exc: BaseException | None,
74
+ traceback: TracebackType | None,
75
+ ) -> None:
76
+ self.close()
77
+
78
+ def convert(
79
+ self,
80
+ file: Upload,
81
+ *,
82
+ name: str,
83
+ spec: JobSpec,
84
+ size: int | None = None,
85
+ timeout: float = 3600.0,
86
+ interval: float | None = None,
87
+ ) -> Conversion:
88
+ """Uploads `file`, converts it and waits for the result.
89
+
90
+ `size` is the file's size in bytes, needed only for a file object that can't seek.
91
+ """
92
+ size = _size_of(file) if size is None else size
93
+ opened = self.conversions.create(name=name, size=size, spec=spec)
94
+ self.conversions.upload(opened, file, size=size)
95
+ self.conversions.start(opened["id"])
96
+
97
+ return self.conversions.wait(opened["id"], timeout=timeout, interval=interval)
98
+
99
+ def convert_url(
100
+ self,
101
+ url: str,
102
+ *,
103
+ spec: JobSpec,
104
+ name: str | None = None,
105
+ timeout: float = 3600.0,
106
+ interval: float | None = None,
107
+ ) -> Conversion:
108
+ """Converts the file at a public HTTPS address and waits for the result."""
109
+ queued = self.conversions.create_from_url(url=url, spec=spec, name=name)
110
+
111
+ return self.conversions.wait(queued["id"], timeout=timeout, interval=interval)
112
+
113
+ def usage(self) -> Usage:
114
+ """Bytes of conversions the plan includes this month, used and left."""
115
+ return cast(Usage, self._request("GET", "/usage")["data"])
116
+
117
+ def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any: # noqa: ANN401
118
+ url = self._url(path)
119
+ headers = {
120
+ "Authorization": f"Bearer {self._api_key}",
121
+ "Accept": "application/json",
122
+ "User-Agent": f"intosql-python/{__version__}",
123
+ }
124
+
125
+ if method == "POST":
126
+ headers["Idempotency-Key"] = str(uuid.uuid4())
127
+
128
+ attempt = 0
129
+
130
+ while True:
131
+ try:
132
+ response = self._http.request(
133
+ method, url, headers=headers, json=body, timeout=self._timeout
134
+ )
135
+ except httpx.TransportError:
136
+ if attempt >= self._max_retries:
137
+ raise
138
+
139
+ self._sleep(_backoff(attempt, None))
140
+ attempt += 1
141
+ continue
142
+
143
+ if response.is_success:
144
+ return None if response.status_code == 204 else response.json()
145
+
146
+ error = _to_error(response)
147
+
148
+ if _retryable(error) and attempt < self._max_retries:
149
+ self._sleep(_backoff(attempt, response.headers.get("Retry-After")))
150
+ attempt += 1
151
+ continue
152
+
153
+ raise error
154
+
155
+ def _url(self, path: str) -> str:
156
+ """A path under the API, or one of its own absolute addresses (pagination links)."""
157
+ if not path.startswith("http"):
158
+ return f"{self._base_url}{path}"
159
+
160
+ if _origin(path) != _origin(self._base_url):
161
+ raise ValueError(f"Refusing to send the API key to {_origin(path)}.")
162
+
163
+ return path
164
+
165
+
166
+ class Conversions:
167
+ """Each step of a conversion on its own: `client.conversions`."""
168
+
169
+ def __init__(self, client: IntoSQL) -> None:
170
+ self._client = client
171
+
172
+ def create(self, *, name: str, size: int, spec: JobSpec) -> Conversion:
173
+ """Opens a conversion; the answer has the address to upload the file to."""
174
+ body = {"name": name, "bytes": size, "spec": spec}
175
+
176
+ return cast(Conversion, self._client._request("POST", "/conversions", body)["data"])
177
+
178
+ def create_from_url(self, *, url: str, spec: JobSpec, name: str | None = None) -> Conversion:
179
+ """Queues a conversion of the file at a public HTTPS address."""
180
+ body: dict[str, Any] = {"url": url, "spec": spec}
181
+
182
+ if name is not None:
183
+ body["name"] = name
184
+
185
+ return cast(Conversion, self._client._request("POST", "/conversions", body)["data"])
186
+
187
+ def upload(self, conversion: Conversion, file: Upload, *, size: int | None = None) -> None:
188
+ """Sends the file to the address `create` answered with, streamed. Never retried."""
189
+ upload = conversion["upload"]
190
+
191
+ if upload is None:
192
+ raise ValueError(
193
+ f"Conversion {conversion['id']} has no upload address: open it with create()."
194
+ )
195
+
196
+ headers = httpx.Headers()
197
+
198
+ for header, value in upload["headers"].items():
199
+ headers[header] = ", ".join(value) if isinstance(value, list) else value
200
+
201
+ if isinstance(file, (str, os.PathLike)):
202
+ with open(file, "rb") as opened:
203
+ response = self._put(upload["url"], headers, opened, size)
204
+ else:
205
+ response = self._put(upload["url"], headers, file, size)
206
+
207
+ if not response.is_success:
208
+ raise IntoSQLError(
209
+ response.status_code,
210
+ "upload_failed",
211
+ f"The upload answered {response.status_code}.",
212
+ )
213
+
214
+ def _put(
215
+ self,
216
+ url: str,
217
+ headers: httpx.Headers,
218
+ file: bytes | bytearray | memoryview | IO[bytes],
219
+ size: int | None,
220
+ ) -> httpx.Response:
221
+ if isinstance(file, (bytes, bytearray, memoryview)):
222
+ content: Any = bytes(file)
223
+ else:
224
+ content = file
225
+ known = _size_of(file) if size is None else size
226
+ headers.setdefault("Content-Length", str(known))
227
+
228
+ return self._client._http.request(
229
+ "PUT", url, headers=headers, content=content, timeout=self._client._timeout
230
+ )
231
+
232
+ def start(self, id: str) -> Conversion: # noqa: A002
233
+ """Starts a conversion once its file is uploaded whole."""
234
+ return cast(
235
+ Conversion, self._client._request("POST", f"/conversions/{_quote(id)}/start")["data"]
236
+ )
237
+
238
+ def retrieve(self, id: str) -> Conversion: # noqa: A002
239
+ """Reads a conversion."""
240
+ return cast(Conversion, self._client._request("GET", f"/conversions/{_quote(id)}")["data"])
241
+
242
+ def list(self, *, limit: int | None = None, status: str | None = None) -> Iterator[Conversion]:
243
+ """Every conversion, newest first, a page at a time; `status` keeps only those in it."""
244
+ query: dict[str, str] = {}
245
+
246
+ if limit is not None:
247
+ query["limit"] = str(limit)
248
+
249
+ if status is not None:
250
+ query["status"] = status
251
+
252
+ path: str | None = f"/conversions?{urlencode(query)}" if query else "/conversions"
253
+
254
+ while path is not None:
255
+ page = cast(Page, self._client._request("GET", path))
256
+
257
+ yield from page["data"]
258
+ path = page["links"].get("next")
259
+
260
+ def cancel(self, id: str) -> Conversion: # noqa: A002
261
+ """Stops a conversion that hasn't ended; canceling it again answers the same."""
262
+ return cast(
263
+ Conversion, self._client._request("POST", f"/conversions/{_quote(id)}/cancel")["data"]
264
+ )
265
+
266
+ def create_case(
267
+ self,
268
+ id: str, # noqa: A002
269
+ *,
270
+ consent: Literal[True],
271
+ share: Literal["file", "report"] | None = None,
272
+ email: str | None = None,
273
+ note: str | None = None,
274
+ ) -> Case:
275
+ """Sends a conversion that didn't come out right to IntoSQL, so support for it gets added.
276
+
277
+ `consent` must be True: the person agrees to send it. `share="file"` sends the file too.
278
+ """
279
+ body: dict[str, Any] = {"consent": consent}
280
+
281
+ for key, value in (("share", share), ("email", email), ("note", note)):
282
+ if value is not None:
283
+ body[key] = value
284
+
285
+ return cast(
286
+ Case, self._client._request("POST", f"/conversions/{_quote(id)}/cases", body)["data"]
287
+ )
288
+
289
+ def delete(self, id: str) -> None: # noqa: A002
290
+ """Deletes its files now instead of after 24 hours."""
291
+ self._client._request("DELETE", f"/conversions/{_quote(id)}")
292
+
293
+ def wait(
294
+ self,
295
+ id: str, # noqa: A002
296
+ *,
297
+ timeout: float = 3600.0,
298
+ interval: float | None = None,
299
+ ) -> Conversion:
300
+ """Reads the conversion until it is done.
301
+
302
+ Raises `ConversionFailedError` if it fails, `ConversionCanceledError` if it is canceled and
303
+ `TimeoutError` after `timeout` seconds. Pauses grow from half a second to 5 seconds, or
304
+ are `interval` seconds each when given.
305
+ """
306
+ deadline = time.monotonic() + timeout
307
+ attempt = 0
308
+
309
+ while True:
310
+ conversion = self.retrieve(id)
311
+ status = conversion["status"]
312
+
313
+ if status == "done":
314
+ return conversion
315
+
316
+ if status == "failed":
317
+ raise ConversionFailedError(id, conversion["error"])
318
+
319
+ if status == "canceled":
320
+ raise ConversionCanceledError(id)
321
+
322
+ if time.monotonic() >= deadline:
323
+ raise TimeoutError(f"Conversion {id} is still {status}.")
324
+
325
+ self._client._sleep(min(0.5 * 2.0**attempt, 5.0) if interval is None else interval)
326
+ attempt += 1
327
+
328
+
329
+ def _quote(id: str) -> str: # noqa: A002
330
+ return quote(id, safe="")
331
+
332
+
333
+ def _origin(url: str) -> str:
334
+ parts = urlsplit(url)
335
+
336
+ return f"{parts.scheme}://{parts.netloc}"
337
+
338
+
339
+ def _size_of(file: Upload) -> int:
340
+ if isinstance(file, (bytes, bytearray)):
341
+ return len(file)
342
+
343
+ if isinstance(file, memoryview):
344
+ return file.nbytes
345
+
346
+ if isinstance(file, (str, os.PathLike)):
347
+ return os.path.getsize(file)
348
+
349
+ try:
350
+ position = file.tell()
351
+ end = file.seek(0, os.SEEK_END)
352
+ file.seek(position)
353
+ except (OSError, ValueError) as error:
354
+ raise TypeError("Pass size= for a file object that can't seek.") from error
355
+
356
+ return end - position
357
+
358
+
359
+ def _backoff(attempt: int, retry_after: str | None) -> float:
360
+ try:
361
+ seconds = float(retry_after) if retry_after is not None else 0.0
362
+ except ValueError:
363
+ seconds = 0.0
364
+
365
+ if seconds > 0 and seconds != float("inf"):
366
+ return seconds
367
+
368
+ return min(0.5 * 2.0**attempt, 8.0)
369
+
370
+
371
+ def _to_error(response: httpx.Response) -> IntoSQLError:
372
+ try:
373
+ body = response.json()
374
+ except ValueError:
375
+ body = None
376
+
377
+ error = body.get("error") if isinstance(body, dict) else None
378
+ error = error if isinstance(error, dict) else {}
379
+
380
+ return IntoSQLError(
381
+ response.status_code,
382
+ error.get("code") or "http_error",
383
+ error.get("message") or f"The API answered {response.status_code}.",
384
+ error.get("fields") or {},
385
+ error.get("request_id") or response.headers.get("X-Request-Id"),
386
+ )
387
+
388
+
389
+ def _retryable(error: IntoSQLError) -> bool:
390
+ return (
391
+ error.code in _RETRYABLE_CODES or error.status in _RETRYABLE_STATUSES or error.status >= 500
392
+ )
intosql/_contract.py ADDED
@@ -0,0 +1,248 @@
1
+ """The engine's conversion contract: what a spec accepts and what a report holds.
2
+
3
+ Mirrors the contract generated from engine/crates/intosql-core (CONTRACT_VERSION 1). The string
4
+ unions list the values known today; the server may add more, so code should not assume a value
5
+ is one of them.
6
+ """
7
+
8
+ from typing import Literal, Optional, TypedDict
9
+
10
+ CONTRACT_VERSION = 1
11
+
12
+ ColumnKind = Literal[
13
+ "boolean",
14
+ "float",
15
+ "date",
16
+ "timestamp",
17
+ "integer",
18
+ "bigint",
19
+ "decimal",
20
+ "time",
21
+ "timestamptz",
22
+ "text",
23
+ ]
24
+ """The kind of value a column holds, before it is mapped to a dialect's type."""
25
+
26
+ ColumnNames = Literal["snake_case", "as_written"]
27
+ """How column names are written."""
28
+
29
+ DateOrder = Literal["day_first", "month_first", "year_first", "auto"]
30
+ """How ambiguous dates such as 03/02/2024 are read."""
31
+
32
+ Dialect = Literal["postgresql", "mysql", "mariadb", "sqlite", "sql_server", "oracle"]
33
+ """The databases IntoSQL writes SQL for."""
34
+
35
+ ErrorCode = Literal[
36
+ "unsupported_version",
37
+ "batch_rows_out_of_range",
38
+ "sample_rows_too_small",
39
+ "table_name_empty",
40
+ "table_name_too_long",
41
+ "column_name_empty",
42
+ "column_name_too_long",
43
+ "column_not_found",
44
+ "no_columns",
45
+ "unsupported_format",
46
+ "encrypted_file",
47
+ "corrupt_file",
48
+ "empty_file",
49
+ "sheet_not_found",
50
+ "invalid_date",
51
+ "invalid_number",
52
+ "invalid_time",
53
+ "invalid_boolean",
54
+ "value_too_long",
55
+ "ambiguous_reading",
56
+ "mixed_number_formats",
57
+ "other_table_skipped",
58
+ "invalid_json_row",
59
+ "ragged_row",
60
+ ]
61
+ """Problems: what stopped a conversion, left a row out or needs a look."""
62
+
63
+ FixCode = Literal[
64
+ "leading_zeros_kept",
65
+ "dates_day_first",
66
+ "dates_month_first",
67
+ "decimal_comma_read",
68
+ "column_renamed",
69
+ "unnamed_column_named",
70
+ "duplicate_column_renamed",
71
+ "reserved_word_renamed",
72
+ "text_transcoded",
73
+ "boolean_words_read",
74
+ "null_markers_read",
75
+ "formatted_numbers_read",
76
+ "code_kept_as_text",
77
+ "read_by_locale",
78
+ "unclosed_quote_read",
79
+ "header_found",
80
+ "no_header_found",
81
+ "total_row_skipped",
82
+ "note_row_skipped",
83
+ "excel1904_dates",
84
+ ]
85
+ """Corrections the engine made, each with its evidence."""
86
+
87
+ InputFormat = Literal["xlsx", "xls", "xlsb", "ods", "csv", "tsv", "json", "ndjson", "sql_dump"]
88
+ """What a file can be read as."""
89
+
90
+ OutputFormat = Literal["sql", "csv", "json"]
91
+ """What a conversion writes."""
92
+
93
+ OutputStyle = Literal["inserts", "bulk"]
94
+ """How rows are written."""
95
+
96
+ Severity = Literal["error", "rejected", "warning"]
97
+ """How serious a problem is."""
98
+
99
+
100
+ class _ColumnReportRequired(TypedDict):
101
+ source_name: str
102
+ name: str
103
+ sql_type: str
104
+ kind: ColumnKind
105
+ nullable: bool
106
+ fixes: list[int]
107
+
108
+
109
+ class ColumnReport(_ColumnReportRequired, total=False):
110
+ """One column as it was read and as it was written."""
111
+
112
+ precision: Optional[int]
113
+ scale: Optional[int]
114
+ max_chars: Optional[int]
115
+ excluded: bool
116
+ as_text: bool
117
+
118
+
119
+ class _ColumnSpecRequired(TypedDict):
120
+ index: int
121
+
122
+
123
+ class ColumnSpec(_ColumnSpecRequired, total=False):
124
+ """What to do with one column of the file, found by its 0-based position."""
125
+
126
+ name: Optional[str]
127
+ as_text: bool
128
+ exclude: bool
129
+
130
+
131
+ class _DetectedInputRequired(TypedDict):
132
+ format: InputFormat
133
+ encoding: str
134
+ bytes: int
135
+
136
+
137
+ class DetectedInput(_DetectedInputRequired, total=False):
138
+ """What was detected about the input."""
139
+
140
+ delimiter: Optional[str]
141
+ sheet: Optional[str]
142
+
143
+
144
+ class _FixRequired(TypedDict):
145
+ number: int
146
+ code: FixCode
147
+ args: dict[str, str]
148
+
149
+
150
+ class Fix(_FixRequired, total=False):
151
+ """A correction the engine made, numbered like a proofreader's mark."""
152
+
153
+ column: Optional[str]
154
+ row: Optional[int]
155
+
156
+
157
+ class _InferenceSpecRequired(TypedDict):
158
+ sample_rows: int
159
+ date_order: DateOrder
160
+ keep_leading_zeros: bool
161
+
162
+
163
+ class InferenceSpec(_InferenceSpecRequired, total=False):
164
+ """How column types are inferred."""
165
+
166
+ locale: Optional[str]
167
+
168
+
169
+ class InputSpec(TypedDict, total=False):
170
+ """How to read the input. Absent values are detected from the file."""
171
+
172
+ format: Optional[InputFormat]
173
+ encoding: Optional[str]
174
+ delimiter: Optional[str]
175
+ sheet: Optional[str]
176
+ header_row: Optional[int]
177
+
178
+
179
+ class _OutputSpecRequired(TypedDict):
180
+ format: OutputFormat
181
+ dialect: Dialect
182
+ create_table: bool
183
+ column_names: ColumnNames
184
+ style: OutputStyle
185
+ batch_rows: int
186
+
187
+
188
+ class OutputSpec(_OutputSpecRequired, total=False):
189
+ """What to write."""
190
+
191
+ table: Optional[str]
192
+ schema: Optional[str]
193
+
194
+
195
+ class _JobSpecRequired(TypedDict):
196
+ version: int
197
+ input: InputSpec
198
+ output: OutputSpec
199
+ inference: InferenceSpec
200
+
201
+
202
+ class JobSpec(_JobSpecRequired, total=False):
203
+ """A conversion request, the same for the browser, the CLI, the API and MCP."""
204
+
205
+ columns: list[ColumnSpec]
206
+
207
+
208
+ class _ProblemRequired(TypedDict):
209
+ code: ErrorCode
210
+ severity: Severity
211
+ args: dict[str, str]
212
+
213
+
214
+ class Problem(_ProblemRequired, total=False):
215
+ """Something that stopped the conversion, left a row out or needs a look."""
216
+
217
+ column: Optional[str]
218
+ row: Optional[int]
219
+
220
+
221
+ class RowCounts(TypedDict):
222
+ """Row totals."""
223
+
224
+ read: int
225
+ converted: int
226
+ rejected: int
227
+
228
+
229
+ class Timings(TypedDict):
230
+ """How long each stage took."""
231
+
232
+ read_ms: int
233
+ convert_ms: int
234
+ total_ms: int
235
+
236
+
237
+ class JobReport(TypedDict):
238
+ """What a conversion did, the same on every surface."""
239
+
240
+ version: int
241
+ input: DetectedInput
242
+ dialect: Dialect
243
+ table: str
244
+ columns: list[ColumnReport]
245
+ rows: RowCounts
246
+ fixes: list[Fix]
247
+ problems: list[Problem]
248
+ timings: Timings
intosql/_errors.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from ._contract import Problem
4
+ from ._types import ConversionFailure
5
+
6
+
7
+ class IntoSQLError(Exception):
8
+ """An answer from the API that wasn't a success. `code` doesn't change between versions."""
9
+
10
+ def __init__(
11
+ self,
12
+ status: int,
13
+ code: str,
14
+ message: str,
15
+ fields: dict[str, list[str]] | None = None,
16
+ request_id: str | None = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.status = status
20
+ self.code = code
21
+ self.message = message
22
+ self.fields: dict[str, list[str]] = fields if fields is not None else {}
23
+ self.request_id = request_id
24
+ """The answer's X-Request-Id; quote it when you write to IntoSQL."""
25
+
26
+
27
+ class ConversionFailedError(Exception):
28
+ """A conversion that ended as `failed`, with the stable code and the engine's problems."""
29
+
30
+ def __init__(self, id: str, failure: ConversionFailure | None) -> None: # noqa: A002
31
+ message = failure["message"] if failure is not None else f"Conversion {id} failed."
32
+ super().__init__(message)
33
+ self.id = id
34
+ self.code = failure["code"] if failure is not None else "internal_error"
35
+ self.problems: list[Problem] = failure["problems"] if failure is not None else []
36
+ self.message = message
37
+
38
+
39
+ class ConversionCanceledError(Exception):
40
+ """A conversion that was canceled while it was awaited."""
41
+
42
+ def __init__(self, id: str) -> None: # noqa: A002
43
+ message = f"Conversion {id} was canceled."
44
+ super().__init__(message)
45
+ self.id = id
46
+ self.message = message
47
+
48
+
49
+ class WebhookSignatureError(ValueError):
50
+ """A webhook call whose IntoSQL-Signature doesn't match its body, or is too old."""
intosql/_spec.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from ._contract import Dialect, JobSpec, OutputFormat
4
+
5
+
6
+ def spec(
7
+ dialect: Dialect,
8
+ *,
9
+ format: OutputFormat = "sql", # noqa: A002
10
+ table: str | None = None,
11
+ schema: str | None = None,
12
+ sheet: str | None = None,
13
+ create_table: bool = True,
14
+ batch_rows: int = 500,
15
+ ) -> JobSpec:
16
+ """A conversion request with IntoSQL's defaults: types inferred, snake_case, batched inserts."""
17
+ return {
18
+ "version": 1,
19
+ "input": {
20
+ "sheet": sheet,
21
+ "header_row": None,
22
+ "delimiter": None,
23
+ "encoding": None,
24
+ },
25
+ "output": {
26
+ "format": format,
27
+ "dialect": dialect,
28
+ "table": table,
29
+ "schema": schema,
30
+ "create_table": create_table,
31
+ "column_names": "snake_case",
32
+ "style": "inserts",
33
+ "batch_rows": batch_rows,
34
+ },
35
+ "inference": {
36
+ "sample_rows": 10_000,
37
+ "date_order": "auto",
38
+ "keep_leading_zeros": True,
39
+ "locale": None,
40
+ },
41
+ "columns": [],
42
+ }
intosql/_types.py ADDED
@@ -0,0 +1,117 @@
1
+ """What the API answers with: decoded JSON, so new fields and values pass through."""
2
+
3
+ from typing import Literal, Optional, TypedDict, Union
4
+
5
+ from ._contract import JobReport, JobSpec, Problem
6
+
7
+ ConversionStatus = Literal["awaiting_upload", "queued", "running", "done", "failed", "canceled"]
8
+ """Where a conversion is. The server may add statuses, so compare against these, don't assume."""
9
+
10
+ ConversionErrorCode = Literal[
11
+ "not_uploaded",
12
+ "unreachable",
13
+ "download_stalled",
14
+ "too_big",
15
+ "over_quota",
16
+ "timed_out",
17
+ "account_suspended",
18
+ "internal_error",
19
+ ]
20
+ """Codes a conversion fails with on IntoSQL's side; a refused file carries an ErrorCode."""
21
+
22
+
23
+ class ConversionFailure(TypedDict):
24
+ """Why a conversion failed. `code` is stable; new codes may be added within v1."""
25
+
26
+ code: str
27
+ message: str
28
+ problems: list[Problem]
29
+
30
+
31
+ class ConversionOutput(TypedDict):
32
+ """The result of a done conversion, downloadable for ten minutes."""
33
+
34
+ bytes: int
35
+ url: str
36
+
37
+
38
+ class ConversionUpload(TypedDict):
39
+ """Where to PUT the file, right after `create`."""
40
+
41
+ method: Literal["PUT"]
42
+ url: str
43
+ headers: dict[str, Union[str, list[str]]]
44
+
45
+
46
+ class Conversion(TypedDict):
47
+ """A conversion as the API answers it."""
48
+
49
+ id: str
50
+ status: str
51
+ name: str
52
+ bytes: int
53
+ spec: JobSpec
54
+ report: Optional[JobReport]
55
+ error: Optional[ConversionFailure]
56
+ output: Optional[ConversionOutput]
57
+ upload: Optional[ConversionUpload]
58
+ created_at: Optional[str]
59
+ started_at: Optional[str]
60
+ finished_at: Optional[str]
61
+ expires_at: str
62
+
63
+
64
+ class Case(TypedDict):
65
+ """A conversion sent to IntoSQL because it didn't come out right."""
66
+
67
+ id: str
68
+ conversion_id: Optional[str]
69
+ share: Literal["file", "report"]
70
+ status: Literal["open", "fixed", "closed"]
71
+ created_at: Optional[str]
72
+ expires_at: str
73
+
74
+
75
+ class Usage(TypedDict):
76
+ """Bytes of conversions the plan includes this month, used and left."""
77
+
78
+ limit: int
79
+ used: int
80
+ left: int
81
+ period_start: str
82
+
83
+
84
+ class PageLinks(TypedDict, total=False):
85
+ next: Optional[str]
86
+ prev: Optional[str]
87
+
88
+
89
+ class PageMeta(TypedDict):
90
+ per_page: int
91
+ next_cursor: Optional[str]
92
+ prev_cursor: Optional[str]
93
+
94
+
95
+ class Page(TypedDict):
96
+ """One page of `GET /conversions`."""
97
+
98
+ data: list[Conversion]
99
+ links: PageLinks
100
+ meta: PageMeta
101
+
102
+
103
+ class PingData(TypedDict):
104
+ sent_at: str
105
+
106
+
107
+ class WebhookEvent(TypedDict):
108
+ """What a webhook call carries. `id` is the same on every retry: handle each event once.
109
+
110
+ `type` is `conversion.done`, `conversion.failed`, `conversion.canceled` or `ping`. `data` is
111
+ the conversion, or `{"sent_at": ...}` for `ping`.
112
+ """
113
+
114
+ id: str
115
+ type: str
116
+ created_at: str
117
+ data: Union[Conversion, PingData]
intosql/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
intosql/_webhooks.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import cast
8
+
9
+ from ._errors import WebhookSignatureError
10
+ from ._types import WebhookEvent
11
+
12
+
13
+ def verify_webhook(
14
+ body: str | bytes,
15
+ header: str,
16
+ secret: str,
17
+ *,
18
+ tolerance: int = 300,
19
+ now: int | None = None,
20
+ ) -> bool:
21
+ """Whether a webhook call came from IntoSQL.
22
+
23
+ `body` is the raw request body, `header` the IntoSQL-Signature header and `secret` the signing
24
+ secret from Settings, API keys. Calls signed more than `tolerance` seconds from `now` (Unix
25
+ seconds, the current time by default) are refused.
26
+ """
27
+ parts = {}
28
+
29
+ for part in header.split(","):
30
+ key, _, value = part.strip().partition("=")
31
+ parts[key] = value
32
+
33
+ try:
34
+ timestamp = int(parts.get("t", ""))
35
+ except ValueError:
36
+ return False
37
+
38
+ signature = parts.get("v1")
39
+ current = int(time.time()) if now is None else now
40
+
41
+ if signature is None or abs(current - timestamp) > tolerance:
42
+ return False
43
+
44
+ raw = body.encode() if isinstance(body, str) else body
45
+ expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256)
46
+
47
+ return hmac.compare_digest(expected.hexdigest().encode(), signature.encode())
48
+
49
+
50
+ def parse_webhook(
51
+ body: str | bytes,
52
+ header: str,
53
+ secret: str,
54
+ *,
55
+ tolerance: int = 300,
56
+ now: int | None = None,
57
+ ) -> WebhookEvent:
58
+ """The event of a webhook call, once its signature checks out.
59
+
60
+ Raises `WebhookSignatureError` when it doesn't, so a forged or replayed call never reaches
61
+ your code.
62
+ """
63
+ if not verify_webhook(body, header, secret, tolerance=tolerance, now=now):
64
+ raise WebhookSignatureError("The IntoSQL-Signature does not match this body.")
65
+
66
+ return cast(WebhookEvent, json.loads(body))
intosql/py.typed ADDED
File without changes
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: intosql
3
+ Version: 0.1.0
4
+ Summary: Convert spreadsheets, CSV and JSON into SQL with the IntoSQL API.
5
+ Project-URL: Homepage, https://intosql.com/docs/sdks/python
6
+ Project-URL: Documentation, https://intosql.com/docs/sdks/python
7
+ Project-URL: Repository, https://github.com/alfonsobries/intosql
8
+ Project-URL: Changelog, https://github.com/alfonsobries/intosql/blob/main/sdks/python/CHANGELOG.md
9
+ Author-email: IntoSQL <hello@intosql.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: converter,csv,excel,mysql,postgresql,sql,sqlite,xlsx
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Database
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx<1,>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # IntoSQL for Python
25
+
26
+ Convert spreadsheets (XLSX, XLS, ODS), CSV and JSON into SQL for PostgreSQL, MySQL, MariaDB, SQLite, SQL Server or Oracle with the [IntoSQL API](https://intosql.com/developers). Column types are inferred and dirty values fixed, so the script loads on the first try.
27
+
28
+ ```sh
29
+ pip install intosql
30
+ ```
31
+
32
+ Python 3.9 or newer. The API is part of IntoSQL Pro; make a key in Settings, API keys.
33
+
34
+ ## Convert a file
35
+
36
+ ```python
37
+ import os
38
+ from intosql import IntoSQL, spec
39
+
40
+ intosql = IntoSQL(os.environ["INTOSQL_API_KEY"])
41
+
42
+ conversion = intosql.convert(
43
+ "ventas.xlsx",
44
+ name="ventas.xlsx",
45
+ spec=spec("postgresql", table="ventas"),
46
+ )
47
+
48
+ print(conversion["report"]["rows"]["converted"], conversion["output"]["url"])
49
+ ```
50
+
51
+ The file streams to storage as it is; the output URL downloads the script for ten minutes. Read the conversion again (`intosql.conversions.retrieve(id)`) for a fresh one.
52
+
53
+ ## Convert a file by URL
54
+
55
+ ```python
56
+ conversion = intosql.convert_url(
57
+ "https://example.com/exports/ventas.csv",
58
+ spec=spec("mysql"),
59
+ )
60
+ ```
61
+
62
+ ## Check a webhook
63
+
64
+ Save a webhook address in Settings, API keys, and IntoSQL calls it when a conversion is done, failed or canceled.
65
+
66
+ ```python
67
+ from intosql import parse_webhook
68
+
69
+ # Raises WebhookSignatureError when the signature doesn't match.
70
+ event = parse_webhook(
71
+ request.get_data(),
72
+ request.headers.get("IntoSQL-Signature", ""),
73
+ os.environ["INTOSQL_WEBHOOK_SECRET"],
74
+ )
75
+ ```
76
+
77
+ ## Errors and retries
78
+
79
+ Failed requests raise `IntoSQLError` with the HTTP `status`, a `code` that doesn't change (`needs_pro`, `over_quota`, `too_big`, `invalid_spec`, `invalid_url` and more) and the `request_id`. A conversion that ends as failed raises `ConversionFailedError` with its `code` and the engine's `problems`. Rate limits, a full account, server errors and lost connections are retried twice with the same idempotency key (`max_retries` changes it); `already_started` never is.
80
+
81
+ Every endpoint: [intosql.com/developers](https://intosql.com/developers). This SDK: [intosql.com/docs/sdks/python](https://intosql.com/docs/sdks/python).
@@ -0,0 +1,13 @@
1
+ intosql/__init__.py,sha256=TBp76mjNGCUfMmUakqCjG0cs0Ooq8ZkmBa8_ZPjibik,1931
2
+ intosql/_client.py,sha256=LghUfjzP4X48QEnxMbW6HiAWeeiwyeK_wZKXSTUOoEk,13035
3
+ intosql/_contract.py,sha256=77NdKJa7isev6G-9W_pATALLgF-OA9BrYcP5_65eKmY,5654
4
+ intosql/_errors.py,sha256=NxxVzgNU8Jw7DkCXze_fU-plkLkVouFnAppbnRIDWUk,1757
5
+ intosql/_spec.py,sha256=sxQTvSl02-Ruda0PldoAaEKiVmFPBHlOHwA_RAw3Zso,1134
6
+ intosql/_types.py,sha256=X_Gtx74HKvDH2XTqzRnFZaEoEo53rS2ASrCCKTUmkiI,2790
7
+ intosql/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
8
+ intosql/_webhooks.py,sha256=oLbKS3wDxl3JUotXNiIKTdgDK4WHIPb_YuyASiHKbd8,1842
9
+ intosql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ intosql-0.1.0.dist-info/METADATA,sha256=v3nbv23g9qvDX91XET4e7x2GLg_e086xvWWIKX9AonA,3048
11
+ intosql-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ intosql-0.1.0.dist-info/licenses/LICENSE,sha256=7-YghfHK2R7-j_AcB_VVurxAn4brtCXq1CXFRIkSlk8,1064
13
+ intosql-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 IntoSQL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.