Skip to content

Commit

Permalink
Make compatible with Python 3.12 (#120)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mwaskom authored Jan 23, 2024
1 parent f65617b commit 2e70cc7
Show file tree
Hide file tree
Showing 4 changed files with 41 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
53 changes: 36 additions & 17 deletions synchronicity/synchronizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion test/asynccontextmanager_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
from contextlib import asynccontextmanager
import pytest

Expand Down Expand Up @@ -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

0 comments on commit 2e70cc7

Please sign in to comment.