Repository: trinodb/trino-python-client

Issue:
`NamedRowTuple` does not support pickling
### Expected behavior

The custom type `NamedRowTuple` does not implement the methods necessary for a successful `pickle` roundtrip. When unpickling (`pickle.load[s]`) it raises a `TypeError`.

It should work with `pickle`.

Some frameworks, for example https://github.com/streamlit/streamlit rely on pickling results/rows for caching which currently fails when using this library.



### Actual behavior

Exception when calling `pickle.load[s]`:

```
 TypeError: NamedRowTuple.__new__() missing 2 required positional arguments: 'names' and 'types'
```


### Steps To Reproduce

```python
import pickle
from trino import types

pickle.loads(pickle.dumps(types.NamedRowTuple(["Alice", 38], ["name", "age"], ["varchar", "integer"])))
```


### Log output

_No response_

### Operating System

Linux

### Trino Python client version

caff0e8c1b8c7d7c9e372b480b4efddf0418d350

### Trino Server version

461

### Python version

Python 3.12.5

### Are you willing to submit PR?

- [x] Yes I am willing to submit a PR!

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ecf2a7..c36c9a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,11 +41,11 @@ jobs:
       fail-fast: false
       matrix:
         python: [
-          "3.8",
           "3.9",
           "3.10",
           "3.11",
           "3.12",
+          "3.13",
           "pypy-3.9",
           "pypy-3.10",
         ]
@@ -57,11 +57,11 @@ jobs:
         ]
         include:
           # Test with older Trino versions for backward compatibility
-          - { python: "3.12", trino: "351", sqlalchemy: "~=1.4.0" }  # first Trino version
+          - { python: "3.13", trino: "351", sqlalchemy: "~=1.4.0" }  # first Trino version
           # Test with sqlalchemy 1.3
-          - { python: "3.12", trino: "latest", sqlalchemy: "~=1.3.0" }
+          - { python: "3.13", trino: "latest", sqlalchemy: "~=1.3.0" }
           # Test with sqlalchemy 2.0
-          - { python: "3.12", trino: "latest", sqlalchemy: "~=2.0.0" }
+          - { python: "3.13", trino: "latest", sqlalchemy: "~=2.0.0" }
     env:
       TRINO_VERSION: "${{ matrix.trino }}"
     steps:
