Metadata-Version: 2.4
Name: firstclass
Version: 1.0.0
Summary: dataclass with bytes, ctypes, C struct, JSON and XML serialization
Project-URL: Homepage, https://github.com/mon231/firstclass
Project-URL: Repository, https://github.com/mon231/firstclass
Project-URL: Issues, https://github.com/mon231/firstclass/issues
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# firstclass

`@firstclass` works like `@dataclass` (same keyword args) and adds conversion to and from
packed bytes, `ctypes`, a C struct header, JSON and XML. Pure Python 3.10+, no dependencies.

```python
from firstclass import firstclass, uint8_t, uint16_t, int32_t, float32_t, bool_t, char_array, bytes_t, array_t

@firstclass
class Point:
    x: int32_t
    y: int32_t

@firstclass
class Packet:
    kind: uint8_t
    ok: bool_t
    temp: float32_t
    name: char_array[8]        # NUL-padded UTF-8
    mac: bytes_t[6]            # exactly 6 bytes
    samples: array_t[uint16_t, 4]
    origin: Point              # nested

p = Packet(1, True, 21.5, "sensor", b"\x00\x11\x22\x33\x44\x55", [1, 2, 3, 4], Point(10, -20))

raw = p.to_bytes()                 # packed, little-endian
assert Packet.from_bytes(raw) == p
Packet.sizeof()                    # 36
```

## Types

| Marker | C | Bytes |
|---|---|---|
| `uint8_t` `uint16_t` `uint32_t` `uint64_t` | `uintN_t` | 1/2/4/8 |
| `int8_t` `int16_t` `int32_t` `int64_t` | `intN_t` | 1/2/4/8 |
| `float32_t` / `float64_t` | `float` / `double` | 4 / 8 |
| `bool_t` | `bool` | 1 |
| `char_t` | `char` | 1 |
| `char_array[N]` | `char x[N]` | N (UTF-8, NUL-padded) |
| `wchar_t` / `wchar_array[N]` | `uint16_t x[N]` | 2 (2*N), UTF-16, NUL-padded |
| `NullTerminatedString` | `std::string` (from headers) | content bytes + one NUL |
| `SizedString` | paired `uint32_t len; char *s` | uint32 count + content bytes |
| `NullTerminatedWString` | `std::wstring` (from headers) | code units + one NUL unit |
| `SizedWString` | paired `uint32_t len; wchar_t *s` | uint32 units + code units |
| `bytes_t[N]` | `uint8_t x[N]` | N |
| `array_t[T, N]` | `T x[N]` | N * size(T) |
| `enum_t[Color, uint8_t]` | `uint8_t x` (+ `enum Color {..}` in header) | storage size |
| `bitfield[uint8_t, 3]` | `uint8_t x : 3` | shares a storage unit |
| another `@firstclass` class | `struct Name` | its size |

Values are checked: out of range ints, over-long strings and wrong lengths raise
`ValueError`; wrong Python types raise `TypeError`. Nothing is truncated or coerced silently.
Bare annotations are mapped to real types once at class definition, never on values:

| bare annotation | becomes |
|---|---|
| `str` | `NullTerminatedString` |
| `int` | `int32_t` |
| `float` | `float64_t` |
| `bool` | `bool_t` |

**Enums.** Int-valued `Enum`/`IntEnum`/`IntFlag` classes. `enum_t[Color, uint8_t]` picks the width;
a bare `Color` annotation is 32-bit like a C `int` (unsigned unless a value is negative). Values
are stored, and written to JSON/XML, as ints; reading an unknown value raises `ValueError`.
`c_header()` emits `enum Color { Color_RED = 1, ... };`.

**Bitfields.** `bitfield[uint8_t, 3]` is 3 bits of a `uint8_t` unit, like `uint8_t x : 3`.
Neighbouring bitfields with the same storage type share a unit while they fit; otherwise a new
unit starts. Little-endian fills from the lowest bit, big-endian from the highest (what ctypes
does). Signed storage (`int8_t`) gives signed fields. Mixing storage types in one run is not
portable across C compilers; keep a run on one type. No arrays of bitfields.

Raw `ctypes` types work as annotations too (packed, same layout as a `_pack_ = 1` Structure):
`ctypes.c_int`, `c_uint64`, `c_size_t`, `c_bool`, `c_float`, `c_double`, `c_char`, `c_uint16 * 4`,
`c_char * 8`. Pointers (`c_void_p`, `POINTER(T)`, `c_char_p`) are stored as the address (an `int`,
0 = NULL) at pointer size; `to_ctypes()` gives a real pointer field you can dereference. The
address only means something inside the same process. Sizes follow the platform
(`c_long` is 4 bytes on Windows, 8 on Linux).

