mctx-core 0.2.4

Runtime-agnostic and portable IPv4 and IPv6 multicast sender library.
Documentation
# Python Bindings

The Python bindings live in the sibling workspace crate `mctx-core-py`.

## Build

From the repository root:

```bash
pip install ./mctx-core-py
```

For local development:

```bash
cd mctx-core-py
maturin develop
```

## Binding Shape

The Python API is intentionally centered on the multicast sender use case
rather than on a direct transliteration of the Rust ownership model.

Core objects:

- `Context`
- `Publication`
- `SendReport`

Async helper:

- `AsyncPublication`

That gives Python callers the four pieces that tend to matter most:

1. create and manage a multicast sender context
2. configure publications with explicit source and outgoing-interface control
3. send packets and inspect the resolved source/destination details
4. integrate a non-blocking sender into `asyncio`

## Basic Example

```python
from mctx_core import Context

ctx = Context()
publication = ctx.add_publication(
    "239.1.2.3",
    5000,
    source="192.168.1.20",
    interface="192.168.1.20",
)

report = publication.send(b"hello multicast")
print(report.source_addr, report.destination, report.bytes_sent)
```

For IPv6 same-host SSM-style testing:

```python
publication = ctx.add_publication(
    "ff31::8000:1234",
    5000,
    source="::1",
    interface="::1",
)

report = publication.send(b"hello ipv6 multicast")
print(publication.announce_tuple())
```

## Explicit Source vs Outgoing Interface

The binding keeps the same distinction as the Rust crate:

- `source`: exact local sender IP to bind before transmitting
- `interface`: outgoing multicast interface selected by local IP address
- `interface_index`: outgoing IPv6 multicast interface selected by interface index

If you provide an IPv6 `source`, `mctx-core` binds that exact address and uses
it for the effective sender IP. If you provide an IPv6 `interface` address and
do not provide a `source`, `mctx-core` binds to that exact interface address.

For IPv6 SSM-oriented testing, the receiver's source filter keys off the exact
observed sender IP, so `source=` is usually the most important knob.

## Asyncio

For direct await-style use:

```python
from mctx_core import AsyncPublication

async_publication = AsyncPublication(publication)
report = await async_publication.send(b"hello from asyncio")
```

### Event Loop Behavior

On selector-based loops, `AsyncPublication` uses `loop.add_writer()` and the
publication file descriptor directly after a non-blocking send reports
`BlockingIOError`.

On platforms or loops where `add_writer()` is not available, such as the
default Windows asyncio loop, it falls back to a thin async polling loop over
the same `Publication.send()` call.

There is intentionally no callback-style `add_writer()` helper here. For UDP
sender sockets, write readiness is usually level-triggered and effectively
always-on, which makes a long-lived callback registration noisy and
surprising.

## Notes

- `mctx-core` remains a pure Rust crate with no PyO3 or `cdylib` packaging.
- `mctx-core-py` depends on `mctx-core` by path inside the same workspace.
- The Python bindings are layered on top of the same non-blocking send path as
  the Rust API.
- `Publication.source_addr()` returns the effective local sender IP selected by
  the socket, while `Publication.configured_source_addr` exposes the explicit
  configured bind address when one was requested.