--- /dev/null
+.. change::
+ :tags: bug, sqlite, regression
+ :tickets: 5848
+
+ Repaired the ``pysqlcipher`` dialect to connect correctly which had
+ regressed in 1.4, and added test + CI support to maintain the driver
+ in working condition. The dialect now imports the ``sqlcipher3`` module
+ for Python 3 by default before falling back to ``pysqlcipher3`` which
+ is documented as now being unmaintained.
+
+ .. seealso::
+
+ :ref:`pysqlcipher`
+
.. automodule:: sqlalchemy.dialects.sqlite.aiosqlite
+
+.. _pysqlcipher:
+
Pysqlcipher
-----------
import os
+import re
+from ... import exc
from ...engine import url as sa_url
from ...testing.provision import create_db
from ...testing.provision import drop_db
from ...testing.provision import follower_url_from_main
+from ...testing.provision import generate_driver_url
from ...testing.provision import log
from ...testing.provision import post_configure_engine
from ...testing.provision import run_reap_dbs
from ...testing.provision import temp_table_keyword_args
-# likely needs a generate_driver_url() def here for the --dbdriver part to
-# work
+# TODO: I can't get this to build dynamically with pytest-xdist procs
+_drivernames = {"pysqlite", "aiosqlite", "pysqlcipher"}
-_drivernames = set()
+
+@generate_driver_url.for_db("sqlite")
+def generate_driver_url(url, driver, query_str):
+ if driver == "pysqlcipher" and url.get_driver_name() != "pysqlcipher":
+ if url.database:
+ url = url.set(database=url.database + ".enc")
+ url = url.set(password="test")
+ url = url.set(drivername="sqlite+%s" % (driver,))
+ try:
+ url.get_dialect()
+ except exc.NoSuchModuleError:
+ return None
+ else:
+ return url
@follower_url_from_main.for_db("sqlite")
def _sqlite_follower_url_from_main(url, ident):
url = sa_url.make_url(url)
+
if not url.database or url.database == ":memory:":
return url
else:
- _drivernames.add(url.get_driver_name())
+
+ m = re.match(r"(.+?)\.(.+)$", url.database)
+ name, ext = m.group(1, 2)
+ drivername = url.get_driver_name()
return sa_url.make_url(
- "sqlite+%s:///%s.db" % (url.get_driver_name(), ident)
+ "sqlite+%s:///%s_%s.%s" % (drivername, drivername, ident, ext)
)
if files:
db.dispose()
-
# some sqlite file tests are not cleaning up well yet, so do this
# just to make things simple for now
for file_ in files:
for ident in idents:
# we don't have a config so we can't call _sqlite_drop_db due to the
# decorator
- for path in (
- [
- "%s.db" % ident,
- ]
- + [
- "%s_test_schema.db" % (drivername,)
- for drivername in _drivernames
- ]
- + [
- "%s_%s_test_schema.db" % (ident, drivername)
- for drivername in _drivernames
- ]
- ):
- if os.path.exists(path):
- log.info("deleting SQLite database file: %s" % path)
- os.remove(path)
+ for ext in ("db", "db.enc"):
+ for path in (
+ ["%s.%s" % (ident, ext)]
+ + [
+ "%s_%s.%s" % (drivername, ident, ext)
+ for drivername in _drivernames
+ ]
+ + [
+ "%s_test_schema.%s" % (drivername, ext)
+ for drivername in _drivernames
+ ]
+ + [
+ "%s_%s_test_schema.%s" % (ident, drivername, ext)
+ for drivername in _drivernames
+ ]
+ ):
+ if os.path.exists(path):
+ log.info("deleting SQLite database file: %s" % path)
+ os.remove(path)
"""
.. dialect:: sqlite+pysqlcipher
:name: pysqlcipher
- :dbapi: pysqlcipher
+ :dbapi: sqlcipher 3 or pysqlcipher
:connectstring: sqlite+pysqlcipher://:passphrase/file_path[?kdf_iter=<iter>]
- :url: https://pypi.python.org/pypi/pysqlcipher
- ``pysqlcipher`` is a fork of the standard ``pysqlite`` driver to make
- use of the `SQLCipher <https://www.zetetic.net/sqlcipher>`_ backend.
+ Dialect for support of DBAPIs that make use of the
+ `SQLCipher <https://www.zetetic.net/sqlcipher>`_ backend.
- ``pysqlcipher3`` is a fork of ``pysqlcipher`` for Python 3. This dialect
- will attempt to import it if ``pysqlcipher`` is non-present.
-
- .. versionadded:: 1.1.4 - added fallback import for pysqlcipher3
-
- .. versionadded:: 0.9.9 - added pysqlcipher dialect
Driver
------
-The driver here is the
-`pysqlcipher <https://pypi.python.org/pypi/pysqlcipher>`_
-driver, which makes use of the SQLCipher engine. This system essentially
+Current dialect selection logic is:
+
+* If the :paramref:`_sa.create_engine.module` parameter supplies a DBAPI module,
+ that module is used.
+* Otherwise for Python 3, choose https://pypi.org/project/sqlcipher3/
+* If not available, fall back to https://pypi.org/project/pysqlcipher3/
+* For Python 2, https://pypi.org/project/pysqlcipher/ is used.
+
+.. warning:: The ``pysqlcipher3`` and ``pysqlcipher`` DBAPI drivers are no
+ longer maintained; the ``sqlcipher3`` driver as of this writing appears
+ to be current. For future compatibility, any pysqlcipher-compatible DBAPI
+ may be used as follows::
+
+ import sqlcipher_compatible_driver
+
+ from sqlalchemy import create_engine
+
+ e = create_engine(
+ "sqlite+pysqlcipher://:password@/dbname.db",
+ module=sqlcipher_compatible_driver
+ )
+
+These drivers make use of the SQLCipher engine. This system essentially
introduces new PRAGMA commands to SQLite which allows the setting of a
-passphrase and other encryption parameters, allowing the database
-file to be encrypted.
+passphrase and other encryption parameters, allowing the database file to be
+encrypted.
-`pysqlcipher3` is a fork of `pysqlcipher` with support for Python 3,
-the driver is the same.
Connect Strings
---------------
from .pysqlite import SQLiteDialect_pysqlite
from ... import pool
-from ...engine import url as _url
+from ... import util
class SQLiteDialect_pysqlcipher(SQLiteDialect_pysqlite):
@classmethod
def dbapi(cls):
- try:
- from pysqlcipher import dbapi2 as sqlcipher
- except ImportError as e:
+ if util.py3k:
try:
- from pysqlcipher3 import dbapi2 as sqlcipher
+ import sqlcipher3 as sqlcipher
except ImportError:
- raise e
+ pass
+ else:
+ return sqlcipher
+
+ from pysqlcipher3 import dbapi2 as sqlcipher
+
+ else:
+ from pysqlcipher import dbapi2 as sqlcipher
return sqlcipher
def get_pool_class(cls, url):
return pool.SingletonThreadPool
- def connect(self, *cargs, **cparams):
- passphrase = cparams.pop("passphrase", "")
+ def on_connect_url(self, url):
+ super_on_connect = super(
+ SQLiteDialect_pysqlcipher, self
+ ).on_connect_url(url)
- pragmas = dict((key, cparams.pop(key, None)) for key in self.pragmas)
+ # pull the info we need from the URL early. Even though URL
+ # is immutable, we don't want any in-place changes to the URL
+ # to affect things
+ passphrase = url.password or ""
+ url_query = dict(url.query)
- conn = super(SQLiteDialect_pysqlcipher, self).connect(
- *cargs, **cparams
- )
- conn.exec_driver_sql('pragma key="%s"' % passphrase)
- for prag, value in pragmas.items():
- if value is not None:
- conn.exec_driver_sql('pragma %s="%s"' % (prag, value))
+ def on_connect(conn):
+ cursor = conn.cursor()
+ cursor.execute('pragma key="%s"' % passphrase)
+ for prag in self.pragmas:
+ value = url_query.get(prag, None)
+ if value is not None:
+ cursor.execute('pragma %s="%s"' % (prag, value))
+ cursor.close()
- return conn
+ if super_on_connect:
+ super_on_connect(conn)
+
+ return on_connect
def create_connect_args(self, url):
- super_url = _url.URL(
- url.drivername,
- username=url.username,
- host=url.host,
- database=url.database,
- query=url.query,
+ plain_url = url._replace(password=None)
+ plain_url = plain_url.difference_update_query(self.pragmas)
+ return super(SQLiteDialect_pysqlcipher, self).create_connect_args(
+ plain_url
)
- c_args, opts = super(
- SQLiteDialect_pysqlcipher, self
- ).create_connect_args(super_url)
- opts["passphrase"] = url.password
- return c_args, opts
dialect = SQLiteDialect_pysqlcipher
engine = engineclass(pool, dialect, u, **engine_args)
if _initialize:
- do_on_connect = dialect.on_connect()
+ do_on_connect = dialect.on_connect_url(url)
if do_on_connect:
def on_connect(dbapi_connection, connection_record):
"""
+ def on_connect_url(self, url):
+ """return a callable which sets up a newly created DBAPI connection.
+
+ This method is a new hook that supersedes the
+ :meth:`_engine.Dialect.on_connect` method when implemented by a
+ dialect. When not implemented by a dialect, it invokes the
+ :meth:`_engine.Dialect.on_connect` method directly to maintain
+ compatibility with existing dialects. There is no deprecation
+ for :meth:`_engine.Dialect.on_connect` expected.
+
+ The callable should accept a single argument "conn" which is the
+ DBAPI connection itself. The inner callable has no
+ return value.
+
+ E.g.::
+
+ class MyDialect(default.DefaultDialect):
+ # ...
+
+ def on_connect_url(self, url):
+ def do_on_connect(connection):
+ connection.execute("SET SPECIAL FLAGS etc")
+
+ return do_on_connect
+
+ This is used to set dialect-wide per-connection options such as
+ isolation modes, Unicode modes, etc.
+
+ This method differs from :meth:`_engine.Dialect.on_connect` in that
+ it is passed the :class:`_engine.URL` object that's relevant to the
+ connect args. Normally the only way to get this is from the
+ :meth:`_engine.Dialect.on_connect` hook is to look on the
+ :class:`_engine.Engine` itself, however this URL object may have been
+ replaced by plugins.
+
+ .. note::
+
+ The default implementation of
+ :meth:`_engine.Dialect.on_connect_url` is to invoke the
+ :meth:`_engine.Dialect.on_connect` method. Therefore if a dialect
+ implements this method, the :meth:`_engine.Dialect.on_connect`
+ method **will not be called** unless the overriding dialect calls
+ it directly from here.
+
+ .. versionadded:: 1.4.3 added :meth:`_engine.Dialect.on_connect_url`
+ which normally calls into :meth:`_engine.Dialect.on_connect`.
+
+ :param url: a :class:`_engine.URL` object representing the
+ :class:`_engine.URL` that was passed to the
+ :meth:`_engine.Dialect.create_connect_args` method.
+
+ :return: a callable that accepts a single DBAPI connection as an
+ argument, or None.
+
+ .. seealso::
+
+ :meth:`_engine.Dialect.on_connect`
+
+ """
+ return self.on_connect()
+
def on_connect(self):
"""return a callable which sets up a newly created DBAPI connection.
for the first connection of a dialect. The on_connect hook is still
called before the :meth:`_engine.Dialect.initialize` method however.
+ .. versionchanged:: 1.4.3 the on_connect hook is invoked from a new
+ method on_connect_url that passes the URL that was used to create
+ the connect args. Dialects can implement on_connect_url instead
+ of on_connect if they need the URL object that was used for the
+ connection in order to get additional context.
+
If None is returned, no event listener is generated.
:return: a callable that accepts a single DBAPI connection as an
:meth:`.Dialect.connect` - allows the DBAPI ``connect()`` sequence
itself to be controlled.
+ :meth:`.Dialect.on_connect_url` - supersedes
+ :meth:`.Dialect.on_connect` to also receive the
+ :class:`_engine.URL` object in context.
+
"""
return None
import platform
import sys
-from sqlalchemy.pool.impl import QueuePool
from . import exclusions
from .. import util
+from ..pool import QueuePool
class Requirements(object):
aiosqlite =
%(asyncio)s
aiosqlite;python_version>="3"
+sqlcipher =
+ sqlcipher3_binary;python_version>="3"
[egg_info]
tag_build = dev
aiosqlite = sqlite+aiosqlite:///:memory:
sqlite_file = sqlite:///querytest.db
aiosqlite_file = sqlite+aiosqlite:///async_querytest.db
+pysqlcipher_file = sqlite+pysqlcipher://:test@/querytest.db.enc
postgresql = postgresql://scott:tiger@127.0.0.1:5432/test
asyncpg = postgresql+asyncpg://scott:tiger@127.0.0.1:5432/test
asyncpg_fallback = postgresql+asyncpg://scott:tiger@127.0.0.1:5432/test?async_fallback=true
__only_on__ = "sqlite"
+ __backend__ = True
+
def test_boolean(self, connection, metadata):
"""Test that the boolean only treats 1 as True"""
__requires__ = ("json_type",)
__only_on__ = "sqlite"
+ __backend__ = True
@testing.requires.reflects_json_type
def test_reflection(self, connection, metadata):
class DefaultsTest(fixtures.TestBase, AssertsCompiledSQL):
__only_on__ = "sqlite"
+ __backend__ = True
def test_default_reflection(self, connection, metadata):
):
__only_on__ = "sqlite"
+ __backend__ = True
def test_3_7_16_warning(self):
with expect_warnings(
class AttachedDBTest(fixtures.TestBase):
__only_on__ = "sqlite"
+ __backend__ = True
def _fixture(self):
meta = self.metadata
- # self.conn = self.engine.connect()
+
Table("created", meta, Column("foo", Integer), Column("bar", String))
Table("local_only", meta, Column("q", Integer), Column("p", Integer))
"""Tests inserts and autoincrement."""
__only_on__ = "sqlite"
+ __backend__ = True
# empty insert was added as of sqlite 3.3.8.
__only_on__ = "sqlite"
__skip_if__ = (full_text_search_missing,)
+ __backend__ = True
@classmethod
def setup_test_class(cls):
class ReflectHeadlessFKsTest(fixtures.TestBase):
__only_on__ = "sqlite"
+ __backend__ = True
def setup_test(self):
exec_sql(testing.db, "CREATE TABLE a (id INTEGER PRIMARY KEY)")
class KeywordInDatabaseNameTest(fixtures.TestBase):
__only_on__ = "sqlite"
+ __backend__ = True
@testing.fixture
def db_fixture(self, connection):
class ConstraintReflectionTest(fixtures.TestBase):
__only_on__ = "sqlite"
+ __backend__ = True
@classmethod
def setup_test_class(cls):
"""test that savepoints work when we use the correct event setup"""
__only_on__ = "sqlite"
+ __backend__ = True
@classmethod
def define_tables(cls, metadata):
class TypeReflectionTest(fixtures.TestBase):
__only_on__ = "sqlite"
+ __backend__ = True
def _fixed_lookup_fixture(self):
return [
class OnConflictTest(fixtures.TablesTest):
__only_on__ = ("sqlite >= 3.24.0",)
+ __backend__ = True
@classmethod
def define_tables(cls, metadata):
sqlite: .[aiosqlite]
sqlite_file: .[aiosqlite]
+ sqlite_file: .[sqlcipher]; python_version >= '3'
postgresql: .[postgresql]
postgresql: .[postgresql_asyncpg]; python_version >= '3'
postgresql: .[postgresql_pg8000]; python_version >= '3'
mssql: .[mssql]
+ dbapimaster-sqlite: git+https://github.com/omnilib/aiosqlite.git#egg=aiosqlite
+ dbapimaster-sqlite: git+https://github.com/coleifer/sqlcipher3.git#egg=sqlcipher3
+
dbapimaster-postgresql: git+https://github.com/psycopg/psycopg2.git@master#egg=psycopg2
dbapimaster-postgresql: git+https://github.com/MagicStack/asyncpg.git#egg=asyncpg
dbapimaster-postgresql: git+https://github.com/tlocke/pg8000.git#egg=pg8000
sqlite: SQLITE={env:TOX_SQLITE:--db sqlite}
sqlite_file: SQLITE={env:TOX_SQLITE_FILE:--db sqlite_file}
py3{,5,6,7,8,9,10,11}-sqlite: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver aiosqlite}
- py3{,5,6,7,8,9,10,11}-sqlite_file: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver aiosqlite}
+ py3{,5,6,7,8,9,10,11}-sqlite_file: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver aiosqlite --dbdriver pysqlcipher}
postgresql: POSTGRESQL={env:TOX_POSTGRESQL:--db postgresql}
py2{,7}-postgresql: POSTGRESQL={env:TOX_POSTGRESQL_PY2K:{env:TOX_POSTGRESQL:--db postgresql}}