• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hasgeek / coaster / 9244418196

26 May 2024 03:39PM UTC coverage: 84.467% (-4.8%) from 89.263%
9244418196

push

github

web-flow
Add async support for Quart+Flask (#470)

This commit bumps the version number from 0.7 to 0.8 as it has extensive changes:

* Ruff replaces black, isort and flake8 for linting and formatting
* All decorators now support async functions and provide async wrapper implementations
* Some obsolete modules have been removed
* Pagination from Flask-SQLAlchemy is now included, removing that dependency (but still used in tests)
* New `compat` module provides wrappers to both Quart and Flake and is used by all other modules
* Some tests run using Quart. The vast majority of tests are not upgraded, nor are there tests for async decorators, so overall line coverage has dropped significantly. Comprehensive test coverage is still pending; for now we are using Funnel's tests as the extended test suite

648 of 1023 new or added lines in 29 files covered. (63.34%)

138 existing lines in 17 files now uncovered.

3948 of 4674 relevant lines covered (84.47%)

3.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.0
/src/coaster/utils/classes.py
1
"""Utility classes."""
4✔
2

3
from __future__ import annotations
4✔
4

5
import dataclasses
4✔
6
import warnings
4✔
7
from collections.abc import Collection, Iterator
4✔
8
from reprlib import recursive_repr
4✔
9
from typing import (
4✔
10
    TYPE_CHECKING,
11
    Any,
12
    Callable,
13
    ClassVar,
14
    Generic,
15
    NamedTuple,
16
    NoReturn,
17
    Optional,
18
    TypeVar,
19
    Union,
20
    cast,
21
    overload,
22
)
23
from typing_extensions import Self
4✔
24

25
__all__ = [
4✔
26
    'DataclassFromType',
27
    'NameTitle',
28
    'LabeledEnum',
29
    'InspectableSet',
30
    'classproperty',
31
    'classmethodproperty',
32
]
33

34
_T = TypeVar('_T')
4✔
35
_R = TypeVar('_R')
4✔
36

37

38
class SelfProperty:
4✔
39
    """Provides :attr:`DataclassFromType.self` (singleton instance)."""
4✔
40

41
    @overload
42
    def __get__(self, obj: None, _cls: Optional[type[Any]] = None) -> NoReturn: ...
43

44
    @overload
45
    def __get__(self, obj: _T, _cls: Optional[type[_T]] = None) -> _T: ...
46

47
    def __get__(self, obj: Optional[_T], _cls: Optional[type[_T]] = None) -> _T:
4✔
48
        if obj is None:
4✔
49
            raise AttributeError("Flag for @dataclass to recognise no default value")
4✔
50

51
        return obj
4✔
52

53
    # The value parameter must be typed `Any` because we cannot make assumptions about
54
    # the acceptable parameters to the base data type's constructor. For example, `str`
55
    # will accept almost anything, not just another string. The type defined here will
56
    # flow down to the eventual dataclass's `self` field's type.
57
    def __set__(self, _obj: Any, _value: Any) -> None:
4✔
58
        # Do nothing. This method will get exactly one call from the dataclass-generated
59
        # __init__. Future attempts to set the attr will be blocked in a frozen
60
        # dataclass. __set__ must exist despite being a no-op to follow Python's data
61
        # descriptor protocol. If not present, a variable named `self` will be inserted
62
        # into the object's instance __dict__, making it a dupe of the data.
63
        return
4✔
64

65

66
# DataclassFromType must be a dataclass itself to ensure the `self` property is
67
# identified as a field. Unfortunately, we also need to be opinionated about `frozen` as
68
# `@dataclass` requires consistency across the hierarchy. Our opinion is that
69
# dataclasses based on immutable types should by immutable.
70
@dataclasses.dataclass(init=False, repr=False, eq=False, frozen=True)
4✔
71
class DataclassFromType:
4✔
72
    """
73
    Base class for constructing dataclasses that annotate an existing type.
74

75
    For use when the context requires a basic datatype like `int` or `str`, but
76
    additional descriptive fields are desired. Example::
77

78
        >>> @dataclasses.dataclass(eq=False, frozen=True)
79
        ... class DescribedString(DataclassFromType, str):  # <- Specify data type here
80
        ...     description: str
81

82
        >>> all = DescribedString("all", "All users")
83
        >>> more = DescribedString(description="Reordered kwargs", self="more")
84
        >>> all
85
        DescribedString('all', description='All users')
86

87
        >>> assert all == "all"
88
        >>> assert "all" == all
89
        >>> assert all.self == "all"
90
        >>> assert all.description == "All users"
91
        >>> assert more == "more"
92
        >>> assert more.description == "Reordered kwargs"
93

94
    The data type specified as the second base class must be immutable, and the
95
    dataclass must be frozen. :class:`DataclassFromType` provides a dataclass field
96
    named ``self`` as the first field. This is a read-only property that returns
97
    ``self``. When instantiating, the provided value is passed to the data type's
98
    constructor. If the type needs additional arguments, call it directly and pass the
99
    result to the dataclass::
100

101
        >>> DescribedString(str(b'byte-value', 'ascii'), "Description here")
102
        DescribedString('byte-value', description='Description here')
103

104
    The data type's ``__eq__`` and ``__hash__`` methods will be copied to each subclass
105
    to ensure it remains interchangeable with the data type, and to prevent the
106
    dataclass decorator from inserting its own definitions. This has the side effect
107
    that equality will be solely on the basis of the data type's value, even if other
108
    fields differ::
109

110
        >>> DescribedString("a", "One") == DescribedString("a", "Two")
111
        True
112

113
    If this is not desired and interchangeability with the base data type can be
114
    foregone, the subclass may define its own ``__eq__`` and ``__hash__`` methods, but
115
    beware that this can break in subtle ways as Python has multiple pathways to test
116
    for equality.
117

118
    :class:`DataclassFromType` also provides a ``__repr__`` that renders the base data
119
    type correctly. The repr provided by :func:`~dataclasses.dataclass` will attempt to
120
    recurse :attr:`self` and will render it as ``...``, hiding the actual value.
121

122
    Note that all methods and attributes of the base data type will also be available
123
    via the dataclass. For example, :meth:`str.title` is an existing method, so a field
124
    named ``title`` will be flagged by static type checkers as an incompatible override,
125
    and if ignored can cause unexpected grief downstream when some code attempts to call
126
    ``.title()``.
127

128
    Dataclasses can be used in an enumeration, making enum members compatible with the
129
    base data type::
130

131
        >>> from enum import Enum
132

133
        >>> # In Python 3.11+, use `ReprEnum` instead of `Enum`
134
        >>> class StringCollection(DescribedString, Enum):
135
        ...     FIRST = 'first', "First item"
136
        ...     SECOND = 'second', "Second item"
137

138
        >>> # Enum provides the `name` and `value` properties
139
        >>> assert StringCollection.FIRST.value == 'first'
140
        >>> assert StringCollection.FIRST.name == 'FIRST'
141
        >>> assert StringCollection.FIRST.description == "First item"
142
        >>> # The enum itself is a string and directly comparable with one
143
        >>> assert StringCollection.FIRST == 'first'
144
        >>> assert 'first' == StringCollection.FIRST
145
        >>> assert StringCollection('first') is StringCollection.FIRST
146

147
    :class:`~enum.Enum` adds ``__str__`` and ``__format__`` methods that block access to
148
    the actual value and behave inconsistently between Python versions. This is fixed
149
    with :class:`~enum.ReprEnum` in Python 3.11. For older Python versions, the
150
    additional methods have to be removed after defining the enum. There is currently no
151
    convenient way to do this::
152

153
        >>> assert str(StringCollection.FIRST) == 'StringCollection.FIRST'
154
        >>> del StringCollection.__str__
155
        >>> del StringCollection.__format__
156
        >>> assert str(StringCollection.FIRST) == 'first'
157
        >>> assert format(StringCollection.FIRST) == 'first'
158

159
    Enum usage may make more sense with int-derived dataclasses, with the same caveat
160
    that ``str(enum) != str(int(enum))`` unless :class:`~enum.ReprEnum` is used::
161

162
        >>> from typing import Optional
163

164
        >>> @dataclasses.dataclass(frozen=True, eq=False)
165
        ... class StatusCode(DataclassFromType, int):
166
        ...     title: str  # title is not an existing attr of int, unlike str.title
167
        ...     comment: Optional[str] = None
168

169
        >>> # In Python 3.11+, use `ReprEnum` instead of `Enum`
170
        >>> class HttpStatus(StatusCode, Enum):
171
        ...     OK = 200, "OK"
172
        ...     CREATED = 201, "Created"
173
        ...     UNAUTHORIZED = 401, "Unauthorized", "This means login is required"
174
        ...     FORBIDDEN = 403, "Forbidden", "This means you don't have the rights"
175

176
        >>> assert HttpStatus(200) is HttpStatus.OK
177
        >>> assert HttpStatus.OK == 200
178
        >>> assert 200 == HttpStatus.OK
179
        >>> assert HttpStatus.CREATED.comment is None
180
        >>> assert HttpStatus.UNAUTHORIZED.comment.endswith("login is required")
181

182
    It is possible to skip the dataclass approach and customize an Enum's constructor
183
    directly. This approach is opaque to type checkers, causes incorrect type
184
    inferences, and is apparently hard for them to fix. Relevant tickets:
185

186
    * Infer attributes from ``__new__``: https://github.com/python/mypy/issues/1021
187
    * Ignore type of custom enum's value: https://github.com/python/mypy/issues/10000
188
    * Enum value type inference fix: https://github.com/python/mypy/pull/16320
189
    * Type error when calling Enum: https://github.com/python/mypy/issues/10573
190
    * Similar error with Pyright: https://github.com/microsoft/pyright/issues/1751
191

192
    ::
193

194
        >>> from enum import IntEnum
195

196
        >>> class HttpIntEnum(IntEnum):
197
        ...     def __new__(cls, code: int, title: str, comment: Optional[str] = None):
198
        ...         obj = int.__new__(cls, code)
199
        ...         obj._value_ = code
200
        ...         obj.title = title
201
        ...         obj.comment = comment
202
        ...         return obj
203
        ...     OK = 200, "OK"
204
        ...     CREATED = 201, "Created"
205
        ...     UNAUTHORIZED = 401, "Unauthorized", "This means login is required"
206
        ...     FORBIDDEN = 403, "Forbidden", "This means you don't have the rights"
207

208
        >>> assert HttpIntEnum(200) is HttpIntEnum.OK
209
        >>> assert HttpIntEnum.OK == 200
210
        >>> assert 200 == HttpIntEnum.OK
211

212
    The end result is similar: the enum is a subclass of the data type with additional
213
    attributes on it, but the dataclass approach is more compatible with type checkers.
214
    The `Enum Properties <https://enum-properties.readthedocs.io/>`_ project provides a
215
    more elegant syntax not requiring dataclasses, but is similarly not compatible with
216
    static type checkers.
217
    """
218

219
    __dataclass_params__: ClassVar[Any]
4✔
220

221
    # Allow subclasses to use `@dataclass(slots=True)` (Python 3.10+). Slots must be
222
    # empty as non-empty slots are incompatible with other base classes that also have
223
    # non-empty slots, and with variable length immutable data types like int, bytes and
224
    # tuple: https://docs.python.org/3/reference/datamodel.html#datamodel-note-slots
225
    __slots__ = ()
4✔
226

227
    if TYPE_CHECKING:
228
        # Mypy bugs: descriptor-based fields without a default value are understood by
229
        # @dataclass, but not by Mypy. Therefore pretend to not be a descriptor.
230
        # Unfortunately, we don't know the data type and have to declare it as Any, but
231
        # we also exploit (buggy) behaviour in Mypy where declaring the descriptor type
232
        # here will replace it with the descriptor's __get__ return type in subclasses,
233
        # but only if both are frozen. Descriptor fields cannot be marked as InitVar
234
        # because mypy thinks they do not exist as attributes on the class. Bug report
235
        # for both: https://github.com/python/mypy/issues/16538
236
        self: Union[SelfProperty, Any]
237
    else:
238
        # For runtime, make `self` a dataclass descriptor field with no default value
239
        self: SelfProperty = SelfProperty()
4✔
240
        """
3✔
241
        Read-only property that returns self and appears as the first field in the
242
        dataclass.
243
        """
244

245
    # Note: self cannot be specified as ``field(init=False)`` because of the way Python
246
    # object construction flows: ``__new__(cls, *a, **kw).__init__(*a, **kw)``.
247
    # Both calls get identical parameters, so `__init__` _must_ receive the parameter
248
    # in the first position and _must_ name it `self`. The autogenerated init function
249
    # will get the signature ``def __init__(__dataclass_self__, self, ...)``
250

251
    # Note: self cannot be marked `InitVar` because that excludes it from the dataclass
252
    # autogenerated hash and compare features: ``@dataclass(eq=True, hash=True,
253
    # compare=True)``. We can't specify it using `field` because that can't be used with
254
    # a descriptor. ``self: InitVar[SelfProperty] = field(hash=True, compare=True)``
255
    # doesn't work. Without a descriptor, we'll be keeping two copies of the value, with
256
    # the risk that the copy can be mutated to differ from the self value.
257

258
    def __new__(cls, self: Any, *_args: Any, **_kwargs: Any) -> Self:
4✔
259
        """Construct a new instance using only the first arg for the base data type."""
260
        if cls is DataclassFromType:
4✔
261
            raise TypeError("DataclassFromType cannot be directly instantiated")
4✔
262
        return super().__new__(cls, self)  # type: ignore[call-arg]
4✔
263

264
    def __init_subclass__(cls) -> None:
4✔
265
        """Audit and configure subclasses."""
266
        if cls.__bases__ == (DataclassFromType,):
4✔
267
            raise TypeError(
4✔
268
                "Subclasses must specify the data type as the second base class"
269
            )
270
        if DataclassFromType in cls.__bases__ and cls.__bases__[0] != DataclassFromType:
4✔
271
            raise TypeError("DataclassFromType must be the first base class")
4✔
272
        if cls.__bases__[0] is DataclassFromType and super().__hash__ in (
4✔
273
            None,  # Base class defined `__eq__` and Python inserted `__hash__ = None`
274
            object.__hash__,  # This returns `id(obj)` and is not a content hash
275
        ):
276
            # Treat content-based hashability as a proxy for immutability
277
            raise TypeError("The data type must be immutable")
4✔
278
        super().__init_subclass__()
4✔
279

280
        # Required to prevent `@dataclass` from overriding these methods. Allowing
281
        # dataclass to produce `__eq__` will break it, causing a recursion error
282
        if '__eq__' not in cls.__dict__:
4✔
283
            cls.__eq__ = super().__eq__  # type: ignore[method-assign]
4✔
284
            # Try to insert `__hash__` only if the class had no custom `__eq__`
285
            if '__hash__' not in cls.__dict__:
4✔
286
                cls.__hash__ = super().__hash__  # type: ignore[method-assign]
4✔
287

288
        if '__repr__' not in cls.__dict__:
4✔
289
            cls.__repr__ = (  # type: ignore[method-assign]
4✔
290
                DataclassFromType.__dataclass_repr__
291
            )
292

293
    @recursive_repr()
4✔
294
    def __dataclass_repr__(self) -> str:
4✔
295
        """Provide a dataclass-like repr that doesn't recurse into self."""
296
        self_repr = super().__repr__()  # Invoke __repr__ on the data type
4✔
297
        if not self.__dataclass_params__.repr:
4✔
298
            # Since this dataclass was configured with repr=False,
299
            # return super().__repr__()
300
            return self_repr
4✔
301
        fields_repr = ', '.join(
4✔
302
            [
303
                f'{field.name}={getattr(self, field.name)!r}'
304
                for field in dataclasses.fields(self)[1:]
305
                if field.repr
306
            ]
307
        )
308
        return f'{self.__class__.__qualname__}({self_repr}, {fields_repr})'
4✔
309

310

311
class NameTitle(NamedTuple):
4✔
312
    """Name and title pair."""
4✔
313

314
    name: str
4✔
315
    title: str
4✔
316

317

318
class LabeledEnumWarning(UserWarning):
4✔
319
    """Warning for labeled enumerations using deprecated syntax."""
4✔
320

321

322
class _LabeledEnumMeta(type):
4✔
323
    """Construct labeled enumeration."""
4✔
324

325
    def __new__(
326
        mcs: type[Any],  # noqa: N804
327
        name: str,
328
        bases: tuple[type[Any], ...],
329
        attrs: dict[str, Any],
330
    ) -> type[LabeledEnum]:
331
        labels: dict[str, Any] = {}
332
        names: dict[str, Any] = {}
333

334
        for key, value in tuple(attrs.items()):
335
            if key != '__order__' and isinstance(value, tuple):
336
                # value = tuple of actual value (0), label/name (1), optional title (2)
337
                if len(value) == 2:
338
                    labels[value[0]] = value[1]
339
                    attrs[key] = names[key] = value[0]
340
                elif len(value) == 3:
341
                    warnings.warn(
342
                        "The (value, name, title) syntax to construct NameTitle objects"
343
                        " is deprecated; pass your own object instead",
344
                        LabeledEnumWarning,
345
                        stacklevel=2,
346
                    )
347
                    labels[value[0]] = NameTitle(value[1], value[2])
348
                    attrs[key] = names[key] = value[0]
349
                else:  # pragma: no cover
350
                    raise AttributeError(f"Unprocessed attribute {key}")
351
            elif key != '__order__' and isinstance(value, set):
352
                # value = set of other unprocessed values
353
                attrs[key] = names[key] = {
354
                    v[0] if isinstance(v, tuple) else v for v in value
355
                }
356

357
        if '__order__' in attrs:  # pragma: no cover
358
            warnings.warn(
359
                "LabeledEnum.__order__ is not required since Python 3.6 and is ignored",
360
                LabeledEnumWarning,
361
                stacklevel=2,
362
            )
363

364
        attrs['__labels__'] = labels
365
        attrs['__names__'] = names
366
        return type.__new__(mcs, name, bases, attrs)
367

368
    def __getitem__(cls, key: Any) -> Any:
4✔
369
        return cls.__labels__[key]  # type: ignore[attr-defined]
4✔
370

371
    def __contains__(cls, key: Any) -> bool:
4✔
372
        return key in cls.__labels__  # type: ignore[attr-defined]
4✔
373

374

375
class LabeledEnum(metaclass=_LabeledEnumMeta):
4✔
376
    """
377
    Labeled enumerations.
378

379
    .. deprecated:: 0.7.0
380
        LabeledEnum is not compatible with static type checking as metaclasses that
381
        modify class attributes are not supported as of late 2023, with no proposal for
382
        adding this support. Use regular Python enums instead, using a
383
        :class:`DataclassFromType`-based :func:`~dataclasses.dataclass` to hold the
384
        label.
385

386
    Declare an enumeration with values and labels (for use in UI)::
387

388
        >>> class MY_ENUM(LabeledEnum):
389
        ...     FIRST = (1, "First")
390
        ...     THIRD = (3, "Third")
391
        ...     SECOND = (2, "Second")
392

393
    :class:`LabeledEnum` will convert any attribute that is a 2-tuple into a value and
394
    label pair. Access values as direct attributes of the enumeration::
395

396
        >>> MY_ENUM.FIRST
397
        1
398
        >>> MY_ENUM.SECOND
399
        2
400
        >>> MY_ENUM.THIRD
401
        3
402

403
    Access labels via dictionary lookup on the enumeration::
404

405
        >>> MY_ENUM[MY_ENUM.FIRST]
406
        'First'
407
        >>> MY_ENUM[2]
408
        'Second'
409
        >>> MY_ENUM.get(3)
410
        'Third'
411
        >>> MY_ENUM.get(4) is None
412
        True
413

414
    Retrieve a full list of values and labels with ``.items()``. Definition order is
415
    preserved::
416

417
        >>> MY_ENUM.items()
418
        [(1, 'First'), (3, 'Third'), (2, 'Second')]
419
        >>> MY_ENUM.keys()
420
        [1, 3, 2]
421
        >>> MY_ENUM.values()
422
        ['First', 'Third', 'Second']
423

424
    Three value tuples are assumed to be (value, name, title) and the name and title are
425
    converted into NameTitle(name, title)::
426

427
        >>> class NAME_ENUM(LabeledEnum):
428
        ...     FIRST = (1, 'first', "First")
429
        ...     THIRD = (3, 'third', "Third")
430
        ...     SECOND = (2, 'second', "Second")
431

432
        >>> NAME_ENUM.FIRST
433
        1
434
        >>> NAME_ENUM[NAME_ENUM.FIRST]
435
        NameTitle(name='first', title='First')
436
        >>> NAME_ENUM[NAME_ENUM.SECOND].name
437
        'second'
438
        >>> NAME_ENUM[NAME_ENUM.THIRD].title
439
        'Third'
440

441
    To make it easier to use with forms and to hide the actual values, a list of (name,
442
    title) pairs is available::
443

444
        >>> [tuple(x) for x in NAME_ENUM.nametitles()]
445
        [('first', 'First'), ('third', 'Third'), ('second', 'Second')]
446

447
    Given a name, the value can be looked up::
448

449
        >>> NAME_ENUM.value_for('first')
450
        1
451
        >>> NAME_ENUM.value_for('second')
452
        2
453

454
    Values can be grouped together using a set, for performing "in" operations. These do
455
    not have labels and cannot be accessed via dictionary access::
456

457
        >>> class RSVP_EXTRA(LabeledEnum):
458
        ...     RSVP_Y = ('Y', "Yes")
459
        ...     RSVP_N = ('N', "No")
460
        ...     RSVP_M = ('M', "Maybe")
461
        ...     RSVP_U = ('U', "Unknown")
462
        ...     RSVP_A = ('A', "Awaiting")
463
        ...     UNCERTAIN = {RSVP_M, RSVP_U, 'A'}
464

465
        >>> isinstance(RSVP_EXTRA.UNCERTAIN, set)
466
        True
467
        >>> sorted(RSVP_EXTRA.UNCERTAIN)
468
        ['A', 'M', 'U']
469
        >>> 'N' in RSVP_EXTRA.UNCERTAIN
470
        False
471
        >>> 'M' in RSVP_EXTRA.UNCERTAIN
472
        True
473
        >>> RSVP_EXTRA.RSVP_U in RSVP_EXTRA.UNCERTAIN
474
        True
475

476
    Labels are stored internally in a dictionary named ``__labels__``, mapping the value
477
    to the label. Symbol names are stored in ``__names__``, mapping name to the value.
478
    The label dictionary will only contain values processed using the tuple syntax,
479
    which excludes grouped values, while the names dictionary will contain both, but
480
    will exclude anything else found in the class that could not be processed (use
481
    ``__dict__`` for everything)::
482

483
        >>> list(RSVP_EXTRA.__labels__.keys())
484
        ['Y', 'N', 'M', 'U', 'A']
485
        >>> list(RSVP_EXTRA.__names__.keys())
486
        ['RSVP_Y', 'RSVP_N', 'RSVP_M', 'RSVP_U', 'RSVP_A', 'UNCERTAIN']
487
    """
488

489
    __labels__: ClassVar[dict[Any, Any]]
4✔
490
    __names__: ClassVar[dict[str, Any]]
4✔
491

492
    @classmethod
4✔
493
    def get(cls, key: Any, default: Optional[Any] = None) -> Any:
4✔
494
        """Get the label for an enum value."""
495
        return cls.__labels__.get(key, default)
4✔
496

497
    @classmethod
4✔
498
    def keys(cls) -> list[Any]:
4✔
499
        """Get all enum values."""
500
        return list(cls.__labels__.keys())
4✔
501

502
    @classmethod
4✔
503
    def values(cls) -> list[Union[str, NameTitle]]:
4✔
504
        """Get all enum labels."""
505
        return list(cls.__labels__.values())
4✔
506

507
    @classmethod
4✔
508
    def items(cls) -> list[tuple[Any, Union[str, NameTitle]]]:
4✔
509
        """Get all enum values and associated labels."""
510
        return list(cls.__labels__.items())
4✔
511

512
    @classmethod
4✔
513
    def value_for(cls, name: str) -> Any:
4✔
514
        """Get enum value given a label name."""
515
        for key, value in list(cls.__labels__.items()):
4✔
516
            if isinstance(value, NameTitle) and value.name == name:
4✔
517
                return key
4✔
UNCOV
518
        return None
×
519

520
    @classmethod
4✔
521
    def nametitles(cls) -> list[NameTitle]:
4✔
522
        """Get names and titles of labels."""
523
        return [label for label in cls.values() if isinstance(label, tuple)]
4✔
524

525

526
_C = TypeVar('_C', bound=Collection)
4✔
527

528

529
class InspectableSet(Generic[_C]):
4✔
530
    """
4✔
531
    InspectableSet provides an ``elem in set`` test via attribute or dictionary access.
532

533
    For example, if ``iset`` is an :class:`InspectableSet` wrapping a regular
534
    :class:`set`, a test for an element in the set can be rewritten from ``if 'elem' in
535
    iset`` to ``if iset.elem``. The concise form improves readability for visual
536
    inspection where code linters cannot help, such as in Jinja2 templates.
537

538
    InspectableSet provides a view to the wrapped data source. The mutation operators
539
    ``+=``, ``-=``, ``&=``, ``|=`` and ``^=`` will be proxied to the underlying data
540
    source, if supported, while the copy operators ``+``, ``-``, ``&``, ``|`` and ``^``
541
    will be proxied and the result re-wrapped with InspectableSet.
542

543
    If no data source is supplied to InspectableSet, an empty set is used.
544

545
    ::
546

547
        >>> myset = InspectableSet({'member', 'other'})
548
        >>> 'member' in myset
549
        True
550
        >>> 'random' in myset
551
        False
552
        >>> myset.member
553
        True
554
        >>> myset.random
555
        False
556
        >>> myset['member']
557
        True
558
        >>> myset['random']
559
        False
560
        >>> joinset = myset | {'added'}
561
        >>> isinstance(joinset, InspectableSet)
562
        True
563
        >>> joinset = joinset | InspectableSet({'inspectable'})
564
        >>> isinstance(joinset, InspectableSet)
565
        True
566
        >>> 'member' in joinset
567
        True
568
        >>> 'other' in joinset
569
        True
570
        >>> 'added' in joinset
571
        True
572
        >>> 'inspectable' in joinset
573
        True
574
        >>> emptyset = InspectableSet()
575
        >>> len(emptyset)
576
        0
577
    """
578

579
    __slots__ = ('_members',)
4✔
580
    _members: _C
4✔
581

582
    def __init__(self, members: Union[_C, InspectableSet[_C], None] = None) -> None:
4✔
583
        if isinstance(members, InspectableSet):
4✔
UNCOV
584
            members = members._members
×
585
        object.__setattr__(self, '_members', members if members is not None else set())
4✔
586

587
    def __repr__(self) -> str:
588
        return f'{self.__class__.__qualname__}({self._members!r})'
589

590
    def __hash__(self) -> int:
4✔
UNCOV
591
        return hash(self._members)
×
592

593
    def __contains__(self, key: Any) -> bool:
4✔
594
        return key in self._members
4✔
595

596
    def __iter__(self) -> Iterator:
4✔
597
        yield from self._members
4✔
598

599
    def __len__(self) -> int:
4✔
600
        return len(self._members)
4✔
601

602
    def __bool__(self) -> bool:
4✔
603
        return bool(self._members)
4✔
604

605
    def __getitem__(self, key: Any) -> bool:
4✔
606
        return key in self._members  # Return True if present, False otherwise
4✔
607

608
    def __setattr__(self, name: str, _value: Any) -> NoReturn:
4✔
609
        """Prevent accidental attempts to set a value."""
610
        raise AttributeError(name)
4✔
611

612
    def __getattr__(self, name: str) -> bool:
4✔
613
        return name in self._members  # Return True if present, False otherwise
4✔
614

615
    def _op_bool(self, op: str, other: Any) -> bool:
4✔
616
        """Return result of a boolean operation."""
617
        if hasattr(self._members, op):
4✔
618
            if isinstance(other, InspectableSet):
4✔
UNCOV
619
                other = other._members  # pylint: disable=protected-access
×
620
            return getattr(self._members, op)(other)
4✔
UNCOV
621
        return NotImplemented
×
622

623
    def __le__(self, other: Any) -> bool:
4✔
624
        """Return self <= other."""
UNCOV
625
        return self._op_bool('__le__', other)
×
626

627
    def __lt__(self, other: Any) -> bool:
4✔
628
        """Return self < other."""
UNCOV
629
        return self._op_bool('__lt__', other)
×
630

631
    def __eq__(self, other: object) -> bool:
4✔
632
        """Return self == other."""
633
        return self._op_bool('__eq__', other)
4✔
634

635
    def __ne__(self, other: object) -> bool:
4✔
636
        """Return self != other."""
637
        return self._op_bool('__ne__', other)
4✔
638

639
    def __gt__(self, other: Any) -> bool:
4✔
640
        """Return self > other."""
UNCOV
641
        return self._op_bool('__gt__', other)
×
642

643
    def __ge__(self, other: Any) -> bool:
4✔
644
        """Return self >= other."""
UNCOV
645
        return self._op_bool('__ge__', other)
×
646

647
    def _op_copy(self, op: str, other: Any) -> InspectableSet[_C]:
4✔
648
        """Return result of a copy operation."""
649
        if hasattr(self._members, op):
4✔
650
            if isinstance(other, InspectableSet):
4✔
651
                other = other._members  # pylint: disable=protected-access
4✔
652
            retval = getattr(self._members, op)(other)
4✔
653
            if retval is not NotImplemented:
4✔
654
                return self.__class__(retval)
4✔
UNCOV
655
        return NotImplemented
×
656

657
    def __add__(self, other: Any) -> InspectableSet[_C]:
4✔
658
        """Return self + other (add)."""
UNCOV
659
        return self._op_copy('__add__', other)
×
660

661
    def __radd__(self, other: Any) -> InspectableSet[_C]:
4✔
662
        """Return other + self (reverse add)."""
UNCOV
663
        return self._op_copy('__radd__', other)
×
664

665
    def __sub__(self, other: Any) -> InspectableSet[_C]:
4✔
666
        """Return self - other (subset)."""
UNCOV
667
        return self._op_copy('__sub__', other)
×
668

669
    def __rsub__(self, other: Any) -> InspectableSet[_C]:
4✔
670
        """Return other - self (reverse subset)."""
UNCOV
671
        return self._op_copy('__rsub__', other)
×
672

673
    def __and__(self, other: Any) -> InspectableSet[_C]:
4✔
674
        """Return self & other (intersection)."""
675
        return self._op_copy('__and__', other)
4✔
676

677
    def __rand__(self, other: Any) -> InspectableSet[_C]:
4✔
678
        """Return other & self (intersection)."""
UNCOV
679
        return self._op_copy('__rand__', other)
×
680

681
    def __or__(self, other: Any) -> InspectableSet[_C]:
4✔
682
        """Return self | other (union)."""
683
        return self._op_copy('__or__', other)
4✔
684

685
    def __ror__(self, other: Any) -> InspectableSet[_C]:
4✔
686
        """Return other | self (union)."""
UNCOV
687
        return self._op_copy('__ror__', other)
×
688

689
    def __xor__(self, other: Any) -> InspectableSet[_C]:
4✔
690
        """Return self ^ other (non-intersecting)."""
UNCOV
691
        return self._op_copy('__xor__', other)
×
692

693
    def __rxor__(self, other: Any) -> InspectableSet[_C]:
4✔
694
        """Return other ^ self (non-intersecting)."""
UNCOV
695
        return self._op_copy('__rxor__', other)
×
696

697
    def _op_inplace(self, op: str, other: Any) -> Self:
4✔
698
        """Return self after an inplace operation."""
699
        if hasattr(self._members, op):
4✔
700
            if isinstance(other, InspectableSet):
4✔
UNCOV
701
                other = other._members  # pylint: disable=protected-access
×
702
            result = getattr(self._members, op)(other)
4✔
703
            if result is NotImplemented:
4✔
UNCOV
704
                return NotImplemented
×
705
            if result is not self._members:
4✔
706
                # Did this operation return a new instance? Then we must too.
UNCOV
707
                return self.__class__(result)
×
708
            return self
4✔
UNCOV
709
        return NotImplemented
×
710

711
    def __iadd__(self, other: Any) -> Self:
4✔
712
        """Operate self += other (list/tuple add)."""
UNCOV
713
        return self._op_inplace('__iadd__', other)
×
714

715
    def __isub__(self, other: Any) -> Self:
4✔
716
        """Operate self -= other (set.difference_update)."""
UNCOV
717
        return self._op_inplace('__isub__', other)
×
718

719
    def __iand__(self, other: Any) -> Self:
4✔
720
        """Operate self &= other (set.intersection_update)."""
UNCOV
721
        return self._op_inplace('__iand__', other)
×
722

723
    def __ior__(self, other: Any) -> Self:
4✔
724
        """Operate self |= other (set.update)."""
725
        return self._op_inplace('__ior__', other)
4✔
726

727
    def __ixor__(self, other: Any) -> Self:
4✔
728
        """Operate self ^= other (set.symmetric_difference_update)."""
UNCOV
729
        return self._op_inplace('__isub__', other)
×
730

731

732
class classproperty(Generic[_T, _R]):  # noqa: N801
4✔
733
    """
734
    Decorator to make class methods behave like read-only properties.
735

736
    Usage::
737

738
        >>> class Foo:
739
        ...     @classmethodproperty
740
        ...     def test(cls):
741
        ...         return repr(cls)
742
        ...
743

744
    Works on classes::
745

746
        >>> Foo.test
747
        "<class 'coaster.utils.classes.Foo'>"
748

749
    Works on class instances::
750

751
        >>> Foo().test
752
        "<class 'coaster.utils.classes.Foo'>"
753

754
    Works on subclasses too::
755

756
        >>> class Bar(Foo):
757
        ...     pass
758
        ...
759
        >>> Bar.test
760
        "<class 'coaster.utils.classes.Bar'>"
761
        >>> Bar().test
762
        "<class 'coaster.utils.classes.Bar'>"
763

764
    Due to limitations in Python's descriptor API, :class:`classmethodproperty` can
765
    block write and delete access on an instance...
766

767
    ::
768

769
        >>> Foo().test = 'bar'
770
        Traceback (most recent call last):
771
        AttributeError: test is read-only
772
        >>> del Foo().test
773
        Traceback (most recent call last):
774
        AttributeError: test is read-only
775

776
    ...but not on the class itself::
777

778
        >>> Foo.test = 'bar'
779
        >>> Foo.test
780
        'bar'
781
    """
782

783
    def __init__(self, func: Callable[[type[_T]], _R]) -> None:
4✔
784
        if isinstance(func, classmethod):
4✔
UNCOV
785
            func = func.__func__  # type: ignore[unreachable]
×
786
        # For using `help(...)` on instances in Python >= 3.9.
787
        self.__doc__ = func.__doc__
4✔
788
        self.__module__ = func.__module__
4✔
789
        self.__name__ = func.__name__
4✔
790
        self.__qualname__ = func.__qualname__
4✔
791

792
        self.__wrapped__ = func
4✔
793

794
    def __set_name__(self, owner: type[_T], name: str) -> None:
4✔
795
        self.__module__ = owner.__module__
4✔
796
        self.__name__ = name
4✔
797
        self.__qualname__ = f'{owner.__qualname__}.{name}'
4✔
798

799
    def __get__(self, obj: Optional[_T], cls: Optional[type[_T]] = None) -> _R:
4✔
800
        if cls is None:
4✔
UNCOV
801
            cls = type(cast(_T, obj))
×
802
        return self.__wrapped__(cls)
4✔
803

804
    def __set__(self, _obj: Any, _value: Any) -> NoReturn:
4✔
805
        raise AttributeError(f"{self.__wrapped__.__name__} is read-only")
4✔
806

807
    def __delete__(self, _obj: Any) -> NoReturn:
4✔
808
        raise AttributeError(f"{self.__wrapped__.__name__} is read-only")
4✔
809

810

811
# Legacy name
812
classmethodproperty = classproperty
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc