Skip to content

Commit 7352ab2

Browse files
authored
FIX: Retain original timezone in Python datetime objects (#281)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below (e.g. AB#37452) For external contributors: Insert Github Issue number below (e.g. #149) Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#29184](https://sqlclientdrivers.visualstudio.com/mssql-python/_workitems/edit/39184) <!-- External contributors: GitHub Issue --> > GitHub Issue: #213 ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request updates how `datetimeoffset` values are handled when reading from SQL Server in the Python bindings. The main change is to preserve the original timezone information in returned Python `datetime` objects, instead of always converting them to UTC. Correspondingly, the test suite has been updated to compare datetimes with their original timezone rather than converting to UTC for assertions. **Datetimeoffset handling improvements:** * Removed forced conversion of `datetimeoffset` values to UTC in `SQLGetData_wrap` and `FetchBatchData`, so Python datetime objects retain their original timezone info. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L2808) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L3321) **Test suite updates:** * Updated all relevant tests in `tests/test_004_cursor.py` to compare datetimes directly, preserving timezone information, instead of converting to UTC for equality checks. This affects tests for read/write, max/min offsets, DST transitions, executemany, and extreme offsets. [[1]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69L7890-R7890) [[2]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69L7929-R7924) [[3]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69L7989-R7979) [[4]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69L8071-R8056) [[5]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69L8147-R8122) <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent 200c35b commit 7352ab2

File tree

2 files changed

+39
-35
lines changed

2 files changed

+39
-35
lines changed

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2805,7 +2805,6 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
28052805
microseconds,
28062806
tzinfo
28072807
);
2808-
py_dt = py_dt.attr("astimezone")(datetime.attr("timezone").attr("utc"));
28092808
row.append(py_dt);
28102809
} else {
28112810
LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret);
@@ -3318,7 +3317,6 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
33183317
dtoValue.fraction / 1000, // ns → µs
33193318
tzinfo
33203319
);
3321-
py_dt = py_dt.attr("astimezone")(datetime.attr("timezone").attr("utc"));
33223320
row.append(py_dt);
33233321
} else {
33243322
row.append(py::none());

tests/test_004_cursor.py

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7887,12 +7887,7 @@ def test_datetimeoffset_read_write(cursor, db_connection):
78877887
assert row is not None
78887888
fetched_id, fetched_dt = row
78897889
assert fetched_dt.tzinfo is not None
7890-
expected_utc = dt.astimezone(timezone.utc)
7891-
fetched_utc = fetched_dt.astimezone(timezone.utc)
7892-
# Ignore sub-microsecond differences
7893-
expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000)
7894-
fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000)
7895-
assert fetched_utc == expected_utc
7890+
assert fetched_dt == dt
78967891
finally:
78977892
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;")
78987893
db_connection.commit()
@@ -7926,12 +7921,7 @@ def test_datetimeoffset_max_min_offsets(cursor, db_connection):
79267921
assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}"
79277922
assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}"
79287923

7929-
# Compare in UTC to avoid offset differences
7930-
expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None)
7931-
fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None)
7932-
assert fetched_utc == expected_utc, (
7933-
f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}"
7934-
)
7924+
assert fetched_dt == expected_dt, f"Value mismatch for id {expected_id}: expected {expected_dt}, got {fetched_dt}"
79357925

79367926
finally:
79377927
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;")
@@ -7986,12 +7976,7 @@ def test_datetimeoffset_dst_transitions(cursor, db_connection):
79867976
assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}"
79877977
assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}"
79887978

7989-
# Compare UTC time to avoid issues due to offsets changing in DST
7990-
expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None)
7991-
fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None)
7992-
assert fetched_utc == expected_utc, (
7993-
f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}"
7994-
)
7979+
assert fetched_dt == expected_dt, f"Value mismatch for id {expected_id}: expected {expected_dt}, got {fetched_dt}"
79957980

