sqlite2duckdb 0.4.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.
@@ -0,0 +1,14 @@
1
+ import importlib.metadata
2
+
3
+ from sqlite2duckdb.sqlite_to_duckdb import ConversionResult, sqlite_to_duckdb
4
+
5
+ try:
6
+ __version__ = importlib.metadata.version("sqlite2duckdb")
7
+ except importlib.metadata.PackageNotFoundError:
8
+ # Running from a source checkout that was never installed.
9
+ __version__ = "0.0.0.dev0"
10
+
11
+ # Deprecated alias, kept so that existing imports keep working.
12
+ __VERSION__ = __version__
13
+
14
+ __all__ = ["ConversionResult", "__version__", "sqlite_to_duckdb"]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sys
7
+
8
+ from sqlite2duckdb import __version__, sqlite_to_duckdb
9
+
10
+
11
+ def main_cli() -> int:
12
+ parser = argparse.ArgumentParser(
13
+ prog="sqlite2duckdb",
14
+ description="Convert Sqlite database to Duckdb database",
15
+ usage="sqlite2duckdb [-f] <sqlite_path> <duckdb_path>",
16
+ )
17
+
18
+ parser.add_argument("sqlite_path", type=str, help="sqlite file path")
19
+ parser.add_argument("duckdb_path", type=str, help="duckdb file path")
20
+ parser.add_argument(
21
+ "-f",
22
+ "--force",
23
+ action="store_true",
24
+ help="overwrite the duckdb file if it already exists",
25
+ )
26
+ parser.add_argument("-q", "--quiet", action="store_true", help="only report errors")
27
+ parser.add_argument("--verbose", action="store_true", help="report every step")
28
+ parser.add_argument(
29
+ "-v", "--version", action="version", version=f"sqlite2duckdb {__version__}"
30
+ )
31
+
32
+ args = parser.parse_args()
33
+
34
+ if args.quiet:
35
+ level = logging.WARNING
36
+ elif args.verbose:
37
+ level = logging.DEBUG
38
+ else:
39
+ level = logging.INFO
40
+ # Progress goes to stderr so that stdout stays free for pipelines.
41
+ logging.basicConfig(level=level, format="%(message)s", stream=sys.stderr)
42
+
43
+ overwrite = args.force
44
+ if not overwrite and os.path.exists(args.duckdb_path):
45
+ if not sys.stdin.isatty():
46
+ print(
47
+ f"{args.duckdb_path} already exists. Use --force to overwrite it.",
48
+ file=sys.stderr,
49
+ )
50
+ return 1
51
+ try:
52
+ answer = (
53
+ input(
54
+ f"{args.duckdb_path} already exists. do you want to delete this file ? (yes/no): "
55
+ )
56
+ .strip()
57
+ .lower()
58
+ )
59
+ except EOFError:
60
+ print(f"{args.duckdb_path} already exists.", file=sys.stderr)
61
+ return 1
62
+ if answer not in ("yes", "y"):
63
+ return 1
64
+ overwrite = True
65
+
66
+ try:
67
+ sqlite_to_duckdb(args.sqlite_path, args.duckdb_path, overwrite=overwrite)
68
+ except (FileNotFoundError, FileExistsError) as error:
69
+ print(f"error: {error}", file=sys.stderr)
70
+ return 1
71
+
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main_cli())
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import os
6
+ import sqlite3
7
+ import time
8
+ from dataclasses import dataclass
9
+
10
+ import duckdb
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class ConversionResult:
17
+ """What a successful conversion produced."""
18
+
19
+ target: str
20
+ tables: int
21
+ elapsed: float
22
+ """Wall clock duration of the conversion, in seconds."""
23
+
24
+
25
+ def _quote_identifier(name: str) -> str:
26
+ return '"' + name.replace('"', '""') + '"'
27
+
28
+
29
+ def _format_duration(seconds: float) -> str:
30
+ if seconds < 1:
31
+ return f"{seconds * 1000:.2f} ms"
32
+ return f"{seconds:.2f} s"
33
+
34
+
35
+ def _remove_quietly(path: str) -> None:
36
+ for candidate in (path, path + ".wal"):
37
+ try:
38
+ os.remove(candidate)
39
+ except OSError:
40
+ pass
41
+
42
+
43
+ def _brackets_to_quotes(sql: str) -> str:
44
+ """Rewrite sqlite's [bracket] identifiers into standard "double quoted" ones.
45
+
46
+ Quoted runs are copied verbatim so that a `[` inside a string literal survives.
47
+ """
48
+
49
+ out = []
50
+ index = 0
51
+ length = len(sql)
52
+
53
+ while index < length:
54
+ char = sql[index]
55
+
56
+ if char in "'\"`":
57
+ end = index + 1
58
+ while end < length:
59
+ if sql[end] == char:
60
+ if end + 1 < length and sql[end + 1] == char:
61
+ end += 2
62
+ continue
63
+ break
64
+ end += 1
65
+ out.append(sql[index : end + 1])
66
+ index = end + 1
67
+
68
+ elif char == "[":
69
+ end = sql.find("]", index)
70
+ if end == -1:
71
+ out.append(char)
72
+ index += 1
73
+ else:
74
+ out.append(_quote_identifier(sql[index + 1 : end]))
75
+ index = end + 1
76
+
77
+ else:
78
+ out.append(char)
79
+ index += 1
80
+
81
+ return "".join(out)
82
+
83
+
84
+ def _copy_indexes(conn: duckdb.DuckDBPyConnection, sqlite_path: str) -> None:
85
+ """Recreate the source indexes, translating sqlite quoting on the way."""
86
+
87
+ with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
88
+ indexes = source.execute(
89
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL"
90
+ ).fetchall()
91
+
92
+ for name, sql in indexes:
93
+ try:
94
+ conn.sql(_brackets_to_quotes(sql))
95
+ except duckdb.Error as error:
96
+ logger.warning("Could not recreate index %s: %s", name, error)
97
+
98
+
99
+ def _copy_tables(
100
+ conn: duckdb.DuckDBPyConnection, tables: list[tuple[str, str]]
101
+ ) -> None:
102
+ """Recreate each table then fill it from the attached database."""
103
+
104
+ for table_name, ddl in tables:
105
+ # This DDL is regenerated by duckdb from the attached catalog, so unlike
106
+ # sqlite's own it always parses, and it still carries the primary keys
107
+ # and the NOT NULL constraints.
108
+ conn.sql(ddl)
109
+ quoted = _quote_identifier(table_name)
110
+ conn.sql(f"INSERT INTO {quoted} SELECT * FROM __other.{quoted}")
111
+
112
+
113
+ def sqlite_to_duckdb(
114
+ sqlite_db: str | os.PathLike[str],
115
+ duck_db: str | os.PathLike[str],
116
+ *,
117
+ overwrite: bool = False,
118
+ ) -> ConversionResult:
119
+ """Copy a sqlite database into a new duckdb database.
120
+
121
+ Tables, data, primary keys, NOT NULL constraints and indexes are copied.
122
+ Views and UNIQUE / FOREIGN KEY / CHECK constraints are not: duckdb's sqlite
123
+ extension does not expose them on the attached database.
124
+
125
+ Raises FileNotFoundError if `sqlite_db` does not exist, and FileExistsError
126
+ if `duck_db` already exists and `overwrite` is False.
127
+ """
128
+
129
+ sqlite_path = os.fspath(sqlite_db)
130
+ duck_path = os.fspath(duck_db)
131
+
132
+ if not os.path.exists(sqlite_path):
133
+ raise FileNotFoundError(f"File {sqlite_path} doesn't exist")
134
+
135
+ if os.path.exists(duck_path):
136
+ if not overwrite:
137
+ raise FileExistsError(f"Database {duck_path} already exists")
138
+ _remove_quietly(duck_path)
139
+
140
+ logger.info("Create %s database", duck_path)
141
+ start_time = time.perf_counter()
142
+
143
+ conn = duckdb.connect(duck_path)
144
+ try:
145
+ ## Install sqlite
146
+ conn.sql("INSTALL sqlite; LOAD sqlite;")
147
+
148
+ # Bound parameters are not allowed in ATTACH, so escape the quotes ourselves
149
+ source_path = sqlite_path.replace("'", "''")
150
+ conn.sql(f"ATTACH '{source_path}' AS __other (TYPE SQLITE, READ_ONLY)")
151
+
152
+ ## Get sqlite Names, along with the CREATE statement duckdb derives for them
153
+ tables = conn.sql(
154
+ "SELECT table_name, sql FROM duckdb_tables WHERE database_name = '__other'"
155
+ ).fetchall()
156
+ logger.info("%d table(s) found", len(tables))
157
+
158
+ _copy_tables(conn, tables)
159
+ _copy_indexes(conn, sqlite_path)
160
+
161
+ conn.sql("DETACH __other")
162
+ except BaseException:
163
+ # Never leave a half-written database behind: it would trip the
164
+ # "already exists" guard on the next run.
165
+ conn.close()
166
+ _remove_quietly(duck_path)
167
+ raise
168
+
169
+ conn.close()
170
+
171
+ elapsed = time.perf_counter() - start_time
172
+ logger.info("Done in %s !", _format_duration(elapsed))
173
+
174
+ return ConversionResult(target=duck_path, tables=len(tables), elapsed=elapsed)
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlite2duckdb
3
+ Version: 0.4.0
4
+ Summary: A tool to convert sqlite database to duckdb database
5
+ Project-URL: Homepage, https://github.com/dridk/sqlite2duckdb
6
+ Project-URL: Issues, https://github.com/dridk/sqlite2duckdb/issues
7
+ Author-email: Sacha Schutz <sacha.schutz@pm.me>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: database,duckdb,olap,oltp,sqlite
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: duckdb>=1.1.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # sqlite2duckdb
23
+
24
+ ![CI](https://github.com/dridk/sqlite2duckdb/actions/workflows/ci.yml/badge.svg)
25
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sqlite2duckdb)
26
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/sqlite2duckdb)
27
+
28
+ A tool for converting a [sqlite](https://www.sqlite.org/) database into a [duckdb](https://duckdb.org/) database
29
+
30
+ ## Description
31
+
32
+ Sqlite is an embedded online database designed for transactional reading and writing.
33
+ Duckdb is also an embedded database, but column-oriented, designed for analytical process with a very high reading efficiency.
34
+
35
+ For more details [https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
36
+
37
+ Requires Python >= 3.9 and duckdb >= 1.1.0 (indexes are only copied from that version on).
38
+
39
+ ## Installation
40
+
41
+ With [uv](https://docs.astral.sh/uv/), no installation is required. `uvx` downloads and runs the tool in one go:
42
+
43
+ ```bash
44
+ uvx sqlite2duckdb source.db target.db
45
+ ```
46
+
47
+ To keep it around:
48
+
49
+ ```bash
50
+ uv tool install sqlite2duckdb
51
+ ```
52
+
53
+ Or with pip:
54
+
55
+ ```bash
56
+ pip install sqlite2duckdb
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ### As a command line
62
+
63
+ ```
64
+ usage: sqlite2duckdb [-f] <sqlite_path> <duckdb_path>
65
+
66
+ Convert Sqlite database to Duckdb database
67
+
68
+ positional arguments:
69
+ sqlite_path sqlite file path
70
+ duckdb_path duckdb file path
71
+
72
+ options:
73
+ -h, --help show this help message and exit
74
+ -f, --force overwrite the duckdb file if it already exists
75
+ -q, --quiet only report errors
76
+ --verbose report every step
77
+ -v, --version show program's version number and exit
78
+ ```
79
+
80
+ The tool never overwrites an existing target silently. On a terminal it asks for
81
+ confirmation; anywhere else (a script, a CI job, a pipe) it exits with code 1 and tells you
82
+ to pass `--force`. Progress is written to stderr, so stdout stays free for pipelines.
83
+
84
+ ### Examples
85
+
86
+ ```bash
87
+ uvx sqlite2duckdb source.db target.db
88
+ uvx sqlite2duckdb --force source.db target.db # overwrite target.db without asking
89
+ ```
90
+
91
+ ### From python
92
+
93
+ ```python
94
+ from sqlite2duckdb import sqlite_to_duckdb
95
+
96
+ result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
97
+ print(result.tables, result.elapsed)
98
+ ```
99
+
100
+ `sqlite_to_duckdb(sqlite_db, duck_db, *, overwrite=False)` accepts `str` or `pathlib.Path`
101
+ and returns a `ConversionResult` (`target`, `tables`, `elapsed`). It raises
102
+ `FileNotFoundError` if the source is missing and `FileExistsError` if the target already
103
+ exists and `overwrite` is False. If the conversion fails halfway, the partially written
104
+ target file is removed rather than left behind. Progress is reported through the standard
105
+ `logging` module (logger `sqlite2duckdb.sqlite_to_duckdb`), never printed.
106
+
107
+ ## What is converted
108
+
109
+ | | |
110
+ |---|---|
111
+ | Tables and data | ✅ |
112
+ | Primary keys, NOT NULL constraints, indexes | ✅ |
113
+ | UNIQUE, FOREIGN KEY and CHECK constraints | ❌ |
114
+ | Views | ❌ (silently dropped) |
115
+
116
+ Duckdb's sqlite extension does not expose the last two on the attached database, so they
117
+ cannot be copied. Reading them back from `sqlite_master` would be needed.
118
+
119
+ Tables are recreated from the DDL duckdb derives for the attached database, then filled
120
+ from it, and the indexes are read back from `sqlite_master`. This is what makes sqlite
121
+ files that quote their DDL with `[brackets]` (chinook.db, MS Access exports) convert
122
+ correctly: duckdb's own parser rejects that syntax, so the quoting is translated first.
123
+
124
+ ## Todo
125
+
126
+ - [ ] Custom type mapping
127
+ - [x] Primary keys, NOT NULL constraints and indexes
128
+ - [ ] Views, and UNIQUE / FOREIGN KEY / CHECK constraints
129
+
130
+ ## Contributing
131
+
132
+ The project uses [uv](https://docs.astral.sh/uv/) for everything:
133
+
134
+ ```bash
135
+ make dev # uv sync — installs duckdb plus the test deps (pytest, faker, ruff)
136
+ make test # uv run pytest
137
+ make lint # uv run ruff check . && uv run ruff format --check .
138
+ make build # uv build
139
+ make publish # uv publish (PyPI trusted publishing, also run on tags by CI)
140
+ ```
141
+
142
+ ### See also
143
+
144
+ - [Harlequin](https://github.com/tconbeer/harlequin): A nice duckdb IDE for your terminal
@@ -0,0 +1,8 @@
1
+ sqlite2duckdb/__init__.py,sha256=GJEB0tNxkIzTIHMFTpUyywaGqLRMp6zWYCiniVMc74I,473
2
+ sqlite2duckdb/__main__.py,sha256=o4BxA4togX5XVZGtqwxebtLK0Sk3K8W9sqEccflKVAo,2319
3
+ sqlite2duckdb/sqlite_to_duckdb.py,sha256=E_8RumKshUB_XqePhNQ2okPKUpJmUnKQB7znb5Sn6Fk,5166
4
+ sqlite2duckdb-0.4.0.dist-info/METADATA,sha256=MQjtJBMAoU1FtiQNotPpW0FiR4QSg-ggRKilmdYzMs4,4951
5
+ sqlite2duckdb-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ sqlite2duckdb-0.4.0.dist-info/entry_points.txt,sha256=HXDOw1q3RRjrTvb8pZ9cfwp41b0AQXaSwCF7CQPlgQA,66
7
+ sqlite2duckdb-0.4.0.dist-info/licenses/LICENSE,sha256=m02ByVVkLjRHEV9sN02uVqtLRTfaO74CBS_s8eA_UII,1069
8
+ sqlite2duckdb-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlite2duckdb = sqlite2duckdb.__main__:main_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 sacha schutz
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.