From: =?utf-8?q?Lum=C3=ADr_=27Frenzy=27_Balhar?= <lbalhar@redhat.com>
Date: Fri, 11 Oct 2024 17:47:37 +0200
Subject: Accommodate class state restoration for Python 3.13 (#534)

Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Pierre Glaser <pierreglaser@msn.com>

Origin: backport, https://github.com/cloudpipe/cloudpickle/commit/b3bac2c3aaecf682a6a0099b26370b2ff12c530b
Last-Update: 2025-01-25
---
 srsly/cloudpickle/cloudpickle.py            | 11 +++++++++++
 srsly/tests/cloudpickle/cloudpickle_test.py | 25 ++++++++++++++++++++++++-
 2 files changed, 35 insertions(+), 1 deletion(-)

diff --git a/srsly/cloudpickle/cloudpickle.py b/srsly/cloudpickle/cloudpickle.py
index 317be69..2697090 100644
--- a/srsly/cloudpickle/cloudpickle.py
+++ b/srsly/cloudpickle/cloudpickle.py
@@ -842,6 +842,17 @@ def _rehydrate_skeleton_class(skeleton_class, class_dict):
             registry = attr
         else:
             setattr(skeleton_class, attrname, attr)
+
+    if sys.version_info >= (3, 13) and "__firstlineno__" in class_dict:
+        # Set the Python 3.13+ only __firstlineno__  attribute one more time, as it
+        # will be automatically deleted by the `setattr(obj, attrname, attr)` call
+        # above when `attrname` is "__firstlineno__". We assume that preserving this
+        # information might be important for some users and that it not stale in the
+        # context of cloudpickle usage, hence legitimate to propagate. Furthermore it
+        # is necessary to do so to keep deterministic chained pickling as tested in
+        # test_deterministic_str_interning_for_chained_dynamic_class_pickling.
+        skeleton_class.__firstlineno__ = class_dict["__firstlineno__"]
+
     if registry is not None:
         for subclass in registry:
             skeleton_class.register(subclass)
diff --git a/srsly/tests/cloudpickle/cloudpickle_test.py b/srsly/tests/cloudpickle/cloudpickle_test.py
index e4dba00..3aead77 100644
--- a/srsly/tests/cloudpickle/cloudpickle_test.py
+++ b/srsly/tests/cloudpickle/cloudpickle_test.py
@@ -112,7 +112,12 @@ def test_extract_class_dict():
             return "c"
 
     clsdict = _extract_class_dict(C)
-    assert sorted(clsdict.keys()) == ["C_CONSTANT", "__doc__", "method_c"]
+    expected_keys = ["C_CONSTANT", "__doc__", "method_c"]
+    # New attribute in Python 3.13 beta 1
+    # https://github.com/python/cpython/pull/118475
+    if sys.version_info >= (3, 13):
+        expected_keys.insert(2, "__firstlineno__")
+    assert sorted(clsdict.keys()) == expected_keys
     assert clsdict["C_CONSTANT"] == 43
     assert clsdict["__doc__"] is None
     assert clsdict["method_c"](C()) == C().method_c()
@@ -341,6 +346,24 @@ class CloudPickleTest(unittest.TestCase):
         g = pickle_depickle(f(), protocol=self.protocol)
         self.assertEqual(g(), 2)
 
+    def test_class_no_firstlineno_deletion_(self):
+        # `__firstlineno__` is a new attribute of classes introduced in Python 3.13.
+        # This attribute used to be automatically deleted when unpickling a class as a
+        # consequence of cloudpickle setting a class's `__module__` attribute at
+        # unpickling time (see https://github.com/python/cpython/blob/73c152b346a18ed8308e469bdd232698e6cd3a63/Objects/typeobject.c#L1353-L1356).
+        # This deletion would cause tests like
+        # `test_deterministic_dynamic_class_attr_ordering_for_chained_pickling` to fail.
+        # This test makes sure that the attribute `__firstlineno__` is preserved
+        # across a cloudpickle roundtrip.
+
+        class A:
+            pass
+
+        if hasattr(A, "__firstlineno__"):
+            A_roundtrip = pickle_depickle(A, protocol=self.protocol)
+            assert hasattr(A_roundtrip, "__firstlineno__")
+            assert A_roundtrip.__firstlineno__ == A.__firstlineno__
+
     def test_dynamically_generated_class_that_uses_super(self):
 
         class Base:
