duckdb-sqlalchemy 1.5.2__tar.gz → 1.5.2.2__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.
Files changed (81) hide show
  1. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.pre-commit-config.yaml +2 -2
  2. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/CHANGELOG.md +35 -0
  3. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/PKG-INFO +6 -6
  4. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/connection-urls.md +11 -0
  5. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/motherduck.md +19 -2
  6. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/olap.md +19 -0
  7. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/__init__.py +103 -74
  8. duckdb_sqlalchemy-1.5.2.2/duckdb_sqlalchemy/_bulk_insert.py +50 -0
  9. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/_validation.py +17 -13
  10. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/bulk.py +22 -14
  11. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/motherduck.py +43 -0
  12. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/olap.py +23 -1
  13. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/conftest.py +55 -55
  14. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_core_units.py +304 -1
  15. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_execution_options.py +18 -0
  16. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_helpers.py +55 -0
  17. duckdb_sqlalchemy-1.5.2.2/duckdb_sqlalchemy/tests/test_validation.py +46 -0
  18. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/noxfile.py +0 -2
  19. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/pyproject.toml +6 -6
  20. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/uv.lock +180 -182
  21. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.codex/environments/environment.toml +0 -0
  22. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  23. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  24. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/dependabot.yml +0 -0
  25. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/workflows/lint.yml +0 -0
  26. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/workflows/pages.yml +0 -0
  27. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/workflows/publish.yaml +0 -0
  28. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.github/workflows/pythonapp.yaml +0 -0
  29. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/.gitignore +0 -0
  30. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/AGENTS.md +0 -0
  31. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/ARCHITECTURE.md +0 -0
  32. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/CLAUDE.md +0 -0
  33. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/CODE_OF_CONDUCT.md +0 -0
  34. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/LICENSE.txt +0 -0
  35. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/PLANS.md +0 -0
  36. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/README.md +0 -0
  37. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/ROADMAP.md +0 -0
  38. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/codecov.yml +0 -0
  39. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/README.md +0 -0
  40. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/_config.yml +0 -0
  41. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/alembic.md +0 -0
  42. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/configuration.md +0 -0
  43. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/exec-plans/active/2026-03-27-maintenance-review.md +0 -0
  44. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/exec-plans/active/2026-03-30-maintenance-review.md +0 -0
  45. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/exec-plans/active/2026-03-31-maintenance-review.md +0 -0
  46. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/getting-started.md +0 -0
  47. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/index.md +0 -0
  48. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/migration-from-duckdb-engine.md +0 -0
  49. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/overview.md +0 -0
  50. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/pandas-jupyter.md +0 -0
  51. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/robots.txt +0 -0
  52. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/seo-checklist.md +0 -0
  53. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/docs/types-and-caveats.md +0 -0
  54. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/_query.py +0 -0
  55. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/_supports.py +0 -0
  56. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/capabilities.py +0 -0
  57. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/config.py +0 -0
  58. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/conftest.py +0 -0
  59. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/datatypes.py +0 -0
  60. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/py.typed +0 -0
  61. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/requirements.py +0 -0
  62. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/__init__.py +0 -0
  63. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/snapshots/test_datatypes/test_interval/schema.sql +0 -0
  64. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/sqlalchemy_suite/conftest.py +0 -0
  65. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/sqlalchemy_suite/test_suite.py +0 -0
  66. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_basic.py +0 -0
  67. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_datatypes.py +0 -0
  68. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_integration.py +0 -0
  69. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_pandas.py +0 -0
  70. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/test_pyarrow.py +0 -0
  71. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/tests/util.py +0 -0
  72. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/duckdb_sqlalchemy/url.py +0 -0
  73. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/examples/motherduck_arrow_reads.py +0 -0
  74. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/examples/motherduck_attach_modes.py +0 -0
  75. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/examples/motherduck_multi_instance_pool.py +0 -0
  76. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/examples/motherduck_queuepool_high_concurrency.py +0 -0
  77. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/examples/motherduck_read_scaling_per_user.py +0 -0
  78. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/examples/sqlalchemy_example.py +0 -0
  79. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/llms.txt +0 -0
  80. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/renovate.json +0 -0
  81. {duckdb_sqlalchemy-1.5.2 → duckdb_sqlalchemy-1.5.2.2}/test.cfg +0 -0
@@ -19,11 +19,11 @@ repos:
19
19
  hooks:
20
20
  - id: ty
21
21
  name: ty
22
- entry: ty check --ignore unresolved-import --ignore unused-type-ignore-comment
22
+ entry: ty check --ignore unresolved-import
23
23
  language: python
24
24
  files: ^duckdb_sqlalchemy/(?!tests/).*\.py$
25
25
  additional_dependencies:
26
- - "ty==0.0.31"
26
+ - "ty==0.0.34"
27
27
  - repo: https://github.com/astral-sh/ruff-pre-commit
28
28
  # Ruff version.
29
29
  rev: 'v0.15.11'
@@ -8,6 +8,41 @@ preserved from the upstream project for historical context.
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## [1.5.2.2](https://github.com/leonardovida/duckdb-sqlalchemy/compare/v1.5.2.1...v1.5.2.2) (2026-05-11)
12
+
13
+ ### Features
14
+
15
+ - add `md_user_info()` for querying MotherDuck user and organization metadata through SQLAlchemy table-valued functions
16
+
17
+ ### Bug Fixes
18
+
19
+ - prefer the documented `MOTHERDUCK_TOKEN` environment variable over the lowercase fallback when both are set
20
+ - reject MotherDuck database names containing commas before building SQLAlchemy URLs
21
+ - ignore common PostgreSQL session setup statements and transaction isolation declarations emitted by PostgreSQL-oriented clients
22
+
23
+ ### Tooling
24
+
25
+ - extract bulk insert builder logic into a focused helper module without changing the public execution option behavior
26
+ - revert unreleased PostgreSQL compatibility-mode aliases before publishing them
27
+ - bump the local `ty` pin to `0.0.34` after validating the current checks against the latest non-breaking release
28
+ - refresh the locked development dependency set to the latest non-breaking releases within existing version caps
29
+ - deduplicate reflection fallback wrapper handling
30
+
31
+ ## [1.5.2.1](https://github.com/leonardovida/duckdb-sqlalchemy/compare/v1.5.2...v1.5.2.1) (2026-04-25)
32
+
33
+ ### Bug Fixes
34
+
35
+ - treat MotherDuck transport overrides such as `host`, `region_host`, `port`, `tls`, and `grpc_local_subchannel_pool` as startup URL parameters so SQLAlchemy URLs and `connect_args["config"]` keep working with current MotherDuck routing behavior
36
+ - derive `duckdb_sqlalchemy.__version__` from installed package metadata so the runtime version matches published package metadata and generated user agents
37
+ - normalize legacy MotherDuck transport aliases such as `motherduck_host`, `motherduck_port`, `motherduck_use_tls`, `motherduck_region_host`, and `motherduck_grpc_local_subchannel_pool` into the startup URL parameters the current extension expects, while emitting deprecation warnings
38
+ - restore the exported package version to `1.5.2` so the runtime version matches published package metadata
39
+
40
+ ### Tooling
41
+
42
+ - bump the local `ty` pin to `0.0.32` after validating the current checks against the latest non-breaking release
43
+ - allow `pre-commit` 4.6.x in the devtools extra
44
+ - refresh validated dev minimums for `nox` and `pytest-cov`
45
+
11
46
  ## [1.5.2](https://github.com/leonardovida/duckdb-sqlalchemy/compare/v1.5.1.4...v1.5.2) (2026-04-17)
12
47
 
13
48
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckdb-sqlalchemy
3
- Version: 1.5.2
3
+ Version: 1.5.2.2
4
4
  Summary: DuckDB SQLAlchemy dialect for DuckDB and MotherDuck
5
5
  Project-URL: Bug Tracker, https://github.com/leonardovida/duckdb-sqlalchemy/issues
6
6
  Project-URL: Changelog, https://github.com/leonardovida/duckdb-sqlalchemy/releases
@@ -33,24 +33,24 @@ Requires-Dist: fsspec<2027.0.0,>=2025.2.0; extra == 'dev'
33
33
  Requires-Dist: github-action-utils<2.0.0,>=1.1.0; extra == 'dev'
34
34
  Requires-Dist: hypothesis<7.0.0,>=6.75.2; extra == 'dev'
35
35
  Requires-Dist: jupysql<0.12.0,>=0.11.1; extra == 'dev'
36
- Requires-Dist: nox<2027.0.0,>=2026.2.9; extra == 'dev'
36
+ Requires-Dist: nox<2027.0.0,>=2026.4.10; extra == 'dev'
37
37
  Requires-Dist: numpy<2.0,>=1.24; (python_version < '3.12') and extra == 'dev'
