Skip to content

BitFieldModel

BitFieldModel enables packing multiple boolean or small integer values into a single byte, word, or double-word. This is essential for C-compatible bit flags and space-efficient binary protocols.

Overview

Use BitFieldModel when you need to:

  • Map individual bits to meaningful boolean flags
  • Pack small integers into bit ranges within a byte/word
  • Interface with C bit fields or hardware registers
  • Create compact flag structures for network protocols

Quick Example

from pdc_struct import BitFieldModel, Bit, StructConfig, StructMode

class FilePermissions(BitFieldModel):
    read: bool = Bit(0)      # Bit 0
    write: bool = Bit(1)     # Bit 1
    execute: bool = Bit(2)   # Bit 2

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        bit_width=8
    )

# Create with individual flags
perms = FilePermissions(read=True, write=True, execute=False)
print(perms.packed_value)  # 3 (binary: 00000011)

# Create from packed value
perms = FilePermissions(packed_value=7)  # binary: 00000111
print(perms.read, perms.write, perms.execute)  # True True True

The Bit() Function

Bit() defines where a field maps within the packed integer:

Single-Bit Fields (Boolean)

class Flags(BitFieldModel):
    active: bool = Bit(0)    # Single bit at position 0
    ready: bool = Bit(1)     # Single bit at position 1
    error: bool = Bit(7)     # Single bit at position 7

Multi-Bit Fields (Integer)

For values that span multiple bits, list all bit positions:

class StatusRegister(BitFieldModel):
    # 3-bit priority field using bits 0, 1, 2 (values 0-7)
    priority: int = Bit(0, 1, 2)

    # 4-bit error code using bits 4, 5, 6, 7 (values 0-15)
    error_code: int = Bit(4, 5, 6, 7)

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        bit_width=8
    )

Contiguous Bits Required

Bit positions must be contiguous. Bit(0, 1, 3) will raise a ValueError because bit 2 is missing.

Bit Width

The bit_width in StructConfig determines the packed size:

bit_width Packed Size Max Bits Struct Format
8 1 byte 0-7 B (unsigned char)
16 2 bytes 0-15 H (unsigned short)
32 4 bytes 0-31 I (unsigned int)
class WideFlags(BitFieldModel):
    # Can use bits 0-31 with bit_width=32
    low_byte: int = Bit(0, 1, 2, 3, 4, 5, 6, 7)
    high_bits: int = Bit(24, 25, 26, 27, 28, 29, 30, 31)

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        bit_width=32
    )

Working with Packed Values

The packed_value Property

Get or set the entire packed integer representation:

flags = FilePermissions(read=True, write=False, execute=True)

# Read packed value
value = flags.packed_value  # 5 (binary: 101)

# Set packed value (updates all fields)
flags.packed_value = 3  # Sets read=True, write=True, execute=False

Initializing from Bytes

Pass raw bytes to unpack into fields:

# From a single byte
flags = FilePermissions(packed_value=b'\x07')

# From network data (16-bit example)
class NetworkFlags(BitFieldModel):
    syn: bool = Bit(0)
    ack: bool = Bit(1)
    fin: bool = Bit(2)

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        bit_width=16,
        byte_order=ByteOrder.BIG_ENDIAN
    )

flags = NetworkFlags(packed_value=b'\x00\x03')  # SYN and ACK set

Embedding in StructModel

BitFieldModel instances can be nested within StructModel:

class Packet(StructModel):
    flags: FilePermissions  # Embedded bit field
    data: bytes = Field(json_schema_extra={"max_length": 64})

    struct_config = StructConfig(
        mode=StructMode.C_COMPATIBLE,
        byte_order=ByteOrder.LITTLE_ENDIAN
    )

# The flags field packs as a single byte within the struct
packet = Packet(
    flags=FilePermissions(read=True, write=True),
    data=b"hello"
)
binary = packet.to_bytes()

Class Reference

Bit Function

Bit(start_bit, *additional_bits, **kwargs)

Define a bit field within a BitFieldModel.

Creates a Pydantic Field with bit position metadata. Use this to map model attributes to specific bit positions within the packed integer representation.

Parameters:

Name Type Description Default
start_bit int

The starting bit position (0-indexed from LSB).

required
*additional_bits int

Additional contiguous bit positions for multi-bit integer fields. For single-bit boolean fields, omit this. For multi-bit fields, list all bit positions (e.g., Bit(0, 1, 2) for a 3-bit field).

()
**kwargs Any

Additional arguments passed to Pydantic's Field(), such as description, default, or json_schema_extra.

{}

Returns:

Type Description
FieldInfo

A Pydantic FieldInfo configured for bit field usage.

Raises:

Type Description
ValueError

If bit positions are not contiguous.

Example

from pdc_struct import BitFieldModel, Bit, StructConfig, StructMode

class StatusByte(BitFieldModel): ... # Single-bit boolean fields ... enabled: bool = Bit(0) # Bit 0 ... ready: bool = Bit(1) # Bit 1 ... error: bool = Bit(7) # Bit 7 ... ... # Multi-bit integer field (bits 2-4, values 0-7) ... priority: int = Bit(2, 3, 4) ... ... struct_config = StructConfig( ... mode=StructMode.C_COMPATIBLE, ... bit_width=8 ... )

status = StatusByte(enabled=True, priority=5) status.packed_value # Binary: 00010101 21

Source code in pdc_struct/models/bit_field.py
def Bit(start_bit: int, *additional_bits: int, **kwargs: Any) -> FieldInfo:  # noqa
    """Define a bit field within a BitFieldModel.

    Creates a Pydantic Field with bit position metadata. Use this to map model attributes
    to specific bit positions within the packed integer representation.

    Args:
        start_bit: The starting bit position (0-indexed from LSB).
        *additional_bits: Additional contiguous bit positions for multi-bit integer fields.
            For single-bit boolean fields, omit this. For multi-bit fields, list all bit
            positions (e.g., `Bit(0, 1, 2)` for a 3-bit field).
        **kwargs: Additional arguments passed to Pydantic's Field(), such as `description`,
            `default`, or `json_schema_extra`.

    Returns:
        A Pydantic FieldInfo configured for bit field usage.

    Raises:
        ValueError: If bit positions are not contiguous.

    Example:
        >>> from pdc_struct import BitFieldModel, Bit, StructConfig, StructMode
        >>>
        >>> class StatusByte(BitFieldModel):
        ...     # Single-bit boolean fields
        ...     enabled: bool = Bit(0)           # Bit 0
        ...     ready: bool = Bit(1)             # Bit 1
        ...     error: bool = Bit(7)             # Bit 7
        ...
        ...     # Multi-bit integer field (bits 2-4, values 0-7)
        ...     priority: int = Bit(2, 3, 4)
        ...
        ...     struct_config = StructConfig(
        ...         mode=StructMode.C_COMPATIBLE,
        ...         bit_width=8
        ...     )
        >>>
        >>> status = StatusByte(enabled=True, priority=5)
        >>> status.packed_value  # Binary: 00010101
        21
    """
    # Calculate bit info
    num_bits = 1 + len(additional_bits)
    is_bool = num_bits == 1

    # Verify bits are contiguous
    if additional_bits:
        bits = [start_bit] + list(additional_bits)
        if bits != list(range(min(bits), max(bits) + 1)):
            raise ValueError(f"Bit positions must be contiguous, got {bits}")

    # Store bit info in json_schema_extra
    bit_info = {"start_bit": start_bit, "num_bits": num_bits, "is_bool": is_bool}

    # Get existing json_schema_extra or create new
    json_schema_extra = kwargs.pop("json_schema_extra", {})
    if isinstance(json_schema_extra, dict):
        json_schema_extra["bit_info"] = bit_info
    else:
        raise ValueError("json_schema_extra must be a dict")

    # Default to False for bools, 0 for multi-bit fields
    default = kwargs.pop("default", False if is_bool else 0)

    field_params = {
        "default": default,
        **kwargs,
        "json_schema_extra": json_schema_extra,
        "ge": 0 if not is_bool else None,
        "lt": 1 << num_bits if not is_bool else None,
    }

    return Field(**field_params)

BitFieldModel Class

BitFieldModel

Bases: BaseModel

Base model for bit field structures, enabling packing of multiple boolean or integer values into a single byte/word/dword for C-compatible serialization.

BitFieldModel maps Python attributes to bits within an integer, facilitating compact storage and C struct compatibility. Fields are defined using Bit() to specify their position and width:

Example

class Flags(BitFieldModel): read: bool = Bit(0) # Maps to bit 0 write: bool = Bit(1) # Maps to bit 1 value: int = Bit(2,3,4) # Maps to bits 2-4

struct_config = StructConfig(
    mode=StructMode.C_COMPATIBLE,
    bit_width=8  # Must be 8, 16, or 32
)

Access individual fields as normal attributes. Use packed_value property to get/set the packed integer representation for serialization.

Source code in pdc_struct/models/bit_field.py
class BitFieldModel(BaseModel):
    """Base model for bit field structures, enabling packing of multiple boolean or integer
    values into a single byte/word/dword for C-compatible serialization.

    BitFieldModel maps Python attributes to bits within an integer, facilitating compact
    storage and C struct compatibility. Fields are defined using `Bit()` to specify their
    position and width:

    Example:
        class Flags(BitFieldModel):
            read: bool = Bit(0)     # Maps to bit 0
            write: bool = Bit(1)    # Maps to bit 1
            value: int = Bit(2,3,4) # Maps to bits 2-4

            struct_config = StructConfig(
                mode=StructMode.C_COMPATIBLE,
                bit_width=8  # Must be 8, 16, or 32
            )

    Access individual fields as normal attributes. Use packed_value property to get/set
    the packed integer representation for serialization.
    """

    # Class variables
    # model_config = dict(arbitrary_types_allowed=True)

    struct_config: ClassVar[StructConfig] = StructConfig()
    _bit_definitions: ClassVar[Dict[str, BitDefinition]] = {}
    _struct_format: ClassVar[str] = (
        "B"  # Default to byte, updated in __pydantic_init_subclass__
    )

    def __init__(self, **data):
        if "packed_value" in data:
            packed_value = data.pop("packed_value")

            # Process packed_value to integer
            if isinstance(packed_value, bytes):
                # Process bit sequence to field values
                byte_order: Literal["little", "big"] = system_byte_order
                if self.struct_config.byte_order is ByteOrder.LITTLE_ENDIAN:
                    byte_order = "little"
                elif self.struct_config.byte_order is ByteOrder.BIG_ENDIAN:
                    byte_order = "big"
                value = int.from_bytes(packed_value, byteorder=byte_order)
            elif isinstance(packed_value, int):
                value = packed_value
            else:
                raise TypeError(
                    f"packed_value must be bytes or int, not {type(packed_value)}"
                )

            # Convert raw value to field values
            field_values = {}
            for name, bit_def in self._bit_definitions.items():
                if bit_def.is_bool:
                    field_values[name] = bool(value & (1 << bit_def.start_bit))
                else:
                    mask = ((1 << bit_def.num_bits) - 1) << bit_def.start_bit
                    field_values[name] = (value & mask) >> bit_def.start_bit

            field_values.update(data)  # let explicit values override
            data = field_values

        super().__init__(**data)

    @classmethod
    def __pydantic_init_subclass__(cls, **kwargs):
        """Initialize and validate a BitFieldStruct subclass."""
        super().__pydantic_init_subclass__(**kwargs)

        # Validate struct_config
        if not hasattr(cls, "struct_config"):
            raise ValueError("BitFieldStruct requires struct_config with bit_width")

        if cls.struct_config.bit_width not in (8, 16, 32):
            raise ValueError("bit_width must be 8, 16, or 32")

        # Set struct format based on bit width
        cls._struct_format = {8: "B", 16: "H", 32: "I"}[cls.struct_config.bit_width]

        # Initialize bit_definitions
        cls._bit_definitions = {}

        # Collect bit definitions from fields
        used_bits: Set[int] = set()
        for name, field in cls.model_fields.items():  # noqa - property returns a dict
            if field.json_schema_extra and "bit_info" in field.json_schema_extra:
                bit_info = field.json_schema_extra["bit_info"]
                start_bit = bit_info["start_bit"]
                num_bits = bit_info["num_bits"]
                is_bool = bit_info["is_bool"]

                bits = set(range(start_bit, start_bit + num_bits))
                if bits & used_bits:
                    raise ValueError(f"Overlapping bits in field {name}")
                if max(bits) >= cls.struct_config.bit_width:
                    raise ValueError(
                        f"Bit field {name} exceeds bit_width {cls.struct_config.bit_width}"
                    )
                used_bits.update(bits)

                cls._bit_definitions[name] = BitDefinition(
                    start_bit=start_bit, num_bits=num_bits, is_bool=is_bool
                )

    @property
    def packed_value(self) -> int:
        """Get or set the packed integer representation of all bit fields.

        When getting, combines all field values into a single integer by setting bits
        according to each field's position and width.

        When setting, unpacks the integer and updates all field values accordingly.

        Returns:
            The integer value with all bit fields packed according to their positions.

        Raises:
            ValueError: If a field value is out of range for its bit width.

        Example:
            >>> from pdc_struct import BitFieldModel, Bit, StructConfig, StructMode
            >>>
            >>> class Permissions(BitFieldModel):
            ...     read: bool = Bit(0)
            ...     write: bool = Bit(1)
            ...     execute: bool = Bit(2)
            ...
            ...     struct_config = StructConfig(
            ...         mode=StructMode.C_COMPATIBLE,
            ...         bit_width=8
            ...     )
            >>>
            >>> # Get packed value
            >>> perms = Permissions(read=True, write=True, execute=False)
            >>> perms.packed_value
            3
            >>>
            >>> # Set packed value (updates all fields)
            >>> perms.packed_value = 7  # All permissions enabled
            >>> perms.read, perms.write, perms.execute
            (True, True, True)
        """
        value = 0
        for name, bit_def in self._bit_definitions.items():
            attr_value = getattr(self, name)
            if bit_def.is_bool:
                if not isinstance(attr_value, bool):
                    raise ValueError(f"Field {name} requires a boolean value")
                if attr_value:
                    value |= 1 << bit_def.start_bit
            else:
                if not isinstance(attr_value, int):
                    raise ValueError(f"Field {name} requires an integer value")
                max_val = (1 << bit_def.num_bits) - 1
                if not 0 <= attr_value <= max_val:
                    raise ValueError(
                        f"Field {name} value {attr_value} out of range (0-{max_val})"
                    )
                value |= attr_value << bit_def.start_bit
        return value

    @packed_value.setter
    def packed_value(self, value: int):
        max_value = (1 << self.struct_config.bit_width) - 1
        if not 0 <= value <= max_value:
            raise ValueError(
                f"Value {value} out of range for {self.struct_config.bit_width} bits"
            )

        for name, bit_def in self._bit_definitions.items():
            if bit_def.is_bool:
                value_to_set = bool(value & (1 << bit_def.start_bit))
            else:
                mask = ((1 << bit_def.num_bits) - 1) << bit_def.start_bit
                value_to_set = (value & mask) >> bit_def.start_bit

            self.__pydantic_validator__.validate_assignment(self, name, value_to_set)

    def clone(self, **field_updates: Any) -> "BitFieldModel":
        """Create a new instance with the same packed value but optionally override specific fields.

        Args:
            **field_updates: Field values to override in the new instance.
                Any fields not specified will retain their values from the current instance.

        Returns:
            A new instance of the same class with the specified updates applied.

        Examples:
            >>> flags = ByteFlags(packed_value=b'\xff')  # all bits set
            >>> new_flags = flags.clone(read=False)  # copy state but clear read bit
        """
        return self.__class__(packed_value=self.packed_value, **field_updates)

    @property
    def struct_format_string(self) -> str:
        """Get the struct format string for this bit field."""
        return self._struct_format

