pandas: FTBFS building against python 3.15

Maximiliano Curia <[email protected]>
Newsgroups gmane.linux.debian.devel.python
Message-ID <[email protected]>
Package: src:pandas
Version: 3.0.5+dfsg-1
User: [email protected]
Usertags: python3.15
Tags: patch

Hi!

While rebuilding the Python related packages against the Python 3.15rc1
version we found that pandas fails to build from source[1]. The upstream
code already has fixes for these issues and since there are many
packages that require pandas to build I already applied these fixes in
the packages that are in the rebuild sandbox[2].

Please consider applying this patches in your next upload, or consider
updating pandas when a new release including these patches is available.

Happy hacking,

[1]: https://debusine.debian.net/debian/r-python-python3.15/work-request/1012982/
[2]: https://debusine.debian.net/debian/r-python-python3.15/

-- 
"recursividad 95, 154, 156, 201, 224, 293"
-- El Lenguaje de Programacion C, pag. 293 (Kernighan & Ritchie)
Saludos /\/\ /\ >< `/
python3p15_compat.patch (text/x-diff, 17.5 KB)
Description: Python 3.15 compatibility fixes
 Backport of upstream commit 7bb284ac43 (PR #66473) for Python 3.15 support:
 - Support optional %z and %:z (colon_z) in strptime
 - Explicitly close XML iterparse parser to avoid unclosed iterator ResourceWarning
 - Accept Python 3.15 TypeError / ValueError error message changes in tests
 - Define and export PY315 in pandas.compat
Origin: upstream, https://github.com/pandas-dev/pandas/commit/7bb284ac438d2b981a7f42e3bdd0fe5ee26aada0
Bug: https://github.com/pandas-dev/pandas/issues/66473
Forwarded: not-needed

Index: pandas/pandas/_libs/tslibs/strptime.pyx
===================================================================
--- pandas.orig/pandas/_libs/tslibs/strptime.pyx
+++ pandas/pandas/_libs/tslibs/strptime.pyx
@@ -167,6 +167,7 @@ cdef dict _parse_code_table = {"y": 0,
                                "Z": 17,
                                "p": 18,  # an additional key, only with I
                                "z": 19,
+                               "colon_z": 24,
                                "G": 20,
                                "V": 21,
                                "u": 22}
@@ -178,7 +179,7 @@ cdef _validate_fmt(str fmt):
             raise ValueError("Cannot use '%W' or '%U' without day and year")
         if "%A" not in fmt and "%a" not in fmt and "%w" not in fmt:
             raise ValueError("Cannot use '%W' or '%U' without day and year")
-    elif "%Z" in fmt and "%z" in fmt:
+    elif "%Z" in fmt and ("%z" in fmt or "%:z" in fmt):
         raise ValueError("Cannot parse both %Z and %z")
     elif "%j" in fmt and "%G" in fmt:
         raise ValueError("Day of the year directive '%j' is not "
@@ -618,9 +619,19 @@ cdef tzinfo _parse_with_format(
                 f"time data \"{val}\" doesn't match format \"{fmt}\""
             )
         if len(val) != found.end():
+            rest = val[found.end():]
+            # Specific check for '%:z' directive
+            if (
+                "colon_z" in found.re.groupindex
+                and found.group("colon_z") is not None
+                and rest[0] != ":"
+            ):
+                raise ValueError(
+                    f"Missing colon in %:z before '{rest}', got '{val}'"
+                )
             raise ValueError(
                 "unconverted data remains when parsing with "
-                f"format \"{fmt}\": \"{val[found.end():]}\""
+                f"format \"{fmt}\": \"{rest}\""
             )
 
     else:
@@ -760,9 +771,15 @@ cdef tzinfo _parse_with_format(
         elif parse_code == 17:
             # e.g. val='2011-12-30T00:00:00.000000UTC'; fmt='%Y-%m-%dT%H:%M:%S.%f%Z'
             tz = zoneinfo.ZoneInfo(found_dict["Z"])
-        elif parse_code == 19:
+        # elif group_key in ('z', 'colon_z'):
+        elif parse_code == 19 or parse_code == 24:
             # e.g. val='March 1, 2018 12:00:00+0400'; fmt='%B %d, %Y %H:%M:%S%z'
-            tz = parse_timezone_directive(found_dict["z"])
+            if found_dict[group_key] is None:
+                raise ValueError(
+                    f"time data \"{val}\" doesn't match format \"{fmt}\""
+                )
+
+            tz = parse_timezone_directive(found_dict[group_key])
         elif parse_code == 20:
             # e.g. val='2015-1-7'; fmt='%G-%V-%u'
             iso_year = int(found_dict["G"])
Index: pandas/pandas/compat/__init__.py
===================================================================
--- pandas.orig/pandas/compat/__init__.py
+++ pandas/pandas/compat/__init__.py
@@ -21,6 +21,7 @@ from pandas.compat._constants import (
     ISMUSL,
     PY312,
     PY314,
+    PY315,
     PYPY,
     WASM,
 )
@@ -161,6 +162,7 @@ __all__ = [
     "ISMUSL",
     "PY312",
     "PY314",
+    "PY315",
     "PYARROW_INSTALLED",
     "PYARROW_MIN_VERSION",
     "PYPY",
Index: pandas/pandas/compat/_constants.py
===================================================================
--- pandas.orig/pandas/compat/_constants.py
+++ pandas/pandas/compat/_constants.py
@@ -15,6 +15,7 @@ IS64 = sys.maxsize > 2**32
 
 PY312 = sys.version_info >= (3, 12)
 PY314 = sys.version_info >= (3, 14)
+PY315 = sys.version_info >= (3, 15)
 PYPY = platform.python_implementation() == "PyPy"
 WASM = (sys.platform == "emscripten") or (platform.machine() in ["wasm32", "wasm64"])
 ISMUSL = "musl" in (sysconfig.get_config_var("HOST_GNU_TYPE") or "")
@@ -30,6 +31,7 @@ __all__ = [
     "ISMUSL",
     "PY312",
     "PY314",
+    "PY315",
     "PYPY",
     "WASM",
 ]
Index: pandas/pandas/io/xml.py
===================================================================
--- pandas.orig/pandas/io/xml.py
+++ pandas/pandas/io/xml.py
@@ -337,44 +337,53 @@ class _XMLFrameParser:
             set(self.iterparse[row_node])
         )
 
-        for event, elem in iterparse(self.path_or_buffer, events=("start", "end")):
-            curr_elem = elem.tag.split("}")[1] if "}" in elem.tag else elem.tag
-
-            if event == "start":
-                if curr_elem == row_node:
-                    row = {}
-
-            if row is not None:
-                if self.names and iterparse_repeats:
-                    for col, nm in zip(
-                        self.iterparse[row_node], self.names, strict=True
-                    ):
-                        if curr_elem == col:
-                            elem_val = elem.text if elem.text else None
-                            if elem_val not in row.values() and nm not in row:
-                                row[nm] = elem_val
-
-                        if col in elem.attrib:
-                            if elem.attrib[col] not in row.values() and nm not in row:
-                                row[nm] = elem.attrib[col]
-                else:
-                    for col in self.iterparse[row_node]:
-                        if curr_elem == col:
-                            row[col] = elem.text if elem.text else None
-                        if col in elem.attrib:
-                            row[col] = elem.attrib[col]
-
-            if event == "end":
-                if curr_elem == row_node and row is not None:
-                    dicts.append(row)
-                    row = None
-
-                elem.clear()
-                if hasattr(elem, "getprevious"):
-                    while (
-                        elem.getprevious() is not None and elem.getparent() is not None
-                    ):
-                        del elem.getparent()[0]
+        parser = iterparse(self.path_or_buffer, events=("start", "end"))
+        try:
+            for event, elem in parser:
+                curr_elem = elem.tag.split("}")[1] if "}" in elem.tag else elem.tag
+
+                if event == "start":
+                    if curr_elem == row_node:
+                        row = {}
+
+                if row is not None:
+                    if self.names and iterparse_repeats:
+                        for col, nm in zip(
+                            self.iterparse[row_node], self.names, strict=True
+                        ):
+                            if curr_elem == col:
+                                elem_val = elem.text if elem.text else None
+                                if elem_val not in row.values() and nm not in row:
+                                    row[nm] = elem_val
+
+                            if col in elem.attrib:
+                                if (
+                                    elem.attrib[col] not in row.values()
+                                    and nm not in row
+                                ):
+                                    row[nm] = elem.attrib[col]
+                    else:
+                        for col in self.iterparse[row_node]:
+                            if curr_elem == col:
+                                row[col] = elem.text if elem.text else None
+                            if col in elem.attrib:
+                                row[col] = elem.attrib[col]
+
+                if event == "end":
+                    if curr_elem == row_node and row is not None:
+                        dicts.append(row)
+                        row = None
+
+                    elem.clear()
+                    if hasattr(elem, "getprevious"):
+                        while (
+                            elem.getprevious() is not None
+                            and elem.getparent() is not None
+                        ):
+                            del elem.getparent()[0]
+        finally:
+            if hasattr(parser, "close"):
+                parser.close()
 
         if dicts == []:
             raise ParserError("No result from selected items in iterparse.")
Index: pandas/pandas/tests/extension/decimal/test_decimal.py
===================================================================
--- pandas.orig/pandas/tests/extension/decimal/test_decimal.py
+++ pandas/pandas/tests/extension/decimal/test_decimal.py
@@ -151,7 +151,12 @@ class TestDecimalArray(base.ExtensionTes
         # GH#57723
         # EAs that don't have special logic for None will raise, unlike pandas'
         # which interpret None as the NA value for the dtype.
-        msg = "conversion from NoneType to Decimal is not supported"
+        msg = "|".join(
+            [
+                "Cannot convert None to Decimal",  # PY315 - maybe linux specific
+                "conversion from NoneType to Decimal is not supported",
+            ]
+        )
         with pytest.raises(TypeError, match=msg):
             super().test_fillna_with_none(data_missing)
 
Index: pandas/pandas/tests/frame/indexing/test_indexing.py
===================================================================
--- pandas.orig/pandas/tests/frame/indexing/test_indexing.py
+++ pandas/pandas/tests/frame/indexing/test_indexing.py
@@ -12,6 +12,7 @@ import pytest
 from pandas._libs import iNaT
 from pandas.errors import InvalidIndexError
 
+from pandas.compat import PY315
 from pandas.core.dtypes.common import is_integer
 
 import pandas as pd
@@ -30,7 +31,10 @@ from pandas import (
 import pandas._testing as tm
 
 # We pass through a TypeError raised by numpy
-_slice_msg = "slice indices must be integers or None or have an __index__ method"
+if PY315:
+    _slice_msg = "slice indices must be integers or have an __index__ method"
+else:
+    _slice_msg = "slice indices must be integers or None or have an __index__ method"
 
 
 class TestDataFrameIndexing:
Index: pandas/pandas/tests/indexes/period/test_partial_slicing.py
===================================================================
--- pandas.orig/pandas/tests/indexes/period/test_partial_slicing.py
+++ pandas/pandas/tests/indexes/period/test_partial_slicing.py
@@ -1,6 +1,8 @@
 import numpy as np
 import pytest
 
+from pandas.compat import PY315
+
 from pandas import (
     DataFrame,
     PeriodIndex,
@@ -53,7 +55,10 @@ class TestPeriodIndex:
         # GH#6716
         idx = make_range(start="2013/01/01", freq="D", periods=400)
 
-        msg = "slice indices must be integers or None or have an __index__ method"
+        if PY315:
+            msg = "slice indices must be integers or have an __index__ method"
+        else:
+            msg = "slice indices must be integers or None or have an __index__ method"
         # slices against index should raise IndexError
         values = [
             "2014",
@@ -82,7 +87,10 @@ class TestPeriodIndex:
     def test_range_slice_seconds(self, make_range):
         # GH#6716
         idx = make_range(start="2013/01/01 09:00:00", freq="s", periods=4000)
-        msg = "slice indices must be integers or None or have an __index__ method"
+        if PY315:
+            msg = "slice indices must be integers or have an __index__ method"
+        else:
+            msg = "slice indices must be integers or None or have an __index__ method"
 
         # slices against index should raise IndexError
         values = [
Index: pandas/pandas/tests/indexes/test_old_base.py
===================================================================
--- pandas.orig/pandas/tests/indexes/test_old_base.py
+++ pandas/pandas/tests/indexes/test_old_base.py
@@ -7,6 +7,7 @@ import numpy as np
 import pytest
 
 from pandas._libs.tslibs import Timestamp
+from pandas.compat import PY315
 from pandas.errors import Pandas4Warning
 
 from pandas.core.dtypes.common import (
@@ -452,6 +453,8 @@ class TestBase:
         if len(index) == 0:
             # 0 vs 0.5 in error message varies with numpy version
             msg = "index (0|0.5) is out of bounds for axis 0 with size 0"
+        elif PY315:
+            msg = "slice indices must be integers or have an __index__ method"
         else:
             msg = "slice indices must be integers or None or have an __index__ method"
 
Index: pandas/pandas/tests/indexing/test_floats.py
===================================================================
--- pandas.orig/pandas/tests/indexing/test_floats.py
+++ pandas/pandas/tests/indexing/test_floats.py
@@ -1,6 +1,8 @@
 import numpy as np
 import pytest
 
+from pandas.compat import PY315
+
 from pandas import (
     DataFrame,
     Index,
@@ -251,7 +253,12 @@ class TestFloatIndexers:
         # setitem
         if indexer_sli is tm.iloc:
             # otherwise we keep the same message as above
-            msg = "slice indices must be integers or None or have an __index__ method"
+            if PY315:
+                msg = "slice indices must be integers or have an __index__ method"
+            else:
+                msg = (
+                    "slice indices must be integers or None or have an __index__ method"
+                )
         with pytest.raises(TypeError, match=msg):
             indexer_sli(s)[idx] = 0
 
Index: pandas/pandas/tests/scalar/timestamp/test_constructors.py
===================================================================
--- pandas.orig/pandas/tests/scalar/timestamp/test_constructors.py
+++ pandas/pandas/tests/scalar/timestamp/test_constructors.py
@@ -18,7 +18,10 @@ import pytest
 
 import pandas.util._test_decorators as td
 from pandas._libs.tslibs.dtypes import NpyDatetimeUnit
-from pandas.compat import PY314
+from pandas.compat import (
+    PY314,
+    PY315,
+)
 from pandas.errors import (
     OutOfBoundsDatetime,
     Pandas4Warning,
@@ -240,7 +243,13 @@ class TestTimestampConstructorPositional
 
     def test_constructor_keyword(self):
         # GH#10758
-        msg = "function missing required argument 'day'|Required argument 'day'"
+        msg = "|".join(
+            [
+                r"datetime\(\) missing required argument 'day'",  # PY315
+                "function missing required argument 'day'",
+                "Required argument 'day'",
+            ]
+        )
         with pytest.raises(TypeError, match=msg):
             Timestamp(year=2000, month=1)
 
@@ -299,7 +308,15 @@ class TestTimestampConstructorPositional
         # GH#31200
 
         # The exact error message of datetime() depends on its version
-        msg1 = r"function missing required argument '(year|month|day)' \(pos [123]\)"
+        if PY315:
+            msg1 = (
+                r"datetime\(\) missing required argument "
+                r"'(year|month|day)' \(pos [123]\)"
+            )
+        else:
+            msg1 = (
+                r"function missing required argument '(year|month|day)' \(pos [123]\)"
+            )
         msg2 = r"Required argument '(year|month|day)' \(pos [123]\) not found"
         msg = "|".join([msg1, msg2])
 
Index: pandas/pandas/tests/tools/test_to_datetime.py
===================================================================
--- pandas.orig/pandas/tests/tools/test_to_datetime.py
+++ pandas/pandas/tests/tools/test_to_datetime.py
@@ -23,6 +23,7 @@ from pandas._libs.tslibs import (
 )
 from pandas.compat import (
     PY314,
+    PY315,
     WASM,
 )
 from pandas.errors import (
@@ -517,6 +518,39 @@ class TestTimeConversionFormats:
         expected = DatetimeIndex(expected_dates)
         tm.assert_index_equal(result, expected)
 
+    @pytest.mark.xfail(
+        not PY315, reason="%:z directive not supported prior to 3.15", raises=ValueError
+    )
+    def test_to_datetime_colon_z_offset(self):
+        dates = [
+            "2010-01-01 12:00:00+04:00",
+            "2010-01-01 12:00:00+04:30",
+            "2010-01-01 12:00:00-05:00",
+        ]
+        expected_dates = [
+            "2010-01-01 08:00:00+00:00",
+            "2010-01-01 07:30:00+00:00",
+            "2010-01-01 17:00:00+00:00",
+        ]
+        fmt = "%Y-%m-%d %H:%M:%S%:z"
+
+        result = to_datetime(dates, format=fmt, utc=True)
+        expected = DatetimeIndex(expected_dates)
+        tm.assert_index_equal(result, expected)
+
+    def test_to_datetime_missing_colon_z_offset(self):
+        # test adapted from python/cpython#136961
+        dates = ["+04:0030"]
+        fmt = "%:z"
+
+        if PY315:
+            msg = r"Missing colon in %:z before '30', got '\+04:0030'"
+        else:
+            msg = "':' is a bad directive in format '%:z'"
+
+        with pytest.raises(ValueError, match=msg):
+            to_datetime(dates, format=fmt, utc=True)
+
     @pytest.mark.parametrize(
         "offset", ["+0", "-1foo", "UTCbar", ":10", "+01:000:01", ""]
     )
@@ -1390,9 +1424,12 @@ class TestToDatetime:
     @pytest.mark.parametrize("errors", ["coerce", "raise"])
     def test_invalid_format_raises(self, errors):
         # https://github.com/pandas-dev/pandas/issues/50255
-        with pytest.raises(
-            ValueError, match="':' is a bad directive in format 'H%:M%:S%"
-        ):
+        if PY315:
+            msg = r"':M' is a bad directive in format 'H%:M%:S%"
+        else:
+            msg = "':' is a bad directive in format 'H%:M%:S%"
+
+        with pytest.raises(ValueError, match=msg):
             to_datetime(["00:00:00"], format="H%:M%:S%", errors=errors)
 
     @pytest.mark.parametrize("value", ["a", "00:01:99"])
signature.asc (application/pgp-signature, 833 B)
-----BEGIN PGP SIGNATURE-----

iQIzBAABCgAdFiEE+JIdOnQEyG4RNSIVxxl2mbKbIyoFAmqFvW0ACgkQxxl2mbKb
IyrAgA//Ygzp+W5fEMheXVEQk+psGFvymtZJbrU+H9YAEqD+/6oSvu7MAvSR2ZWC
K8ROBmam3dlfKPO8sRuzuLWtQFY/+86p6GHBaD6XnJYnYEaCnH3nCkpXT4hVpJO1
qiuM2+bF80N5N2Rm0jclatj6/pQmGyYi1ubnS2nfGYNW2JhXptQh7XA4G1erOM0e
+6FDibT9aBWmv4Hd2rd7n9SGDdKxNj/WJ/xWq/Pn2vgHqs1TpP5+4N8qOipl2KeO
d/Q2Wahcz/KH+a/AQwdBZeif4+RoBE3zoX5MQ53kHXOOesz/0tzNSXLBZIFn7zkL
q1Obqu/XkgXX5atJTCY27pnnM9MaetVWZ3LIsEJnC5VwIiIP06h1oVhpNTSohmzU
9VCKVN4pfG94nloQKaRW3FS5TB+MVMdG1FoujAuLVsWUEkFs+Zi39nssEYxwu350
hUMHjPkl8/w8+U1CwfZH6UIdhS9OdER+dMXgXK/dKlvWQQAr5D732Hoja8CSELxt
ZX7creVpLcboUzTuIIOliCV6no44f75mzKEL7+By1rCUsN49RbwATDgmyslQGlU3
BtNo3TRvKxIAcMlhGRg6Q3bhtaGHxUBGty92dK3W5xfUsuRq5pVucyGvyXg0W355
vf1xBNXhViJxO3tR/g3noKf6+mu8H1F16UOGRPbV4+wiIJoKXls=
=lc6u
-----END PGP SIGNATURE-----
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.