From 2e70cc767c43d04058270e20cc701f9942b24701 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 23 Jan 2024 10:42:49 -0500 Subject: [PATCH] Make compatible with Python 3.12 (#120) * Run test on 3.11 and 3.12 * Fix expected error class on Python 3.11+ * Attempt to handle lack of __dict__ on TypeVar in Python 3.12 * Disable ruff check on type comparison that looks intentional * Swallow RuntimeError triggered at interpreter shutdown * Drop Python 3.7 --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- synchronicity/synchronizer.py | 53 ++++++++++++++++++++++---------- test/asynccontextmanager_test.py | 4 ++- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7839eb7..3f149a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 - name: Install Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 9a8596f..3560d2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" dependencies = [ "sigtools==4.0.1", ] -requires-python = ">=3.7.9" +requires-python = ">=3.8" [build-system] requires = ["setuptools", "wheel"] diff --git a/synchronicity/synchronizer.py b/synchronicity/synchronizer.py index c829ae8..32310ab 100644 --- a/synchronicity/synchronizer.py +++ b/synchronicity/synchronizer.py @@ -156,7 +156,14 @@ async def loop_inner(): is_ready.set() await self._stopping.wait() # wait until told to stop - asyncio.run(loop_inner()) + try: + asyncio.run(loop_inner()) + except RuntimeError as exc: + # Python 3.12 raises a RuntimeError when new threads are created at shutdown. + # Swallowing it here is innocuous, but ideally we will revisit this after + # refactoring the shutdown handlers that modal uses to avoid triggering it. + if "can't create new thread at interpreter shutdown" not in str(exc): + raise exc self._thread = threading.Thread(target=thread_inner, daemon=True) self._thread.start() @@ -243,12 +250,17 @@ def _translate_scalar_out(self, obj, interface): interface = Interface.BLOCKING # If it's an internal object, translate it to the external interface - if inspect.isclass(obj) or isinstance(obj, typing.TypeVar): # TODO: functions? + if inspect.isclass(obj): # TODO: functions? cls_dct = obj.__dict__ if self._wrapped_attr in cls_dct: return cls_dct[self._wrapped_attr][interface] else: return obj + elif isinstance(obj, typing.TypeVar): + if hasattr(obj, self._wrapped_attr): + return getattr(obj, self._wrapped_attr)[interface] + else: + return obj else: cls_dct = obj.__class__.__dict__ if self._wrapped_attr in cls_dct: @@ -258,11 +270,11 @@ def _translate_scalar_out(self, obj, interface): return obj def _recurse_map(self, mapper, obj): - if type(obj) == list: + if type(obj) == list: # noqa: E721 return list(self._recurse_map(mapper, item) for item in obj) - elif type(obj) == tuple: + elif type(obj) == tuple: # noqa: E721 return tuple(self._recurse_map(mapper, item) for item in obj) - elif type(obj) == dict: + elif type(obj) == dict: # noqa: E721 return dict((key, self._recurse_map(mapper, item)) for key, item in obj.items()) else: return mapper(obj) @@ -620,16 +632,22 @@ def _wrap( # It wraps the object, and caches the wrapped object # Get the list of existing interfaces - if self._wrapped_attr not in obj.__dict__: - if isinstance(obj.__dict__, dict): - # This works for instances - obj.__dict__.setdefault(self._wrapped_attr, {}) - else: - # This works for classes & functions + if hasattr(obj, "__dict__"): + if self._wrapped_attr not in obj.__dict__: + if isinstance(obj.__dict__, dict): + # This works for instances + obj.__dict__.setdefault(self._wrapped_attr, {}) + else: + # This works for classes & functions + setattr(obj, self._wrapped_attr, {}) + interfaces = obj.__dict__[self._wrapped_attr] + else: + # e.g., TypeVar in Python>=3.12 + if not hasattr(obj, self._wrapped_attr): setattr(obj, self._wrapped_attr, {}) + interfaces = getattr(obj, self._wrapped_attr) # If this is already wrapped, return the existing interface - interfaces = obj.__dict__[self._wrapped_attr] if interface in interfaces: if self._multiwrap_warning: warnings.warn(f"Object {obj} is already wrapped, but getting wrapped again") @@ -670,12 +688,13 @@ def _wrap_type_var(self, obj, interface, name, target_module): # TODO(elias): Refactor - since this isn't used for live apps, move type stub generation into genstub new_obj = typing.TypeVar(name, bound=obj.__bound__) # noqa - new_obj.__dict__[self._original_attr] = obj - new_obj.__dict__[SYNCHRONIZER_ATTR] = self - new_obj.__dict__[TARGET_INTERFACE_ATTR] = interface + setattr(new_obj, self._original_attr, obj) + setattr(new_obj, SYNCHRONIZER_ATTR, self) + setattr(new_obj, TARGET_INTERFACE_ATTR, interface) new_obj.__module__ = target_module - obj.__dict__.setdefault(self._wrapped_attr, {}) - obj.__dict__[self._wrapped_attr][interface] = new_obj + if not hasattr(obj, self._wrapped_attr): + setattr(obj, self._wrapped_attr, {}) + getattr(obj, self._wrapped_attr)[interface] = new_obj return new_obj def nowrap(self, obj): diff --git a/test/asynccontextmanager_test.py b/test/asynccontextmanager_test.py index d40fb54..7071958 100644 --- a/test/asynccontextmanager_test.py +++ b/test/asynccontextmanager_test.py @@ -1,3 +1,4 @@ +import sys from contextlib import asynccontextmanager import pytest @@ -117,6 +118,7 @@ async def b(): @pytest.mark.asyncio async def test_asynccontextmanager_with_in_async(): r = s.create_async(Resource)() - with pytest.raises(AttributeError): + err_cls = AttributeError if sys.version_info < (3, 11) else TypeError + with pytest.raises(err_cls): with r.wrap(): pass