diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index ffd4e8764a..3a5057595e 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -70,23 +70,22 @@ UNPICKLABLE_KEYS = ( ) -def _reconstruct_extended_instance(doctype, state): - """Helper function to reconstruct an extended class instance during unpickling. +def _reduce_extended_instance(doc): + """Make extended class instances pickle-able. - This function is called during unpickling to recreate the extended class - based on current hooks and restore the instance state. + When unpickling, this will use get_controller() to recreate the extended class. + Respects the __getstate__ method for proper state handling. + """ + return (_reconstruct_extended_instance, (doc.doctype,), doc.__getstate__()) + + +def _reconstruct_extended_instance(doctype): + """ + Helper function to reconstruct an extended class instance during unpickling. """ # Get the current extended class (uses caching from get_controller) extended_class = get_controller(doctype) - instance = extended_class.__new__(extended_class) - - # Use __setstate__ if available, otherwise directly update __dict__ - if hasattr(instance, "__setstate__"): - instance.__setstate__(state) - else: - instance.__dict__.update(state) - - return instance + return extended_class.__new__(extended_class) def get_controller(doctype): @@ -146,10 +145,10 @@ def import_controller(doctype): if not issubclass(class_, BaseDocument): raise ImportError(f"{doctype}: {classname} is not a subclass of BaseDocument") - return get_extended_class(class_, doctype) + return _get_extended_class(class_, doctype) -def get_extended_class(base_class, doctype): +def _get_extended_class(base_class, doctype): """Create an extended class by mixing extension classes with the base class. Args: @@ -169,38 +168,24 @@ def get_extended_class(base_class, doctype): for extension_path in reversed(extensions): try: extension_class = frappe.get_attr(extension_path) - except Exception: - frappe.throw( - _("Error retrieving extension class from path:
{0}").format(extension_path) - ) + except Exception as e: + raise ImportError( + "Error retrieving extension class from path:\n{0}".format(extension_path) + ) from e extension_classes.append(extension_class) # Create the extended class by combining extension classes with base class # Extension classes come first in MRO, then base class - class_name = f"Extended{base_class.__name__}" - - def __reduce__(self): - """Make extended class instances pickle-able. - - When unpickling, this will use get_controller() to recreate the extended class - based on current hooks, ensuring the instance respects the current environment. - Respects the BaseDocument's __getstate__ method for proper state handling. - """ - - return (_reconstruct_extended_instance, (self.doctype, self.__getstate__())) - - extended_class = type( - class_name, + return type( + f"Extended{base_class.__name__}", (*extension_classes, base_class), { - "__reduce__": __reduce__, + "__reduce__": _reduce_extended_instance, "__module__": base_class.__module__, }, ) - return extended_class - RESERVED_KEYWORDS = frozenset( ( diff --git a/frappe/tests/test_base_document.py b/frappe/tests/test_base_document.py index 7f22be0be6..803a2ea4e1 100644 --- a/frappe/tests/test_base_document.py +++ b/frappe/tests/test_base_document.py @@ -2,7 +2,7 @@ import pickle import frappe from frappe.desk.doctype.todo.todo import ToDo -from frappe.model.base_document import BaseDocument, get_extended_class +from frappe.model.base_document import BaseDocument, _get_extended_class from frappe.tests import IntegrationTestCase @@ -42,18 +42,18 @@ class TestBaseDocument(IntegrationTestCase): self.assertEqual(doc.docstatus, 2) def test_get_extended_class_with_no_extensions(self): - """Test that get_extended_class returns the base class when no extensions are provided.""" + """Test that _get_extended_class returns the base class when no extensions are provided.""" with self.patch_hooks({"extend_doctype_class": {}}): - result = get_extended_class(ToDo, "ToDo") + result = _get_extended_class(ToDo, "ToDo") self.assertEqual(result, ToDo) with self.patch_hooks({"extend_doctype_class": {"ToDo": []}}): - result = get_extended_class(ToDo, "ToDo") + result = _get_extended_class(ToDo, "ToDo") self.assertEqual(result, ToDo) def test_get_extended_class_with_extensions(self): - """Test that get_extended_class properly combines extension classes with base class.""" + """Test that _get_extended_class properly combines extension classes with base class.""" # Mock frappe.get_hooks to return extension paths extensions = [ "frappe.tests.test_base_document.TestExtensionA", @@ -61,7 +61,7 @@ class TestBaseDocument(IntegrationTestCase): ] with self.patch_hooks({"extend_doctype_class": {"ToDo": extensions}}): - extended_class = get_extended_class(ToDo, "ToDo") + extended_class = _get_extended_class(ToDo, "ToDo") # Test that the extended class is different from base class self.assertNotEqual(extended_class, ToDo) @@ -96,7 +96,7 @@ class TestBaseDocument(IntegrationTestCase): extensions = ["frappe.tests.test_base_document.TestToDoExtension"] with self.patch_hooks({"extend_doctype_class": {"ToDo": extensions}}): - extended_class = get_extended_class(ToDo, "ToDo") + extended_class = _get_extended_class(ToDo, "ToDo") # Test that the extended class is different from base ToDo self.assertNotEqual(extended_class, ToDo) @@ -136,9 +136,9 @@ class TestBaseDocument(IntegrationTestCase): ] with self.patch_hooks({"extend_doctype_class": {"ToDo": extensions}}): - # Test that frappe.ValidationError is raised for invalid extension path - with self.assertRaises(frappe.ValidationError) as context: - get_extended_class(ToDo, "ToDo") + # Test that ImportError is raised for invalid extension path + with self.assertRaises(ImportError) as context: + _get_extended_class(ToDo, "ToDo") # Check that the error message mentions the invalid path error_message = str(context.exception) @@ -152,7 +152,7 @@ class TestBaseDocument(IntegrationTestCase): extensions = ["frappe.tests.test_base_document.TestToDoExtension"] with self.patch_hooks({"extend_doctype_class": {"ToDo": extensions}}): - extended_class = get_extended_class(ToDo, "ToDo") + extended_class = _get_extended_class(ToDo, "ToDo") # Create an instance with some data original_instance = extended_class(