Skip to content
Open
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: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ dev

**Bugfixes**

-
- Fix set operations on frame flags raising ``AttributeError``, including
in-place intersection. Non-mutating operations now return ordinary sets.

6.1.0 (2025-01-22)
------------------
Expand Down
13 changes: 11 additions & 2 deletions src/hyperframe/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
from __future__ import annotations

from collections.abc import Iterable, Iterator, MutableSet
from typing import NamedTuple
from typing import NamedTuple, TypeVar

_T = TypeVar("_T")


class Flag(NamedTuple):
Expand All @@ -18,13 +20,20 @@ class Flags(MutableSet): # type: ignore
elements.

Will behave like a regular set(), except that a ValueError will be thrown
when .add()ing unexpected flags.
when .add()ing unexpected flags. Non-mutating set operations return
regular sets.
"""

def __init__(self, defined_flags: Iterable[Flag]) -> None:
self._valid_flags = {flag.name for flag in defined_flags}
self._flags: set[str] = set()

@classmethod
def _from_iterable(cls, it: Iterable[_T]) -> set[_T]:
# The set mixins pass flag names, not the definitions our constructor
# needs. Results are ordinary sets; in-place operations still validate.
return set(it)

def __repr__(self) -> str:
return repr(sorted(self._flags))

Expand Down
58 changes: 58 additions & 0 deletions tests/test_flags.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import operator

from hyperframe.frame import (
Flags, Flag,
)
Expand Down Expand Up @@ -40,3 +42,59 @@ def test_repr(self):
assert repr(flags) == "['VALID_FLAG']"
flags.add("OTHER_FLAG")
assert repr(flags) == "['OTHER_FLAG', 'VALID_FLAG']"


@pytest.mark.parametrize("operation, expected", [
(operator.and_, {"FIRST"}),
(operator.or_, {"FIRST", "SECOND", "OTHER"}),
(operator.sub, {"SECOND"}),
(operator.xor, {"SECOND", "OTHER"}),
])
@pytest.mark.parametrize("reflected", [False, True])
def test_set_operations(operation, expected, reflected):
flags = Flags([Flag("FIRST", 0x01), Flag("SECOND", 0x02)])
flags |= {"FIRST", "SECOND"}
other = {"FIRST", "OTHER"}

if reflected:
result = operation(other, flags)
if operation is operator.sub:
expected = {"OTHER"}
else:
result = operation(flags, other)

assert result == expected
assert isinstance(result, set)
assert flags == {"FIRST", "SECOND"}


@pytest.mark.parametrize("operation, expected", [
(operator.iand, {"FIRST"}),
(operator.isub, {"SECOND"}),
(operator.ixor, {"SECOND"}),
(operator.ior, {"FIRST", "SECOND"}),
])
def test_in_place_set_operations(operation, expected):
flags = Flags([Flag("FIRST", 0x01), Flag("SECOND", 0x02)])
flags |= {"FIRST", "SECOND"}

result = operation(flags, {"FIRST"})

assert result is flags
assert flags == expected
with pytest.raises(ValueError):
flags.add("OTHER")


@pytest.mark.parametrize("operation", [operator.ior, operator.ixor])
def test_in_place_operations_reject_unknown_flags(operation):
flags = Flags([Flag("FIRST", 0x01)])
with pytest.raises(ValueError):
operation(flags, {"OTHER"})


def test_empty_set_operation():
flags = Flags([Flag("FIRST", 0x01)])
result = flags & {"FIRST"}
assert result == set()
assert isinstance(result, set)