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.
@@ -0,0 +1,9 @@
1
+ # Credits
2
+
3
+ ## Development Lead
4
+
5
+ - Eduard Christian Dumitrescu <eduard.c.dumitrescu@gmail.com>
6
+
7
+ ## Contributors
8
+
9
+ None yet. Why not be the first?
@@ -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
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup(name="sqlalchemy_boltons", version="0.0.0.1")
@@ -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
+ sqlalchemy_boltons
@@ -0,0 +1,4 @@
1
+ from pathlib import Path
2
+ import sys
3
+
4
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
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()