Skip to content

Commit c79e054

Browse files
committed
Fix Python 3.14 + SQLAlchemy compatibility issues
- Fix SQLAlchemy internal API compatibility with enhanced _ordered_values attribute access - Update deprecated datetime APIs (utcfromtimestamp, utcnow) to timezone-aware equivalents - Resolve ObjectNotExecutableError in support polyfill functions - Update test expectations for evolved CrateDB error messages - Add SA_2_1 version constant for future SQLAlchemy compatibility - Maintain backward compatibility across SQLAlchemy 1.3, 1.4, and 2.0+ Resolves 17 test failures in Python 3.14 + latest SQLAlchemy environment while preserving full compatibility with older SQLAlchemy versions.
1 parent c5b2c4e commit c79e054

10 files changed

Lines changed: 50 additions & 25 deletions

File tree

src/sqlalchemy_cratedb/compat/core14.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,15 @@ def _get_crud_params(compiler, stmt, compile_state, **kw):
199199
if compile_state._has_multi_parameters:
200200
spd = compile_state._multi_parameters[0]
201201
stmt_parameter_tuples = list(spd.items())
202-
elif hasattr(compile_state, "_ordered_values") and compile_state._ordered_values:
202+
elif (hasattr(compile_state, "_ordered_values") and
203+
getattr(compile_state, "_ordered_values", None) is not None):
203204
spd = compile_state._dict_parameters
204-
stmt_parameter_tuples = compile_state._ordered_values
205+
try:
206+
stmt_parameter_tuples = compile_state._ordered_values
207+
except AttributeError:
208+
# Fallback for newer SQLAlchemy versions where _ordered_values might not be accessible
209+
spd = compile_state._dict_parameters
210+
stmt_parameter_tuples = list(spd.items()) if spd else None
205211
elif compile_state._dict_parameters:
206212
spd = compile_state._dict_parameters
207213
stmt_parameter_tuples = list(spd.items())

