sqlalchemy-boltons 1.0.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.
- sqlalchemy_boltons-1.0.0/AUTHORS.md +9 -0
- sqlalchemy_boltons-1.0.0/CHANGELOG.md +38 -0
- sqlalchemy_boltons-1.0.0/LICENSE +0 -0
- sqlalchemy_boltons-1.0.0/MANIFEST.in +15 -0
- sqlalchemy_boltons-1.0.0/PKG-INFO +57 -0
- sqlalchemy_boltons-1.0.0/README.md +43 -0
- sqlalchemy_boltons-1.0.0/doc/Makefile +20 -0
- sqlalchemy_boltons-1.0.0/doc/conf.py +55 -0
- sqlalchemy_boltons-1.0.0/doc/index.rst +20 -0
- sqlalchemy_boltons-1.0.0/doc/make.bat +35 -0
- sqlalchemy_boltons-1.0.0/pyproject.toml +29 -0
- sqlalchemy_boltons-1.0.0/setup.cfg +14 -0
- sqlalchemy_boltons-1.0.0/setup.py +3 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons/__init__.py +0 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons/py.typed +0 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons/sqlite.py +224 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons.egg-info/PKG-INFO +57 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons.egg-info/SOURCES.txt +22 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons.egg-info/dependency_links.txt +1 -0
- sqlalchemy_boltons-1.0.0/src/sqlalchemy_boltons.egg-info/top_level.txt +1 -0
- sqlalchemy_boltons-1.0.0/tests/__init__.py +4 -0
- sqlalchemy_boltons-1.0.0/tests/conftest.py +0 -0
- sqlalchemy_boltons-1.0.0/tests/test_sqlite.py +196 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
4
|
+
|
5
|
+
## [Unreleased]
|
6
|
+
|
7
|
+
## [1.0.0] - 2025-02-22
|
8
|
+
|
9
|
+
### Added
|
10
|
+
|
11
|
+
- New module `sqlalchemy_boltons.sqlite` to customize transaction, foreign key enforcement, and journal mode settings.
|
12
|
+
|
13
|
+
## [0.0.0] - 1970-01-01
|
14
|
+
|
15
|
+
### Added
|
16
|
+
|
17
|
+
- This is an example entry.
|
18
|
+
- See below for the other types of changes.
|
19
|
+
|
20
|
+
### Changed
|
21
|
+
|
22
|
+
- Change in functionality.
|
23
|
+
|
24
|
+
### Deprecated
|
25
|
+
|
26
|
+
- Feature that will be removed soon.
|
27
|
+
|
28
|
+
### Removed
|
29
|
+
|
30
|
+
- Feature that is now removed.
|
31
|
+
|
32
|
+
### Fixed
|
33
|
+
|
34
|
+
- Bug that was fixed.
|
35
|
+
|
36
|
+
### Security
|
37
|
+
|
38
|
+
- Security vulnerability notice.
|
File without changes
|
@@ -0,0 +1,15 @@
|
|
1
|
+
include AUTHORS.md
|
2
|
+
include CHANGELOG.md
|
3
|
+
include LICENSE
|
4
|
+
include README.md
|
5
|
+
|
6
|
+
recursive-include tests *
|
7
|
+
recursive-exclude * __pycache__
|
8
|
+
recursive-exclude * *.py[co]
|
9
|
+
|
10
|
+
recursive-include doc *.rst conf.py Makefile make.bat *.jpg *.png *.gif
|
11
|
+
recursive-exclude doc/_build *
|
12
|
+
recursive-include doc/_templates *
|
13
|
+
recursive-include doc/_static *
|
14
|
+
|
15
|
+
recursive-exclude * .gitkeep
|
@@ -0,0 +1,57 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: sqlalchemy_boltons
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: Utilities that should've been inside SQLAlchemy but aren't
|
5
|
+
Author-email: Eduard Christian Dumitrescu <eduard.c.dumitrescu@gmail.com>
|
6
|
+
Maintainer-email: Eduard Christian Dumitrescu <eduard.c.dumitrescu@gmail.com>
|
7
|
+
License: MIT
|
8
|
+
Project-URL: Homepage, https://hydra.ecd.space/deaduard/sqlalchemy_boltons/
|
9
|
+
Project-URL: Changelog, https://hydra.ecd.space/deaduard/sqlalchemy_boltons/file?name=CHANGELOG.md&ci=trunk
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Description-Content-Type: text/markdown
|
12
|
+
License-File: LICENSE
|
13
|
+
License-File: AUTHORS.md
|
14
|
+
|
15
|
+
# sqlalchemy_boltons
|
16
|
+
|
17
|
+
SQLAlchemy is great. However, it doesn't have everything built-in. Some important things are missing, and need to be
|
18
|
+
"bolted on".
|
19
|
+
|
20
|
+
(Name inspired from [boltons](https://pypi.org/project/boltons/). Not affiliated.)
|
21
|
+
|
22
|
+
## sqlite
|
23
|
+
|
24
|
+
SQLAlchemy doesn't automatically fix pysqlite's broken transaction handling. Instead, it provides a recipe for doing so
|
25
|
+
inside the [documentation](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl).
|
26
|
+
This module implements a fix for that broken behaviour.
|
27
|
+
|
28
|
+
You can customize, on a per-engine or per-connection basis:
|
29
|
+
|
30
|
+
- The type of transaction to be started, such as BEGIN or
|
31
|
+
[BEGIN IMMEDIATE](https://www.sqlite.org/lang_transaction.html) (or
|
32
|
+
[BEGIN CONCURRENT](https://www.sqlite.org/cgi/src/doc/begin-concurrent/doc/begin_concurrent.md) someday maybe).
|
33
|
+
- The [foreign-key enforcement setting](https://www.sqlite.org/foreignkeys.html). Can be `True`, `False`, or `"defer"`.
|
34
|
+
- The [journal mode](https://www.sqlite.org/pragma.html#pragma_journal_mode) such as DELETE or WAL.
|
35
|
+
|
36
|
+
```python
|
37
|
+
from sqlalchemy.orm import sessionmaker
|
38
|
+
from sqlalchemy_boltons.sqlite import create_engine_sqlite
|
39
|
+
|
40
|
+
engine = create_engine_sqlite("file.db", journal_mode="WAL", timeout=0.5, create_engine_args={"echo": True})
|
41
|
+
|
42
|
+
# use standard "BEGIN" and use deferred enforcement of foreign keys
|
43
|
+
engine = engine.execution_options(x_sqlite_begin_mode=None, x_sqlite_foreign_keys="defer")
|
44
|
+
|
45
|
+
# make a separate engine for write transactions using "BEGIN IMMEDIATE" for eager locking
|
46
|
+
engine_w = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE")
|
47
|
+
|
48
|
+
Session = sessionmaker(engine)
|
49
|
+
SessionW = sessionmaker(engine_w)
|
50
|
+
|
51
|
+
with Session() as session:
|
52
|
+
session.execute(select(...))
|
53
|
+
|
54
|
+
# this locks the database eagerly
|
55
|
+
with SessionW() as session:
|
56
|
+
session.execute(update(...))
|
57
|
+
```
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# sqlalchemy_boltons
|
2
|
+
|
3
|
+
SQLAlchemy is great. However, it doesn't have everything built-in. Some important things are missing, and need to be
|
4
|
+
"bolted on".
|
5
|
+
|
6
|
+
(Name inspired from [boltons](https://pypi.org/project/boltons/). Not affiliated.)
|
7
|
+
|
8
|
+
## sqlite
|
9
|
+
|
10
|
+
SQLAlchemy doesn't automatically fix pysqlite's broken transaction handling. Instead, it provides a recipe for doing so
|
11
|
+
inside the [documentation](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl).
|
12
|
+
This module implements a fix for that broken behaviour.
|
13
|
+
|
14
|
+
You can customize, on a per-engine or per-connection basis:
|
15
|
+
|
16
|
+
- The type of transaction to be started, such as BEGIN or
|
17
|
+
[BEGIN IMMEDIATE](https://www.sqlite.org/lang_transaction.html) (or
|
18
|
+
[BEGIN CONCURRENT](https://www.sqlite.org/cgi/src/doc/begin-concurrent/doc/begin_concurrent.md) someday maybe).
|
19
|
+
- The [foreign-key enforcement setting](https://www.sqlite.org/foreignkeys.html). Can be `True`, `False`, or `"defer"`.
|
20
|
+
- The [journal mode](https://www.sqlite.org/pragma.html#pragma_journal_mode) such as DELETE or WAL.
|
21
|
+
|
22
|
+
```python
|
23
|
+
from sqlalchemy.orm import sessionmaker
|
24
|
+
from sqlalchemy_boltons.sqlite import create_engine_sqlite
|
25
|
+
|
26
|
+
engine = create_engine_sqlite("file.db", journal_mode="WAL", timeout=0.5, create_engine_args={"echo": True})
|
27
|
+
|
28
|
+
# use standard "BEGIN" and use deferred enforcement of foreign keys
|
29
|
+
engine = engine.execution_options(x_sqlite_begin_mode=None, x_sqlite_foreign_keys="defer")
|
30
|
+
|
31
|
+
# make a separate engine for write transactions using "BEGIN IMMEDIATE" for eager locking
|
32
|
+
engine_w = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE")
|
33
|
+
|
34
|
+
Session = sessionmaker(engine)
|
35
|
+
SessionW = sessionmaker(engine_w)
|
36
|
+
|
37
|
+
with Session() as session:
|
38
|
+
session.execute(select(...))
|
39
|
+
|
40
|
+
# this locks the database eagerly
|
41
|
+
with SessionW() as session:
|
42
|
+
session.execute(update(...))
|
43
|
+
```
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# Minimal makefile for Sphinx documentation
|
2
|
+
#
|
3
|
+
|
4
|
+
# You can set these variables from the command line, and also
|
5
|
+
# from the environment for the first two.
|
6
|
+
SPHINXOPTS ?=
|
7
|
+
SPHINXBUILD ?= sphinx-build
|
8
|
+
SOURCEDIR = .
|
9
|
+
BUILDDIR = _build
|
10
|
+
|
11
|
+
# Put it first so that "make" without argument is like "make help".
|
12
|
+
help:
|
13
|
+
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
14
|
+
|
15
|
+
.PHONY: help Makefile
|
16
|
+
|
17
|
+
# Catch-all target: route all unknown targets to Sphinx using the new
|
18
|
+
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
19
|
+
%: Makefile
|
20
|
+
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# Configuration file for the Sphinx documentation builder.
|
2
|
+
#
|
3
|
+
# For the full list of built-in configuration values, see the documentation:
|
4
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
5
|
+
|
6
|
+
# -- Project information -----------------------------------------------------
|
7
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
8
|
+
|
9
|
+
import datetime as _datetime
|
10
|
+
from importlib.metadata import version as _get_version
|
11
|
+
|
12
|
+
|
13
|
+
project = "sqlalchemy_boltons"
|
14
|
+
project_import_name = "sqlalchemy_boltons"
|
15
|
+
project_dist_name = "sqlalchemy_boltons"
|
16
|
+
author = "Eduard Christian Dumitrescu"
|
17
|
+
copyright = "2025, " + author
|
18
|
+
|
19
|
+
|
20
|
+
try:
|
21
|
+
version = _get_version(project_dist_name)
|
22
|
+
except Exception:
|
23
|
+
version = "UNKNOWN"
|
24
|
+
release = version + "~" + _datetime.date.today().strftime("%Y-%m-%d")
|
25
|
+
|
26
|
+
language = "en"
|
27
|
+
|
28
|
+
|
29
|
+
# -- General configuration ---------------------------------------------------
|
30
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
31
|
+
|
32
|
+
extensions = [
|
33
|
+
"sphinx.ext.autodoc",
|
34
|
+
"sphinx.ext.todo",
|
35
|
+
# "sphinx.ext.imgmath",
|
36
|
+
"sphinx.ext.viewcode",
|
37
|
+
"sphinx.ext.napoleon",
|
38
|
+
"sphinx.ext.autosummary",
|
39
|
+
]
|
40
|
+
|
41
|
+
source_suffix = ".rst"
|
42
|
+
master_doc = "index"
|
43
|
+
|
44
|
+
templates_path = ["_templates"]
|
45
|
+
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
46
|
+
|
47
|
+
todo_include_todos = True
|
48
|
+
pygments_style = "sphinx"
|
49
|
+
|
50
|
+
# -- Options for HTML output -------------------------------------------------
|
51
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
52
|
+
|
53
|
+
html_theme = "classic"
|
54
|
+
html_static_path = ["_static"]
|
55
|
+
htmlhelp_basename = project_dist_name + "doc"
|
@@ -0,0 +1,20 @@
|
|
1
|
+
.. documentation master file, created by
|
2
|
+
sphinx-quickstart on Mon Dec 30 17:43:13 2024.
|
3
|
+
You can adapt this file completely to your liking, but it should at least
|
4
|
+
contain the root `toctree` directive.
|
5
|
+
|
6
|
+
Welcome to sqlalchemy_boltons's documentation!
|
7
|
+
==============================================
|
8
|
+
|
9
|
+
.. toctree::
|
10
|
+
:maxdepth: 3
|
11
|
+
:caption: Contents:
|
12
|
+
|
13
|
+
|
14
|
+
|
15
|
+
Indices and tables
|
16
|
+
==================
|
17
|
+
|
18
|
+
* :ref:`genindex`
|
19
|
+
* :ref:`modindex`
|
20
|
+
* :ref:`search`
|
@@ -0,0 +1,35 @@
|
|
1
|
+
@ECHO OFF
|
2
|
+
|
3
|
+
pushd %~dp0
|
4
|
+
|
5
|
+
REM Command file for Sphinx documentation
|
6
|
+
|
7
|
+
if "%SPHINXBUILD%" == "" (
|
8
|
+
set SPHINXBUILD=sphinx-build
|
9
|
+
)
|
10
|
+
set SOURCEDIR=.
|
11
|
+
set BUILDDIR=_build
|
12
|
+
|
13
|
+
%SPHINXBUILD% >NUL 2>NUL
|
14
|
+
if errorlevel 9009 (
|
15
|
+
echo.
|
16
|
+
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
17
|
+
echo.installed, then set the SPHINXBUILD environment variable to point
|
18
|
+
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
19
|
+
echo.may add the Sphinx directory to PATH.
|
20
|
+
echo.
|
21
|
+
echo.If you don't have Sphinx installed, grab it from
|
22
|
+
echo.https://www.sphinx-doc.org/
|
23
|
+
exit /b 1
|
24
|
+
)
|
25
|
+
|
26
|
+
if "%1" == "" goto help
|
27
|
+
|
28
|
+
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
29
|
+
goto end
|
30
|
+
|
31
|
+
:help
|
32
|
+
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
33
|
+
|
34
|
+
:end
|
35
|
+
popd
|
@@ -0,0 +1,29 @@
|
|
1
|
+
[tool.black]
|
2
|
+
line-length = 120
|
3
|
+
|
4
|
+
[project]
|
5
|
+
name = "sqlalchemy_boltons"
|
6
|
+
version = "1.0.0"
|
7
|
+
description = "Utilities that should've been inside SQLAlchemy but aren't"
|
8
|
+
readme = "README.md"
|
9
|
+
license = { text = "MIT" }
|
10
|
+
authors = [
|
11
|
+
{ name = "Eduard Christian Dumitrescu", email = "eduard.c.dumitrescu@gmail.com" },
|
12
|
+
]
|
13
|
+
maintainers = [
|
14
|
+
{ name = "Eduard Christian Dumitrescu", email = "eduard.c.dumitrescu@gmail.com" },
|
15
|
+
]
|
16
|
+
classifiers = [
|
17
|
+
"Programming Language :: Python :: 3"
|
18
|
+
]
|
19
|
+
dependencies = []
|
20
|
+
|
21
|
+
[project.urls]
|
22
|
+
Homepage = "https://hydra.ecd.space/deaduard/sqlalchemy_boltons/"
|
23
|
+
Changelog = "https://hydra.ecd.space/deaduard/sqlalchemy_boltons/file?name=CHANGELOG.md&ci=trunk"
|
24
|
+
|
25
|
+
[tool.setuptools]
|
26
|
+
package-dir = {"" = "src"}
|
27
|
+
|
28
|
+
[tool.setuptools.package-data]
|
29
|
+
"*" = ["*.*"]
|
@@ -0,0 +1,14 @@
|
|
1
|
+
[flake8]
|
2
|
+
ignore = E203,E302,E305,E704,E741,W,C901,MC0001
|
3
|
+
max-line-length = 120
|
4
|
+
max-complexity = 99
|
5
|
+
|
6
|
+
[pycodestyle]
|
7
|
+
inherit = false
|
8
|
+
ignore = E203,E302,E305,E704,E741,W,C901,MC0001
|
9
|
+
max-line-length = 120
|
10
|
+
|
11
|
+
[egg_info]
|
12
|
+
tag_build =
|
13
|
+
tag_date = 0
|
14
|
+
|
File without changes
|
File without changes
|
@@ -0,0 +1,224 @@
|
|
1
|
+
import dataclasses as dc
|
2
|
+
import functools
|
3
|
+
from pathlib import Path
|
4
|
+
import typing as ty
|
5
|
+
from urllib import parse as _up
|
6
|
+
|
7
|
+
import sqlalchemy as sa
|
8
|
+
from sqlalchemy import pool as sap
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
"create_engine_sqlite",
|
12
|
+
"SQLAlchemySqliteTransactionFix",
|
13
|
+
"sqlite_file_uri",
|
14
|
+
"sqlite_journal_mode",
|
15
|
+
"MissingExecutionOptionError",
|
16
|
+
]
|
17
|
+
|
18
|
+
|
19
|
+
class MissingExecutionOptionError(ValueError):
|
20
|
+
pass
|
21
|
+
|
22
|
+
|
23
|
+
@functools.lru_cache(maxsize=64)
|
24
|
+
def make_journal_mode_statement(mode: str | None) -> str:
|
25
|
+
if mode:
|
26
|
+
if not mode.isalnum():
|
27
|
+
raise ValueError(f"invalid mode {mode!r}")
|
28
|
+
return f"PRAGMA journal_mode={mode}"
|
29
|
+
else:
|
30
|
+
return "PRAGMA journal_mode"
|
31
|
+
|
32
|
+
|
33
|
+
@functools.lru_cache(maxsize=64)
|
34
|
+
def make_begin_statement(mode: str | None) -> str:
|
35
|
+
if mode and not mode.isalpha():
|
36
|
+
raise ValueError(f"invalid mode {mode!r}")
|
37
|
+
return f"BEGIN {mode}" if mode else "BEGIN"
|
38
|
+
|
39
|
+
|
40
|
+
@functools.lru_cache(maxsize=64)
|
41
|
+
def make_foreign_keys_settings(foreign_keys: bool | str) -> tuple[str, str | None]:
|
42
|
+
if foreign_keys is True:
|
43
|
+
return "PRAGMA foreign_keys=1", None
|
44
|
+
elif foreign_keys is False:
|
45
|
+
return "PRAGMA foreign_keys=0", None
|
46
|
+
elif foreign_keys == "defer":
|
47
|
+
return "PRAGMA foreign_keys=1", "PRAGMA defer_foreign_keys=1"
|
48
|
+
else:
|
49
|
+
raise ValueError("invalid value {foreign_keys!r} for x_sqlite_foreign_keys")
|
50
|
+
|
51
|
+
|
52
|
+
class SQLAlchemySqliteTransactionFix:
|
53
|
+
"""
|
54
|
+
This class exists because sqlalchemy doesn't automatically fix pysqlite's stupid default behaviour. Additionally,
|
55
|
+
we implement support for foreign keys.
|
56
|
+
|
57
|
+
https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl
|
58
|
+
"""
|
59
|
+
|
60
|
+
def register(self, engine):
|
61
|
+
sa.event.listen(engine, "connect", self.event_connect)
|
62
|
+
sa.event.listen(engine, "begin", self.event_begin)
|
63
|
+
|
64
|
+
def make_journal_mode_statement(self, mode: str | None):
|
65
|
+
return make_journal_mode_statement(mode)
|
66
|
+
|
67
|
+
def make_begin_statement(self, mode: str | None):
|
68
|
+
return make_begin_statement(mode)
|
69
|
+
|
70
|
+
def make_foreign_keys_settings(self, foreign_keys: bool | str) -> tuple[str, str | None]:
|
71
|
+
return make_foreign_keys_settings(foreign_keys)
|
72
|
+
|
73
|
+
def event_connect(self, dbapi_connection, connection_record):
|
74
|
+
# disable pysqlite's emitting of the BEGIN statement entirely.
|
75
|
+
# also stops it from emitting COMMIT before any DDL.
|
76
|
+
dbapi_connection.isolation_level = None
|
77
|
+
|
78
|
+
def event_begin(self, conn):
|
79
|
+
execution_options = conn.get_execution_options()
|
80
|
+
|
81
|
+
try:
|
82
|
+
opt_begin = execution_options["x_sqlite_begin_mode"]
|
83
|
+
opt_fk = execution_options["x_sqlite_foreign_keys"]
|
84
|
+
except KeyError as exc:
|
85
|
+
raise MissingExecutionOptionError(
|
86
|
+
"You must configure your engine (or connection) execution options. "
|
87
|
+
"For example:\n\n"
|
88
|
+
" engine = create_engine_sqlite(...)\n"
|
89
|
+
' engine = engine.execution_options(x_sqlite_foreign_keys="defer")\n'
|
90
|
+
" engine_ro = engine.execution_options(x_sqlite_begin_mode=None)\n"
|
91
|
+
' engine_rw = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE")'
|
92
|
+
) from exc
|
93
|
+
begin = self.make_begin_statement(opt_begin)
|
94
|
+
before, after = self.make_foreign_keys_settings(opt_fk)
|
95
|
+
|
96
|
+
if mode := execution_options.get("x_sqlite_journal_mode"):
|
97
|
+
conn.exec_driver_sql(self.make_journal_mode_statement(mode)).close()
|
98
|
+
|
99
|
+
conn.exec_driver_sql(before).close()
|
100
|
+
conn.exec_driver_sql(begin).close()
|
101
|
+
if after:
|
102
|
+
conn.exec_driver_sql(after).close()
|
103
|
+
|
104
|
+
|
105
|
+
@dc.dataclass
|
106
|
+
class Memory:
|
107
|
+
"""
|
108
|
+
We keep a reference to an open connection because SQLite will free up the database otherwise.
|
109
|
+
|
110
|
+
Note that you still can't get concurrent readers and writers because you cannot currently set WAL mode on an
|
111
|
+
in-memory database. See https://sqlite.org/forum/info/6700ab1f9f6e8a00
|
112
|
+
"""
|
113
|
+
|
114
|
+
uri: str = dc.field(init=False)
|
115
|
+
connection_reference = None
|
116
|
+
|
117
|
+
def __post_init__(self):
|
118
|
+
self.uri = f"file:/sqlalchemy_boltons_memdb_{id(self)}"
|
119
|
+
|
120
|
+
def as_uri(self):
|
121
|
+
return self.uri
|
122
|
+
|
123
|
+
|
124
|
+
def sqlite_file_uri(path: Path | str | Memory, parameters: ty.Sequence[tuple[str | bytes, str | bytes]] = ()) -> str:
|
125
|
+
if isinstance(path, str):
|
126
|
+
path = Path(path)
|
127
|
+
if isinstance(path, Path) and not path.is_absolute():
|
128
|
+
path = path.absolute()
|
129
|
+
|
130
|
+
qs = _up.urlencode(parameters, quote_via=_up.quote)
|
131
|
+
qm = "?" if qs else ""
|
132
|
+
return f"{path.as_uri()}{qm}{qs}"
|
133
|
+
|
134
|
+
|
135
|
+
def sqlite_journal_mode(connection, mode: str | None = None) -> str:
|
136
|
+
[[value]] = connection.exec_driver_sql(make_journal_mode_statement(mode))
|
137
|
+
return value
|
138
|
+
|
139
|
+
|
140
|
+
def create_engine_sqlite(
|
141
|
+
path: Path | str | Memory,
|
142
|
+
*,
|
143
|
+
timeout: float | int | None,
|
144
|
+
parameters: ty.Iterable[tuple[str | bytes, str | bytes]] = (),
|
145
|
+
journal_mode: str | None = None,
|
146
|
+
check_same_thread: bool | None = False,
|
147
|
+
create_engine_args: dict | None = None,
|
148
|
+
create_engine: ty.Callable | None = None,
|
149
|
+
transaction_fix: SQLAlchemySqliteTransactionFix | bool = True,
|
150
|
+
) -> sa.Engine:
|
151
|
+
"""
|
152
|
+
Create a sqlite engine.
|
153
|
+
|
154
|
+
Parameters
|
155
|
+
----------
|
156
|
+
path: Path | str | Memory
|
157
|
+
Path to the db file, or a :class:`Memory` object. The same memory object can be shared across multiple engines.
|
158
|
+
timeout: float
|
159
|
+
How long will SQLite wait if the database is locked? See
|
160
|
+
https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
|
161
|
+
parameters: ty.Sequence, optional
|
162
|
+
SQLite URI query parameters as described in https://www.sqlite.org/uri.html
|
163
|
+
journal_mode: str, optional
|
164
|
+
If provided, set the journal mode to this string before every transaction. This option simply sets the
|
165
|
+
``x_sqlite_journal_mode`` engine execution option.
|
166
|
+
check_same_thread: bool, optional
|
167
|
+
Defaults to False. See https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
|
168
|
+
create_engine_args: dict, optional
|
169
|
+
Keyword arguments to be passed to :func:`sa.create_engine`.
|
170
|
+
create_engine: callable, optional
|
171
|
+
If provided, this will be used instead of :func:`sa.create_engine`. You can use this to further customize
|
172
|
+
the engine creation.
|
173
|
+
transaction_fix: SQLAlchemySqliteTransactionFix | bool, optional
|
174
|
+
See :class:`SQLAlchemySqliteTransactionFix`. If True, then instantiate one. If False, then do not apply the fix.
|
175
|
+
(default: True)
|
176
|
+
"""
|
177
|
+
|
178
|
+
parameters = list(parameters)
|
179
|
+
if isinstance(path, Memory):
|
180
|
+
parameters += (("vfs", "memdb"),)
|
181
|
+
parameters.append(("uri", "true"))
|
182
|
+
|
183
|
+
uri = sqlite_file_uri(path, parameters)
|
184
|
+
|
185
|
+
if create_engine_args is None:
|
186
|
+
create_engine_args = {} # pragma: no cover
|
187
|
+
|
188
|
+
# always default to QueuePool
|
189
|
+
if (k := "poolclass") not in create_engine_args:
|
190
|
+
create_engine_args[k] = sap.QueuePool
|
191
|
+
|
192
|
+
if (v := create_engine_args.get(k := "connect_args")) is None:
|
193
|
+
create_engine_args[k] = v = {}
|
194
|
+
if timeout is not None:
|
195
|
+
v["timeout"] = timeout
|
196
|
+
if check_same_thread is not None:
|
197
|
+
v["check_same_thread"] = check_same_thread
|
198
|
+
|
199
|
+
# do not pass through poolclass=None
|
200
|
+
if create_engine_args.get((k := "poolclass"), True) is None:
|
201
|
+
create_engine_args.pop(k, None) # pragma: no cover
|
202
|
+
|
203
|
+
if create_engine is None:
|
204
|
+
create_engine = sa.create_engine
|
205
|
+
|
206
|
+
engine = create_engine("sqlite:///" + uri, **create_engine_args)
|
207
|
+
|
208
|
+
if transaction_fix is True:
|
209
|
+
transaction_fix = SQLAlchemySqliteTransactionFix()
|
210
|
+
|
211
|
+
if transaction_fix:
|
212
|
+
transaction_fix.register(engine)
|
213
|
+
|
214
|
+
engine = engine.execution_options(x_sqlite_journal_mode=journal_mode)
|
215
|
+
|
216
|
+
if isinstance(path, Memory):
|
217
|
+
(conn := engine.raw_connection()).detach()
|
218
|
+
|
219
|
+
# force the connection to actually happen
|
220
|
+
conn.execute("SELECT 0 WHERE 0").close()
|
221
|
+
|
222
|
+
path.connection_reference = conn
|
223
|
+
|
224
|
+
return engine
|
@@ -0,0 +1,57 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: sqlalchemy-boltons
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: Utilities that should've been inside SQLAlchemy but aren't
|
5
|
+
Author-email: Eduard Christian Dumitrescu <eduard.c.dumitrescu@gmail.com>
|
6
|
+
Maintainer-email: Eduard Christian Dumitrescu <eduard.c.dumitrescu@gmail.com>
|
7
|
+
License: MIT
|
8
|
+
Project-URL: Homepage, https://hydra.ecd.space/deaduard/sqlalchemy_boltons/
|
9
|
+
Project-URL: Changelog, https://hydra.ecd.space/deaduard/sqlalchemy_boltons/file?name=CHANGELOG.md&ci=trunk
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Description-Content-Type: text/markdown
|
12
|
+
License-File: LICENSE
|
13
|
+
License-File: AUTHORS.md
|
14
|
+
|
15
|
+
# sqlalchemy_boltons
|
16
|
+
|
17
|
+
SQLAlchemy is great. However, it doesn't have everything built-in. Some important things are missing, and need to be
|
18
|
+
"bolted on".
|
19
|
+
|
20
|
+
(Name inspired from [boltons](https://pypi.org/project/boltons/). Not affiliated.)
|
21
|
+
|
22
|
+
## sqlite
|
23
|
+
|
24
|
+
SQLAlchemy doesn't automatically fix pysqlite's broken transaction handling. Instead, it provides a recipe for doing so
|
25
|
+
inside the [documentation](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl).
|
26
|
+
This module implements a fix for that broken behaviour.
|
27
|
+
|
28
|
+
You can customize, on a per-engine or per-connection basis:
|
29
|
+
|
30
|
+
- The type of transaction to be started, such as BEGIN or
|
31
|
+
[BEGIN IMMEDIATE](https://www.sqlite.org/lang_transaction.html) (or
|
32
|
+
[BEGIN CONCURRENT](https://www.sqlite.org/cgi/src/doc/begin-concurrent/doc/begin_concurrent.md) someday maybe).
|
33
|
+
- The [foreign-key enforcement setting](https://www.sqlite.org/foreignkeys.html). Can be `True`, `False`, or `"defer"`.
|
34
|
+
- The [journal mode](https://www.sqlite.org/pragma.html#pragma_journal_mode) such as DELETE or WAL.
|
35
|
+
|
36
|
+
```python
|
37
|
+
from sqlalchemy.orm import sessionmaker
|
38
|
+
from sqlalchemy_boltons.sqlite import create_engine_sqlite
|
39
|
+
|
40
|
+
engine = create_engine_sqlite("file.db", journal_mode="WAL", timeout=0.5, create_engine_args={"echo": True})
|
41
|
+
|
42
|
+
# use standard "BEGIN" and use deferred enforcement of foreign keys
|
43
|
+
engine = engine.execution_options(x_sqlite_begin_mode=None, x_sqlite_foreign_keys="defer")
|
44
|
+
|
45
|
+
# make a separate engine for write transactions using "BEGIN IMMEDIATE" for eager locking
|
46
|
+
engine_w = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE")
|
47
|
+
|
48
|
+
Session = sessionmaker(engine)
|
49
|
+
SessionW = sessionmaker(engine_w)
|
50
|
+
|
51
|
+
with Session() as session:
|
52
|
+
session.execute(select(...))
|
53
|
+
|
54
|
+
# this locks the database eagerly
|
55
|
+
with SessionW() as session:
|
56
|
+
session.execute(update(...))
|
57
|
+
```
|
@@ -0,0 +1,22 @@
|
|
1
|
+
AUTHORS.md
|
2
|
+
CHANGELOG.md
|
3
|
+
LICENSE
|
4
|
+
MANIFEST.in
|
5
|
+
README.md
|
6
|
+
pyproject.toml
|
7
|
+
setup.cfg
|
8
|
+
setup.py
|
9
|
+
doc/Makefile
|
10
|
+
doc/conf.py
|
11
|
+
doc/index.rst
|
12
|
+
doc/make.bat
|
13
|
+
src/sqlalchemy_boltons/__init__.py
|
14
|
+
src/sqlalchemy_boltons/py.typed
|
15
|
+
src/sqlalchemy_boltons/sqlite.py
|
16
|
+
src/sqlalchemy_boltons.egg-info/PKG-INFO
|
17
|
+
src/sqlalchemy_boltons.egg-info/SOURCES.txt
|
18
|
+
src/sqlalchemy_boltons.egg-info/dependency_links.txt
|
19
|
+
src/sqlalchemy_boltons.egg-info/top_level.txt
|
20
|
+
tests/__init__.py
|
21
|
+
tests/conftest.py
|
22
|
+
tests/test_sqlite.py
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
sqlalchemy_boltons
|
File without changes
|
@@ -0,0 +1,196 @@
|
|
1
|
+
import contextlib
|
2
|
+
import os
|
3
|
+
|
4
|
+
import pytest
|
5
|
+
import sqlalchemy as sa
|
6
|
+
import sqlalchemy.orm as sao
|
7
|
+
|
8
|
+
from sqlalchemy_boltons import sqlite as _sq
|
9
|
+
|
10
|
+
|
11
|
+
# change this if every this situation changes https://sqlite.org/forum/info/6700ab1f9f6e8a00
|
12
|
+
HAS_MEMDB_MVCC = False
|
13
|
+
|
14
|
+
Base = sao.declarative_base()
|
15
|
+
|
16
|
+
|
17
|
+
class Example(Base):
|
18
|
+
__tablename__ = "ex1"
|
19
|
+
|
20
|
+
id = sa.Column(sa.Integer, primary_key=True)
|
21
|
+
|
22
|
+
|
23
|
+
class Example2(Base):
|
24
|
+
__tablename__ = "ex2"
|
25
|
+
|
26
|
+
id = sa.Column(sa.ForeignKey("ex1.id"), primary_key=True)
|
27
|
+
|
28
|
+
|
29
|
+
@contextlib.contextmanager
|
30
|
+
def temporary_chdir(path):
|
31
|
+
old = os.getcwd()
|
32
|
+
try:
|
33
|
+
os.chdir(str(path))
|
34
|
+
yield
|
35
|
+
finally:
|
36
|
+
os.chdir(old)
|
37
|
+
|
38
|
+
|
39
|
+
def test_invalid():
|
40
|
+
with pytest.raises(ValueError):
|
41
|
+
_sq.make_journal_mode_statement("!@#$")
|
42
|
+
with pytest.raises(ValueError):
|
43
|
+
_sq.make_begin_statement("!@#$")
|
44
|
+
with pytest.raises(ValueError):
|
45
|
+
_sq.make_foreign_keys_settings("!@#$")
|
46
|
+
|
47
|
+
|
48
|
+
@pytest.fixture(scope="function")
|
49
|
+
def simple_sqlite_engine(tmp_path, database_type):
|
50
|
+
if database_type == "file":
|
51
|
+
path = tmp_path / "x.db"
|
52
|
+
elif database_type == "memory":
|
53
|
+
path = _sq.Memory()
|
54
|
+
else:
|
55
|
+
raise AssertionError
|
56
|
+
|
57
|
+
return _sq.create_engine_sqlite(path, journal_mode="WAL", timeout=0.5, create_engine_args={"echo": True})
|
58
|
+
|
59
|
+
|
60
|
+
@pytest.mark.parametrize("database_type", ["file", "memory"])
|
61
|
+
def test_transaction(simple_sqlite_engine, database_type):
|
62
|
+
has_mvcc: bool = (database_type != "memory") or HAS_MEMDB_MVCC
|
63
|
+
|
64
|
+
engine = simple_sqlite_engine
|
65
|
+
engine = engine.execution_options(x_sqlite_foreign_keys="defer")
|
66
|
+
|
67
|
+
with pytest.raises(Exception, match="You must configure your engine"):
|
68
|
+
with engine.begin():
|
69
|
+
pass
|
70
|
+
|
71
|
+
engine_r = engine.execution_options(x_sqlite_begin_mode=None)
|
72
|
+
engine_w = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE")
|
73
|
+
|
74
|
+
SessionR = sao.sessionmaker(engine_r)
|
75
|
+
SessionW = sao.sessionmaker(engine_w)
|
76
|
+
|
77
|
+
if has_mvcc:
|
78
|
+
with engine_r.begin() as conn:
|
79
|
+
actual_journal_mode = _sq.sqlite_journal_mode(conn)
|
80
|
+
assert actual_journal_mode.upper() == "WAL"
|
81
|
+
|
82
|
+
# read transactions can be concurrent
|
83
|
+
with engine_r.begin() as s:
|
84
|
+
with engine_r.begin() as s2:
|
85
|
+
pass
|
86
|
+
|
87
|
+
# write transactions cannot
|
88
|
+
with engine_w.begin() as s:
|
89
|
+
with pytest.raises(sa.exc.OperationalError):
|
90
|
+
with engine_w.begin() as s2:
|
91
|
+
pass
|
92
|
+
|
93
|
+
# create schema
|
94
|
+
with SessionW() as s:
|
95
|
+
Base.metadata.create_all(s.connection())
|
96
|
+
s.add(Example(id=1))
|
97
|
+
s.flush()
|
98
|
+
s.commit()
|
99
|
+
|
100
|
+
# test basic ACID assumptions
|
101
|
+
with SessionW() as s:
|
102
|
+
s.add(Example(id=2))
|
103
|
+
s.flush()
|
104
|
+
|
105
|
+
assert len(s.execute(sa.select(Example)).all()) == 2
|
106
|
+
|
107
|
+
if has_mvcc:
|
108
|
+
# concurrent connections not supported for memdb yet :(
|
109
|
+
with SessionR() as s2:
|
110
|
+
assert len(s2.execute(sa.select(Example)).all()) == 1
|
111
|
+
|
112
|
+
with pytest.raises(sa.exc.OperationalError):
|
113
|
+
with SessionW() as s2:
|
114
|
+
s2.add(Example(id=2))
|
115
|
+
s2.flush()
|
116
|
+
|
117
|
+
assert len(s.execute(sa.select(Example)).all()) == 2
|
118
|
+
s.commit()
|
119
|
+
|
120
|
+
|
121
|
+
@pytest.mark.parametrize("path_type", ["str", "Path"])
|
122
|
+
def test_create_engine_path(tmp_path, path_type):
|
123
|
+
path = tmp_path / "x.db"
|
124
|
+
|
125
|
+
if path_type == "str":
|
126
|
+
path_ = str(path)
|
127
|
+
else:
|
128
|
+
path_ = path
|
129
|
+
|
130
|
+
assert not path.exists()
|
131
|
+
|
132
|
+
engine = _sq.create_engine_sqlite(path_, journal_mode="WAL", timeout=0.5, create_engine_args={"echo": True})
|
133
|
+
engine = engine.execution_options(x_sqlite_begin_mode=None, x_sqlite_foreign_keys="defer")
|
134
|
+
|
135
|
+
with sao.Session(bind=engine) as s:
|
136
|
+
Base.metadata.create_all(s.connection())
|
137
|
+
s.commit()
|
138
|
+
|
139
|
+
assert path.exists()
|
140
|
+
|
141
|
+
|
142
|
+
def test_relative_path(tmp_path):
|
143
|
+
name = "relative.db"
|
144
|
+
path = tmp_path / name
|
145
|
+
assert not path.exists()
|
146
|
+
|
147
|
+
with temporary_chdir(tmp_path):
|
148
|
+
engine = _sq.create_engine_sqlite(name, journal_mode="WAL", timeout=0.5)
|
149
|
+
|
150
|
+
engine = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE", x_sqlite_foreign_keys="defer")
|
151
|
+
with engine.begin():
|
152
|
+
pass
|
153
|
+
|
154
|
+
assert path.exists()
|
155
|
+
|
156
|
+
|
157
|
+
def _test_fk_common(engine, foreign_keys):
|
158
|
+
engine = engine.execution_options(x_sqlite_begin_mode="IMMEDIATE", x_sqlite_foreign_keys=foreign_keys)
|
159
|
+
|
160
|
+
with sao.Session(bind=engine) as s:
|
161
|
+
Base.metadata.create_all(s.connection())
|
162
|
+
s.add(Example(id=1))
|
163
|
+
s.commit()
|
164
|
+
|
165
|
+
return engine
|
166
|
+
|
167
|
+
|
168
|
+
@pytest.mark.parametrize("database_type", ["file", "memory"])
|
169
|
+
def test_fk_off(simple_sqlite_engine):
|
170
|
+
engine = _test_fk_common(simple_sqlite_engine, False)
|
171
|
+
|
172
|
+
with sao.Session(bind=engine) as s:
|
173
|
+
s.add(Example2(id=2))
|
174
|
+
s.flush()
|
175
|
+
s.commit()
|
176
|
+
|
177
|
+
|
178
|
+
@pytest.mark.parametrize("database_type", ["file", "memory"])
|
179
|
+
def test_fk_defer(simple_sqlite_engine):
|
180
|
+
engine = _test_fk_common(simple_sqlite_engine, "defer")
|
181
|
+
|
182
|
+
with sao.Session(bind=engine) as s:
|
183
|
+
s.add(Example2(id=2))
|
184
|
+
s.flush()
|
185
|
+
with pytest.raises(sa.exc.IntegrityError):
|
186
|
+
s.commit()
|
187
|
+
|
188
|
+
|
189
|
+
@pytest.mark.parametrize("database_type", ["file", "memory"])
|
190
|
+
def test_fk_on(simple_sqlite_engine):
|
191
|
+
engine = _test_fk_common(simple_sqlite_engine, True)
|
192
|
+
|
193
|
+
with sao.Session(bind=engine) as s:
|
194
|
+
with pytest.raises(sa.exc.IntegrityError):
|
195
|
+
s.add(Example2(id=2))
|
196
|
+
s.flush()
|