79967981
finally:
79977982
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_dst_transitions;")
@@ -8068,17 +8053,7 @@ def test_datetimeoffset_executemany(cursor, db_connection):
80688053
fetched_id, fetched_dto = rows[i]
80698054
assert fetched_dto.tzinfo is not None, "Fetched datetime object is naive."
80708055

8071-
expected_utc = python_dt.astimezone(timezone.utc).replace(tzinfo=None)
8072-
fetched_utc = fetched_dto.astimezone(timezone.utc).replace(tzinfo=None)
8073-
8074-
# Round microseconds to nearest millisecond for comparison
8075-
expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000)
8076-
fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000)
8077-
8078-
assert fetched_utc == expected_utc, (
8079-
f"Value mismatch for test case {i}. "
8080-
f"Expected UTC: {expected_utc}, Got UTC: {fetched_utc}"
8081-
)
8056+
assert fetched_dto == python_dt, f"Value mismatch for id {fetched_id}: expected {python_dt}, got {fetched_dto}"
80828057
finally:
80838058
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
80848059
db_connection.commit()
@@ -8144,13 +8119,44 @@ def test_datetimeoffset_extreme_offsets(cursor, db_connection):
81448119
for i, dt in enumerate(extreme_offsets):
81458120
_, fetched = rows[i]
81468121
assert fetched.tzinfo is not None
8147-
# Round-trip comparison via UTC
8148-
expected_utc = dt.astimezone(timezone.utc).replace(tzinfo=None)
8149-
fetched_utc = fetched.astimezone(timezone.utc).replace(tzinfo=None)
8150-
assert expected_utc == fetched_utc, f"Extreme offset round-trip failed for {dt.tzinfo}"
8122+
assert fetched == dt, f"Value mismatch for id {i}: expected {dt}, got {fetched}"
81518123
finally:
81528124
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
81538125
db_connection.commit()
8126+
8127+
def test_datetimeoffset_native_vs_string_simple(cursor, db_connection):
8128+
"""
8129+
Replicates the user's testing scenario: fetch DATETIMEOFFSET as native datetime
8130+
and as string using CONVERT(nvarchar(35), ..., 121).
8131+
"""
8132+
try:
8133+
cursor.execute("CREATE TABLE #pytest_dto_user_test (id INT PRIMARY KEY, Systime DATETIMEOFFSET);")
8134+
db_connection.commit()
8135+
8136+
# Insert rows similar to user's example
8137+
test_rows = [
8138+
(1, datetime(2025, 5, 14, 12, 35, 52, 501000, tzinfo=timezone(timedelta(hours=1)))),
8139+
(2, datetime(2025, 5, 14, 15, 20, 30, 123000, tzinfo=timezone(timedelta(hours=-5))))
8140+
]
8141+
8142+
for i, dt in test_rows:
8143+
cursor.execute("INSERT INTO #pytest_dto_user_test (id, Systime) VALUES (?, ?);", i, dt)
8144+
db_connection.commit()
8145+
8146+
# Native fetch (like the user's first execute)
8147+
cursor.execute("SELECT Systime FROM #pytest_dto_user_test WHERE id=1;")
8148+
dt_native = cursor.fetchone()[0]
8149+
assert dt_native.tzinfo is not None
8150+
assert dt_native == test_rows[0][1]
8151+
8152+
# String fetch (like the user's convert to nvarchar)
8153+
cursor.execute("SELECT CONVERT(nvarchar(35), Systime, 121) FROM #pytest_dto_user_test WHERE id=1;")
8154+
dt_str = cursor.fetchone()[0]
8155+
assert dt_str.endswith("+01:00") # original offset preserved
8156+
8157+
finally:
8158+
cursor.execute("DROP TABLE IF EXISTS #pytest_dto_user_test;")
8159+
db_connection.commit()
81548160

81558161
def test_lowercase_attribute(cursor, db_connection):
81568162
"""Test that the lowercase attribute properly converts column names to lowercase"""

0 commit comments

Comments
 (0)