**Strings.** Four types for content of any length: `NullTerminatedString` (UTF-8 bytes + one NUL
byte — this is what a plain `str` field is), `SizedString` (a uint32 UTF-8 byte-count prefix in the
class's byteorder + the content), and the wide variants `NullTerminatedWString` / `SizedWString`
(UTF-16 code units, two bytes each; the wide count is in code units). A string holding a NUL (or
any zero code unit) is refused. Such fields are created, validated, compared and pickled like any
other, but the class has no fixed size, so `sizeof()`, the ctypes conversions, `c_header()` and
`rust_source()` raise `TypeError`, and the class can neither be nested nor used with `align=True`
(both throw at class definition). Instance bytes are written field by field with no padding;
`unpack_from` reads one object at an offset as usual, and `from_bytes` accepts trailing bytes (an
exact size is unknowable). A truncated or unterminated buffer raises `ValueError` naming the field.

## Methods added to every class

| Method | |
|---|---|
| `obj.to_bytes()` / `Cls.from_bytes(data)` | binary; fixed-size classes: `from_bytes` needs exactly `sizeof()` bytes. Variable-length classes (strings) read one object like `unpack_from` |
| `Cls.unpack_from(buffer, offset=0)` | like `struct.unpack_from`: read one object at `offset`, trailing bytes are fine; any buffer type |
| `Cls.sizeof()` | size in bytes |
| `obj.to_dict()` / `Cls.from_dict(d)` | JSON-compatible dict (bytes as hex) |
| `obj.to_json(**kw)` / `Cls.from_json(s)` | `kw` goes to `json.dumps` |
| `obj.to_xml(indent=None)` / `Cls.from_xml(s)` | `<Packet><kind>1</kind>...</Packet>`, arrays as `<item>` |
| `obj.to_ctypes()` / `Cls.from_ctypes(c)` / `Cls.ctypes_type()` | `ctypes.Structure` with matching layout |
| `Cls.c_header()` | C source for the struct(s), nested ones first |
| `Cls.rust_source()` | Rust `#[repr(C)]` source for the struct(s) and enums |

## Import C/C++ headers

```python
from firstclass import from_header          # also: firstclass.from_header

t = from_header("protocol.h")               # or from_header(text="struct P { ... };")
pkt = t.Packet(1, True, ...)                # structs -> @firstclass classes
t.Color.RED                                 # enums / enum class -> IntEnum
t.constants["MAX_LEN"]                      # integer #defines, consts, enum members
t.skipped                                   # {"name": "reason"} for anything not convertible
```

Understood: `struct`/`class`/`typedef struct`, nested and anonymous-member structs, `enum` and
`enum class : type` (explicit values, implicit increments, constant expressions), bitfields, arrays
(multi-dimensional, sizes from `#define`/enum/`const`/`sizeof`), `char[N]` -> `char_array`,
`uint8_t[N]` -> `bytes_t`, `std::string`/`std::wstring` -> `NullTerminatedString`/`NullTerminatedWString`,
pointers and function pointers (stored as addresses), `typedef`/`using`,
`#define`/`#if`/`#ifdef`/`#elif` (preset macros with `defines={...}`), `namespace`, `extern "C"`,
`#pragma pack(push/pop/N)` and `__attribute__((packed))`. Layout follows C: natural alignment
padding unless the struct is packed; force it with `align=True/False` (a struct holding a paired
inline string is always packed — a variable-length member has no fixed position to pad around).
Options: `byteorder`,
`long_size` (size of C `long`; default is this machine's).

A `uint32_t` member named `<name>_len`, `<name>_length`, `<name>_size` (or literally `len`,
`length`, `size`) immediately before `char *name` / `wchar_t *name` / `char16_t *name` pairs with
it into one `SizedString`/`SizedWString` field: the length member is consumed as the prefix, and
the C struct's pointer member is serialised inline as `uint32` length + content when the class is
packed.

Skipped with a reason instead of failing: unions, flexible array members, unnamed padding
bitfields, inheritance, virtual members, unknown types (`#include`d headers are not read), `pack(2)`
/`pack(4)`, `aligned(N)`, `long double`, empty structs, anything depending on a skipped struct, and
unpaired `char *`/`wchar_t *`/`char16_t *` pointers (pair one with a preceding `uint32_t <name>_len`
member, or size the string instead: `char name[N]`).
Plain C enums are 32-bit like C unless `: type` is given. C field names that are Python keywords
(`class`, `from`, ...) get a trailing underscore (`class_`).

## Validation

A value that cannot be encoded never becomes an instance. Fields are checked when the object is
created and on every attribute assignment, so the mistake surfaces where it was made, not later in
`to_bytes()`:

```python
Header(kind=300, tag="x")    # ValueError: Header.kind: 300 out of range for uint8_t [0, 255]
Header(kind=1, tag="ab")     # ValueError: Header.tag: 'ab' is 2 bytes, char_array[1] holds 1
h.name = "far too long"      # ValueError, and h.name keeps its old value
```

Wrong type -> `TypeError`, wrong value or size -> `ValueError`. Sequences and bytes-likes are
normalised (`(1, 2)` becomes `[1, 2]`, `bytearray` becomes `bytes`) and copied. The one gap is
mutating a container in place (`obj.nums[0] = 300`), which is not an assignment to the field;
`to_bytes()` still refuses it. Applies to hand-written classes and to classes made by
`from_header` / `from_rust`.

Type problems are caught even earlier: every annotation resolves when the class is defined. A field
annotated with something that has no binary form (`pathlib.Path`, `typing.List[str]`, `object`, ...)
raises `TypeError` at class definition — no field can ever silently skip validation.

## Comparison

Like a dataclass, `==` / `!=` compare field by field. Unlike a plain dataclass, ordering is on by
default: `<`, `<=`, `>`, `>=` compare fields in declaration order, like tuples, so you can `sorted()`,
`min()` and `max()` instances. Comparing with another class raises `TypeError` (`==` is just `False`).
Switch it off with `@firstclass(order=False)`; `eq=False` turns off both.

## Rust

```python
from firstclass import from_rust

t = from_rust("src/protocol.rs")            # or from_rust(text="...")
t.Header, t.Status, t.constants, t.skipped  # same result object as from_header
print(t.Header.rust_source())               # and back: any firstclass class -> Rust definitions
```

`from_rust` reads simple `#[repr(C)]` structs (C layout with padding) and `#[repr(C, packed)]`
structs (no padding), plus C-like enums with `#[repr(u8)]`, `#[repr(i32)]` or `#[repr(C)]` and
plain discriminants (constant expressions, `as` casts, `const`s allowed). Types: `u8`..`u64`,
`i8`..`i64`, `usize`, `isize`, `f32`, `f64`, `bool`, `c_int` and the other `c_*` names, `[T; N]`
(`N` may be a `const` expression), nested structs, type aliases, tuple structs, raw pointers,
references, `Box`, `Option<&T>` and `fn` pointers (stored as addresses). `[u8; N]` becomes
`bytes_t[N]`, `[c_char; N]` becomes `char_array[N]`, `wchar_t` the 2-byte UTF-16 types. Modules are
flattened and `#[cfg(test)]` items are ignored.

Skipped with a reason in `.skipped`: structs without `#[repr(C)]` (Rust may reorder their fields;
pass `assume_repr_c=True` to read them anyway), `repr(transparent)`, `repr(align(N))`,
`packed(N)` for N > 1, generics, unions, `char`, `u128`, `String`/`Vec`/slices/tuples, enums with
data. Options: `byteorder`, `align`, `assume_repr_c`, `long_size`.

`Cls.rust_source()` writes `#[repr(C)]` / `#[repr(C, packed)]` structs, `#[repr(u8)]` enums,
`[u8; N]` for strings and bytes, `[u16; N]` for wide strings, `*mut c_void` for pointers and
`r#` for keyword names. Rust has no bitfields, so each group of bitfields becomes one integer
field with the member names in a comment.

## Layout options

```python
@firstclass(byteorder="big")   # default "little"
@firstclass(align=True)        # default False = packed, no padding (like __attribute__((packed)))
@firstclass(frozen=True, slots=True)   # any dataclass option
```

`align=True` inserts natural-alignment padding like a normal C struct (assumes the x86-64 style
rule: alignment equals size). Padding bytes are written as zero and ignored on read.
C headers describe the struct only; C compilers use native byte order, so `byteorder` is noted
in a comment.

## Notes

- Use `from __future__ import annotations` freely; annotations resolve at class definition, or on
  first use for a forward reference to a name defined later in the module (the checks run whenever
  they resolve — never silently skipped). Classes
  defined inside functions that refer to local names are not resolvable.
- Don't parse untrusted XML with `from_xml` without extra care (stdlib `xml.etree`).
- Field names that collide with the generated methods (`to_bytes`, ...) raise `TypeError`.

## Example

`python examples/example.py` defines a class in Python, then imports `examples/protocol.h` (enums, `enum class`,
bitfields, `#pragma pack` structs) and uses the generated classes.

## Develop

```
python -m unittest discover -s tests
```

The suite (about 450 tests, 40 s) includes random-class fuzzing of every conversion path, parser
fuzzing, byte-for-byte checks against real packets, and layout checks against the system C compiler
and `rustc` (skipped when they are not installed). CI runs it on Ubuntu for Python 3.10 to 3.14.
