From dffd96e7545348d6d1830cdfc4fcf231237010d2 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Mon, 24 Jun 2024 15:07:41 -0400 Subject: [PATCH] create JoinedDispatcher subclasses up front Fixed additional issues in the event system triggered by unpickling of a :class:`.Enum` datatype, continuing from :ticket:`11365` and :ticket:`11360`, where dynamically generated elements of the event structure would not be present when unpickling in a new process. Fixes: #11530 Change-Id: Ie1f2b3453d4891051f8719f6d3f6703302d5a86e --- doc/build/changelog/unreleased_20/11530.rst | 8 ++ lib/sqlalchemy/event/base.py | 91 +++++++++++---------- test/sql/test_types.py | 57 +++++++++++++ 3 files changed, 111 insertions(+), 45 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/11530.rst diff --git a/doc/build/changelog/unreleased_20/11530.rst b/doc/build/changelog/unreleased_20/11530.rst new file mode 100644 index 0000000000..30c60cd152 --- /dev/null +++ b/doc/build/changelog/unreleased_20/11530.rst @@ -0,0 +1,8 @@ +.. change:: + :tags: bug, events + :tickets: 11530 + + Fixed additional issues in the event system triggered by unpickling of a + :class:`.Enum` datatype, continuing from :ticket:`11365` and + :ticket:`11360`, where dynamically generated elements of the event + structure would not be present when unpickling in a new process. diff --git a/lib/sqlalchemy/event/base.py b/lib/sqlalchemy/event/base.py index 434886316f..cddfc982a6 100644 --- a/lib/sqlalchemy/event/base.py +++ b/lib/sqlalchemy/event/base.py @@ -191,16 +191,7 @@ class _Dispatch(_DispatchCommon[_ET]): :class:`._Dispatch` objects. """ - if "_joined_dispatch_cls" not in self.__class__.__dict__: - cls = type( - "Joined%s" % self.__class__.__name__, - (_JoinedDispatcher,), - {"__slots__": self._event_names}, - ) - self.__class__._joined_dispatch_cls = cls - - # establish pickle capability by adding it to this module - globals()[cls.__name__] = cls + assert "_joined_dispatch_cls" in self.__class__.__dict__ return self._joined_dispatch_cls(self, other) @@ -332,6 +323,51 @@ class _HasEventsDispatch(Generic[_ET]): else: dispatch_target_cls.dispatch = dispatcher(cls) + klass = type( + "Joined%s" % dispatch_cls.__name__, + (_JoinedDispatcher,), + {"__slots__": event_names}, + ) + dispatch_cls._joined_dispatch_cls = klass + + # establish pickle capability by adding it to this module + globals()[klass.__name__] = klass + + +class _JoinedDispatcher(_DispatchCommon[_ET]): + """Represent a connection between two _Dispatch objects.""" + + __slots__ = "local", "parent", "_instance_cls" + + local: _DispatchCommon[_ET] + parent: _DispatchCommon[_ET] + _instance_cls: Optional[Type[_ET]] + + def __init__( + self, local: _DispatchCommon[_ET], parent: _DispatchCommon[_ET] + ): + self.local = local + self.parent = parent + self._instance_cls = self.local._instance_cls + + def __reduce__(self) -> Any: + return (self.__class__, (self.local, self.parent)) + + def __getattr__(self, name: str) -> _JoinedListener[_ET]: + # Assign _JoinedListeners as attributes on demand + # to reduce startup time for new dispatch objects. + ls = getattr(self.local, name) + jl = _JoinedListener(self.parent, ls.name, ls) + setattr(self, ls.name, jl) + return jl + + def _listen(self, event_key: _EventKey[_ET], **kw: Any) -> None: + return self.parent._listen(event_key, **kw) + + @property + def _events(self) -> Type[_HasEventsDispatch[_ET]]: + return self.parent._events + class Events(_HasEventsDispatch[_ET]): """Define event listening functions for a particular target type.""" @@ -386,41 +422,6 @@ class Events(_HasEventsDispatch[_ET]): cls.dispatch._clear() -class _JoinedDispatcher(_DispatchCommon[_ET]): - """Represent a connection between two _Dispatch objects.""" - - __slots__ = "local", "parent", "_instance_cls" - - local: _DispatchCommon[_ET] - parent: _DispatchCommon[_ET] - _instance_cls: Optional[Type[_ET]] - - def __init__( - self, local: _DispatchCommon[_ET], parent: _DispatchCommon[_ET] - ): - self.local = local - self.parent = parent - self._instance_cls = self.local._instance_cls - - def __reduce__(self) -> Any: - return (self.__class__, (self.local, self.parent)) - - def __getattr__(self, name: str) -> _JoinedListener[_ET]: - # Assign _JoinedListeners as attributes on demand - # to reduce startup time for new dispatch objects. - ls = getattr(self.local, name) - jl = _JoinedListener(self.parent, ls.name, ls) - setattr(self, ls.name, jl) - return jl - - def _listen(self, event_key: _EventKey[_ET], **kw: Any) -> None: - return self.parent._listen(event_key, **kw) - - @property - def _events(self) -> Type[_HasEventsDispatch[_ET]]: - return self.parent._events - - class dispatcher(Generic[_ET]): """Descriptor used by target classes to deliver the _Dispatch class at the class level diff --git a/test/sql/test_types.py b/test/sql/test_types.py index 5214ebac53..36c6a74c27 100644 --- a/test/sql/test_types.py +++ b/test/sql/test_types.py @@ -3,6 +3,10 @@ import decimal import importlib import operator import os +import pickle +import subprocess +import sys +from tempfile import mkstemp import sqlalchemy as sa from sqlalchemy import and_ @@ -531,6 +535,59 @@ class PickleTypesTest(fixtures.TestBase): loads(dumps(column_type)) loads(dumps(meta)) + @testing.combinations( + ("Str", String()), + ("Tex", Text()), + ("Uni", Unicode()), + ("Boo", Boolean()), + ("Dat", DateTime()), + ("Dat", Date()), + ("Tim", Time()), + ("Lar", LargeBinary()), + ("Pic", PickleType()), + ("Int", Interval()), + ("Enu", Enum("one", "two", "three")), + argnames="name,type_", + id_="ar", + ) + @testing.variation("use_adapt", [True, False]) + def test_pickle_types_other_process(self, name, type_, use_adapt): + """test for #11530 + + this does a full exec of python interpreter so the number of variations + here is reduced to just a single pickler, else each case takes + a full second. + + """ + + if use_adapt: + type_ = type_.copy() + + column_type = Column(name, type_) + meta = MetaData() + Table("foo", meta, column_type) + + for target in column_type, meta: + f, name = mkstemp("pkl") + with os.fdopen(f, "wb") as f: + pickle.dump(target, f) + + name = name.replace(os.sep, "/") + code = ( + "import sqlalchemy; import pickle; " + f"pickle.load(open('''{name}''', 'rb'))" + ) + parts = list(sys.path) + if os.environ.get("PYTHONPATH"): + parts.append(os.environ["PYTHONPATH"]) + pythonpath = os.pathsep.join(parts) + proc = subprocess.run( + [sys.executable, "-c", code], + env={**os.environ, "PYTHONPATH": pythonpath}, + ) + eq_(proc.returncode, 0) + os.unlink(name) + class _UserDefinedTypeFixture: @classmethod -- 2.47.2