sqlite2duckdb 0.3.0__tar.gz → 0.5.0__tar.gz

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.
@@ -158,3 +158,6 @@ cython_debug/
158
158
  # and can be added to the global gitignore or merged into this file. For a more nuclear
159
159
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
160
  #.idea/
161
+
162
+ # Test databases generated at the repo root
163
+ /*.db
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlite2duckdb
3
+ Version: 0.5.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 [Medium post](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
36
+
37
+
38
+ ## Installation
39
+
40
+ With [uv](https://docs.astral.sh/uv/), no installation is required. `uvx` downloads and runs the tool in one go:
41
+
42
+ ```bash
43
+ uvx sqlite2duckdb source.db target.db
44
+ ```
45
+
46
+ To keep it around:
47
+
48
+ ```bash
49
+ uv tool install sqlite2duckdb
50
+ ```
51
+
52
+ Or with pip:
53
+
54
+ ```bash
55
+ pip install sqlite2duckdb
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ### As a command line
61
+
62
+ ```
63
+ usage: sqlite2duckdb [-f] <sqlite_path> <duckdb_path>
64
+
65
+ Convert Sqlite database to Duckdb database
66
+
67
+ positional arguments:
68
+ sqlite_path sqlite file path
69
+ duckdb_path duckdb file path
70
+
71
+ options:
72
+ -h, --help show this help message and exit
73
+ -f, --force overwrite the duckdb file if it already exists
74
+ -q, --quiet only report errors
75
+ --verbose report every step
76
+ -v, --version show program's version number and exit
77
+ ```
78
+
79
+ ### Examples
80
+
81
+ ```bash
82
+ uvx sqlite2duckdb source.db target.db
83
+ uvx sqlite2duckdb --force source.db target.db # overwrite target.db without asking
84
+ ```
85
+
86
+ ### From python
87
+
88
+ ```python
89
+ from sqlite2duckdb import sqlite_to_duckdb
90
+
91
+ result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
92
+ print(result.tables, result.views, result.elapsed)
93
+ ```
94
+
95
+ ## What is converted
96
+
97
+ | | |
98
+ |---|---|
99
+ | Tables and data | ✅ |
100
+ | Primary keys, NOT NULL and UNIQUE constraints | ✅ |
101
+ | Indexes | ✅ |
102
+ | Views | ✅ best effort |
103
+ | FOREIGN KEY and CHECK constraints | ❌ |
104
+
105
+ A view whose SQL uses something duckdb has no equivalent for (`MATCH`, `julianday()`) is
106
+ skipped with a warning instead of failing the conversion.
107
+
108
+ FOREIGN KEY and CHECK are not copied, though not for lack of support: duckdb accepts and
109
+ enforces both in `CREATE TABLE`. It checks foreign keys row by row, so a self-referencing
110
+ table cannot be bulk loaded, and there is no `ALTER TABLE ADD CONSTRAINT` to add them once
111
+ the data is in.
112
+
113
+
114
+ ## Contributing
115
+
116
+ The project uses [uv](https://docs.astral.sh/uv/) for everything:
117
+
118
+ ```bash
119
+ make dev # uv sync — installs duckdb plus the test deps (pytest, faker, ruff)
120
+ make test # uv run pytest
121
+ make lint # uv run ruff check . && uv run ruff format --check .
122
+ make build # uv build
123
+ make publish # uv publish (PyPI trusted publishing, also run on tags by CI)
124
+ ```
125
+
126
+ ### See also
127
+
128
+ - [Harlequin](https://github.com/tconbeer/harlequin): A nice duckdb IDE for your terminal
@@ -0,0 +1,107 @@
1
+ # sqlite2duckdb
2
+
3
+ ![CI](https://github.com/dridk/sqlite2duckdb/actions/workflows/ci.yml/badge.svg)
4
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sqlite2duckdb)
5
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/sqlite2duckdb)
6
+
7
+ A tool for converting a [sqlite](https://www.sqlite.org/) database into a [duckdb](https://duckdb.org/) database
8
+
9
+ ## Description
10
+
11
+ Sqlite is an embedded online database designed for transactional reading and writing.
12
+ Duckdb is also an embedded database, but column-oriented, designed for analytical process with a very high reading efficiency.
13
+
14
+ For more details [Medium post](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
15
+
16
+
17
+ ## Installation
18
+
19
+ With [uv](https://docs.astral.sh/uv/), no installation is required. `uvx` downloads and runs the tool in one go:
20
+
21
+ ```bash
22
+ uvx sqlite2duckdb source.db target.db
23
+ ```
24
+
25
+ To keep it around:
26
+
27
+ ```bash
28
+ uv tool install sqlite2duckdb
29
+ ```
30
+
31
+ Or with pip:
32
+
33
+ ```bash
34
+ pip install sqlite2duckdb
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### As a command line
40
+
41
+ ```
42
+ usage: sqlite2duckdb [-f] <sqlite_path> <duckdb_path>
43
+
44
+ Convert Sqlite database to Duckdb database
45
+
46
+ positional arguments:
47
+ sqlite_path sqlite file path
48
+ duckdb_path duckdb file path
49
+
50
+ options:
51
+ -h, --help show this help message and exit
52
+ -f, --force overwrite the duckdb file if it already exists
53
+ -q, --quiet only report errors
54
+ --verbose report every step
55
+ -v, --version show program's version number and exit
56
+ ```
57
+
58
+ ### Examples
59
+
60
+ ```bash
61
+ uvx sqlite2duckdb source.db target.db
62
+ uvx sqlite2duckdb --force source.db target.db # overwrite target.db without asking
63
+ ```
64
+
65
+ ### From python
66
+
67
+ ```python
68
+ from sqlite2duckdb import sqlite_to_duckdb
69
+
70
+ result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
71
+ print(result.tables, result.views, result.elapsed)
72
+ ```
73
+
74
+ ## What is converted
75
+
76
+ | | |
77
+ |---|---|
78
+ | Tables and data | ✅ |
79
+ | Primary keys, NOT NULL and UNIQUE constraints | ✅ |
80
+ | Indexes | ✅ |
81
+ | Views | ✅ best effort |
82
+ | FOREIGN KEY and CHECK constraints | ❌ |
83
+
84
+ A view whose SQL uses something duckdb has no equivalent for (`MATCH`, `julianday()`) is
85
+ skipped with a warning instead of failing the conversion.
86
+
87
+ FOREIGN KEY and CHECK are not copied, though not for lack of support: duckdb accepts and
88
+ enforces both in `CREATE TABLE`. It checks foreign keys row by row, so a self-referencing
89
+ table cannot be bulk loaded, and there is no `ALTER TABLE ADD CONSTRAINT` to add them once
90
+ the data is in.
91
+
92
+
93
+ ## Contributing
94
+
95
+ The project uses [uv](https://docs.astral.sh/uv/) for everything:
96
+
97
+ ```bash
98
+ make dev # uv sync — installs duckdb plus the test deps (pytest, faker, ruff)
99
+ make test # uv run pytest
100
+ make lint # uv run ruff check . && uv run ruff format --check .
101
+ make build # uv build
102
+ make publish # uv publish (PyPI trusted publishing, also run on tags by CI)
103
+ ```
104
+
105
+ ### See also
106
+
107
+ - [Harlequin](https://github.com/tconbeer/harlequin): A nice duckdb IDE for your terminal
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+
6
+ [project]
7
+ name = "sqlite2duckdb"
8
+ version = "0.5.0"
9
+ authors = [{name="Sacha Schutz", email="sacha.schutz@pm.me"}]
10
+ description = "A tool to convert sqlite database to duckdb database"
11
+ readme = "README.md"
12
+ requires-python = ">=3.9"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ keywords = ["sqlite", "duckdb", "database", "olap", "oltp"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+ dependencies = [
26
+ 'duckdb >= 1.1.0'
27
+ ]
28
+
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/dridk/sqlite2duckdb"
32
+ Issues = "https://github.com/dridk/sqlite2duckdb/issues"
33
+
34
+ [project.scripts]
35
+ sqlite2duckdb = "sqlite2duckdb.__main__:main_cli"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest >= 7.0",
40
+ "faker",
41
+ "ruff",
42
+ ]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["sqlite2duckdb"]
46
+
47
+ # Allow list rather than a deny list, so that anything new landing in the repo
48
+ # stays out of the distribution unless it is explicitly wanted.
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["sqlite2duckdb", "tests", "README.md"]
51
+
52
+ [tool.pytest.ini_options]
53
+ pythonpath = ["."]
54
+ testpaths = ["tests"]
55
+
56
+ [tool.ruff]
57
+ target-version = "py39"
58
+
59
+ [tool.ruff.lint]
60
+ extend-select = ["I", "UP"]
@@ -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,267 @@
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
+ views: int = 0
24
+
25
+
26
+ def _quote_identifier(name: str) -> str:
27
+ return '"' + name.replace('"', '""') + '"'
28
+
29
+
30
+ def _format_duration(seconds: float) -> str:
31
+ if seconds < 1:
32
+ return f"{seconds * 1000:.2f} ms"
33
+ return f"{seconds:.2f} s"
34
+
35
+
36
+ def _remove_quietly(path: str) -> None:
37
+ for candidate in (path, path + ".wal"):
38
+ try:
39
+ os.remove(candidate)
40
+ except OSError:
41
+ pass
42
+
43
+
44
+ def _brackets_to_quotes(sql: str) -> str:
45
+ """Rewrite sqlite's [bracket] identifiers into standard "double quoted" ones.
46
+
47
+ Quoted runs are copied verbatim so that a `[` inside a string literal survives.
48
+ """
49
+
50
+ out = []
51
+ index = 0
52
+ length = len(sql)
53
+
54
+ while index < length:
55
+ char = sql[index]
56
+
57
+ if char in "'\"`":
58
+ end = index + 1
59
+ while end < length:
60
+ if sql[end] == char:
61
+ if end + 1 < length and sql[end + 1] == char:
62
+ end += 2
63
+ continue
64
+ break
65
+ end += 1
66
+ out.append(sql[index : end + 1])
67
+ index = end + 1
68
+
69
+ elif char == "[":
70
+ end = sql.find("]", index)
71
+ if end == -1:
72
+ out.append(char)
73
+ index += 1
74
+ else:
75
+ out.append(_quote_identifier(sql[index + 1 : end]))
76
+ index = end + 1
77
+
78
+ else:
79
+ out.append(char)
80
+ index += 1
81
+
82
+ return "".join(out)
83
+
84
+
85
+ def _copy_indexes(conn: duckdb.DuckDBPyConnection, sqlite_path: str) -> None:
86
+ """Recreate the source indexes, translating sqlite quoting on the way."""
87
+
88
+ with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
89
+ indexes = source.execute(
90
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL"
91
+ ).fetchall()
92
+
93
+ for name, sql in indexes:
94
+ try:
95
+ conn.sql(_brackets_to_quotes(sql))
96
+ except duckdb.Error as error:
97
+ logger.warning("Could not recreate index %s: %s", name, error)
98
+
99
+
100
+ def _copy_unique_constraints(
101
+ conn: duckdb.DuckDBPyConnection, sqlite_path: str, table_names: list[str]
102
+ ) -> None:
103
+ """Replay the UNIQUE constraints that sqlite records without any SQL.
104
+
105
+ A column or table level UNIQUE becomes an autoindex whose sqlite_master row
106
+ has a NULL sql, so _copy_indexes cannot see it. Duckdb has no ALTER TABLE ADD
107
+ CONSTRAINT either, so a unique index is how the guarantee is carried over.
108
+ """
109
+
110
+ with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
111
+ for table in table_names:
112
+ indexes = source.execute(
113
+ f"PRAGMA index_list({_quote_identifier(table)})"
114
+ ).fetchall()
115
+
116
+ for _, index_name, unique, origin, _partial in indexes:
117
+ # 'c' indexes carry their own SQL and are handled by _copy_indexes,
118
+ # and 'pk' is already part of the table DDL.
119
+ if not unique or origin != "u":
120
+ continue
121
+
122
+ columns = [
123
+ row[2]
124
+ for row in source.execute(
125
+ f"PRAGMA index_info({_quote_identifier(index_name)})"
126
+ ).fetchall()
127
+ ]
128
+ if any(column is None for column in columns):
129
+ logger.warning(
130
+ "Skipping unique index %s: it is built on an expression",
131
+ index_name,
132
+ )
133
+ continue
134
+
135
+ targets = ", ".join(_quote_identifier(column) for column in columns)
136
+ try:
137
+ conn.sql(
138
+ f"CREATE UNIQUE INDEX {_quote_identifier(index_name)} "
139
+ f"ON {_quote_identifier(table)} ({targets})"
140
+ )
141
+ except duckdb.Error as error:
142
+ logger.warning(
143
+ "Could not recreate unique index %s: %s", index_name, error
144
+ )
145
+
146
+
147
+ def _copy_views(conn: duckdb.DuckDBPyConnection, sqlite_path: str) -> int:
148
+ """Recreate the source views, and return how many made it across."""
149
+
150
+ with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
151
+ views = source.execute(
152
+ "SELECT name, sql FROM sqlite_master WHERE type = 'view' AND sql IS NOT NULL"
153
+ ).fetchall()
154
+
155
+ pending = [(name, _brackets_to_quotes(sql)) for name, sql in views]
156
+ errors: dict[str, duckdb.Error] = {}
157
+ created = 0
158
+
159
+ # A view can sit on top of another one and sqlite_master does not guarantee
160
+ # dependency order, so keep retrying while a pass still makes progress.
161
+ while pending:
162
+ failed = []
163
+ for name, statement in pending:
164
+ try:
165
+ conn.sql(statement)
166
+ except duckdb.Error as error:
167
+ errors[name] = error
168
+ failed.append((name, statement))
169
+ else:
170
+ created += 1
171
+
172
+ if len(failed) == len(pending):
173
+ break
174
+ pending = failed
175
+
176
+ for name, _ in pending:
177
+ logger.warning("Could not recreate view %s: %s", name, errors[name])
178
+
179
+ return created
180
+
181
+
182
+ def _copy_tables(
183
+ conn: duckdb.DuckDBPyConnection, tables: list[tuple[str, str]]
184
+ ) -> None:
185
+ """Recreate each table then fill it from the attached database."""
186
+
187
+ for table_name, ddl in tables:
188
+ # This DDL is regenerated by duckdb from the attached catalog, so unlike
189
+ # sqlite's own it always parses, and it still carries the primary keys
190
+ # and the NOT NULL constraints.
191
+ conn.sql(ddl)
192
+ quoted = _quote_identifier(table_name)
193
+ conn.sql(f"INSERT INTO {quoted} SELECT * FROM __other.{quoted}")
194
+
195
+
196
+ def sqlite_to_duckdb(
197
+ sqlite_db: str | os.PathLike[str],
198
+ duck_db: str | os.PathLike[str],
199
+ *,
200
+ overwrite: bool = False,
201
+ ) -> ConversionResult:
202
+ """Copy a sqlite database into a new duckdb database.
203
+
204
+ Tables, data, views, primary keys, NOT NULL and UNIQUE constraints and
205
+ indexes are copied. FOREIGN KEY and CHECK constraints are not: duckdb checks
206
+ foreign keys row by row, so a self-referencing table cannot be bulk loaded,
207
+ and there is no ALTER TABLE ADD CONSTRAINT to add them once the data is in.
208
+
209
+ A view duckdb cannot bind is skipped with a warning rather than failing the
210
+ whole conversion.
211
+
212
+ Raises FileNotFoundError if `sqlite_db` does not exist, and FileExistsError
213
+ if `duck_db` already exists and `overwrite` is False.
214
+ """
215
+
216
+ sqlite_path = os.fspath(sqlite_db)
217
+ duck_path = os.fspath(duck_db)
218
+
219
+ if not os.path.exists(sqlite_path):
220
+ raise FileNotFoundError(f"File {sqlite_path} doesn't exist")
221
+
222
+ if os.path.exists(duck_path):
223
+ if not overwrite:
224
+ raise FileExistsError(f"Database {duck_path} already exists")
225
+ _remove_quietly(duck_path)
226
+
227
+ logger.info("Create %s database", duck_path)
228
+ start_time = time.perf_counter()
229
+
230
+ conn = duckdb.connect(duck_path)
231
+ try:
232
+ ## Install sqlite
233
+ conn.sql("INSTALL sqlite; LOAD sqlite;")
234
+
235
+ # Bound parameters are not allowed in ATTACH, so escape the quotes ourselves
236
+ source_path = sqlite_path.replace("'", "''")
237
+ conn.sql(f"ATTACH '{source_path}' AS __other (TYPE SQLITE, READ_ONLY)")
238
+
239
+ ## Get sqlite Names, along with the CREATE statement duckdb derives for them
240
+ tables = conn.sql(
241
+ "SELECT table_name, sql FROM duckdb_tables WHERE database_name = '__other'"
242
+ ).fetchall()
243
+ logger.info("%d table(s) found", len(tables))
244
+
245
+ _copy_tables(conn, tables)
246
+ _copy_indexes(conn, sqlite_path)
247
+ _copy_unique_constraints(conn, sqlite_path, [name for name, _ in tables])
248
+ views = _copy_views(conn, sqlite_path)
249
+ if views:
250
+ logger.info("%d view(s) copied", views)
251
+
252
+ conn.sql("DETACH __other")
253
+ except BaseException:
254
+ # Never leave a half-written database behind: it would trip the
255
+ # "already exists" guard on the next run.
256
+ conn.close()
257
+ _remove_quietly(duck_path)
258
+ raise
259
+
260
+ conn.close()
261
+
262
+ elapsed = time.perf_counter() - start_time
263
+ logger.info("Done in %s !", _format_duration(elapsed))
264
+
265
+ return ConversionResult(
266
+ target=duck_path, tables=len(tables), elapsed=elapsed, views=views
267
+ )