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

hasgeek / coaster / 8263869591

13 Mar 2024 11:27AM UTC coverage: 90.227% (+0.03%) from 90.195%
8263869591

push

github

web-flow
Import symbols from typing/typing_extensions for pyupgrade (#449)

578 of 592 new or added lines in 26 files covered. (97.64%)

1 existing line in 1 file now uncovered.

3970 of 4400 relevant lines covered (90.23%)

3.61 hits per line

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

87.67
/src/coaster/utils/classes.py
1
"""
4✔
2
Utility classes
3
---------------
4
"""
5

6
from __future__ import annotations
4✔
7

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

28
__all__ = [
4✔
29
    'DataclassFromType',
30
    'NameTitle',
31
    'LabeledEnum',
32
    'InspectableSet',
33
    'classproperty',
34
    'classmethodproperty',
35
]
36

37
_T = TypeVar('_T')
4✔
38
_R = TypeVar('_R')
4✔
39

40

41
class SelfProperty:
4✔
42
    """Provides :attr:`DataclassFromType.self` (singleton instance)."""
4✔
43

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

47
    @overload
4✔
48
    def __get__(self, obj: _T, _cls: Optional[type[_T]] = None) -> _T: ...
4✔
49

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

54
        return obj
4✔
55

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

68

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

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

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

85
        >>> all = DescribedString("all", "All users")
86
        >>> more = DescribedString(description="Reordered kwargs", self="more")
87
        >>> all
88
        DescribedString('all', description='All users')
89

90
        >>> assert all == "all"
91
        >>> assert "all" == all
92
        >>> assert all.self == "all"
93
        >>> assert all.description == "All users"
94
        >>> assert more == "more"
95
        >>> assert more.description == "Reordered kwargs"
96

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

104
        >>> DescribedString(str(b'byte-value', 'ascii'), "Description here")
105
        DescribedString('byte-value', description='Description here')
106

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

113
        >>> DescribedString("a", "One") == DescribedString("a", "Two")
114
        True
115

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

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

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

131
    Dataclasses can be used in an enumeration, making enum members compatible with the
132
    base data type::
133

134
        >>> from enum import Enum
135

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

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

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

156
        >>> assert str(StringCollection.FIRST) == 'StringCollection.FIRST'
157
        >>> del StringCollection.__str__
158
        >>> del StringCollection.__format__
159
        >>> assert str(StringCollection.FIRST) == 'first'
160
        >>> assert format(StringCollection.FIRST) == 'first'
161

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

165
        >>> from typing import Optional
166

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

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

179
        >>> assert HttpStatus(200) is HttpStatus.OK
180
        >>> assert HttpStatus.OK == 200
181
        >>> assert 200 == HttpStatus.OK
182
        >>> assert HttpStatus.CREATED.comment is None
183
        >>> assert HttpStatus.UNAUTHORIZED.comment.endswith("login is required")
184

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

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

195
    ::
196

197
        >>> from enum import IntEnum
198

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

211
        >>> assert HttpIntEnum(200) is HttpIntEnum.OK
212
        >>> assert HttpIntEnum.OK == 200
213
        >>> assert 200 == HttpIntEnum.OK
214

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

222
    __dataclass_params__: ClassVar[Any]
4✔
223

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

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

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

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

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

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

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

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

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

313

314
class NameTitle(NamedTuple):
4✔
315
    name: str
4✔
316
    title: str
4✔
317

318

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

322

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

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

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

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

366
        attrs['__labels__'] = labels
4✔
367
        attrs['__names__'] = names
4✔
368
        return type.__new__(mcs, name, bases, attrs)
4✔
369

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

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

376

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

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

388
    Declare an enumeration with values and labels (for use in UI)::
389

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

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

398
        >>> MY_ENUM.FIRST
399
        1
400
        >>> MY_ENUM.SECOND
401
        2
402
        >>> MY_ENUM.THIRD
403
        3
404

405
    Access labels via dictionary lookup on the enumeration::
406

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

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

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

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

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

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

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

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

449
    Given a name, the value can be looked up::
450

451
        >>> NAME_ENUM.value_for('first')
452
        1
453
        >>> NAME_ENUM.value_for('second')
454
        2
455

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

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

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

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

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

491
    __labels__: ClassVar[dict[Any, Any]]
4✔
492
    __names__: ClassVar[dict[str, Any]]
4✔
493

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

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

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

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

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

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

527

528
_C = TypeVar('_C', bound=Collection)
4✔
529

530

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

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

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

545
    If no data source is supplied to InspectableSet, an empty set is used.
546

547
    ::
548

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

581
    __slots__ = ('_members',)
4✔
582
    _members: _C
4✔
583

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

589
    def __repr__(self) -> str:
4✔
590
        return f'self.__class__.__qualname__({self._members!r})'
4✔
591

592
    def __hash__(self) -> int:
4✔
593
        return hash(self._members)
×
594

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

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

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

604
    def __bool__(self) -> bool:
4✔
605
        return bool(self._members)
4✔
606

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

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

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

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

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

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

633
    def __eq__(self, other: Any) -> bool:
4✔
634
        """Return self == other."""
635
        return self._op_bool('__eq__', other)
4✔
636

637
    def __ne__(self, other: Any) -> bool:
4✔
638
        """Return self != other."""
639
        return self._op_bool('__ne__', other)
4✔
640

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

733

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

738
    Usage::
739

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

746
    Works on classes::
747

748
        >>> Foo.test
749
        "<class 'coaster.utils.classes.Foo'>"
750

751
    Works on class instances::
752

753
        >>> Foo().test
754
        "<class 'coaster.utils.classes.Foo'>"
755

756
    Works on subclasses too::
757

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

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

769
    ::
770

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

778
    ...but not on the class itself::
779

780
        >>> Foo.test = 'bar'
781
        >>> Foo.test
782
        'bar'
783
    """
784

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

794
        self.__wrapped__ = func
4✔
795

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

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

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

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

812

813
# Legacy name
814
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