diff --git a/benchmarks/requirements-bench.txt b/benchmarks/requirements-bench.txt index 8a9f49e68..50a6edee9 100644 --- a/benchmarks/requirements-bench.txt +++ b/benchmarks/requirements-bench.txt @@ -18,6 +18,5 @@ pygments==2.20.0 pytest==9.1.1 pytest-codspeed==5.0.3 python-dateutil==2.9.0.post0 -pytz==2026.2 rich==15.0.0 six==1.17.0 diff --git a/benchmarks/test_fetch_perf.py b/benchmarks/test_fetch_perf.py index 1b07dfb74..afb60ec1b 100644 --- a/benchmarks/test_fetch_perf.py +++ b/benchmarks/test_fetch_perf.py @@ -67,7 +67,7 @@ def run() -> None: # small-N *_gate variants: at range(2048) the measured region is ~60% SQL compile + engine, ~40% fetch, so these -# catch a fixed-cost regression (not a pure per-row one). Plus expensive scalar types (timestamptz pytz-per-row, +# catch a fixed-cost regression (not a pure per-row one). Plus expensive scalar types (timestamptz zoneinfo per row, # blob, null-heavy), a heterogeneous per-cell-dispatch row, and the batched fetchmany loop. diff --git a/pyproject.toml b/pyproject.toml index ca3d9fffe..63d3b4874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -291,7 +291,6 @@ test = [ # dependencies used for running tests "py4j", "pyotp", "pyspark", - "pytz", "requests", "urllib3", "fsspec>=2022.11.0; sys_platform != 'win32' or platform_machine != 'ARM64'", @@ -316,7 +315,6 @@ bench = [ # Pinned deps for the benchmark suite (see benchmarks/README.md). Mini "pytest", "pytest_codspeed", "polars>=1.33.0", - "pytz", "numpy<2; ( sys_platform != 'win32' or platform_machine != 'ARM64' ) and python_version < '3.12'", "numpy>=2; ( sys_platform != 'win32' or platform_machine != 'ARM64' ) and python_version >= '3.12'", "numpy>=2.3; sys_platform == 'win32' and platform_machine == 'ARM64'", @@ -331,7 +329,6 @@ scripts = [ # dependencies used for running scripts "pandas", "polars>=1.33.0", "pyarrow; sys_platform != 'win32' or platform_machine != 'ARM64'", - "pytz" ] pypi = [ # dependencies used by the pypi cleanup script "pyotp>=2.9.0", diff --git a/scripts/cache_data.json b/scripts/cache_data.json index a822fa81d..97e26642f 100644 --- a/scripts/cache_data.json +++ b/scripts/cache_data.json @@ -258,7 +258,9 @@ "type": "attribute", "full_path": "datetime.timezone", "name": "timezone", - "children": [] + "children": [ + "datetime.timezone.utc" + ] }, "decimal": { "type": "module", @@ -582,20 +584,6 @@ "name": "Value", "children": [] }, - "pytz": { - "type": "module", - "full_path": "pytz", - "name": "pytz", - "children": [ - "pytz.timezone" - ] - }, - "pytz.timezone": { - "type": "attribute", - "full_path": "pytz.timezone", - "name": "timezone", - "children": [] - }, "types": { "type": "module", "full_path": "types", @@ -858,5 +846,25 @@ "full_path": "pandas.arrays.StringArray", "name": "StringArray", "children": [] + }, + "datetime.timezone.utc": { + "type": "attribute", + "full_path": "datetime.timezone.utc", + "name": "utc", + "children": [] + }, + "zoneinfo": { + "type": "module", + "full_path": "zoneinfo", + "name": "zoneinfo", + "children": [ + "zoneinfo.ZoneInfo" + ] + }, + "zoneinfo.ZoneInfo": { + "type": "attribute", + "full_path": "zoneinfo.ZoneInfo", + "name": "ZoneInfo", + "children": [] } } \ No newline at end of file diff --git a/scripts/imports.py b/scripts/imports.py index 477e8a8f9..b21ab9023 100644 --- a/scripts/imports.py +++ b/scripts/imports.py @@ -60,6 +60,7 @@ datetime.datetime.max datetime.datetime.combine datetime.timezone +datetime.timezone.utc import decimal @@ -122,9 +123,9 @@ duckdb.filesystem.ModifiedMemoryFileSystem duckdb.Value -import pytz +import zoneinfo -pytz.timezone +zoneinfo.ZoneInfo import types diff --git a/src/include/duckdb_python/import_cache/modules/datetime_module.hpp b/src/include/duckdb_python/import_cache/modules/datetime_module.hpp index c05f77d4f..c3e4426a9 100644 --- a/src/include/duckdb_python/import_cache/modules/datetime_module.hpp +++ b/src/include/duckdb_python/import_cache/modules/datetime_module.hpp @@ -20,6 +20,18 @@ namespace duckdb { +struct DatetimeTimezoneCacheItem : public PythonImportCacheItem { + +public: + DatetimeTimezoneCacheItem(optional_ptr parent) + : PythonImportCacheItem("timezone", parent), utc("utc", this) { + } + ~DatetimeTimezoneCacheItem() override { + } + + PythonImportCacheItem utc; +}; + struct DatetimeDatetimeCacheItem : public PythonImportCacheItem { public: @@ -55,7 +67,7 @@ struct DatetimeCacheItem : public PythonImportCacheItem { public: DatetimeCacheItem() : PythonImportCacheItem("datetime"), date(this), time("time", this), timedelta("timedelta", this), - datetime(this), timezone("timezone", this) { + datetime(this), timezone(this) { } ~DatetimeCacheItem() override { } @@ -64,7 +76,7 @@ struct DatetimeCacheItem : public PythonImportCacheItem { PythonImportCacheItem time; PythonImportCacheItem timedelta; DatetimeDatetimeCacheItem datetime; - PythonImportCacheItem timezone; + DatetimeTimezoneCacheItem timezone; }; } // namespace duckdb diff --git a/src/include/duckdb_python/import_cache/modules/pytz_module.hpp b/src/include/duckdb_python/import_cache/modules/zoneinfo_module.hpp similarity index 67% rename from src/include/duckdb_python/import_cache/modules/pytz_module.hpp rename to src/include/duckdb_python/import_cache/modules/zoneinfo_module.hpp index ccdde0c89..5671433dd 100644 --- a/src/include/duckdb_python/import_cache/modules/pytz_module.hpp +++ b/src/include/duckdb_python/import_cache/modules/zoneinfo_module.hpp @@ -2,7 +2,7 @@ //===----------------------------------------------------------------------===// // DuckDB // -// duckdb_python/import_cache/modules/pytz_module.hpp +// duckdb_python/import_cache/modules/zoneinfo_module.hpp // // //===----------------------------------------------------------------------===// @@ -20,18 +20,18 @@ namespace duckdb { -struct PytzCacheItem : public PythonImportCacheItem { +struct ZoneinfoCacheItem : public PythonImportCacheItem { public: - static constexpr const char *Name = "pytz"; + static constexpr const char *Name = "zoneinfo"; public: - PytzCacheItem() : PythonImportCacheItem("pytz"), timezone("timezone", this) { + ZoneinfoCacheItem() : PythonImportCacheItem("zoneinfo"), ZoneInfo("ZoneInfo", this) { } - ~PytzCacheItem() override { + ~ZoneinfoCacheItem() override { } - PythonImportCacheItem timezone; + PythonImportCacheItem ZoneInfo; }; } // namespace duckdb diff --git a/src/include/duckdb_python/import_cache/python_import_cache.hpp b/src/include/duckdb_python/import_cache/python_import_cache.hpp index 6438332f6..bbe67d00b 100644 --- a/src/include/duckdb_python/import_cache/python_import_cache.hpp +++ b/src/include/duckdb_python/import_cache/python_import_cache.hpp @@ -33,11 +33,11 @@ struct PythonImportCache { PathlibCacheItem pathlib; PolarsCacheItem polars; DuckdbCacheItem duckdb; - PytzCacheItem pytz; TypesCacheItem types; TypingCacheItem typing; UuidCacheItem uuid; CollectionsCacheItem collections; + ZoneinfoCacheItem zoneinfo; public: nb::handle AddCache(nb::object item); diff --git a/src/include/duckdb_python/import_cache/python_import_cache_modules.hpp b/src/include/duckdb_python/import_cache/python_import_cache_modules.hpp index e82cd2dcc..f500b8995 100644 --- a/src/include/duckdb_python/import_cache/python_import_cache_modules.hpp +++ b/src/include/duckdb_python/import_cache/python_import_cache_modules.hpp @@ -8,8 +8,8 @@ #include "duckdb_python/import_cache/modules/pathlib_module.hpp" #include "duckdb_python/import_cache/modules/polars_module.hpp" #include "duckdb_python/import_cache/modules/duckdb_module.hpp" -#include "duckdb_python/import_cache/modules/pytz_module.hpp" #include "duckdb_python/import_cache/modules/types_module.hpp" #include "duckdb_python/import_cache/modules/typing_module.hpp" #include "duckdb_python/import_cache/modules/uuid_module.hpp" -#include "duckdb_python/import_cache/modules/collections_module.hpp" \ No newline at end of file +#include "duckdb_python/import_cache/modules/collections_module.hpp" +#include "duckdb_python/import_cache/modules/zoneinfo_module.hpp" \ No newline at end of file diff --git a/src/native/python_objects.cpp b/src/native/python_objects.cpp index 2b476ddd6..f147cb988 100644 --- a/src/native/python_objects.cpp +++ b/src/native/python_objects.cpp @@ -661,11 +661,29 @@ nb::object PythonObject::FromValue(const Value &val, const LogicalType &type, return nb::str(fallback_str.c_str(), fallback_str.size()); } if (type.id() == LogicalTypeId::TIMESTAMP_TZ || type.id() == LogicalTypeId::TIMESTAMP_TZ_NS) { - // We have to add the timezone info - auto tz_utc = import_cache.pytz.timezone()("UTC"); - auto timestamp_utc = tz_utc.attr("localize")(py_timestamp); - auto tz_info = import_cache.pytz.timezone()(client_properties.time_zone); - return timestamp_utc.attr("astimezone")(tz_info); + nb::object zone; + try { + zone = import_cache.zoneinfo.ZoneInfo()(client_properties.time_zone); + } catch (nb::python_error &e) { + if (!e.matches(PyExc_KeyError)) { + throw; + } + throw InvalidInputException( + "Time zone '%s' is not available to Python's zoneinfo. Install the 'tzdata' " + "package, or SET TimeZone to a name the IANA database has.", + client_properties.time_zone); + } + auto timestamp_utc = py_timestamp.attr("replace")(nb::arg("tzinfo") = import_cache.datetime.timezone.utc()); + try { + return timestamp_utc.attr("astimezone")(zone); + } catch (nb::python_error &e) { + // The local wall time can leave Python's year range while the UTC instant is inside it. + if (!e.matches(PyExc_OverflowError)) { + throw; + } + auto fallback_str = val.ToString(); + return nb::str(fallback_str.c_str(), fallback_str.size()); + } } return py_timestamp; } diff --git a/tests/fast/api/test_cursor.py b/tests/fast/api/test_cursor.py index 044e736dd..351b69a13 100644 --- a/tests/fast/api/test_cursor.py +++ b/tests/fast/api/test_cursor.py @@ -1,5 +1,8 @@ # simple DB API testcase +import datetime +import zoneinfo + import pytest import duckdb @@ -93,7 +96,8 @@ def test_cursor_timezone(self): # Because the 'timezone' setting was not explicitly set for the connection # the setting of the DBConfig is used instead res = con1.execute("SELECT make_timestamptz(2000,01,20,03,30,59)").fetchone() - assert str(res) == "(datetime.datetime(2000, 1, 20, 3, 30, 59, tzinfo=),)" + assert res == (datetime.datetime(2000, 1, 20, 3, 30, 59, tzinfo=zoneinfo.ZoneInfo("UTC")),) + assert res[0].tzinfo.key == "UTC" def test_cursor_closed(self): con = duckdb.connect(":memory:") diff --git a/tests/fast/api/test_native_tz.py b/tests/fast/api/test_native_tz.py index 0545182ee..d869bffa1 100644 --- a/tests/fast/api/test_native_tz.py +++ b/tests/fast/api/test_native_tz.py @@ -30,23 +30,23 @@ def test_native_python_timestamp_timezone(self, duckdb_cursor): res = duckdb_cursor.execute(f"select TimeRecStart as tz from '{filename}'").fetchone() assert res[0].hour == 14 assert res[0].minute == 52 - assert res[0].tzinfo.zone == "America/Los_Angeles" + assert res[0].tzinfo.key == "America/Los_Angeles" res = duckdb_cursor.execute(f"select TimeRecStart as tz from '{filename}'").fetchall()[0] assert res[0].hour == 14 assert res[0].minute == 52 - assert res[0].tzinfo.zone == "America/Los_Angeles" + assert res[0].tzinfo.key == "America/Los_Angeles" res = duckdb_cursor.execute(f"select TimeRecStart as tz from '{filename}'").fetchmany(1)[0] assert res[0].hour == 14 assert res[0].minute == 52 - assert res[0].tzinfo.zone == "America/Los_Angeles" + assert res[0].tzinfo.key == "America/Los_Angeles" duckdb_cursor.execute("SET timezone='UTC';") res = duckdb_cursor.execute(f"select TimeRecStart as tz from '{filename}'").fetchone() assert res[0].hour == 21 assert res[0].minute == 52 - assert res[0].tzinfo.zone == "UTC" + assert res[0].tzinfo.key == "UTC" def test_native_python_time_timezone(self, duckdb_cursor): res = duckdb_cursor.execute(f"select TimeRecStart::TIMETZ as tz from '{filename}'").fetchone() diff --git a/tests/fast/arrow/test_arrow_offsets.py b/tests/fast/arrow/test_arrow_offsets.py index 304b87958..85949cb99 100644 --- a/tests/fast/arrow/test_arrow_offsets.py +++ b/tests/fast/arrow/test_arrow_offsets.py @@ -3,7 +3,6 @@ import decimal import pytest -import pytz pa = pytest.importorskip("pyarrow") @@ -324,10 +323,10 @@ def test_struct_of_duration(self, duckdb_cursor, constructor, unit, expected, co @pytest.mark.parametrize( ("constructor", "unit", "expected"), [ - (pa_timestamp(), "ms", datetime.datetime(1970, 1, 1, 0, 2, 11, 72000, tzinfo=pytz.utc)), - (pa_timestamp(), "s", datetime.datetime(1970, 1, 2, 12, 24, 32, 0, tzinfo=pytz.utc)), - (pa_timestamp(), "ns", datetime.datetime(1970, 1, 1, 0, 0, 0, 131, tzinfo=pytz.utc)), - (pa_timestamp(), "us", datetime.datetime(1970, 1, 1, 0, 0, 0, 131072, tzinfo=pytz.utc)), + (pa_timestamp(), "ms", datetime.datetime(1970, 1, 1, 0, 2, 11, 72000, tzinfo=datetime.timezone.utc)), + (pa_timestamp(), "s", datetime.datetime(1970, 1, 2, 12, 24, 32, 0, tzinfo=datetime.timezone.utc)), + (pa_timestamp(), "ns", datetime.datetime(1970, 1, 1, 0, 0, 0, 131, tzinfo=datetime.timezone.utc)), + (pa_timestamp(), "us", datetime.datetime(1970, 1, 1, 0, 0, 0, 131072, tzinfo=datetime.timezone.utc)), ], ) def test_struct_of_timestamp_tz(self, duckdb_cursor, constructor, unit, expected, col1_null, col2_null): diff --git a/tests/fast/arrow/test_timestamp_timezone.py b/tests/fast/arrow/test_timestamp_timezone.py index c847c28d6..f14edfc2b 100644 --- a/tests/fast/arrow/test_timestamp_timezone.py +++ b/tests/fast/arrow/test_timestamp_timezone.py @@ -1,7 +1,6 @@ import datetime import pytest -import pytz import duckdb @@ -21,7 +20,7 @@ def generate_table(current_time, precision, timezone): class TestArrowTimestampsTimezone: def test_timestamp_timezone(self, duckdb_cursor): precisions = ["us", "s", "ns", "ms"] - current_time = datetime.datetime(2017, 11, 28, 23, 55, 59, tzinfo=pytz.UTC) + current_time = datetime.datetime(2017, 11, 28, 23, 55, 59, tzinfo=datetime.timezone.utc) con = duckdb.connect() con.execute("SET TimeZone = 'UTC'") for precision in precisions: diff --git a/tests/fast/pandas/test_append_df.py b/tests/fast/pandas/test_append_df.py index 839fe4085..1a98abca0 100644 --- a/tests/fast/pandas/test_append_df.py +++ b/tests/fast/pandas/test_append_df.py @@ -2,10 +2,10 @@ import enum import math import pathlib +import zoneinfo from decimal import Decimal import pytest -import pytz import duckdb import numpy as np @@ -416,7 +416,7 @@ def test_naive_datetime_into_timestamptz_reads_in_the_session_time_zone(self, du duckdb_cursor.execute("CREATE TABLE t (a TIMESTAMPTZ)") duckdb_cursor.append("t", pd.DataFrame({"a": _outside_the_sample(AWARE, naive)})) values = _column(duckdb_cursor) - assert (values[0], values[-1]) == (AWARE, pytz.timezone(zone).localize(NAIVE)) + assert (values[0], values[-1]) == (AWARE, NAIVE.replace(tzinfo=zoneinfo.ZoneInfo(zone))) @pytest.mark.parametrize( "zone", @@ -439,7 +439,7 @@ def test_date_into_timestamptz_reads_as_midnight_in_the_session_time_zone(self, duckdb_cursor.execute("CREATE TABLE t (a TIMESTAMPTZ)") duckdb_cursor.append("t", pd.DataFrame({"a": _outside_the_sample(AWARE, datetime.date(2024, 5, 6))})) values = _column(duckdb_cursor) - assert (values[0], values[-1]) == (AWARE, pytz.timezone(zone).localize(datetime.datetime(2024, 5, 6))) + assert (values[0], values[-1]) == (AWARE, datetime.datetime(2024, 5, 6, tzinfo=zoneinfo.ZoneInfo(zone))) @pytest.mark.xfail(strict=True, reason=NOT_YET_DECLARED) def test_by_name_declares_each_column_from_its_name(self, duckdb_cursor): diff --git a/tests/fast/test_all_types.py b/tests/fast/test_all_types.py index f346fa903..df04b9e80 100644 --- a/tests/fast/test_all_types.py +++ b/tests/fast/test_all_types.py @@ -6,7 +6,6 @@ from uuid import UUID import pytest -import pytz import duckdb import numpy as np @@ -133,9 +132,9 @@ def test_fetchall(self, cur_type): "time_tz": """CASE WHEN time_tz = '24:00:00-1559'::TIMETZ THEN '23:59:59.999999-1559'::TIMETZ ELSE time_tz END AS "time_tz" """, # noqa: E501 } min_datetime = datetime.datetime.min - min_datetime_with_utc = min_datetime.replace(tzinfo=pytz.UTC) + min_datetime_with_utc = min_datetime.replace(tzinfo=datetime.timezone.utc) max_datetime = datetime.datetime.max - max_datetime_with_utc = max_datetime.replace(tzinfo=pytz.UTC) + max_datetime_with_utc = max_datetime.replace(tzinfo=datetime.timezone.utc) correct_answer_map = { "bool": [(False,), (True,), (None,)], "tinyint": [(-128,), (127,), (None,)], @@ -196,7 +195,7 @@ def test_fetchall(self, cur_type): ( [], [ - datetime.datetime(1970, 1, 1, tzinfo=pytz.UTC), + datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc), None, min_datetime_with_utc, max_datetime_with_utc, @@ -237,7 +236,7 @@ def test_fetchall(self, cur_type): "timestamp_s": [(datetime.datetime(1990, 1, 1, 0, 0),)], "timestamp_ns": [(datetime.datetime(1990, 1, 1, 0, 0),)], "timestamp_ms": [(datetime.datetime(1990, 1, 1, 0, 0),)], - "timestamp_tz": [(datetime.datetime(1990, 1, 1, 0, 0, tzinfo=pytz.UTC),)], + "timestamp_tz": [(datetime.datetime(1990, 1, 1, 0, 0, tzinfo=datetime.timezone.utc),)], "union": [("Frank",), (5,), (None,)], "fixed_int_array": [((None, 2, 3),), ((4, 5, 6),), (None,)], "fixed_varchar_array": [(("a", None, "c"),), (("d", "e", "f"),), (None,)], diff --git a/tests/fast/test_optional_dependencies.py b/tests/fast/test_optional_dependencies.py index 2cacc71f3..706e68a6b 100644 --- a/tests/fast/test_optional_dependencies.py +++ b/tests/fast/test_optional_dependencies.py @@ -1,4 +1,5 @@ import importlib.util +import pathlib import subprocess import sys import textwrap @@ -11,7 +12,6 @@ "pyarrow", "polars", "fsspec", - "pytz", "typing_extensions", "pyspark", "IPython", @@ -41,8 +41,11 @@ def find_spec(self, fullname, path=None, target=None): """ -def run_blocked(cwd, script, blocked=OPTIONAL_PACKAGES): +def run_blocked(cwd, script, blocked=OPTIONAL_PACKAGES, pytz_tripwire=False): # An empty working directory keeps modules next to the caller's off the path that -c puts first. + if pytz_tripwire: + # pandas and pyarrow import pytz inside a try block, so the stand-in only serves scripts without them. + (pathlib.Path(cwd) / "pytz.py").write_text('raise AssertionError("pytz imported")\n') proc = subprocess.run( [sys.executable, "-S", "-c", BLOCK_IMPORTS + textwrap.dedent(script), ",".join(blocked)], capture_output=True, @@ -162,7 +165,6 @@ def test_spark_timestamps(tmp_path): def test_spark_timestamps_to_pandas(tmp_path): - # pandas before 3.0 cannot be imported without pytz. pytest.importorskip("pandas", minversion="3.0.0") run_blocked( tmp_path, @@ -201,14 +203,15 @@ def test_spark_udf_needs_numpy(tmp_path, blocked): ) -def test_spark_udf_with_a_timestamp_needs_pytz(tmp_path): - """The binding's Python UDF path needs pytz to pass a timestamp to a function.""" +def test_spark_udf_with_a_timestamp_needs_no_package(tmp_path): + """A timestamp reaches a Python UDF through the standard library's zoneinfo, so pytz is not needed.""" if importlib.util.find_spec("numpy") is None: pytest.skip("a UDF needs numpy") run_blocked( tmp_path, """ import datetime + import sys import duckdb from duckdb.experimental.spark.sql import SparkSession @@ -218,12 +221,30 @@ def test_spark_udf_with_a_timestamp_needs_pytz(tmp_path): assert spark.createDataFrame([(1,)], "n int").select(plus_one("n")).collect()[0][0] == 2 year = spark.udf.register("year_of", lambda t: t.year, "int") df = spark.createDataFrame([(datetime.datetime(2024, 1, 2),)], "t timestamp") - try: - df.select(year("t")).collect() - except duckdb.InvalidInputException as error: - assert "'pytz' failed to import" in str(error), repr(error) - else: - raise AssertionError("a UDF of a timestamp without pytz did not raise") + assert df.select(year("t")).collect()[0][0] == 2024 + assert "pytz" not in sys.modules + """, + blocked=tuple(package for package in OPTIONAL_PACKAGES if package != "numpy"), + pytz_tripwire=True, + ) + + +def test_timestamptz_fetch_needs_no_package(tmp_path): + run_blocked( + tmp_path, + """ + import datetime + import sys + import zoneinfo + + import duckdb + + con = duckdb.connect() + con.execute("SET TimeZone = 'Europe/Amsterdam'") + value = con.execute("SELECT TIMESTAMPTZ '2024-07-01 00:00:00+00'").fetchone()[0] + assert value == datetime.datetime(2024, 7, 1, 2, tzinfo=zoneinfo.ZoneInfo("Europe/Amsterdam")) + assert value.tzinfo is zoneinfo.ZoneInfo("Europe/Amsterdam") + assert "pytz" not in sys.modules """, - ("pytz",), + pytz_tripwire=True, ) diff --git a/tests/fast/types/test_timestamp_tz.py b/tests/fast/types/test_timestamp_tz.py new file mode 100644 index 000000000..87f580747 --- /dev/null +++ b/tests/fast/types/test_timestamp_tz.py @@ -0,0 +1,160 @@ +import datetime +import os +import subprocess +import sys +import textwrap +import zoneinfo + +import pytest + +import duckdb + +UTC = datetime.timezone.utc + + +def fetch(con, literal): + return con.execute(f"SELECT TIMESTAMPTZ '{literal}'").fetchone()[0] + + +@pytest.fixture +def con(): + with duckdb.connect() as con: + yield con + + +@pytest.mark.parametrize("zone", ["UTC", "Europe/Amsterdam", "America/Los_Angeles", "Asia/Kolkata"]) +def test_fetch_in_the_session_time_zone(con, zone): + con.execute(f"SET TimeZone = '{zone}'") + value = fetch(con, "2024-07-01 12:00:00+00") + expected = datetime.datetime(2024, 7, 1, 12, tzinfo=UTC).astimezone(zoneinfo.ZoneInfo(zone)) + assert value == expected + assert value.utcoffset() == expected.utcoffset() + assert value.tzinfo is zoneinfo.ZoneInfo(zone) + assert value.tzinfo.key == zone + + +@pytest.mark.parametrize("literal", ["2024-10-27 00:30:00+00", "2024-10-27 01:30:00+00"]) +def test_repeated_hour_keeps_the_instant(con, literal): + con.execute("SET TimeZone = 'Europe/Amsterdam'") + value = fetch(con, literal) + instant = datetime.datetime.fromisoformat(literal.replace(" ", "T") + ":00") + assert value.replace(tzinfo=None) == datetime.datetime(2024, 10, 27, 2, 30) + assert value.fold == (0 if literal.startswith("2024-10-27 00") else 1) + assert value.utcoffset() == datetime.timedelta(hours=2 if value.fold == 0 else 1) + assert value.timestamp() == instant.timestamp() + assert value.astimezone(UTC) == instant + + +def test_values_of_one_session_share_the_zone_and_compare_by_wall_clock(con): + con.execute("SET TimeZone = 'Europe/Amsterdam'") + first, second = con.execute( + "SELECT TIMESTAMPTZ '2024-10-27 00:30:00+00', TIMESTAMPTZ '2024-10-27 01:30:00+00'" + ).fetchone() + assert first.tzinfo is second.tzinfo + assert first == second + assert first.fold == 0 + assert second.fold == 1 + assert first.astimezone(UTC) != second.astimezone(UTC) + assert second.timestamp() - first.timestamp() == 3600 + keyed = con.execute( + "SELECT MAP {TIMESTAMPTZ '2024-10-27 00:30:00+00': 1, TIMESTAMPTZ '2024-10-27 01:30:00+00': 2}" + ).fetchone()[0] + assert keyed == {second: 2} + + +def test_timestamptz_ns_takes_the_same_path(con): + con.execute("SET TimeZone = 'Asia/Tokyo'") + value = con.execute("SELECT '2024-01-01 00:00:00.123456789+00'::TIMESTAMPTZ_NS").fetchone()[0] + assert value.tzinfo is zoneinfo.ZoneInfo("Asia/Tokyo") + assert value.replace(tzinfo=None) == datetime.datetime(2024, 1, 1, 9, 0, 0, 123456) + assert value == datetime.datetime(2024, 1, 1, 0, 0, 0, 123456, tzinfo=UTC) + + +def test_dst_offset_after_2037(con): + con.execute("SET TimeZone = 'America/Chicago'") + assert fetch(con, "2037-08-01 08:00:00+00").isoformat() == "2037-08-01T03:00:00-05:00" + assert fetch(con, "2038-08-01 08:00:00+00").isoformat() == "2038-08-01T03:00:00-05:00" + assert fetch(con, "2050-01-15 08:00:00+00").isoformat() == "2050-01-15T02:00:00-06:00" + + +def test_nested_values_carry_the_zone(con): + con.execute("SET TimeZone = 'Europe/Amsterdam'") + literal = "TIMESTAMPTZ '2024-07-01 00:00:00+00'" + row = con.execute(f"SELECT [{literal}], {{'t': {literal}}}, MAP {{'k': {literal}}}").fetchone() + expected = datetime.datetime(2024, 7, 1, 2, tzinfo=zoneinfo.ZoneInfo("Europe/Amsterdam")) + assert row == ([expected], {"t": expected}, {"k": expected}) + assert row[0][0].tzinfo is expected.tzinfo + + +def test_udf_argument_is_aware(con): + pytest.importorskip("numpy") + con.execute("SET TimeZone = 'Asia/Tokyo'") + con.create_function( + "describe_tz", lambda t: f"{t.isoformat()} {type(t.tzinfo).__name__} {t.tzinfo.key}", ["TIMESTAMPTZ"], "VARCHAR" + ) + assert con.execute("SELECT describe_tz(TIMESTAMPTZ '2024-01-01 00:00:00+00')").fetchone()[0] == ( + "2024-01-01T09:00:00+09:00 ZoneInfo Asia/Tokyo" + ) + + +def test_icu_only_zone_name_raises(con): + con.execute("SET TimeZone = 'ACT'") + with pytest.raises(duckdb.InvalidInputException, match=r"'ACT'.*tzdata"): + fetch(con, "2024-01-01 00:00:00+00") + + +def test_without_a_tz_database_raises(tmp_path): + script = textwrap.dedent( + """ + import sys + + sys.modules["tzdata"] = None + + import duckdb + con = duckdb.connect() + con.execute("SET TimeZone = 'Europe/Amsterdam'") + try: + con.execute("SELECT TIMESTAMPTZ '2024-01-01 00:00:00+00'").fetchone() + except duckdb.InvalidInputException as error: + assert "'Europe/Amsterdam'" in str(error) and "tzdata" in str(error), repr(error) + else: + raise AssertionError("a fetch without a tz database did not raise") + """ + ) + env = {**os.environ, "PYTHONTZPATH": ""} + proc = subprocess.run( + [sys.executable, "-c", script], env=env, cwd=tmp_path, capture_output=True, text=True, timeout=60 + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + +@pytest.mark.parametrize( + ("zone", "literal"), + [ + ("Asia/Tokyo", "9999-12-31 23:00:00+00"), + ("America/Los_Angeles", "0001-01-01 00:00:00+00"), + ], +) +def test_local_wall_time_outside_python_range_falls_back_to_str(con, zone, literal): + con.execute(f"SET TimeZone = '{zone}'") + assert fetch(con, literal) == literal + + +def test_utc_wall_time_outside_python_range_is_already_str(con): + con.execute("SET TimeZone = 'America/Los_Angeles'") + assert fetch(con, "9999-12-31 23:59:59.999999-08") == "10000-01-01 07:59:59.999999+00" + + +def test_local_mean_time_offset_keeps_seconds(con): + con.execute("SET TimeZone = 'Asia/Tokyo'") + value = fetch(con, "0001-01-01 00:00:00+00") + assert value == datetime.datetime(1, 1, 1, tzinfo=UTC) + assert value.utcoffset() == datetime.timedelta(hours=9, minutes=18, seconds=59) + + +def test_infinity_and_null_are_unchanged(con): + con.execute("SET TimeZone = 'Europe/Amsterdam'") + row = con.execute("SELECT TIMESTAMPTZ '-infinity', TIMESTAMPTZ 'infinity', NULL::TIMESTAMPTZ").fetchone() + assert row == (datetime.datetime.min, datetime.datetime.max, None) + assert row[0].tzinfo is None + assert row[1].tzinfo is None