Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce private instantiation and inheritance through metaclass #1043

Merged
merged 1 commit into from
May 6, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions newsfragments/1021.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Any attempt to inherit from :class:`CancelScope` now raises TypeError.
(Trio has never been able to safely support subclassing here;
this change just makes it more obvious.)
23 changes: 4 additions & 19 deletions trio/_core/_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import attr

from trio._util import NoPublicConstructor


class TrioInternalError(Exception):
"""Raised by :func:`run` if we encounter a bug in trio, or (possibly) a
Expand Down Expand Up @@ -31,7 +33,7 @@ class WouldBlock(Exception):
pass


class Cancelled(BaseException):
class Cancelled(BaseException, metaclass=NoPublicConstructor):
"""Raised by blocking calls if the surrounding scope has been cancelled.

You should let this exception propagate, to be caught by the relevant
Expand All @@ -47,7 +49,7 @@ class Cancelled(BaseException):
then this *won't* catch a :exc:`Cancelled` exception.

You cannot raise :exc:`Cancelled` yourself. Attempting to do so
will produce a :exc:`RuntimeError`. Use :meth:`cancel_scope.cancel()
will produce a :exc:`TypeError`. Use :meth:`cancel_scope.cancel()
<trio.CancelScope.cancel>` instead.

.. note::
Expand All @@ -63,27 +65,10 @@ class Cancelled(BaseException):
everywhere.

"""
__marker = object()

def __init__(self, _marker=None):
if _marker is not self.__marker:
raise RuntimeError(
'Cancelled should not be raised directly. Use the cancel() '
'method on your cancel scope.'
)
super().__init__()

def __str__(self):
return "Cancelled"

@classmethod
def _init(cls):
"""A private constructor so that a user-created instance of Cancelled
can raise an appropriate error. see `issue #342
<https://github.com/python-trio/trio/issues/342>`__.
"""
return cls(_marker=cls.__marker)


class BusyResourceError(Exception):
"""Raised when a task attempts to use a resource that some other task is
Expand Down
5 changes: 3 additions & 2 deletions trio/_core/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from .. import _core
from .._deprecate import deprecated
from .._util import Final

# At the bottom of this file there's also some "clever" code that generates
# wrapper functions for runner and io manager methods, and adds them to
Expand Down Expand Up @@ -125,7 +126,7 @@ def deadline_to_sleep_time(self, deadline):


@attr.s(cmp=False, repr=False, slots=True)
class CancelScope:
class CancelScope(metaclass=Final):
"""A *cancellation scope*: the link between a unit of cancellable
work and Trio's cancellation system.

Expand Down Expand Up @@ -737,7 +738,7 @@ def _attempt_delivery_of_any_pending_cancel(self):
return

def raise_cancel():
raise Cancelled._init()
raise Cancelled._create()

self._attempt_abort(raise_cancel)

Expand Down
24 changes: 21 additions & 3 deletions trio/_core/tests/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2057,7 +2057,7 @@ async def inner():

def test_Cancelled_init():
check_Cancelled_error = pytest.raises(
RuntimeError, match='should not be raised directly'
TypeError, match='no public constructor available'
)

with check_Cancelled_error:
Expand All @@ -2067,14 +2067,32 @@ def test_Cancelled_init():
_core.Cancelled()

# private constructor should not raise
_core.Cancelled._init()
_core.Cancelled._create()


def test_Cancelled_str():
cancelled = _core.Cancelled._init()
cancelled = _core.Cancelled._create()
assert str(cancelled) == 'Cancelled'


def test_Cancelled_subclass():
with pytest.raises(
TypeError, match='`Cancelled` does not support subclassing'
):

class Subclass(_core.Cancelled):
pass


def test_CancelScope_subclass():
with pytest.raises(
TypeError, match='`CancelScope` does not support subclassing'
):

class Subclass(_core.CancelScope):
pass


def test_sniffio_integration():
with pytest.raises(sniffio.AsyncLibraryNotFoundError):
sniffio.current_async_library()
Expand Down
50 changes: 50 additions & 0 deletions trio/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,53 @@ def __call__(self, *args, **kwargs):

def __getitem__(self, _):
return self


class Final(type):
"""Metaclass that enforces a class to be final (i.e., subclass not allowed).

If a class uses this metaclass like this::

class SomeClass(metaclass=Final):
pass

The metaclass will ensure that no sub class can be created.

Raises
------
- TypeError if a sub class is created
"""

def __new__(cls, name, bases, cls_namespace):
for base in bases:
if isinstance(base, Final):
raise TypeError(
"`%s` does not support subclassing" % base.__name__
)
return type.__new__(cls, name, bases, cls_namespace)


class NoPublicConstructor(Final):
"""Metaclass that enforces a class to be final (i.e., subclass not allowed)
and ensures a private constructor.

If a class uses this metaclass like this::

class SomeClass(metaclass=NoPublicConstructor):
pass

The metaclass will ensure that no sub class can be created, and that no instance
can be initialized.

If you try to instantiate your class (SomeClass()), a TypeError will be thrown.

Raises
------
- TypeError if a sub class or an instance is created.
"""

def __call__(self, *args, **kwargs):
raise TypeError("no public constructor available")

def _create(self, *args, **kwargs):
return super().__call__(*args, **kwargs)
33 changes: 32 additions & 1 deletion trio/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from .. import _core
from trio import run_sync_in_worker_thread
from .._util import (
signal_raise, ConflictDetector, fspath, is_main_thread, generic_function
signal_raise, ConflictDetector, fspath, is_main_thread, generic_function,
Final, NoPublicConstructor
)
from ..testing import wait_all_tasks_blocked, assert_checkpoints

Expand Down Expand Up @@ -184,3 +185,33 @@ def test_func(arg):
assert test_func.__qualname__ == "test_generic_function.<locals>.test_func"
assert test_func.__name__ == "test_func"
assert test_func.__module__ == __name__


def test_final_metaclass():
class FinalClass(metaclass=Final):
pass

with pytest.raises(
TypeError, match="`FinalClass` does not support subclassing"
):

class SubClass(FinalClass):
pass


def test_no_public_constructor_metaclass():
class SpecialClass(metaclass=NoPublicConstructor):
pass

with pytest.raises(TypeError, match="no public constructor available"):
SpecialClass()

with pytest.raises(
TypeError, match="`SpecialClass` does not support subclassing"
):

class SubClass(SpecialClass):
pass

# Private constructor should not raise
assert isinstance(SpecialClass._create(), SpecialClass)