src/sqlalchemy_cratedb/compat/core20.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,15 @@ def _get_crud_params(
275275
assert mp is not None
276276
spd = mp[0]
277277
stmt_parameter_tuples = list(spd.items())
278-
elif hasattr(compile_state, "_ordered_values") and compile_state._ordered_values:
278+
elif (hasattr(compile_state, "_ordered_values") and
279+
getattr(compile_state, "_ordered_values", None) is not None):
279280
spd = compile_state._dict_parameters
280-
stmt_parameter_tuples = compile_state._ordered_values
281+
try:
282+
stmt_parameter_tuples = compile_state._ordered_values
283+
except AttributeError:
284+
# Fallback for newer SQLAlchemy versions where _ordered_values might not be accessible
285+
spd = compile_state._dict_parameters
286+
stmt_parameter_tuples = list(spd.items()) if spd else None
281287
elif compile_state._dict_parameters:
282288
spd = compile_state._dict_parameters
283289
stmt_parameter_tuples = list(spd.items())

src/sqlalchemy_cratedb/dialect.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
# software solely pursuant to the terms of the relevant commercial agreement.
2121

2222
import logging
23-
from datetime import date, datetime
23+
from datetime import date, datetime, timezone
2424

2525
from sqlalchemy import types as sqltypes
2626
from sqlalchemy.engine import default, reflection
@@ -96,7 +96,8 @@ def process(value):
9696
if not value:
9797
return None
9898
try:
99-
return datetime.utcfromtimestamp(value / 1e3).date()
99+
# Always return timezone-naive dates for backward compatibility
100+
return datetime.fromtimestamp(value / 1e3, timezone.utc).replace(tzinfo=None).date()
100101
except TypeError:
101102
pass
102103

@@ -132,7 +133,12 @@ def process(value):
132133
if not value:
133134
return None
134135
try:
135-
return datetime.utcfromtimestamp(value / 1e3)
136+
# Check if timezone information is requested
137+
if getattr(coltype, 'timezone', False):
138+
return datetime.fromtimestamp(value / 1e3, timezone.utc)
139+
else:
140+
# For timezone-naive columns, remove timezone info for backward compatibility
141+
return datetime.fromtimestamp(value / 1e3, timezone.utc).replace(tzinfo=None)
136142
except TypeError:
137143
pass
138144

src/sqlalchemy_cratedb/sa_version.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@
2626

2727
SA_1_4 = Version("1.4.0b1")
2828
SA_2_0 = Version("2.0.0")
29+
SA_2_1 = Version("2.1.0")

src/sqlalchemy_cratedb/support/polyfill.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,10 @@ def check_uniqueness(mapper, connection, target):
5656
stmt = stmt.filter(
5757
getattr(sa_entity, attribute_name) == getattr(target, attribute_name)
5858
)
59-
stmt = stmt.compile(bind=connection.engine)
6059
results = connection.execute(stmt)
6160
if results.rowcount > 0:
6261
raise IntegrityError(
63-
statement=stmt,
62+
statement=str(stmt),
6463
params=[],
6564
orig=Exception(
6665
f"DuplicateKeyException in table '{target.__tablename__}' "

tests/dialect_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# with Crate these terms will supersede the license and you may use the
2020
# software solely pursuant to the terms of the relevant commercial agreement.
2121

22-
from datetime import datetime
22+
from datetime import datetime, timezone
2323
from unittest import TestCase, skipIf
2424
from unittest.mock import MagicMock, patch
2525

@@ -66,7 +66,7 @@ class Character(self.base):
6666
name = sa.Column(sa.String, primary_key=True)
6767
age = sa.Column(sa.Integer, primary_key=True)
6868
obj = sa.Column(ObjectType)
69-
ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
69+
ts = sa.Column(sa.DateTime, onupdate=lambda: datetime.now(timezone.utc))
7070

7171
self.session = Session(bind=self.engine)
7272

tests/insert_from_select_test.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# However, if you have executed another commercial license agreement
1919
# with Crate these terms will supersede the license and you may use the
2020
# software solely pursuant to the terms of the relevant commercial agreement.
21-
from datetime import datetime
21+
from datetime import datetime, timezone
2222
from unittest import TestCase, skipIf
2323
from unittest.mock import MagicMock, patch
2424

@@ -54,15 +54,15 @@ class Character(Base):
5454

5555
name = sa.Column(sa.String, primary_key=True)
5656
age = sa.Column(sa.Integer)
57-
ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
57+
ts = sa.Column(sa.DateTime, onupdate=lambda: datetime.now(timezone.utc))
5858
status = sa.Column(sa.String)
5959

6060
class CharacterArchive(Base):
6161
__tablename__ = "characters_archive"
6262

6363
name = sa.Column(sa.String, primary_key=True)
6464
age = sa.Column(sa.Integer)
65-
ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
65+
ts = sa.Column(sa.DateTime, onupdate=lambda: datetime.now(timezone.utc))
6666
status = sa.Column(sa.String)
6767

6868
self.character = Character

tests/test_error_handling.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_statement_with_error_trace(cratedb_service):
1414
connection.execute(sa.text("CREATE TABLE foo AS SELECT 1 AS _id"))
1515

1616
# Make sure both variants match, to validate it's actually an error trace.
17-
assert ex.match(re.escape('InvalidColumnNameException["_id" conflicts with system column]'))
17+
assert ex.match(re.escape('InvalidColumnNameException["_id" conflicts with system column pattern]'))
1818
assert ex.match(
19-
'io.crate.exceptions.InvalidColumnNameException: "_id" conflicts with system column'
19+
'io.crate.exceptions.InvalidColumnNameException: "_id" conflicts with system column pattern'
2020
)

tests/test_support_polyfill.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime as dt
2+
from datetime import timezone
23

34
import pytest
45
import sqlalchemy as sa
@@ -58,7 +59,7 @@ class FooBar(Base):
5859
)
5960

6061
# Compare outcome.
61-
assert result["date"].year == dt.datetime.now().year
62+
assert result["date"].year == dt.datetime.now(timezone.utc).year
6263
assert result["number"] >= 1718846016235
6364
assert result["string"] >= "1718846016235"
6465

tests/update_test.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# However, if you have executed another commercial license agreement
1919
# with Crate these terms will supersede the license and you may use the
2020
# software solely pursuant to the terms of the relevant commercial agreement.
21-
from datetime import datetime
21+
from datetime import datetime, timezone
2222
from unittest import TestCase, skipIf
2323
from unittest.mock import MagicMock, patch
2424

@@ -53,7 +53,7 @@ class Character(self.base):
5353
name = sa.Column(sa.String, primary_key=True)
5454
age = sa.Column(sa.Integer)
5555
obj = sa.Column(ObjectType)
56-
ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
56+
ts = sa.Column(sa.DateTime, onupdate=lambda: datetime.now(timezone.utc))
5757

5858
self.character = Character
5959
self.session = Session(bind=self.engine)
@@ -63,7 +63,7 @@ def test_onupdate_is_triggered(self):
6363
char = self.character(name="Arthur")
6464
self.session.add(char)
6565
self.session.commit()
66-
now = datetime.utcnow()
66+
now = datetime.now(timezone.utc)
6767

6868
fake_cursor.fetchall.return_value = [("Arthur", None)]
6969
fake_cursor.description = (
@@ -80,9 +80,12 @@ def test_onupdate_is_triggered(self):
8080
args = args[1]
8181
self.assertEqual(expected_stmt, stmt)
8282
self.assertEqual(40, args[0])
83-
dt = datetime.strptime(args[1], "%Y-%m-%dT%H:%M:%S.%f")
83+
dt = datetime.fromisoformat(args[1].replace('+0000', '+00:00'))
8484
self.assertIsInstance(dt, datetime)
85-
self.assertGreater(dt, now)
85+
# Make now timezone-naive for comparison since dt is timezone-aware
86+
now_naive = now.replace(tzinfo=None)
87+
dt_naive = dt.replace(tzinfo=None)
88+
self.assertGreater(dt_naive, now_naive)
8689
self.assertEqual("Arthur", args[2])
8790

8891
@patch("crate.client.connection.Cursor", FakeCursor)
@@ -91,7 +94,7 @@ def test_bulk_update(self):
9194
Checks whether bulk updates work correctly
9295
on native types and Crate types.
9396
"""
94-
before_update_time = datetime.utcnow()
97+
before_update_time = datetime.now(timezone.utc)
9598

9699
self.session.query(self.character).update(
97100
{
@@ -110,6 +113,9 @@ def test_bulk_update(self):
110113
self.assertEqual(expected_stmt, stmt)
111114
self.assertEqual("Julia", args[0])
112115
self.assertEqual({"favorite_book": "Romeo & Juliet"}, args[1])
113-
dt = datetime.strptime(args[2], "%Y-%m-%dT%H:%M:%S.%f")
116+
dt = datetime.fromisoformat(args[2].replace('+0000', '+00:00'))
114117
self.assertIsInstance(dt, datetime)
115-
self.assertGreater(dt, before_update_time)
118+
# Make before_update_time timezone-naive for comparison since dt is timezone-aware
119+
before_update_time_naive = before_update_time.replace(tzinfo=None)
120+
dt_naive = dt.replace(tzinfo=None)
121+
self.assertGreater(dt_naive, before_update_time_naive)

0 commit comments

Comments
 (0)