From 6dbf974b3eaba1c72ab1b7bc63acea3bf3ecdace Mon Sep 17 00:00:00 2001 From: esynr3z Date: Mon, 4 Nov 2024 22:36:26 +0300 Subject: [PATCH] feat: implement core/bitfield --- corsair/__init__.py | 14 +- corsair/core/__init__.py | 24 ++- corsair/core/bitfield.py | 242 ++++++++++++++++++++++++++- corsair/core/item.py | 65 +++++++ corsair/core/types.py | 52 ++++++ corsair/input/__init__.py | 3 +- corsair/input/config.py | 23 +-- corsair/input/target.py | 43 +---- poetry.lock | 6 +- pyproject.toml | 1 + tests/core/__init__.py | 2 +- tests/core/bitfield/__init__.py | 1 + tests/core/bitfield/test_access.py | 53 ++++++ tests/core/bitfield/test_bitfield.py | 198 ++++++++++++++++++++++ tests/core/bitfield/test_enum.py | 41 +++++ tests/core/bitfield/test_hardware.py | 182 ++++++++++++++++++++ tests/core/bitfield/test_item.py | 119 +++++++++++++ tests/core/test_hwmode.py | 174 ------------------- tests/input/test_config.py | 23 +-- 19 files changed, 995 insertions(+), 271 deletions(-) create mode 100644 corsair/core/item.py create mode 100644 corsair/core/types.py create mode 100644 tests/core/bitfield/__init__.py create mode 100644 tests/core/bitfield/test_access.py create mode 100644 tests/core/bitfield/test_bitfield.py create mode 100644 tests/core/bitfield/test_enum.py create mode 100644 tests/core/bitfield/test_hardware.py create mode 100644 tests/core/bitfield/test_item.py delete mode 100644 tests/core/test_hwmode.py diff --git a/corsair/__init__.py b/corsair/__init__.py index 0dcc1d9..6b35418 100755 --- a/corsair/__init__.py +++ b/corsair/__init__.py @@ -11,13 +11,17 @@ __description__ = "Control and status register (CSR) map generator for HDL projects." -from .core import HwMode +from .core import ( + Access, + Hardware, + StrictBitField, + StrictEnumMember, +) from .input import ( AnyTarget, BaseTarget, BuildSpecification, CustomTarget, - ForceNameCase, GlobalConfig, MapCHeaderTarget, MapMarkdownTarget, @@ -44,9 +48,11 @@ "MapMarkdownTarget", "MapCHeaderTarget", # configuration - "ForceNameCase", "RegisterReset", "GlobalConfig", # core - "HwMode", + "Hardware", + "Access", + "StrictEnumMember", + "StrictBitField", ) diff --git a/corsair/core/__init__.py b/corsair/core/__init__.py index 06f65a3..9fe475b 100644 --- a/corsair/core/__init__.py +++ b/corsair/core/__init__.py @@ -2,6 +2,26 @@ from __future__ import annotations -from .bitfield import HwMode +from .bitfield import ( + Access, + Hardware, + StrictBitField, + StrictEnumMember, +) +from .types import ( + IdentifierStr, + PyClassPathStr, + SingleLineStr, + TextStr, +) -__all__ = ("HwMode",) +__all__ = ( + "Hardware", + "Access", + "StrictEnumMember", + "StrictBitField", + "IdentifierStr", + "SingleLineStr", + "TextStr", + "PyClassPathStr", +) diff --git a/corsair/core/bitfield.py b/corsair/core/bitfield.py index 0ba9c86..eb2e42f 100644 --- a/corsair/core/bitfield.py +++ b/corsair/core/bitfield.py @@ -2,16 +2,62 @@ from __future__ import annotations -from enum import Flag +import enum from typing import TYPE_CHECKING, Iterable, Iterator -if TYPE_CHECKING: - from enum import Enum +from pydantic import ( + NonNegativeInt, + PositiveInt, + model_validator, +) + +from .item import StrictBaseItem +if TYPE_CHECKING: from typing_extensions import Self -class HwMode(str, Flag): +class Access(str, enum.Enum): + """Access mode for the field. + + It is related to the bus accesses of the field and possible side-effects. + """ + + RW = "rw" + """Read and Write. The field can be read or written.""" + + RW1C = "rw1c" + """Read and Write 1 to Clear. The field can be read, and when 1 is written field is cleared.""" + + RW1S = "rw1s" + """ Read and Write 1 to Set. The field can be read, and when 1 is written field is set.""" + + RO = "ro" + """Read Only. Write has no effect.""" + + ROC = "roc" + """Read Only to Clear. The field is cleared after every read.""" + + ROLL = "roll" + """Read Only + Latch Low. The field capture hardware active low pulse signal and stuck in 0. + The field is set after every read.""" + + ROLH = "rolh" + """Read Only + Latch High. The field capture hardware active high pulse signal and stuck in 1. + Read the field to clear it.""" + + WO = "wo" + """Write Only. Zeros are always read.""" + + WOSC = "wosc" + """Write Only + Self Clear. The field is cleared on the next clock tick after write.""" + + def __str__(self) -> str: + """Convert enumeration mamber into string.""" + return self.value + + +class Hardware(str, enum.Flag): """Hardware mode for a bitfield. Mode reflects hardware possibilities and interfaces to observe and modify bitfield value. @@ -81,7 +127,7 @@ class HwMode(str, Flag): _value_: str # pyright: ignore [reportIncompatibleVariableOverride] @classmethod - def _missing_(cls, value: object) -> Enum: + def _missing_(cls, value: object) -> enum.Enum: """Return member if one can be found for value, otherwise create a composite member. Composite member is created only iff value contains only members, else `ValueError` is raised. @@ -118,8 +164,8 @@ def _missing_(cls, value: object) -> Enum: def _split_flags(cls, value: str) -> Iterator[str]: """Split string into flag values.""" # For legacy reasons there could be a string without separators, where all flags are single chars. - # Code below allows "ioe" and "i|o|e" as well. - raw_flags = set(value.split("|") if "|" in value else value) + # Code below allows "ioe" and "i-o-e" as well. + raw_flags = set(value.split("-") if "-" in value else value) # Collect all known flags in order of declaration self_flags = [member._value_ for member in cls] @@ -139,7 +185,11 @@ def _join_flags(cls, flags: Iterable[str]) -> str: """Concatenate all flag values into single string.""" # For input strings flags without separators are allowed (refer to `_split_flags`), # but all other representations always use separators. - return "|".join(flags) + return "-".join(flags) + + def __len__(self) -> int: + """Return number of combined flags.""" + return sum(1 for _ in self) def __repr__(self) -> str: """Represent flags as a string in the same style as in `enum.Flag.__repr__()`.""" @@ -207,3 +257,179 @@ def __gt__(self, other: object) -> bool: if not isinstance(other, cls): raise TypeError(f"Can't compare {type(self)} with {type(other)}") return self._value_ != other._value_ and self.__ge__(other) + + +class StrictEnumMember(StrictBaseItem): + """Member of a bitfield enumeration. + + This is an internal strict immutable representation to be used with output generators. + Consider using `EnumMember` for user side code as a much more flexible analog. + """ + + value: NonNegativeInt + """Enumeration value.""" + + +class StrictBitField(StrictBaseItem): + """Bitfield inside a register. + + This is an internal strict immutable representation to be used with output generators. + Consider using `BitField` for user side code as a much more flexible analog. + """ + + reset: NonNegativeInt + """Reset value.""" + + width: PositiveInt + """Bit width.""" + + offset: NonNegativeInt + """Bit offset.""" + + access: Access + """Access mode.""" + + hardware: Hardware + """Hardware interaction options.""" + + enum: tuple[StrictEnumMember, ...] + """Enumeration values.""" + + def __len__(self) -> int: + """Get number of bits in a bitfield.""" + return self.width + + @property + def bit_indices(self) -> Iterator[int]: + """Iterate over field bit positions inside register from LSB to MSB.""" + yield from range(self.offset, self.offset + self.width) + + @property + def byte_indices(self) -> Iterator[int]: + """Iterate over field byte indices inside register from lower to higher.""" + yield from range(self.offset // 8, (self.offset + self.width - 1) // 8 + 1) + + @property + def lsb(self) -> int: + """Position of the least significant bit (LSB) inside register.""" + return self.offset + + @property + def msb(self) -> int: + """Position of the most significant bit (MSB) inside register.""" + return self.lsb + self.width - 1 + + @property + def mask(self) -> int: + """Bit mask for the bitfield inside register.""" + return (2 ** (self.width) - 1) << self.offset + + @property + def is_multibit(self) -> bool: + """Bitfield has more than one bit width.""" + return self.width > 1 + + def byte_select(self, byte_idx: int) -> tuple[int, int]: + """Return register bit slice infomation (MSB, LSB) for a field projection into Nth byte of a register. + + This method facilates organization of "byte select" logic within HDL templates. + + Example for `offset=3` and `width=7` bitfield: + + ``` + 6 3 0 <-- field bits + | | | + field: 1 1 1 1 1 1 1 + reg: 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 + | byte 1 | byte 0 | + 15 7 0 <-- register bits + ``` + + For `byte_idx=0` result is `(7, 3)`, for `byte_idx=1` -- `(9, 8)` + """ + byte_indices = tuple(self.byte_indices) + if byte_idx not in byte_indices: + raise ValueError(f"Provided {byte_idx=} has to be one of {byte_indices} for the field.") + + lsb = self.lsb if byte_idx == byte_indices[0] else byte_idx * 8 + msb = (byte_idx + 1) * 8 - 1 if ((byte_idx + 1) * 8 - 1 - self.msb) < 0 else self.msb + + return (msb, lsb) + + def byte_select_self(self, byte_idx: int) -> tuple[int, int]: + """Return field bit slice infomation for field projection into Nth byte of a register. + + Refer to `byte_select` to get the idea. The only difference is + that current method returns non-ofsetted bit slice to represent positions within bitfield itself. + """ + msb, lsb = self.byte_select(byte_idx) + return (msb - self.offset, lsb - self.offset) + + @model_validator(mode="after") + def check_hardware_constraints(self) -> Self: + """Check that `hardware` field follows expected constraints.""" + # Check exclusive hardware flags + for flag in (Hardware.NA, Hardware.QUEUE, Hardware.FIXED): + if flag in self.hardware and len(self.hardware) > 1: + raise ValueError(f"Harware mode '{flag}' must be exclusive, but current mode is '{self.hardware}'") + + # Hardware queue mode can be only combined with specific access values + if Hardware.QUEUE in self.hardware: + q_access_allowed = [Access.RW, Access.RO, Access.WO] + if self.access not in q_access_allowed: + raise ValueError( + f"Hardware mode 'q' is allowed to use only with '{q_access_allowed}', " + f"but current access mode is '{self.access}'" + ) + + # Enable must be used with Input + if Hardware.ENABLE in self.hardware and Hardware.INPUT not in self.hardware: + raise ValueError( + f"Hardware mode 'e' is allowed to use only with 'i', " f"but current hardware mode is '{self.hardware}'" + ) + return self + + @model_validator(mode="after") + def check_reset_width(self) -> Self: + """Check that reset value width less or equal field width.""" + reset_value_width = self.reset.bit_length() + if reset_value_width > self.width: + raise ValueError( + f"Reset value 0x{self.reset:x} requires {reset_value_width} bits to represent," + f" but field is {self.width} bits wide" + ) + return self + + @model_validator(mode="after") + def check_enum_values_width(self) -> Self: + """Check that enumeration members has values, which width fit field width.""" + for e in self.enum: + enum_value_width = e.value.bit_length() + if enum_value_width > self.width: + raise ValueError( + f"Reset value 0x{self.reset:x} requires {enum_value_width} bits to represent," + f" but field is {self.width} bits wide" + ) + return self + + @model_validator(mode="after") + def check_enum_unique_values(self) -> Self: + """Check that all values inside enumeration are unique.""" + if len({member.value for member in self.enum}) != len(self.enum): + raise ValueError(f"Enumeration member values are not unique: {self.enum}") + return self + + @model_validator(mode="after") + def check_enum_unique_names(self) -> Self: + """Check that all names inside enumeration are unique.""" + if len({member.name for member in self.enum}) != len(self.enum): + raise ValueError(f"Enumeration member names are not unique: {self.enum}") + return self + + @model_validator(mode="after") + def check_enum_members_order(self) -> Self: + """Check that all enum members are sorted by value.""" + for idx, member in enumerate(sorted(self.enum, key=lambda e: e.value)): + if member != self.enum[idx]: + raise ValueError(f"Enumeration members has to be sorted by value: {self.enum}") + return self diff --git a/corsair/core/item.py b/corsair/core/item.py new file mode 100644 index 0000000..c7123ec --- /dev/null +++ b/corsair/core/item.py @@ -0,0 +1,65 @@ +"""Base strict model to encapsulate common fields for any register map item.""" + +from __future__ import annotations + +from abc import ABC +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from .types import IdentifierStr, TextStr + + +class StrictBaseItem(BaseModel, ABC): + """Base strict model for a register map item. + + This is a strict immutable internal representation to be used with output generators: + + * it is frozen (immutable) + * has no defaults and no no optional fields + * not adapted for getting pretty dump and schema + + This allows to provide more solid contract for generators, + when all fields are filled, validated, available and immutable. + """ + + name: IdentifierStr + """Name of an item.""" + + doc: TextStr + """Docstring for an item. + + Follows Python docstring rules - first line is a brief summary, + then optionally, empty line following detailed description. + """ + + metadata: dict[str, Any] + """Additional user metadata attached to an item.""" + + @property + def brief(self) -> str: + """Brief description for an item, extracted from `doc`. + + First line of `doc` is used. + """ + return self.doc.split("\n", 1)[0].strip() + + @property + def description(self) -> str: + """Detailed description for in item, extracted from `doc`. + + For a single-line `doc` it is the same as `brief`, but for multiline text block following brief is returned. + """ + parts = self.doc.split("\n", 1) + return (parts[1] if len(parts) > 1 else parts[0]).strip() + + model_config = ConfigDict( + # Docstrings of attributesshould be used for field descriptions + use_attribute_docstrings=True, + # Model is faux-immutable + frozen=True, + # Strict validation (no coercion) is applied to all fields on the model + strict=True, + # Extra values are not permitted + extra="forbid", + ) diff --git a/corsair/core/types.py b/corsair/core/types.py new file mode 100644 index 0000000..ccbacb4 --- /dev/null +++ b/corsair/core/types.py @@ -0,0 +1,52 @@ +"""Common types used in most classes and data models.""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import ( + NonNegativeInt, + StringConstraints, + TypeAdapter, +) + +IdentifierStr = Annotated[ + str, + StringConstraints( + to_lower=True, + strip_whitespace=True, + min_length=1, + pattern=r"^[A-Za-z_][A-Za-z0-9_]*$", + ), +] +"""A string that represents a valid identifier/name.""" + +SingleLineStr = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + pattern=r"^[^\n\r]*$", + ), +] +"""A string that represents a single line text.""" + +TextStr = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + ), +] +"""A string that represent a generic text (can be multiline).""" + +PyClassPathStr = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + pattern=r"^.+\.py::[A-Za-z0-9_]+$", + ), +] +"""A string that represents a path to a class within some python file.""" + + +non_negative_int_adapter = TypeAdapter(NonNegativeInt) +"""Type adapter for `NonNegativeInt` type to validation of objects of that type.""" diff --git a/corsair/input/__init__.py b/corsair/input/__init__.py index 293ba41..b414af2 100644 --- a/corsair/input/__init__.py +++ b/corsair/input/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from .buildspec import BuildSpecification -from .config import ForceNameCase, GlobalConfig, RegisterReset +from .config import GlobalConfig, RegisterReset from .target import ( AnyTarget, BaseTarget, @@ -30,7 +30,6 @@ "MapMarkdownTarget", "MapCHeaderTarget", # configuration - "ForceNameCase", "RegisterReset", "GlobalConfig", ] diff --git a/corsair/input/config.py b/corsair/input/config.py index 609966a..c3f8731 100644 --- a/corsair/input/config.py +++ b/corsair/input/config.py @@ -14,6 +14,8 @@ StrictBool, ) +from corsair.core import PyClassPathStr + class RegisterReset(str, Enum): """Flip-flop reset style.""" @@ -31,26 +33,13 @@ class RegisterReset(str, Enum): """Asynchronous active low reset.""" -class ForceNameCase(str, Enum): - """Force case for all names.""" - - CURRENT = "current" - """Do not change case.""" - - LOWER = "lower" - """Force lower case for names.""" - - UPPER = "upper" - """Force upper case for names.""" - - class GlobalConfig(BaseModel): """Global configuration parameters of the Corsair build specification.""" regmap: Path = Path("csrmap.yaml") """Path to a register map to be processed.""" - regmap_parser: str | None = Field(default=None, pattern=r"^.+\.py::\w+$", examples=["foo.py::FooParser"]) + regmap_parser: PyClassPathStr | None = Field(default=None, examples=["foo.py::FooParser"]) """Select register map parser class explicitly. Parser is selected automatically based on file extension if value is not provided. @@ -85,12 +74,6 @@ class GlobalConfig(BaseModel): * integer - do checks based on provided number of bytes """ - force_name_case: ForceNameCase = ForceNameCase.CURRENT - """Force case for all the names (registers, bitfields, enums, etc.). - - Case transformations are done for the internal register map representation and may not affect specific generator. - """ - model_config = ConfigDict( extra="allow", arbitrary_types_allowed=True, diff --git a/corsair/input/target.py b/corsair/input/target.py index dda5a47..d31b6ca 100644 --- a/corsair/input/target.py +++ b/corsair/input/target.py @@ -7,9 +7,9 @@ from typing import Annotated, Literal, Union -from pydantic import BaseModel, ConfigDict, Field, StringConstraints +from pydantic import BaseModel, ConfigDict, Field -_VAR_NAME_RE = r"^[A-Za-z_][A-Za-z0-9_]*$" +from corsair.core import IdentifierStr, PyClassPathStr, SingleLineStr class BaseTarget(BaseModel): @@ -28,13 +28,7 @@ class CustomTarget(BaseTarget): kind: Literal["custom"] """Target kind discriminator.""" - generator: Annotated[ - str, - StringConstraints( - strip_whitespace=True, - pattern=r"^.*\.py::\w+$", - ), - ] = Field(examples=["bar.py::BarGenerator"]) + generator: PyClassPathStr = Field(..., examples=["bar.py::BarGenerator"]) """Path to a custom generator class to be used.""" @@ -58,14 +52,7 @@ class MapVerilogHeaderTarget(BaseTarget): kind: Literal["map_verilog_header"] """Target kind discriminator.""" - prefix: Annotated[ - str, - StringConstraints( - strip_whitespace=True, - to_lower=True, - pattern=_VAR_NAME_RE, - ), - ] = "csr" + prefix: IdentifierStr = "csr" """Prefix for all defines. Case does not matter.""" @@ -75,14 +62,7 @@ class MapCHeaderTarget(BaseTarget): kind: Literal["map_c_header"] """Target kind discriminator.""" - prefix: Annotated[ - str, - StringConstraints( - strip_whitespace=True, - to_lower=True, - pattern=_VAR_NAME_RE, - ), - ] = "csr" + prefix: IdentifierStr = "csr" """Prefix for all defines. Case does not matter.""" @@ -92,14 +72,7 @@ class MapSvPackageTarget(BaseTarget): kind: Literal["map_sv_package"] """Target kind discriminator.""" - prefix: Annotated[ - str, - StringConstraints( - strip_whitespace=True, - to_lower=True, - pattern=_VAR_NAME_RE, - ), - ] = "csr" + prefix: IdentifierStr = "csr" """Prefix for all parameters. Case does not matter.""" @@ -109,7 +82,7 @@ class MapMarkdownTarget(BaseTarget): kind: Literal["map_markdown"] """Target kind discriminator.""" - title: str = "Register map" + title: SingleLineStr = "Register map" """Document title.""" print_images: bool = True @@ -131,4 +104,4 @@ class MapMarkdownTarget(BaseTarget): ], Field(discriminator="kind"), ] -"""Any known target.""" +"""Any known build target for Corsair.""" diff --git a/poetry.lock b/poetry.lock index 54397a4..1592e96 100644 --- a/poetry.lock +++ b/poetry.lock @@ -549,13 +549,13 @@ wcwidth = "*" [[package]] name = "pyright" -version = "1.1.387" +version = "1.1.388" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.387-py3-none-any.whl", hash = "sha256:6a1f495a261a72e12ad17e20d1ae3df4511223c773b19407cfa006229b1b08a5"}, - {file = "pyright-1.1.387.tar.gz", hash = "sha256:577de60224f7fe36505d5b181231e3a395d427b7873be0bbcaa962a29ea93a60"}, + {file = "pyright-1.1.388-py3-none-any.whl", hash = "sha256:c7068e9f2c23539c6ac35fc9efac6c6c1b9aa5a0ce97a9a8a6cf0090d7cbf84c"}, + {file = "pyright-1.1.388.tar.gz", hash = "sha256:0166d19b716b77fd2d9055de29f71d844874dbc6b9d3472ccd22df91db3dfa34"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index fc46d86..d54b69a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ ignore = [ "S101", # assert "PLR2004", # magic-value-comparison "E712", # true-false-comparison + "ANN401", # any-type ] [tool.ruff.lint.flake8-type-checking] diff --git a/tests/core/__init__.py b/tests/core/__init__.py index 2f71c40..e5d2b38 100644 --- a/tests/core/__init__.py +++ b/tests/core/__init__.py @@ -1 +1 @@ -"""Tests for register map internal representation.""" +"""Tests for register map internal strict representation.""" diff --git a/tests/core/bitfield/__init__.py b/tests/core/bitfield/__init__.py new file mode 100644 index 0000000..678ea41 --- /dev/null +++ b/tests/core/bitfield/__init__.py @@ -0,0 +1 @@ +"""Tests for internal strict representation of a bitfield.""" diff --git a/tests/core/bitfield/test_access.py b/tests/core/bitfield/test_access.py new file mode 100644 index 0000000..f94e3ac --- /dev/null +++ b/tests/core/bitfield/test_access.py @@ -0,0 +1,53 @@ +"""Tests access mode of a bitfield.""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel, ValidationError + +from corsair import Access + +# All tests below can be used in smoke testing +pytestmark = pytest.mark.smoke + + +def test_str() -> None: + """Test access to string conversion.""" + mode = Access("rw") + assert str(mode) == "rw" == mode.value + mode = Access("wosc") + assert str(mode) == "wosc" == mode.value + + +def test_repr() -> None: + """Test access to repr string conversion.""" + mode = Access("rw") + assert repr(mode) == "" + mode = Access("wosc") + assert repr(mode) == "" + + +def test_pydantic_validate() -> None: + """Test of validation with pydantic model.""" + + class Wrapper(BaseModel): + mode: Access + + model = Wrapper(mode=Access("rw")) + assert model.mode == Access("rw") == Access.RW + + model = Wrapper.model_validate({"mode": "wo"}) + assert model.mode == Access("wo") == Access.WO + + with pytest.raises(ValidationError, match="Input should be"): + Wrapper.model_validate({"mode": "we"}) + + +def test_pydantic_dump_json() -> None: + """Test of dump from pydantic model.""" + + class Wrapper(BaseModel): + mode: Access + + dump = Wrapper(mode=Access("roc")).model_dump_json() + assert dump == '{"mode":"roc"}' diff --git a/tests/core/bitfield/test_bitfield.py b/tests/core/bitfield/test_bitfield.py new file mode 100644 index 0000000..d25d93c --- /dev/null +++ b/tests/core/bitfield/test_bitfield.py @@ -0,0 +1,198 @@ +"""Tests bitfield.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from corsair import Access, Hardware, StrictBitField, StrictEnumMember + +# All tests below can be used in smoke testing +pytestmark = pytest.mark.smoke + + +def default_bitfield(**kwargs: Any) -> StrictBitField: + """Return a default StrictBitField instance with optional overrides.""" + defaults = { + "name": "field_name", + "doc": "A brief description.\n\nA detailed description of the field.", + "reset": 0, + "width": 8, + "offset": 0, + "access": Access.RW, + "hardware": Hardware.NA, + "enum": (), + "metadata": {}, + } + defaults.update(kwargs) + return StrictBitField(**defaults) + + +def test_validation() -> None: + """Test that a valid StrictBitField instance can be created.""" + bf = default_bitfield() + assert isinstance(bf, StrictBitField) + assert bf.name == "field_name" + assert bf.doc == "A brief description.\n\nA detailed description of the field." + assert bf.reset == 0 + assert bf.width == 8 + assert bf.offset == 0 + assert bf.access == Access.RW + assert bf.hardware == Hardware.NA + assert bf.enum == () + assert bf.metadata == {} + + +def test_hardware_exclusive_na() -> None: + """Test that Hardware.NA must be exclusive in hardware flags.""" + with pytest.raises(ValueError, match="Harware mode 'n' must be exclusive"): + default_bitfield(hardware=Hardware.NA | Hardware.INPUT) + + +def test_hardware_exclusive_queue() -> None: + """Test that Hardware.QUEUE must be exclusive in hardware flags.""" + with pytest.raises(ValueError, match="Harware mode 'q' must be exclusive"): + default_bitfield(hardware=Hardware.QUEUE | Hardware.INPUT) + + +def test_hardware_exclusive_fixed() -> None: + """Test that Hardware.FIXED must be exclusive in hardware flags.""" + with pytest.raises(ValueError, match="Harware mode 'f' must be exclusive"): + default_bitfield(hardware=Hardware.FIXED | Hardware.INPUT) + + +def test_hardware_queue_access() -> None: + """Test that Hardware.QUEUE can only be used with certain access modes.""" + default_bitfield(hardware=Hardware.QUEUE, access=Access.RW) + default_bitfield(hardware=Hardware.QUEUE, access=Access.RO) + default_bitfield(hardware=Hardware.QUEUE, access=Access.WO) + + with pytest.raises(ValueError, match="Hardware mode 'q' is allowed to use only with"): + default_bitfield(hardware=Hardware.QUEUE, access=Access.RW1C) + + +def test_hardware_enable_requires_input() -> None: + """Test that Hardware.ENABLE requires Hardware.INPUT in hardware flags.""" + default_bitfield(hardware=Hardware.ENABLE | Hardware.INPUT) + + with pytest.raises(ValueError, match="Hardware mode 'e' is allowed to use only with 'i'"): + default_bitfield(hardware=Hardware.ENABLE) + + +def test_reset_value_width() -> None: + """Test that reset value width must not exceed field width.""" + with pytest.raises(ValueError, match="5 bits to represent, but field is 4 bits wide"): + default_bitfield(width=4, reset=0b10000) + + +def test_enum_values_width() -> None: + """Test that enumeration member values must fit within field width.""" + enum_member = StrictEnumMember( + name="ENUM_VALUE", + doc="An enum value.", + value=0b10000, # requires 5 bits + metadata={}, + ) + with pytest.raises(ValueError, match="5 bits to represent, but field is 4 bits wide"): + default_bitfield(width=4, enum=(enum_member,)) + + +def test_enum_unique_values() -> None: + """Test that enumeration member values must be unique.""" + enum_members = ( + StrictEnumMember(name="ENUM_VALUE_1", doc="First enum value.", value=1, metadata={}), + StrictEnumMember( + name="ENUM_VALUE_2", + doc="Second enum value.", + value=1, # Duplicate value + metadata={}, + ), + ) + with pytest.raises(ValueError, match="Enumeration member values are not unique"): + default_bitfield(enum=enum_members) + + +def test_enum_unique_names() -> None: + """Test that enumeration member names must be unique.""" + enum_members = ( + StrictEnumMember(name="ENUM_VALUE", doc="First enum value.", value=1, metadata={}), + StrictEnumMember( + name="ENUM_VALUE", # Duplicate name + doc="Second enum value.", + value=2, + metadata={}, + ), + ) + with pytest.raises(ValueError, match="Enumeration member names are not unique"): + default_bitfield(enum=enum_members) + + +def test_enum_members_sorted() -> None: + """Test that enumeration members are sorted by value.""" + enum_members = ( + StrictEnumMember(name="ENUM_VALUE_2", doc="Second enum value.", value=2, metadata={}), + StrictEnumMember(name="ENUM_VALUE_1", doc="First enum value.", value=1, metadata={}), + ) + with pytest.raises(ValueError, match="Enumeration members has to be sorted by value"): + default_bitfield(enum=enum_members) + + default_bitfield(enum=tuple(sorted(enum_members, key=lambda e: e.value))) + + +def test_bitfield_length() -> None: + """Test that the length of the bitfield equals its width.""" + bf = default_bitfield(width=16) + assert len(bf) == 16 + + +def test_bit_indices() -> None: + """Test bit_indices property.""" + bf = default_bitfield(width=4, offset=2) + assert list(bf.bit_indices) == [2, 3, 4, 5] + + +def test_byte_indices() -> None: + """Test byte_indices property.""" + bf = default_bitfield(width=16, offset=9) + assert list(bf.byte_indices) == [1, 2, 3] + + +def test_lsb_msb() -> None: + """Test lsb and msb properties.""" + bf = default_bitfield(width=8, offset=4) + assert bf.lsb == 4 + assert bf.msb == 11 + + +def test_mask() -> None: + """Test mask property.""" + bf = default_bitfield(width=4, offset=4) + expected_mask = (2**4 - 1) << 4 + assert bf.mask == expected_mask + + +def test_is_multibit() -> None: + """Test is_multibit property.""" + assert not default_bitfield(width=1).is_multibit + assert default_bitfield(width=2).is_multibit + + +def test_byte_select() -> None: + """Test byte_select property.""" + bf = default_bitfield(width=5, offset=13) + assert bf.byte_select(1) == (15, 13) + assert bf.byte_select(2) == (17, 16) + with pytest.raises(ValueError, match="Provided byte_idx=0 has to be one of"): + bf.byte_select(0) + + +def test_byte_select_self() -> None: + """Test byte_select_self property.""" + bf = default_bitfield(width=16, offset=7) + assert bf.byte_select_self(0) == (0, 0) + assert bf.byte_select_self(1) == (8, 1) + assert bf.byte_select_self(2) == (15, 9) + + with pytest.raises(ValueError, match="Provided byte_idx=3 has to be one of"): + bf.byte_select(3) diff --git a/tests/core/bitfield/test_enum.py b/tests/core/bitfield/test_enum.py new file mode 100644 index 0000000..5dc9133 --- /dev/null +++ b/tests/core/bitfield/test_enum.py @@ -0,0 +1,41 @@ +"""Tests enumeration mode of a bitfield.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError + +from corsair import StrictEnumMember + +# All tests below can be used in smoke testing +pytestmark = pytest.mark.smoke + + +def default_member(**kwargs: Any) -> StrictEnumMember: + """Create default enum member.""" + defaults = { + "name": "ok", + "doc": "Indicates status is OK", + "value": 0, + "metadata": {}, + } + defaults.update(kwargs) + return StrictEnumMember(**defaults) + + +def test_validation_success() -> None: + """Test successful validation.""" + member = default_member() + assert member.name == "ok" + assert member.doc == "Indicates status is OK" + assert member.value == 0 + assert isinstance(member.metadata, dict) + assert len(member.metadata) == 0 + + +def test_invalid_value() -> None: + """Test invalid negative value.""" + with pytest.raises(ValidationError, match="Input should be greater than or equal to 0"): + default_member(value=-1) diff --git a/tests/core/bitfield/test_hardware.py b/tests/core/bitfield/test_hardware.py new file mode 100644 index 0000000..ea1b6ab --- /dev/null +++ b/tests/core/bitfield/test_hardware.py @@ -0,0 +1,182 @@ +"""Tests hardware mode of a bitfield.""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel, ValidationError + +from corsair import Hardware + +# All tests below can be used in smoke testing +pytestmark = pytest.mark.smoke + + +def test_aliases() -> None: + """Test for aliases.""" + assert Hardware.INPUT == Hardware.I == Hardware("i") == Hardware("I") + assert Hardware.OUTPUT == Hardware.O == Hardware("o") == Hardware("O") + assert Hardware.CLEAR == Hardware.C == Hardware("c") == Hardware("C") + assert Hardware.SET == Hardware.S == Hardware("s") == Hardware("S") + assert Hardware.ENABLE == Hardware.E == Hardware("e") == Hardware("E") + assert Hardware.LOCK == Hardware.L == Hardware("l") == Hardware("L") + assert Hardware.ACCESS == Hardware.A == Hardware("a") == Hardware("A") + assert Hardware.QUEUE == Hardware.Q == Hardware("q") == Hardware("Q") + assert Hardware.FIXED == Hardware.F == Hardware("f") == Hardware("F") + assert Hardware.NA == Hardware.N == Hardware("n") == Hardware("N") + + +def test_single_item_operations() -> None: + """Test operations over single item.""" + mode = Hardware.I + assert repr(mode) == "" + assert str(mode) == "i" + assert set(mode) == set(Hardware.I) + assert mode == Hardware.I + assert mode != Hardware.O + assert Hardware.I in mode + assert mode <= Hardware.I + assert mode >= Hardware.I + assert not mode < Hardware.I + assert not mode > Hardware.I + assert not mode <= Hardware.O + assert not mode >= Hardware.O + assert not mode < Hardware.O + assert not mode > Hardware.O + assert len(mode) == 1 + + +def test_comb_item_operations() -> None: + """Test operations over item as a combination of flags.""" + mode = Hardware.I | Hardware.O | Hardware.E + assert repr(mode) == "" + assert str(mode) == "i-o-e" + assert set(mode) == {Hardware.I, Hardware.O, Hardware.E} + assert Hardware.I in mode + assert Hardware.O in mode + assert Hardware.L not in mode + assert mode != Hardware.I + assert mode != Hardware.O + assert mode != Hardware.E + assert len(mode) == 3 + + subset_mode = Hardware.I | Hardware.O + assert not subset_mode > mode + assert not subset_mode >= mode + assert subset_mode < mode + assert subset_mode <= mode + + superset_mode = Hardware.I | Hardware.O | Hardware.E | Hardware.L + assert superset_mode > mode + assert superset_mode >= mode + assert not superset_mode < mode + assert not superset_mode <= mode + + +def test_creation_from_string() -> None: + """Test creation from literals.""" + mode_io = Hardware("io") + assert mode_io == (Hardware.I | Hardware.O) + mode_io = Hardware("ioioio") + assert mode_io == (Hardware.I | Hardware.O) + mode_io = Hardware("i-o-e") + assert mode_io == (Hardware.I | Hardware.O | Hardware.E) + mode_cs = Hardware("cS") + assert mode_cs == (Hardware.C | Hardware.S) + mode_ei = Hardware("EI") + assert mode_ei == (Hardware.E | Hardware.I) + mode_n = Hardware("") + assert mode_n == Hardware.N + mode_f = Hardware("f") + assert mode_f == Hardware.F + mode_q = Hardware("q") + assert mode_q == Hardware.Q + + +def test_invalid_string_creation() -> None: + """Test for unknown flags.""" + with pytest.raises(ValueError, match="Unknown hardware mode"): + Hardware("x") + with pytest.raises(ValueError, match="Unknown hardware mode"): + Hardware("xyz") + with pytest.raises(ValueError, match="Unknown hardware mode"): + Hardware("z|i") + + +def test_single_str_item_operations() -> None: + """Test operations over a single item represented as a string.""" + mode = Hardware.I + assert "i" in mode + assert mode <= "i" + assert mode >= "i" + assert not mode < "i" + assert not mode > "i" + assert not mode <= "o" + assert not mode >= "o" + assert not mode < "o" + assert not mode > "o" + + +def test_comb_str_item_operations() -> None: + """Test operations over a string item as a combination of flags.""" + mode = Hardware.I | Hardware.O | Hardware.E + assert "i" in mode + assert "o" in mode + assert "io" in mode + assert "ioe" in mode + assert "l" not in mode + assert "ioel" not in mode + + subset = "io" + assert not subset > mode + assert not subset >= mode + assert subset < mode + assert subset <= mode + + superset = "ioel" + assert superset > mode + assert superset >= mode + assert not superset < mode + assert not superset <= mode + + +def test_na_flag_from_str() -> None: + """Test for NA flag.""" + mode = Hardware("") + assert str(mode) == "n" + assert mode == Hardware.N + + +def test_str_conversion() -> None: + """Test flags to string conversion.""" + mode = Hardware("iocl") + assert str(mode) == "i-o-c-l" == mode.value + mode = Hardware("oicl") + assert str(mode) == "i-o-c-l" + mode = Hardware("o-i-l-c") + assert str(mode) == "i-o-c-l" + + +def test_pydantic_validate() -> None: + """Test of validation with pydantic model.""" + + class Wrapper(BaseModel): + mode: Hardware + + model = Wrapper(mode=Hardware("i-o")) + assert model.mode == Hardware("io") == (Hardware.I | Hardware.O) + + model = Wrapper.model_validate({"mode": "f"}) + assert model.mode == Hardware("f") == Hardware.F + + with pytest.raises(ValidationError, match="Input should be"): + Wrapper.model_validate({"mode": "xyz"}) + + +def test_pydantic_dump_json() -> None: + """Test of dump from pydantic model.""" + + class Wrapper(BaseModel): + mode: Hardware + + dump = Wrapper(mode=Hardware("io")).model_dump_json() + assert dump == '{"mode":"i-o"}' diff --git a/tests/core/bitfield/test_item.py b/tests/core/bitfield/test_item.py new file mode 100644 index 0000000..79f9fce --- /dev/null +++ b/tests/core/bitfield/test_item.py @@ -0,0 +1,119 @@ +"""Tests base register map item.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from corsair.core.item import StrictBaseItem + +# All tests below can be used in smoke testing +pytestmark = pytest.mark.smoke + + +class ItemWrapper(StrictBaseItem): + """Wrapper to be able to construct an object.""" + + +def default_item( + name: str = "some_name", + doc: str = "Some description.", + metadata: dict | None = None, +) -> ItemWrapper: + """Create default item.""" + return ItemWrapper( + name=name, + doc=doc, + metadata=metadata if metadata else {}, + ) + + +def test_validation_success() -> None: + """Test successful validation.""" + item = default_item() + assert isinstance(item, ItemWrapper) + assert isinstance(item, StrictBaseItem) + assert item.name == "some_name" + assert item.doc == "Some description." + assert isinstance(item.metadata, dict) + assert len(item.metadata) == 0 + + +def test_invalid_name_empty() -> None: + """Test name is empty.""" + with pytest.raises(ValidationError, match="at least 1 character"): + default_item(name="") + + +def test_invalid_name_pattern() -> None: + """Test name is bad pattern.""" + with pytest.raises(ValidationError, match="should match pattern"): + default_item(name="123foo") + with pytest.raises(ValidationError, match="should match pattern"): + default_item(name="foo-bar") + with pytest.raises(ValidationError, match="should match pattern"): + default_item(name="!baz") + with pytest.raises(ValidationError, match="should match pattern"): + default_item(name="FOO?") + + +def test_brief() -> None: + """Test valid brief.""" + item = default_item(doc="First line\n\nSecond line") + assert item.brief == "First line" + + item = default_item(doc="First line\nSecond line") + assert item.brief == "First line" + + item = default_item(doc="First line") + assert item.brief == "First line" + + item = default_item(doc=" First line \n\nSecond line") + assert item.brief == "First line" + + +def test_description() -> None: + """Test valid description.""" + item = default_item(doc="First line\n\nSecond line") + assert item.description == "Second line" + + item = default_item(doc="First line\n\nSecond line\nThird line\n") + assert item.description == "Second line\nThird line" + + item = default_item(doc="First line\nSecond line") + assert item.description == "Second line" + + item = default_item(doc="First line") + assert item.description == "First line" + + item = default_item(doc=" First line \n\nSecond line \n") + assert item.description == "Second line" + + +def test_metadata() -> None: + """Test metadata is attached.""" + item = default_item(metadata={"foo": 42}) + assert item.metadata["foo"] == 42 + + +def test_string_preprocessing() -> None: + """Test whitespace stripping and lowercase conversion for string fields.""" + item = default_item( + name=" ValidName ", + doc=" Valid doc \n\n with spaces ", + ) + assert item.name == "validname" # Whitespace stripped and converted to lowercase + assert item.doc == "Valid doc \n\n with spaces" # Whitespace stripped only + + +def test_immatability() -> None: + """Test item immatability.""" + item = default_item() + with pytest.raises(ValidationError, match="Instance is frozen"): + item.name = "bar" + + +def test_extra_values_forbid() -> None: + """Test that extra values are not permitted.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ItemWrapper.model_validate({"name": "name", "doc": "doc", "metadata": {}, "the_answer": 42}) diff --git a/tests/core/test_hwmode.py b/tests/core/test_hwmode.py deleted file mode 100644 index 029ba1c..0000000 --- a/tests/core/test_hwmode.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Tests hardware mode of a bitfield.""" - -from __future__ import annotations - -import pytest -from pydantic import BaseModel - -from corsair import HwMode - -# All tests below can be used in smoke testing -pytestmark = pytest.mark.smoke - - -def test_aliases() -> None: - """Test for aliases.""" - assert HwMode.INPUT == HwMode.I == HwMode("i") == HwMode("I") - assert HwMode.OUTPUT == HwMode.O == HwMode("o") == HwMode("O") - assert HwMode.CLEAR == HwMode.C == HwMode("c") == HwMode("C") - assert HwMode.SET == HwMode.S == HwMode("s") == HwMode("S") - assert HwMode.ENABLE == HwMode.E == HwMode("e") == HwMode("E") - assert HwMode.LOCK == HwMode.L == HwMode("l") == HwMode("L") - assert HwMode.ACCESS == HwMode.A == HwMode("a") == HwMode("A") - assert HwMode.QUEUE == HwMode.Q == HwMode("q") == HwMode("Q") - assert HwMode.FIXED == HwMode.F == HwMode("f") == HwMode("F") - assert HwMode.NA == HwMode.N == HwMode("n") == HwMode("N") - - -def test_single_item_operations() -> None: - """Test operations over single item.""" - mode = HwMode.I - assert repr(mode) == "" - assert str(mode) == "i" - assert set(mode) == set(HwMode.I) - assert mode == HwMode.I - assert mode != HwMode.O - assert HwMode.I in mode - assert mode <= HwMode.I - assert mode >= HwMode.I - assert not mode < HwMode.I - assert not mode > HwMode.I - assert not mode <= HwMode.O - assert not mode >= HwMode.O - assert not mode < HwMode.O - assert not mode > HwMode.O - - -def test_comb_item_operations() -> None: - """Test operations over item as a combination of flags.""" - mode = HwMode.I | HwMode.O | HwMode.E - assert repr(mode) == "" - assert str(mode) == "i|o|e" - assert set(mode) == {HwMode.I, HwMode.O, HwMode.E} - assert HwMode.I in mode - assert HwMode.O in mode - assert HwMode.L not in mode - assert mode != HwMode.I - assert mode != HwMode.O - assert mode != HwMode.E - - subset_mode = HwMode.I | HwMode.O - assert not subset_mode > mode - assert not subset_mode >= mode - assert subset_mode < mode - assert subset_mode <= mode - - superset_mode = HwMode.I | HwMode.O | HwMode.E | HwMode.L - assert superset_mode > mode - assert superset_mode >= mode - assert not superset_mode < mode - assert not superset_mode <= mode - - -def test_creation_from_string() -> None: - """Test creation from literals.""" - mode_io = HwMode("io") - assert mode_io == (HwMode.I | HwMode.O) - mode_io = HwMode("ioioio") - assert mode_io == (HwMode.I | HwMode.O) - mode_io = HwMode("i|o|e") - assert mode_io == (HwMode.I | HwMode.O | HwMode.E) - mode_cs = HwMode("cS") - assert mode_cs == (HwMode.C | HwMode.S) - mode_ei = HwMode("EI") - assert mode_ei == (HwMode.E | HwMode.I) - mode_n = HwMode("") - assert mode_n == HwMode.N - mode_f = HwMode("f") - assert mode_f == HwMode.F - mode_q = HwMode("q") - assert mode_q == HwMode.Q - - -def test_invalid_string_creation() -> None: - """Test for unknown flags.""" - with pytest.raises(ValueError, match="Unknown hardware mode"): - HwMode("x") - with pytest.raises(ValueError, match="Unknown hardware mode"): - HwMode("xyz") - with pytest.raises(ValueError, match="Unknown hardware mode"): - HwMode("z|i") - - -def test_single_str_item_operations() -> None: - """Test operations over a single item represented as a string.""" - mode = HwMode.I - assert "i" in mode - assert mode <= "i" - assert mode >= "i" - assert not mode < "i" - assert not mode > "i" - assert not mode <= "o" - assert not mode >= "o" - assert not mode < "o" - assert not mode > "o" - - -def test_comb_str_item_operations() -> None: - """Test operations over a string item as a combination of flags.""" - mode = HwMode.I | HwMode.O | HwMode.E - assert "i" in mode - assert "o" in mode - assert "io" in mode - assert "ioe" in mode - assert "l" not in mode - assert "ioel" not in mode - - subset = "io" - assert not subset > mode - assert not subset >= mode - assert subset < mode - assert subset <= mode - - superset = "ioel" - assert superset > mode - assert superset >= mode - assert not superset < mode - assert not superset <= mode - - -def test_na_flag_from_str() -> None: - """Test for NA flag.""" - mode = HwMode("") - assert str(mode) == "n" - assert mode == HwMode.N - - -def test_str_conversion() -> None: - """Test flags to string conversion.""" - mode = HwMode("iocl") - assert str(mode) == "i|o|c|l" == mode.value - mode = HwMode("oicl") - assert str(mode) == "i|o|c|l" - mode = HwMode("o|i|l|c") - assert str(mode) == "i|o|c|l" - - -def test_pydantic_validate() -> None: - """Test of validation with pydantic model.""" - - class Wrapper(BaseModel): - mode: HwMode - - model = Wrapper(mode=HwMode("i|o")) - assert model.mode == HwMode("io") == (HwMode.I | HwMode.O) - - -def test_pydantic_dump_json() -> None: - """Test of dump from pydantic model.""" - - class Wrapper(BaseModel): - mode: HwMode - - dump = Wrapper(mode=HwMode("io")).model_dump_json() - assert dump == '{"mode":"i|o"}' diff --git a/tests/input/test_config.py b/tests/input/test_config.py index 4cb46ef..cf5d71d 100644 --- a/tests/input/test_config.py +++ b/tests/input/test_config.py @@ -7,7 +7,7 @@ import pytest from pydantic import ValidationError -from corsair import ForceNameCase, GlobalConfig, RegisterReset +from corsair import GlobalConfig, RegisterReset # All tests below can be used in smoke testing pytestmark = pytest.mark.smoke @@ -24,7 +24,6 @@ def test_default_config() -> None: assert config.register_reset == RegisterReset.SYNC_POS assert config.address_increment == False assert config.address_alignment == True - assert config.force_name_case == ForceNameCase.CURRENT def test_valid_config() -> None: @@ -38,7 +37,6 @@ def test_valid_config() -> None: register_reset=RegisterReset.SYNC_POS, address_increment=4, address_alignment=True, - force_name_case=ForceNameCase.CURRENT, ) assert config.regmap == Path("path/to/regmap") assert config.regmap_parser == "parser.py::ParserClass" @@ -48,7 +46,6 @@ def test_valid_config() -> None: assert config.register_reset == RegisterReset.SYNC_POS assert config.address_increment == 4 assert config.address_alignment == True - assert config.force_name_case == ForceNameCase.CURRENT def test_regmap_parser_none() -> None: @@ -175,21 +172,3 @@ def test_register_reset_invalid_value() -> None: """Test that register_reset rejects invalid values.""" with pytest.raises(ValidationError): GlobalConfig(register_reset="invalid_rst") # pyright: ignore [reportArgumentType] - - -def test_force_name_case_enum() -> None: - """Test that force_name_case accepts valid enum values.""" - config = GlobalConfig(force_name_case=ForceNameCase.CURRENT) - assert config.force_name_case == "current" - - config = GlobalConfig(force_name_case=ForceNameCase.LOWER) - assert config.force_name_case == "lower" - - config = GlobalConfig(force_name_case=ForceNameCase.UPPER) - assert config.force_name_case == "upper" - - -def test_force_name_case_invalid_value() -> None: - """Test that force_name_case rejects invalid values.""" - with pytest.raises(ValidationError): - GlobalConfig(force_name_case="invalid") # pyright: ignore [reportArgumentType]