diff --git a/README.md b/README.md
index 98cd44f..3c911e4 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 Client for [Trino](https://trino.io/), a distributed SQL engine for interactive and batch big data processing.
 Provides a low-level client and a DBAPI 2.0 implementation and a SQLAlchemy adapter.
-It supports Python>=3.8 and PyPy.
+It supports Python>=3.9 and PyPy.
 
 [![Build Status](https://github.com/trinodb/trino-python-client/workflows/ci/badge.svg)](https://github.com/trinodb/trino-python-client/actions?query=workflow%3Aci+event%3Apush+branch%3Amaster)
 [![Trino Slack](https://img.shields.io/static/v1?logo=slack&logoColor=959DA5&label=Slack&labelColor=333a41&message=join%20conversation&color=3AC358)](https://trino.io/slack.html)
diff --git a/setup.py b/setup.py
index fcd7cf5..65fa960 100755
--- a/setup.py
+++ b/setup.py
@@ -70,18 +70,17 @@ setup(
         "Operating System :: Microsoft :: Windows",
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
-        "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 :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Database :: Front-Ends",
     ],
-    python_requires=">=3.8",
+    python_requires=">=3.9",
     install_requires=[
-        "backports.zoneinfo;python_version<'3.9'",
         "python-dateutil",
         "pytz",
         # requests CVE https://github.com/advisories/GHSA-j8r2-6x86-q33q
diff --git a/tox.ini b/tox.ini
index 19f0536..0db8fbe 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py37,py38,py39,py310,py311
+envlist = py39,py310,py311,py312,py313
 
 [testenv]
 extras = tests
diff --git a/trino/auth.py b/trino/auth.py
index 8a24ecd..3cad6c5 100644
--- a/trino/auth.py
+++ b/trino/auth.py
@@ -546,17 +546,17 @@ class _OAuth2TokenBearer(AuthBase):
 
     @staticmethod
     def _parse_authenticate_header(header: str) -> Dict[str, str]:
-        split_challenge = header.split(" ", 1)
-        trimmed_challenge = split_challenge[1] if len(split_challenge) > 1 else ""
+        logger.debug(f"Authentication header: {header}")
+        components = header.split(",")
         auth_info_headers = {}
 
-        for item in trimmed_challenge.split(","):
-            comps = item.split("=")
-            if len(comps) == 2:
-                key = comps[0].strip(' "')
-                value = comps[1].strip(' "')
-                if key:
-                    auth_info_headers[key.lower()] = value
+        for component in components:
+            component = component.strip()
+            if "=" in component:
+                key, value = component.split("=", 1)
+                if value[0] == '"' and value[-1] == '"':
+                    value = value[1:-1]
+                auth_info_headers[key.lower()] = value
         return auth_info_headers
 
 
diff --git a/trino/client.py b/trino/client.py
index 4fea832..61db944 100644
--- a/trino/client.py
+++ b/trino/client.py
@@ -47,13 +47,7 @@ from datetime import datetime
 from email.utils import parsedate_to_datetime
 from time import sleep
 from typing import Any, Dict, List, Optional, Tuple, Union
-
-from trino.mapper import RowMapper, RowMapperFactory
-
-try:
-    from zoneinfo import ZoneInfo
-except ModuleNotFoundError:
-    from backports.zoneinfo import ZoneInfo
+from zoneinfo import ZoneInfo
 
 import requests
 from tzlocal import get_localzone_name  # type: ignore
@@ -61,6 +55,7 @@ from tzlocal import get_localzone_name  # type: ignore
 import trino.logging
 from trino import constants, exceptions
 from trino._version import __version__
+from trino.mapper import RowMapper, RowMapperFactory
 
 __all__ = ["ClientSession", "TrinoQuery", "TrinoRequest", "PROXIES"]
 
diff --git a/trino/dbapi.py b/trino/dbapi.py
index 9125a48..4f3dfdc 100644
--- a/trino/dbapi.py
+++ b/trino/dbapi.py
@@ -27,11 +27,7 @@ from threading import Lock
 from time import time
 from typing import Any, Dict, List, NamedTuple, Optional  # NOQA for mypy types
 from urllib.parse import urlparse
-
-try:
-    from zoneinfo import ZoneInfo
-except ModuleNotFoundError:
-    from backports.zoneinfo import ZoneInfo
+from zoneinfo import ZoneInfo
 
 import trino.client
 import trino.exceptions
diff --git a/trino/mapper.py b/trino/mapper.py
index 48a905a..53ed619 100644
--- a/trino/mapper.py
+++ b/trino/mapper.py
@@ -2,16 +2,13 @@ from __future__ import annotations
 
 import abc
 import base64
-import sys
 import uuid
 from datetime import date, datetime, time, timedelta, timezone, tzinfo
 from decimal import Decimal
 from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
+from zoneinfo import ZoneInfo
 
-if sys.version_info >= (3, 9):
-    from zoneinfo import ZoneInfo
-else:
-    from backports.zoneinfo import ZoneInfo
+from dateutil.relativedelta import relativedelta
 
 import trino.exceptions
 from trino.types import (
@@ -172,6 +169,40 @@ def _fraction_to_decimal(fractional_str: str) -> Decimal:
     return Decimal(fractional_str or 0) / POWERS_OF_TEN[len(fractional_str)]
 
 
+class IntervalYearToMonthMapper(ValueMapper[relativedelta]):
+    def map(self, value: Any) -> Optional[relativedelta]:
+        if value is None:
+            return None
+        is_negative = value[0] == "-"
+        years, months = (value[1:] if is_negative else value).split('-')
+        years, months = int(years), int(months)
+        if is_negative:
+            years, months = -years, -months
+        return relativedelta(years=years, months=months)
+
+
+class IntervalDayToSecondMapper(ValueMapper[timedelta]):
+    def map(self, value: Any) -> Optional[timedelta]:
+        if value is None:
+            return None
+        is_negative = value[0] == "-"
+        days, time = (value[1:] if is_negative else value).split(' ')
+        hours, minutes, seconds_milliseconds = time.split(':')
+        seconds, milliseconds = seconds_milliseconds.split('.')
+        days, hours, minutes, seconds, milliseconds = (int(days), int(hours), int(minutes), int(seconds),
+                                                       int(milliseconds))
+        if is_negative:
+            days, hours, minutes, seconds, milliseconds = -days, -hours, -minutes, -seconds, -milliseconds
+        try:
+            return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds)
+        except OverflowError as e:
+            error_str = (
+                f"Could not convert '{value}' into the associated python type, as the value "
+                "exceeds the maximum or minimum limit."
+            )
+            raise trino.exceptions.TrinoDataError(error_str) from e
+
+
 class ArrayValueMapper(ValueMapper[List[Optional[Any]]]):
     def __init__(self, mapper: ValueMapper[Any]):
         self.mapper = mapper
@@ -276,6 +307,10 @@ class RowMapperFactory:
             return TimestampValueMapper(self._get_precision(column))
         if col_type == 'timestamp with time zone':
             return TimestampWithTimeZoneValueMapper(self._get_precision(column))
+        if col_type == 'interval year to month':
+            return IntervalYearToMonthMapper()
+        if col_type == 'interval day to second':
+            return IntervalDayToSecondMapper()
 
         # structural types
         if col_type == 'array':
diff --git a/trino/types.py b/trino/types.py
index 0807c2f..91bcb2e 100644
--- a/trino/types.py
+++ b/trino/types.py
@@ -111,5 +111,14 @@ class NamedRowTuple(Tuple[Any, ...]):
         if self._names.count(name):
             raise ValueError("Ambiguous row field reference: " + name)
 
+    def __getnewargs__(self) -> Any:
+        return (tuple(self), (), ())
+
+    def __getstate__(self) -> Any:
+        return vars(self)
+
+    def __setstate__(self, state: Any) -> None:
+        vars(self).update(state)
+
     def __repr__(self) -> str:
         return self._repr


Test patch:
diff --git a/tests/integration/test_dbapi_integration.py b/tests/integration/test_dbapi_integration.py
index 7347844..5cd77d1 100644
--- a/tests/integration/test_dbapi_integration.py
+++ b/tests/integration/test_dbapi_integration.py
@@ -15,11 +15,7 @@ import uuid
 from datetime import date, datetime, time, timedelta, timezone
 from decimal import Decimal
 from typing import Tuple
-
-try:
-    from zoneinfo import ZoneInfo
-except ModuleNotFoundError:
-    from backports.zoneinfo import ZoneInfo
+from zoneinfo import ZoneInfo
 
 import pytest
 import requests
diff --git a/tests/integration/test_types_integration.py b/tests/integration/test_types_integration.py
index 09611d3..73be522 100644
--- a/tests/integration/test_types_integration.py
+++ b/tests/integration/test_types_integration.py
@@ -3,13 +3,10 @@ import re
 import uuid
 from datetime import date, datetime, time, timedelta, timezone, tzinfo
 from decimal import Decimal
-
-try:
-    from zoneinfo import ZoneInfo
-except ModuleNotFoundError:
-    from backports.zoneinfo import ZoneInfo
+from zoneinfo import ZoneInfo
 
 import pytest
+from dateutil.relativedelta import relativedelta
 
 import trino
 from tests.integration.conftest import trino_version
@@ -737,14 +734,80 @@ def create_timezone(timezone_str: str) -> tzinfo:
         return ZoneInfo(timezone_str)
 
 
-def test_interval(trino_connection):
-    SqlTest(trino_connection) \
-        .add_field(sql="CAST(null AS INTERVAL YEAR TO MONTH)", python=None) \
-        .add_field(sql="CAST(null AS INTERVAL DAY TO SECOND)", python=None) \
-        .add_field(sql="INTERVAL '3' MONTH", python='0-3') \
-        .add_field(sql="INTERVAL '2' DAY", python='2 00:00:00.000') \
-        .add_field(sql="INTERVAL '-2' DAY", python='-2 00:00:00.000') \
-        .execute()
+def test_interval_year_to_month(trino_connection):
+    (
+        SqlTest(trino_connection)
+        .add_field(
+            sql="CAST(null AS INTERVAL YEAR TO MONTH)",
+            python=None)
+        .add_field(
+            sql="INTERVAL '10' YEAR",
+            python=relativedelta(years=10))
+        .add_field(
+            sql="INTERVAL '-5' YEAR",
+            python=relativedelta(years=-5))
+        .add_field(
+            sql="INTERVAL '3' MONTH",
+            python=relativedelta(months=3))
+        .add_field(
+            sql="INTERVAL '-18' MONTH",
+            python=relativedelta(years=-1, months=-6))
+        .add_field(
+            sql="INTERVAL '30' MONTH",
+            python=relativedelta(years=2, months=6))
+        # max supported INTERVAL in Trino
+        .add_field(
+            sql="INTERVAL '178956970-7' YEAR TO MONTH",
+            python=relativedelta(years=178956970, months=7))
+        # min supported INTERVAL in Trino
+        .add_field(
+            sql="INTERVAL '-178956970-8' YEAR TO MONTH",
+            python=relativedelta(years=-178956970, months=-8))
+    ).execute()
+
+
+def test_interval_day_to_second(trino_connection):
+    (
+        SqlTest(trino_connection)
+        .add_field(
+            sql="CAST(null AS INTERVAL DAY TO SECOND)",
+            python=None)
+        .add_field(
+            sql="INTERVAL '2' DAY",
+            python=timedelta(days=2))
+        .add_field(
+            sql="INTERVAL '-2' DAY",
+            python=timedelta(days=-2))
+        .add_field(
+            sql="INTERVAL '-2' SECOND",
+            python=timedelta(seconds=-2))
+        .add_field(
+            sql="INTERVAL '1 11:11:11.116555' DAY TO SECOND",
+            python=timedelta(days=1, seconds=40271, microseconds=116000))
+        .add_field(
+            sql="INTERVAL '-5 23:59:57.000' DAY TO SECOND",
+            python=timedelta(days=-6, seconds=3))
+        .add_field(
+            sql="INTERVAL '12 10:45' DAY TO MINUTE",
+            python=timedelta(days=12, seconds=38700))
+        .add_field(
+            sql="INTERVAL '45:32.123' MINUTE TO SECOND",
+            python=timedelta(seconds=2732, microseconds=123000))
+        .add_field(
+            sql="INTERVAL '32.123' SECOND",
+            python=timedelta(seconds=32, microseconds=123000))
+        # max supported timedelta in Python
+        .add_field(
+            sql="INTERVAL '999999999 23:59:59.999' DAY TO SECOND",
+            python=timedelta(days=999999999, hours=23, minutes=59, seconds=59, milliseconds=999))
+        # min supported timedelta in Python
+        .add_field(
+            sql="INTERVAL '-999999999' DAY",
+            python=timedelta(days=-999999999))
+    ).execute()
+
+    SqlExpectFailureTest(trino_connection).execute("INTERVAL '1000000000' DAY")
+    SqlExpectFailureTest(trino_connection).execute("INTERVAL '-999999999 00:00:00.001' DAY TO SECOND")
 
 
 def test_array(trino_connection):
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index b2ac31b..a522cde 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -18,6 +18,7 @@ from contextlib import nullcontext as does_not_raise
 from typing import Any, Dict, Optional
 from unittest import TestCase, mock
 from urllib.parse import urlparse
+from zoneinfo import ZoneInfoNotFoundError
 
 import gssapi
 import httpretty
@@ -59,11 +60,6 @@ from trino.client import (
     _RetryWithExponentialBackoff,
 )
 
-try:
-    from zoneinfo import ZoneInfoNotFoundError  # type: ignore
-except ModuleNotFoundError:
-    from backports.zoneinfo._common import ZoneInfoNotFoundError  # type: ignore
-
 
 @mock.patch("trino.client.TrinoRequest.http")
 def test_trino_initial_request(mock_requests, sample_post_response_data):
@@ -547,13 +543,14 @@ def test_oauth2_authentication_missing_headers(header, error):
     'Bearer x_redirect_server="{redirect_server}", x_token_server="{token_server}", Basic realm="Trino"',
     'Basic realm="Trino", Bearer realm="Trino", token_type="JWT", Bearer x_redirect_server="{redirect_server}", '
     'x_token_server="{token_server}"'
+    'Bearer x_redirect_server="{redirect_server}",x_token_server="{token_server}",additional_challenge',
 ])
 @httprettified
 def test_oauth2_header_parsing(header, sample_post_response_data):
     token = str(uuid.uuid4())
     challenge_id = str(uuid.uuid4())
 
-    redirect_server = f"{REDIRECT_RESOURCE}/{challenge_id}"
+    redirect_server = f"{REDIRECT_RESOURCE}/{challenge_id}?role=test"
     token_server = f"{TOKEN_RESOURCE}/{challenge_id}"
 
     # noinspection PyUnusedLocal
diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py
new file mode 100644
index 0000000..e51da48
--- /dev/null
+++ b/tests/unit/test_types.py
@@ -0,0 +1,39 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pickle
+from datetime import datetime, time
+from decimal import Decimal
+
+import pytest
+
+from trino import types
+
+
+def identity(x):
+    return x
+
+
+type_instances = [
+    (types.Time(time(11, 47, 23), Decimal(0.314)), lambda v: v.to_python_type()),
+    (types.TimeWithTimeZone(time(11, 47, 23), Decimal(0.314)), lambda v: v.to_python_type()),
+    (types.Timestamp(datetime(2024, 10, 15, 11, 47, 23), Decimal(0.314)), lambda v: v.to_python_type()),
+    (types.TimestampWithTimeZone(datetime(2024, 10, 15, 11, 47, 23), Decimal(0.314)), lambda v: v.to_python_type()),
+    (types.NamedRowTuple(["Alice", 38], ["name", "age"], ["varchar", "integer"]), identity),
+]
+
+
+@pytest.mark.parametrize("value,fn", type_instances)
+def test_pickle_roundtripping(value, fn):
+    bytes = pickle.dumps(value)
+    unpickled_value = pickle.loads(bytes)
+    assert fn(value) == fn(unpickled_value)