diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 039326d..1e55cc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,27 +24,26 @@ jobs: if: ${{ always() }} runs-on: ubuntu-latest name: Test Results - needs: [test-py39] + needs: [test-py310] steps: - run: | - result="${{ needs.test-py39.result }}" + result="${{ needs.test-py310.result }}" if [[ $result == "success" || $result == "skipped" ]]; then exit 0 else exit 1 fi - test-py39: + test-py310: runs-on: ubuntu-latest strategy: fail-fast: false matrix: crdb-version: [ - "cockroach:latest-v24.1", "cockroach:latest-v24.3", "cockroach:latest-v25.2", - "cockroach:latest-v25.3", - "cockroach:latest-v25.4" + "cockroach:latest-v25.4", + "cockroach:latest-v26.1" ] db-alias: [ "psycopg2", @@ -52,13 +51,13 @@ jobs: "psycopg" ] env: - TOXENV: py39 - TOX_VERSION: 3.23.1 + TOXENV: py310 + TOX_VERSION: 4.34.1 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' - name: Start CockroachDB run: | docker pull cockroachdb/${{ matrix.crdb-version }} @@ -75,13 +74,13 @@ jobs: lint: runs-on: ubuntu-latest env: - TOXENV: py39 - TOX_VERSION: 3.23.1 + TOXENV: py310 + TOX_VERSION: 4.34.1 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' - name: Install testrunner run: pip install --user tox==${TOX_VERSION} - name: Lint diff --git a/CHANGES.md b/CHANGES.md index 3ea7155..7970154 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,8 @@ Unreleased - Fix reflection of CHAR columns (#275) - Fix reflection of TIMESTAMPTZ columns (#276), thanks to @nvachhar - Fix reflection of JSONB columns (#277) +- Fix compatibility issues with Alembic 1.18 (via SQLA 2.0.47) +- Update minimum Python version to 3.10 # Version 2.0.3 diff --git a/README.md b/README.md index a3cf5a1..91c2650 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,12 @@ Use `pip` to install the latest release of this dialect. pip install sqlalchemy-cockroachdb ``` -NOTE: This version of the dialect requires SQLAlchemy 2.0 or later. To work with +NOTE: This version of the dialect requires SQLAlchemy 2.0.x. To work with earlier versions of SQLAlchemy you'll need to install an earlier version of this dialect. ``` -pip install sqlalchemy-cockroachdb<2.0.0 +pip install "sqlalchemy-cockroachdb<2.0" ``` Use a `cockroachdb` connection string when creating the `Engine`. For example, diff --git a/dev-requirements.in b/dev-requirements.in index e56721d..0a03773 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -4,7 +4,7 @@ # generated dev-requirements.txt), run make update-requirements, # then make bootstrap. -tox==3.23.1 +tox==4.34.1 # Twine is used in the release process to upload the package. twine diff --git a/dev-requirements.txt b/dev-requirements.txt index f8ea875..58bbad8 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,28 +1,38 @@ -certifi==2025.11.12 +backports-tarfile==1.2.0 + # via jaraco-context +cachetools==7.0.1 + # via tox +certifi==2026.1.4 # via requests cffi==2.0.0 # via cryptography +chardet==6.0.0.post1 + # via tox charset-normalizer==3.4.4 # via requests +colorama==0.4.6 + # via tox cryptography==46.0.5 # via secretstorage distlib==0.4.0 # via virtualenv -docutils==0.22.3 +docutils==0.22.4 # via readme-renderer -filelock==3.20.3 +filelock==3.24.3 # via # tox # virtualenv -id==1.5.0 +id==1.6.1 # via twine idna==3.11 # via requests +importlib-metadata==8.7.1 + # via keyring jaraco-classes==3.4.0 # via keyring -jaraco-context==6.0.1 +jaraco-context==6.1.0 # via keyring -jaraco-functools==4.3.0 +jaraco-functools==4.4.0 # via keyring jeepney==0.9.0 # via @@ -30,7 +40,7 @@ jeepney==0.9.0 # secretstorage keyring==25.7.0 # via twine -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich mdurl==0.1.2 # via markdown-it-py @@ -38,50 +48,60 @@ more-itertools==10.8.0 # via # jaraco-classes # jaraco-functools -nh3==0.3.2 +nh3==0.3.3 # via readme-renderer -packaging==25.0 +packaging==26.0 # via + # pyproject-api # tox # twine -platformdirs==4.4.0 - # via virtualenv +platformdirs==4.9.2 + # via + # tox + # virtualenv pluggy==1.6.0 # via tox -py==1.11.0 - # via tox -pycparser==2.23 +pycparser==3.0 # via cffi pygments==2.19.2 # via # readme-renderer # rich +pyproject-api==1.10.0 + # via tox readme-renderer==44.0 # via twine requests==2.32.5 # via - # id # requests-toolbelt # twine requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine -rich==14.2.0 +rich==14.3.3 # via twine -secretstorage==3.3.3 +secretstorage==3.5.0 # via keyring -six==1.17.0 - # via tox -toml==0.10.2 - # via tox -tox==3.23.1 +tomli==2.4.0 + # via + # pyproject-api + # tox +tox==4.34.1 # via -r dev-requirements.in twine==6.2.0 # via -r dev-requirements.in +typing-extensions==4.15.0 + # via + # cryptography + # tox + # virtualenv urllib3==2.6.3 # via + # id # requests # twine -virtualenv==20.36.1 +virtualenv==20.39.0 # via tox +zipp==3.23.0 + # via importlib-metadata diff --git a/pyproject.toml b/pyproject.toml index 47d0b5e..b4ea95a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,5 @@ addopts = "--tb native -v -r sfxX --maxfail=250 -p warnings -p logging --strict- markers = [ "backend: tests that should run on all backends; typically dialect-sensitive", "mypy: mypy integration / plugin tests", + "sparse_driver_backend: tests that should run on just one kind of driver for each kind of db", ] diff --git a/setup.py b/setup.py index 250a488..75a4944 100644 --- a/setup.py +++ b/setup.py @@ -23,10 +23,11 @@ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], keywords="SQLAlchemy CockroachDB", project_urls={ @@ -36,7 +37,7 @@ }, packages=find_packages(include=["sqlalchemy_cockroachdb"]), include_package_data=True, - install_requires=["SQLAlchemy"], + install_requires=["SQLAlchemy>=2.0.47,<2.1"], zip_safe=False, entry_points={ "sqlalchemy.dialects": [ diff --git a/sqlalchemy_cockroachdb/base.py b/sqlalchemy_cockroachdb/base.py index d6f82bc..8ca0bec 100644 --- a/sqlalchemy_cockroachdb/base.py +++ b/sqlalchemy_cockroachdb/base.py @@ -2,7 +2,6 @@ import re import threading from sqlalchemy import text -from sqlalchemy import util from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import INET @@ -10,7 +9,6 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.compiler import compiles from sqlalchemy.util import warn -import sqlalchemy.sql as sql import sqlalchemy.types as sqltypes @@ -142,6 +140,7 @@ def initialize(self, connection): self._is_v252plus = self._is_v251plus and (" v25.1." not in sversion) self._is_v253plus = self._is_v252plus and (" v25.2." not in sversion) self._is_v254plus = self._is_v253plus and (" v25.3." not in sversion) + self._is_v261plus = self._is_v254plus and (" v25.4." not in sversion) self._has_native_json = self._is_v2plus self._has_native_jsonb = self._is_v2plus self._supports_savepoints = self._is_v201plus @@ -364,150 +363,6 @@ def get_multi_indexes( result.pop(k, None) return result - def get_foreign_keys_v1(self, conn, table_name, schema=None, **kw): - fkeys = [] - FK_REGEX = re.compile(r"(?P.+)?\.\[(?P.+)?]") - - for row in conn.execute( - text(f'SHOW CONSTRAINTS FROM "{schema or self.default_schema_name}"."{table_name}"') - ): - if row.Type.startswith("FOREIGN KEY"): - m = re.search(FK_REGEX, row.Details) - - name = row.Name - constrained_columns = row["Column(s)"].split(", ") - referred_table = m.group("referred_table") - referred_columns = m.group("referred_columns").split() - referred_schema = schema - fkey_d = { - "name": name, - "constrained_columns": constrained_columns, - "referred_table": referred_table, - "referred_columns": referred_columns, - "referred_schema": referred_schema, - } - fkeys.append(fkey_d) - return fkeys - - @util.memoized_property - def _fk_regex_pattern(self): - # optionally quoted token - qtoken = r'(?:"[^"]+"|[\w]+?)' - - # https://www.postgresql.org/docs/current/static/sql-createtable.html - return re.compile( - r"FOREIGN KEY \((.*?)\) " - rf"REFERENCES (?:({qtoken})\.)?({qtoken})\(((?:{qtoken}(?: *, *)?)+)\)" # noqa: E501 - r"[\s]?(MATCH (FULL|PARTIAL|SIMPLE)+)?" - r"[\s]?(ON DELETE " - r"(CASCADE|RESTRICT|NO ACTION|SET NULL|SET DEFAULT)+)?" - r"[\s]?(ON UPDATE " - r"(CASCADE|RESTRICT|NO ACTION|SET NULL|SET DEFAULT)+)?" - r"[\s]?(DEFERRABLE|NOT DEFERRABLE)?" - r"[\s]?(INITIALLY (DEFERRED|IMMEDIATE)+)?" - ) - - def get_foreign_keys( - self, connection, table_name, schema=None, postgresql_ignore_search_path=False, **kw - ): - if not self._is_v2plus: - # v1.1 or earlier. - return self.get_foreign_keys_v1(connection, table_name, schema, **kw) - - # v2.0 or later. - # This method is the same as the one in SQLAlchemy's pg dialect, with - # a tweak to the FK regular expressions to tolerate whitespace between - # the table name and the column list. - # See also: https://github.com/cockroachdb/cockroach/issues/27123 - - preparer = self.identifier_preparer - table_oid = self.get_table_oid( - connection, table_name, schema, info_cache=kw.get("info_cache") - ) - - FK_SQL = """ - SELECT r.conname, - pg_catalog.pg_get_constraintdef(r.oid, true) as condef, - n.nspname as conschema - FROM pg_catalog.pg_constraint r, - pg_namespace n, - pg_class c - - WHERE r.conrelid = :table AND - r.contype = 'f' AND - c.oid = confrelid AND - n.oid = c.relnamespace - ORDER BY 1 - """ - # http://www.postgresql.org/docs/9.0/static/sql-createtable.html - FK_REGEX = self._fk_regex_pattern - - t = sql.text(FK_SQL).columns(conname=sqltypes.Unicode, condef=sqltypes.Unicode) - c = connection.execute(t, {"table": table_oid}) - fkeys = [] - for conname, condef, conschema in c.fetchall(): - m = re.search(FK_REGEX, condef).groups() - - ( - constrained_columns, - referred_schema, - referred_table, - referred_columns, - _, - match, - _, - ondelete, - _, - onupdate, - deferrable, - _, - initially, - ) = m - - if deferrable is not None: - deferrable = True if deferrable == "DEFERRABLE" else False - constrained_columns = [ - preparer._unquote_identifier(x) for x in re.split(r"\s*,\s*", constrained_columns) - ] - - if postgresql_ignore_search_path: - # when ignoring search path, we use the actual schema - # provided it isn't the "default" schema - if conschema != self.default_schema_name: - referred_schema = conschema - else: - referred_schema = schema - elif referred_schema: - # referred_schema is the schema that we regexp'ed from - # pg_get_constraintdef(). If the schema is in the search - # path, pg_get_constraintdef() will give us None. - referred_schema = preparer._unquote_identifier(referred_schema) - elif schema is not None and schema == conschema: - # If the actual schema matches the schema of the table - # we're reflecting, then we will use that. - referred_schema = schema - - referred_table = preparer._unquote_identifier(referred_table) - referred_columns = [ - preparer._unquote_identifier(x) for x in re.split(r"\s*,\s", referred_columns) - ] - fkey_d = { - "name": conname, - "constrained_columns": constrained_columns, - "referred_schema": referred_schema, - "referred_table": referred_table, - "referred_columns": referred_columns, - "options": { - "onupdate": onupdate, - "ondelete": ondelete, - "deferrable": deferrable, - "initially": initially, - "match": match, - }, - } - fkeys.append(fkey_d) - return fkeys - def get_pk_constraint(self, conn, table_name, schema=None, **kw): if self._is_v21plus: return super().get_pk_constraint(conn, table_name, schema, **kw) diff --git a/test-requirements.in b/test-requirements.in index c95264e..f2d04f8 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -12,4 +12,4 @@ more-itertools psycopg psycopg2 pytest -sqlalchemy>=2.0.0 +sqlalchemy>=2.0.47,<2.1 diff --git a/test-requirements.txt b/test-requirements.txt index a3004bd..1131f7e 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,16 +1,16 @@ -alembic==1.16.5 +alembic==1.18.4 # via -r test-requirements.in async-timeout==5.0.1 # via asyncpg -asyncpg==0.30.0 +asyncpg==0.31.0 # via -r test-requirements.in -exceptiongroup==1.3.0 +exceptiongroup==1.3.1 # via pytest futures==3.0.5 # via -r test-requirements.in -greenlet==3.2.4 +greenlet==3.3.2 # via sqlalchemy -iniconfig==2.1.0 +iniconfig==2.3.0 # via pytest mako==1.3.10 # via alembic @@ -20,23 +20,23 @@ mock==5.2.0 # via -r test-requirements.in more-itertools==10.8.0 # via -r test-requirements.in -packaging==25.0 +packaging==26.0 # via pytest pluggy==1.6.0 # via pytest -psycopg==3.2.12 +psycopg==3.3.3 # via -r test-requirements.in psycopg2==2.9.11 # via -r test-requirements.in pygments==2.19.2 # via pytest -pytest==8.4.2 +pytest==9.0.2 # via -r test-requirements.in -sqlalchemy==2.0.44 +sqlalchemy==2.0.47 # via # -r test-requirements.in # alembic -tomli==2.3.0 +tomli==2.4.0 # via # alembic # pytest diff --git a/test/test_run_transaction_core.py b/test/test_run_transaction_core.py index c1431b9..78bdc36 100644 --- a/test/test_run_transaction_core.py +++ b/test/test_run_transaction_core.py @@ -66,7 +66,7 @@ def barrier(): # If this is the first iteration, wait for the other txn to also read. with cv: wait_count[0] -= 1 - cv.notifyAll() + cv.notify_all() while wait_count[0] > 0: cv.wait() @@ -133,6 +133,8 @@ def txn_body(conn): def test_run_transaction_retry(self): def txn_body(conn): rs = conn.execute(text("select acct, balance from account where acct = 1")) + if conn.dialect._is_v261plus: + conn.execute(text("SET allow_unsafe_internals = true")) conn.execute(text("select crdb_internal.force_retry('1s')")) return [r for r in rs] @@ -143,6 +145,8 @@ def txn_body(conn): def test_run_transaction_retry_with_nested(self): def txn_body(conn): rs = conn.execute(text("select acct, balance from account where acct = 1")) + if conn.dialect._is_v261plus: + conn.execute(text("SET allow_unsafe_internals = true")) conn.execute(text("select crdb_internal.force_retry('1s')")) return [r for r in rs] @@ -154,6 +158,8 @@ def test_run_chained_transaction(self): def txn_body(conn): # first transaction inserts conn.execute(account_table.insert(), [dict(acct=99, balance=100)]) + if conn.dialect._is_v261plus: + conn.execute(text("SET allow_unsafe_internals = true")) conn.execute(text("select crdb_internal.force_retry('1s')")) def _get_val(s): diff --git a/test/test_run_transaction_session.py b/test/test_run_transaction_session.py index 08c726c..7dae1f7 100644 --- a/test/test_run_transaction_session.py +++ b/test/test_run_transaction_session.py @@ -58,7 +58,7 @@ def barrier(): # If this is the first iteration, wait for the other txn to also read. with cv: wait_count[0] -= 1 - cv.notifyAll() + cv.notify_all() while wait_count[0] > 0: cv.wait() @@ -111,6 +111,8 @@ def txn_body(session): def test_run_transaction_retry(self): def txn_body(sess): rs = sess.execute(text("select acct, balance from account where acct = 1")) + if sess.bind.dialect._is_v261plus: + sess.execute(text("SET allow_unsafe_internals = true")) sess.execute(text("select crdb_internal.force_retry('1s')")) return [r for r in rs] diff --git a/test/test_suite_sqlalchemy.py b/test/test_suite_sqlalchemy.py index 778d6f9..b96f9e2 100644 --- a/test/test_suite_sqlalchemy.py +++ b/test/test_suite_sqlalchemy.py @@ -17,7 +17,6 @@ QuotedNameArgumentTest as _QuotedNameArgumentTest, ) from sqlalchemy.testing.suite import TrueDivTest as _TrueDivTest -from sqlalchemy.testing.suite import UnicodeSchemaTest as _UnicodeSchemaTest class ComponentReflectionTest(_ComponentReflectionTest): @@ -503,9 +502,3 @@ def test_floordiv_integer(self): def test_floordiv_integer_bound(self): # we return SELECT 15 / 10 as Decimal('1.5'), not Integer pass - - -class UnicodeSchemaTest(_UnicodeSchemaTest): - def test_reflect(self, connection): - if not (config.db.dialect.driver == "asyncpg" and not config.db.dialect._is_v231plus): - super().test_reflect(connection) diff --git a/tox.ini b/tox.ini index a7c991b..21da79b 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ commands = [testenv:pip-compile] skip_install = True deps = - pip-tools==6.13.0 + pip-tools==7.5.2 commands = pip-compile --upgrade --no-emit-index-url --no-header --resolver=backtracking dev-requirements.in pip-compile --upgrade --no-emit-index-url --no-header --resolver=backtracking test-requirements.in