38
38
  Requires-Dist: numpy<3.0,>=1.26; (python_version >= '3.12' and python_version < '3.13') and extra == 'dev'
39
39
  Requires-Dist: numpy<3.0,>=2.0; (python_version >= '3.13') and extra == 'dev'
40
40
  Requires-Dist: pandas<2.0,>=1; (python_version < '3.12') and extra == 'dev'
41
41
  Requires-Dist: pandas<4.0,>=2.2; (python_version >= '3.12') and extra == 'dev'
42
42
  Requires-Dist: pyarrow>=22.0.0; (python_version >= '3.10') and extra == 'dev'
43
- Requires-Dist: pytest-cov<8.0.0,>=5.0.0; extra == 'dev'
43
+ Requires-Dist: pytest-cov<8.0.0,>=7.1.0; extra == 'dev'
44
44
  Requires-Dist: pytest-remotedata<0.5.0,>=0.4.0; extra == 'dev'
45
45
  Requires-Dist: pytest-snapshot<1.0.0,>=0.9.0; extra == 'dev'
46
46
  Requires-Dist: pytest<10.0.0,>=8.0.0; extra == 'dev'
47
47
  Requires-Dist: pytz>=2024.2; (python_version >= '3.12') and extra == 'dev'
48
48
  Requires-Dist: toml<0.11.0,>=0.10.2; extra == 'dev'
49
- Requires-Dist: ty==0.0.31; extra == 'dev'
49
+ Requires-Dist: ty==0.0.34; extra == 'dev'
50
50
  Provides-Extra: devtools
51
- Requires-Dist: nox<2027.0.0,>=2026.2.9; extra == 'devtools'
51
+ Requires-Dist: nox<2027.0.0,>=2026.4.10; extra == 'devtools'
52
52
  Requires-Dist: pdbpp<0.13.0,>=0.11.0; extra == 'devtools'
53
- Requires-Dist: pre-commit<4.6.0,>=4.5.1; (python_version >= '3.10') and extra == 'devtools'
53
+ Requires-Dist: pre-commit<4.7.0,>=4.6.0; (python_version >= '3.10') and extra == 'devtools'
54
54
  Description-Content-Type: text/markdown
55
55
 
56
56
  # duckdb-sqlalchemy
@@ -82,6 +82,17 @@ url = MotherDuckURL(
82
82
  engine = create_engine(url)
83
83
  ```
84
84
 
85
+ Local or staging endpoint overrides belong in the same path-query bucket:
86
+
87
+ ```python
88
+ url = MotherDuckURL(
89
+ database="md:my_db",
90
+ host="localhost",
91
+ port=1984,
92
+ tls="off",
93
+ )
94
+ ```
95
+
85
96
  ## Manual escaping
86
97
 
87
98
  If you build URLs manually and your token contains special characters, escape it:
@@ -6,6 +6,7 @@ title: MotherDuck
6
6
  # MotherDuck
7
7
 
8
8
  MotherDuck connections use the `md:` database prefix.
9
+ Database names may not contain commas.
9
10
 
10
11
  ```python
11
12
  from sqlalchemy import create_engine
@@ -62,12 +63,13 @@ string for MotherDuck connections.
62
63
  Parameters that are treated as part of the database string:
63
64
 
64
65
  - `user`
66
+ - `host`, `region_host`, `port`, `tls`, `grpc_local_subchannel_pool`
65
67
  - `session_name` (read-scaling affinity)
66
68
  - `attach_mode` (`workspace` or `single`)
67
69
  - `access_mode` (`read_only` for read-scaling tokens)
68
70
  - `dbinstance_inactivity_ttl` (preferred; `motherduck_dbinstance_inactivity_ttl`
69
71
  remains supported but is deprecated)
70
- - `saas_mode`
72
+ - `saas_mode` (`pgendpoint` is supported when you need PG endpoint-compatible routing)
71
73
  - `cache_buster`
72
74
 
73
75
  For backward compatibility the dialect also accepts `session_hint`,
@@ -81,6 +83,13 @@ Example:
81
83
  duckdb:///md:my_db?attach_mode=single&access_mode=read_only&session_name=team-a
82
84
  ```
83
85
 
86
+ For local or staging routing, keep the endpoint override in the database string
87
+ as well:
88
+
89
+ ```
90
+ duckdb:///md:my_db?host=localhost&port=1984&tls=off
91
+ ```
92
+
84
93
  If you pass these in `connect_args["config"]`, the dialect will move them into the database string automatically.
85
94
 
86
95
  ### Config parameters
@@ -88,7 +97,15 @@ If you pass these in `connect_args["config"]`, the dialect will move them into t
88
97
  Other DuckDB settings can be passed as URL query params or via `connect_args["config"]`:
89
98
 
90
99
  - Any DuckDB `SET`-table config option (for example `memory_limit`, `threads`)
91
- - MotherDuck startup auth keys such as `motherduck_token` and `motherduck_oauth_token`
100
+ - MotherDuck startup auth keys such as `motherduck_token`, `token`, `motherduck_oauth_token`, and `oauth_token`
101
+
102
+ Example with a MotherDuck host override / PG endpoint-style routing:
103
+
104
+ ```python
105
+ engine = create_engine(
106
+ "duckdb:///md:my_db?host=custom.motherduck.com&port=443&tls=true&saas_mode=pgendpoint"
107
+ )
108
+ ```
92
109
 
93
110
  ## Recommended defaults for apps/BI
94
111
 
@@ -50,6 +50,25 @@ parquet = table_function(
50
50
  stmt = select(parquet.c.event_id, parquet.c.ts)
51
51
  ```
52
52
 
53
+ ## MotherDuck user metadata
54
+
55
+ MotherDuck exposes `md_user_info()` for the current user and organization.
56
+ The helper names the released columns so they are available through SQLAlchemy:
57
+
58
+ ```python
59
+ from sqlalchemy import select
60
+ from duckdb_sqlalchemy import md_user_info
61
+
62
+ user_info = md_user_info()
63
+ stmt = select(
64
+ user_info.c.user_id,
65
+ user_info.c.username,
66
+ user_info.c.org_id,
67
+ user_info.c.org_name,
68
+ user_info.c.org_type,
69
+ )
70
+ ```
71
+
53
72
  ## Arrow results
54
73
 
55
74
  For large reads, you can request Arrow tables directly:
@@ -5,9 +5,12 @@ import uuid
5
5
  import warnings
6
6
  from collections import defaultdict
7
7
  from functools import lru_cache
8
+ from importlib.metadata import PackageNotFoundError
9
+ from importlib.metadata import version as package_version
8
10
  from typing import (
9
11
  TYPE_CHECKING,
10
12
  Any,
13
+ Callable,
11
14
  Collection,
12
15
  Dict,
13
16
  Iterable,
@@ -38,6 +41,7 @@ from sqlalchemy.ext.compiler import compiles
38
41
  from sqlalchemy.sql import bindparam
39
42
  from sqlalchemy.sql.selectable import Select
40
43
 
44
+ from ._bulk_insert import build_bulk_insert_data as _build_bulk_insert_data
41
45
  from ._supports import has_comment_support
42
46
  from ._validation import validate_extension_name
43
47
  from .bulk import copy_from_csv, copy_from_parquet, copy_from_rows
@@ -56,8 +60,9 @@ from .motherduck import (
56
60
  split_url_query,
57
61
  stable_session_hint,
58
62
  stable_session_name,
63
+ validate_motherduck_database_name,
59
64
  )
60
- from .olap import read_csv, read_csv_auto, read_parquet, table_function
65
+ from .olap import md_user_info, read_csv, read_csv_auto, read_parquet, table_function
61
66
  from .url import URL, make_url
62
67
 
63
68
  try:
@@ -69,7 +74,10 @@ else:
69
74
  _pg_base, "PGExecutionContext", DefaultExecutionContext
70
75
  )
71
76
 
72
- __version__ = "1.5.1.4"
77
+ try:
78
+ __version__ = package_version("duckdb-sqlalchemy")
79
+ except PackageNotFoundError: # pragma: no cover - source tree import fallback
80
+ __version__ = "1.5.2.2"
73
81
  sqlalchemy_version = sqlalchemy.__version__
74
82
  SQLALCHEMY_VERSION = Version(sqlalchemy_version)
75
83
  SQLALCHEMY_2 = SQLALCHEMY_VERSION >= Version("2.0.0")
@@ -111,6 +119,7 @@ __all__ = [
111
119
  "read_parquet",
112
120
  "read_csv",
113
121
  "read_csv_auto",
122
+ "md_user_info",
114
123
  "copy_from_parquet",
115
124
  "copy_from_csv",
116
125
  "copy_from_rows",
@@ -202,6 +211,26 @@ class ConnectionWrapper:
202
211
 
203
212
  _REGISTER_NAME_KEYS = ("name", "view_name", "table")
204
213
  _REGISTER_DATA_KEYS = ("df", "dataframe", "relation", "data")
214
+ _IGNORED_POSTGRES_CONFIG_SETTINGS = frozenset(
215
+ {
216
+ "extra_float_digits",
217
+ "application_name",
218
+ "standard_conforming_strings",
219
+ "client_min_messages",
220
+ "datestyle",
221
+ "ssl_renegotiation_limit",
222
+ "statement_timeout",
223
+ }
224
+ )
225
+ _SET_POSTGRES_CONFIG_RE = re.compile(r"^set\s+(?:session\s+|local\s+)?(\w+)\b")
226
+ _SET_TRANSACTION_ISOLATION_RE = re.compile(
227
+ r"^set\s+(?:session\s+characteristics\s+as\s+)?transaction\s+isolation\s+level\s+"
228
+ r"(?:serializable|repeatable\s+read|read\s+committed|read\s+uncommitted)$"
229
+ )
230
+ _BEGIN_TRANSACTION_ISOLATION_RE = re.compile(
231
+ r"^begin\s+(?:transaction\s+)?isolation\s+level\s+"
232
+ r"(?:serializable|repeatable\s+read|read\s+committed|read\s+uncommitted)$"
233
+ )
205
234
 
206
235
 
207
236
  def _parse_register_params(parameters: Optional[Any]) -> Tuple[str, Any]:
@@ -236,6 +265,9 @@ class CursorWrapper:
236
265
  self.__c = c
237
266
  self.__connection_wrapper = connection_wrapper
238
267
 
268
+ def _clear_result(self) -> None:
269
+ self.__c.execute("")
270
+
239
271
  def executemany(
240
272
  self,
241
273
  statement: str,
@@ -260,11 +292,19 @@ class CursorWrapper:
260
292
  norm = statement.strip().lower().rstrip(";")
261
293
  if norm == "commit": # this is largely for ipython-sql
262
294
  self.__c.commit()
295
+ elif _BEGIN_TRANSACTION_ISOLATION_RE.fullmatch(norm):
296
+ self.__c.begin()
297
+ elif _SET_TRANSACTION_ISOLATION_RE.fullmatch(norm):
298
+ self._clear_result()
299
+ elif _is_ignored_postgres_config_set(norm):
300
+ self._clear_result()
263
301
  elif norm.startswith("register"):
264
302
  view_name, df = _parse_register_params(parameters)
265
303
  self.__c.register(view_name, df)
266
304
  elif norm == "show transaction isolation level":
267
305
  self.__c.execute("select 'read committed' as transaction_isolation")
306
+ elif norm == "show standard_conforming_strings":
307
+ self.__c.execute("select 'on' as standard_conforming_strings")
268
308
  elif parameters is None:
269
309
  self.__c.execute(statement)
270
310
  else:
@@ -316,6 +356,13 @@ class CursorWrapper:
316
356
  return self.__c.fetchmany(size)
317
357
 
318
358
 
359
+ def _is_ignored_postgres_config_set(statement: str) -> bool:
360
+ match = _SET_POSTGRES_CONFIG_RE.match(statement)
361
+ if match is None:
362
+ return False
363
+ return match.group(1) in _IGNORED_POSTGRES_CONFIG_SETTINGS
364
+
365
+
319
366
  class DuckDBEngineWarning(Warning):
320
367
  pass
321
368
 
@@ -350,49 +397,6 @@ def _normalize_execution_options(execution_options: Dict[str, Any]) -> Dict[str,
350
397
  return execution_options
351
398
 
352
399
 
353
- def _build_bulk_insert_dataframe(
354
- rows: Sequence[Any], column_names: Sequence[str]
355
- ) -> Any:
356
- try:
357
- import pandas as pd # type: ignore[import-not-found]
358
- except Exception:
359
- return None
360
-
361
- try:
362
- if isinstance(rows[0], dict):
363
- return pd.DataFrame.from_records(rows, columns=column_names)
364
- return pd.DataFrame(rows, columns=cast(Any, column_names))
365
- except Exception:
366
- return None
367
-
368
-
369
- def _build_bulk_insert_arrow_table(
370
- rows: Sequence[Any], column_names: Sequence[str]
371
- ) -> Any:
372
- try:
373
- import pyarrow as pa # type: ignore[import-not-found]
374
- except Exception:
375
- return None
376
-
377
- try:
378
- if isinstance(rows[0], dict):
379
- table = pa.Table.from_pylist(rows)
380
- if column_names:
381
- return table.select(column_names)
382
- return table
383
- columns = list(zip(*rows)) if rows else [[] for _ in column_names]
384
- return pa.Table.from_arrays(columns, names=column_names)
385
- except Exception:
386
- return None
387
-
388
-
389
- def _build_bulk_insert_data(rows: Sequence[Any], column_names: Sequence[str]) -> Any:
390
- data = _build_bulk_insert_dataframe(rows, column_names)
391
- if data is not None:
392
- return data
393
- return _build_bulk_insert_arrow_table(rows, column_names)
394
-
395
-
396
400
  class DuckDBArrowResult:
397
401
  def __init__(self, result: Any) -> None:
398
402
  self._result = result
@@ -622,7 +626,7 @@ def _default_pool_class_for_database(
622
626
 
623
627
  def _apply_motherduck_defaults(config: Dict[str, Any], database: Optional[str]) -> None:
624
628
  if "motherduck_token" not in config:
625
- token = os.getenv("motherduck_token") or os.getenv("MOTHERDUCK_TOKEN")
629
+ token = os.getenv("MOTHERDUCK_TOKEN") or os.getenv("motherduck_token")
626
630
  if token and _looks_like_motherduck(database, config):
627
631
  config["motherduck_token"] = token
628
632
 
@@ -751,6 +755,7 @@ class Dialect(PGDialect_psycopg2):
751
755
  cparams["database"] = append_query_to_database(
752
756
  cparams.get("database"), path_query
753
757
  )
758
+ validate_motherduck_database_name(cparams.get("database"))
754
759
  _normalize_motherduck_config(config)
755
760
 
756
761
  ext = {k: config.pop(k) for k in list(config) if k not in core_keys}
@@ -966,6 +971,35 @@ class Dialect(PGDialect_psycopg2):
966
971
  sql += where_sql
967
972
  return connection.execute(text(sql), params).first() is not None
968
973
 
974
+ def _get_reflection_or_empty_for_existing_table(
975
+ self,
976
+ getter: Callable[[], List[Any]],
977
+ connection: "Connection",
978
+ table_name: str,
979
+ schema: Optional[str],
980
+ ) -> List[Any]:
981
+ try:
982
+ return getter()
983
+ except NoSuchTableError:
984
+ if self._duckdb_table_exists(connection, table_name, schema):
985
+ return []
986
+ raise
987
+
988
+ def _get_super_reflection_or_empty(
989
+ self,
990
+ getter: Callable[..., List[Any]],
991
+ connection: "Connection",
992
+ table_name: str,
993
+ schema: Optional[str],
994
+ **kw: Any,
995
+ ) -> List[Any]:
996
+ return self._get_reflection_or_empty_for_existing_table(
997
+ lambda: getter(connection, table_name, schema=schema, **kw),
998
+ connection,
999
+ table_name,
1000
+ schema,
1001
+ )
1002
+
969
1003
  def _duckdb_columns(
970
1004
  self, connection: "Connection", table_name: str, schema: Optional[str]
971
1005
  ) -> Optional[List[Dict[str, Any]]]:
@@ -1352,18 +1386,14 @@ class Dialect(PGDialect_psycopg2):
1352
1386
  postgresql_ignore_search_path: bool = False,
1353
1387
  **kw: Any,
1354
1388
  ) -> List["ReflectedForeignKeyConstraint"]:
1355
- try:
1356
- return super().get_foreign_keys(
1357
- connection,
1358
- table_name,
1359
- schema=schema,
1360
- postgresql_ignore_search_path=postgresql_ignore_search_path,
1361
- **kw,
1362
- )
1363
- except NoSuchTableError:
1364
- if self._duckdb_table_exists(connection, table_name, schema):
1365
- return []
1366
- raise
1389
+ return self._get_super_reflection_or_empty(
1390
+ super().get_foreign_keys,
1391
+ connection,
1392
+ table_name,
1393
+ schema,
1394
+ postgresql_ignore_search_path=postgresql_ignore_search_path,
1395
+ **kw,
1396
+ )
1367
1397
 
1368
1398
  @cache # type: ignore[call-arg]
1369
1399
  def get_unique_constraints(
@@ -1373,14 +1403,13 @@ class Dialect(PGDialect_psycopg2):
1373
1403
  schema: Optional[str] = None,
1374
1404
  **kw: Any,
1375
1405
  ) -> List["ReflectedUniqueConstraint"]:
1376
- try:
1377
- return super().get_unique_constraints(
1378
- connection, table_name, schema=schema, **kw
1379
- )
1380
- except NoSuchTableError:
1381
- if self._duckdb_table_exists(connection, table_name, schema):
1382
- return []
1383
- raise
1406
+ return self._get_super_reflection_or_empty(
1407
+ super().get_unique_constraints,
1408
+ connection,
1409
+ table_name,
1410
+ schema,
1411
+ **kw,
1412
+ )
1384
1413
 
1385
1414
  @cache # type: ignore[call-arg]
1386
1415
  def get_check_constraints(
@@ -1390,14 +1419,13 @@ class Dialect(PGDialect_psycopg2):
1390
1419
  schema: Optional[str] = None,
1391
1420
  **kw: Any,
1392
1421
  ) -> List["ReflectedCheckConstraint"]:
1393
- try:
1394
- return super().get_check_constraints(
1395
- connection, table_name, schema=schema, **kw
1396
- )
1397
- except NoSuchTableError:
1398
- if self._duckdb_table_exists(connection, table_name, schema):
1399
- return []
1400
- raise
1422
+ return self._get_super_reflection_or_empty(
1423
+ super().get_check_constraints,
1424
+ connection,
1425
+ table_name,
1426
+ schema,
1427
+ **kw,
1428
+ )
1401
1429
 
1402
1430
  def get_indexes(
1403
1431
  self,
@@ -1462,6 +1490,7 @@ class Dialect(PGDialect_psycopg2):
1462
1490
  database = opts.get("database")
1463
1491
  if database in {None, ""}:
1464
1492
  database = ":memory:"
1493
+ validate_motherduck_database_name(database)
1465
1494
  opts["database"] = append_query_to_database(database, path_query)
1466
1495
  return (), opts
1467
1496
 
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence, cast
4
+
5
+
6
+ def _rows_use_mapping_shape(rows: Sequence[Any]) -> bool:
7
+ return bool(rows) and isinstance(rows[0], dict)
8
+
9
+
10
+ def build_bulk_insert_dataframe(
11
+ rows: Sequence[Any], column_names: Sequence[str]
12
+ ) -> Any:
13
+ try:
14
+ import pandas as pd # type: ignore[import-not-found]
15
+ except Exception:
16
+ return None
17
+
18
+ try:
19
+ if _rows_use_mapping_shape(rows):
20
+ return pd.DataFrame.from_records(rows, columns=column_names)
21
+ return pd.DataFrame(rows, columns=cast(Any, column_names))
22
+ except Exception:
23
+ return None
24
+
25
+
26
+ def build_bulk_insert_arrow_table(
27
+ rows: Sequence[Any], column_names: Sequence[str]
28
+ ) -> Any:
29
+ try:
30
+ import pyarrow as pa # type: ignore[import-not-found]
31
+ except Exception:
32
+ return None
33
+
34
+ try:
35
+ if _rows_use_mapping_shape(rows):
36
+ table = pa.Table.from_pylist(rows)
37
+ if column_names:
38
+ return table.select(column_names)
39
+ return table
40
+ columns = list(zip(*rows)) if rows else [[] for _ in column_names]
41
+ return pa.Table.from_arrays(columns, names=column_names)
42
+ except Exception:
43
+ return None
44
+
45
+
46
+ def build_bulk_insert_data(rows: Sequence[Any], column_names: Sequence[str]) -> Any:
47
+ data = build_bulk_insert_dataframe(rows, column_names)
48
+ if data is not None:
49
+ return data
50
+ return build_bulk_insert_arrow_table(rows, column_names)
@@ -5,31 +5,35 @@ IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
5
5
  EXTENSION_RE = re.compile(r"^[A-Za-z0-9_]+$")
6
6
 
7
7
 
8
- def validate_identifier(value: str, *, kind: str = "identifier") -> str:
8
+ def _require_string(value: str, *, kind: str) -> str:
9
9
  if not isinstance(value, str):
10
10
  raise ValueError(f"{kind} must be a string")
11
- if not IDENTIFIER_RE.fullmatch(value):
12
- raise ValueError(f"invalid {kind}: {value!r}")
13
11
  return value
14
12
 
15
13
 
14
+ def _validate_pattern(value: str, *, kind: str, pattern: re.Pattern[str]) -> str:
15
+ validated = _require_string(value, kind=kind)
16
+ if not pattern.fullmatch(validated):
17
+ raise ValueError(f"invalid {kind}: {validated!r}")
18
+ return validated
19
+
20
+
21
+ def validate_identifier(value: str, *, kind: str = "identifier") -> str:
22
+ return _validate_pattern(value, kind=kind, pattern=IDENTIFIER_RE)
23
+
24
+
16
25
  def validate_dotted_identifier(value: str, *, kind: str = "identifier") -> str:
17
- if not isinstance(value, str):
18
- raise ValueError(f"{kind} must be a string")
19
- parts = value.split(".")
26
+ validated = _require_string(value, kind=kind)
27
+ parts = validated.split(".")
20
28
  if not parts or any(not part for part in parts):
21
- raise ValueError(f"invalid {kind}: {value!r}")
29
+ raise ValueError(f"invalid {kind}: {validated!r}")
22
30
  for part in parts:
23
31
  validate_identifier(part, kind=kind)
24
- return value
32
+ return validated
25
33
 
26
34
 
27
35
  def validate_extension_name(value: str) -> str:
28
- if not isinstance(value, str):
29
- raise ValueError("extension name must be a string")
30
- if not EXTENSION_RE.fullmatch(value):
31
- raise ValueError(f"invalid extension name: {value!r}")
32
- return value
36
+ return _validate_pattern(value, kind="extension name", pattern=EXTENSION_RE)
33
37
 
34
38
 
35
39
  def validate_identifier_list(
@@ -155,6 +155,27 @@ def _copy_rows_as_csv_chunks(
155
155
  _close_and_unlink_tempfile(tmp)
156
156
 
157
157
 
158
+ def _copy_rows_as_sequences(
159
+ first: Union[Mapping[str, Any], Sequence[Any]],
160
+ rows: Iterable[Union[Mapping[str, Any], Sequence[Any]]],
161
+ columns: Optional[Sequence[str]],
162
+ ) -> Tuple[Iterable[Sequence[Any]], Optional[Sequence[str]]]:
163
+ if isinstance(first, Mapping):
164
+ if columns is None:
165
+ columns = [str(col) for col in cast(Mapping[str, Any], first).keys()]
166
+
167
+ def row_to_seq(row: Mapping[str, Any]) -> Sequence[Any]:
168
+ return [row.get(col) for col in columns or []]
169
+
170
+ first_row = row_to_seq(cast(Mapping[str, Any], first))
171
+ remaining_rows = (row_to_seq(cast(Mapping[str, Any], row)) for row in rows)
172
+ return chain((first_row,), remaining_rows), columns
173
+
174
+ first_row = cast(Sequence[Any], first)
175
+ remaining_rows = (cast(Sequence[Any], row) for row in rows)
176
+ return chain((first_row,), remaining_rows), columns
177
+
178
+
158
179
  def copy_from_parquet(
159
180
  connection: Any,
160
181
  table: TableLike,
@@ -227,20 +248,7 @@ def copy_from_rows(
227
248
 
228
249
  copy_options = {"header": include_header, **copy_options}
229
250
 
230
- if isinstance(first, Mapping):
231
- if columns is None:
232
- columns = [str(col) for col in cast(Mapping[str, Any], first).keys()]
233
-
234
- def row_to_seq(row: Mapping[str, Any]) -> Sequence[Any]:
235
- return [row.get(col) for col in columns or []]
236
-
237
- first_row = row_to_seq(cast(Mapping[str, Any], first))
238
- remaining_rows = (row_to_seq(cast(Mapping[str, Any], row)) for row in iterator)
239
- else:
240
- first_row = cast(Sequence[Any], first)
241
- remaining_rows = (cast(Sequence[Any], row) for row in iterator)
242
-
243
- chunked_rows = chain((first_row,), remaining_rows)
251
+ chunked_rows, columns = _copy_rows_as_sequences(first, iterator, columns)
244
252
  _copy_rows_as_csv_chunks(
245
253
  connection,
246
254
  table,