struct_config = StructConfig() class-attribute

packed_value property writable

Get or set the packed integer representation of all bit fields.

When getting, combines all field values into a single integer by setting bits according to each field's position and width.

When setting, unpacks the integer and updates all field values accordingly.

Returns:

Type Description
int

The integer value with all bit fields packed according to their positions.

Raises:

Type Description
ValueError

If a field value is out of range for its bit width.

Example

from pdc_struct import BitFieldModel, Bit, StructConfig, StructMode

class Permissions(BitFieldModel): ... read: bool = Bit(0) ... write: bool = Bit(1) ... execute: bool = Bit(2) ... ... struct_config = StructConfig( ... mode=StructMode.C_COMPATIBLE, ... bit_width=8 ... )

Get packed value

perms = Permissions(read=True, write=True, execute=False) perms.packed_value 3

Set packed value (updates all fields)

perms.packed_value = 7 # All permissions enabled perms.read, perms.write, perms.execute (True, True, True)

struct_format_string property

Get the struct format string for this bit field.

clone(**field_updates)

Create a new instance with the same packed value but optionally override specific fields.

Parameters:

Name Type Description Default
**field_updates Any

Field values to override in the new instance. Any fields not specified will retain their values from the current instance.

{}

Returns:

Type Description
BitFieldModel

A new instance of the same class with the specified updates applied.

Examples:

>>> flags = ByteFlags(packed_value=b'ÿ')  # all bits set
>>> new_flags = flags.clone(read=False)  # copy state but clear read bit
Source code in pdc_struct/models/bit_field.py
def clone(self, **field_updates: Any) -> "BitFieldModel":
    """Create a new instance with the same packed value but optionally override specific fields.

    Args:
        **field_updates: Field values to override in the new instance.
            Any fields not specified will retain their values from the current instance.

    Returns:
        A new instance of the same class with the specified updates applied.

    Examples:
        >>> flags = ByteFlags(packed_value=b'\xff')  # all bits set
        >>> new_flags = flags.clone(read=False)  # copy state but clear read bit
    """
    return self.__class__(packed_value=self.packed_value, **field_updates)

See Also