Metadata-Version: 2.4
Name: swim-py
Version: 0.1.0a2
Summary: Pure-Python SWIM+ membership protocol implementation, wire-compatible with foca via shared protobuf codec.
Project-URL: Homepage, https://github.com/nicolasnoble/swim-py
Project-URL: Repository, https://github.com/nicolasnoble/swim-py
Project-URL: Issues, https://github.com/nicolasnoble/swim-py/issues
Project-URL: Changelog, https://github.com/nicolasnoble/swim-py/blob/main/CHANGELOG.md
Author-email: Nicolas Noble <nicolas@nobis-crew.org>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: distributed-systems,gossip,membership,p2p,swim
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Distributed Computing
Classifier: Topic :: System :: Networking
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: protobuf>=5.27.0
Provides-Extra: dev
Requires-Dist: hypothesis>=6.150; extra == 'dev'
Requires-Dist: mypy>=1.20; extra == 'dev'
Requires-Dist: pytest-asyncio>=1.3; extra == 'dev'
Requires-Dist: pytest-cov>=7.1; extra == 'dev'
Requires-Dist: pytest>=9.0; extra == 'dev'
Requires-Dist: ruff>=0.15.12; extra == 'dev'
Requires-Dist: types-protobuf>=7.34.1.20260408; extra == 'dev'
Description-Content-Type: text/markdown

# swim-py

[![CI](https://github.com/nicolasnoble/swim-py/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolasnoble/swim-py/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/nicolasnoble/swim-py/graph/badge.svg)](https://codecov.io/gh/nicolasnoble/swim-py)
[![PyPI](https://img.shields.io/pypi/v/swim-py.svg)](https://pypi.org/project/swim-py/)
[![Python](https://img.shields.io/pypi/pyversions/swim-py.svg)](https://pypi.org/project/swim-py/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/nicolasnoble/swim-py#license)

Pure-Python implementation of the SWIM+ membership protocol, designed to be wire-compatible with the Rust [foca](https://github.com/caio/foca) crate via a shared protobuf codec.

**Status: 0.1.0a2 (alpha).** The `Swim` class composes the SWIM+ state machine, failure detector (direct + indirect probes, suspicion mechanism), push-pull anti-entropy, and broadcast piggyback behind a single entry point. Default plug-in implementations ship for `Codec` (protobuf), `Transport` (asyncio UDP), and `Discovery` (static peer list). A 5-node simulated cluster smoke test exercises convergence, single-node failure detection, rejoin, and end-to-end broadcast dissemination over real UDP datagrams. 0.1.0a2 adds a bootstrap-immediate push-pull (drops fresh-joiner cold-start convergence from one `push_pull_interval` cycle to ~1 RTT) and an adaptive push-pull cadence ramp; both are backward compatible with 0.1.0a1 consumers. API may still change between 0.x releases; backward compatibility commitments begin at 1.0.

## Installation

```bash
pip install swim-py
```

Requires Python 3.10+.

## Quickstart

A minimal two-node cluster on localhost. Run two copies of the script in separate terminals, passing `0` and `1` as the `node_id` argument so each instance binds its own port from the per-node address list:

```python
import asyncio
import sys

from swim_py import (
    AsyncIoUdpTransport,
    Config,
    EventKind,
    ProtobufCodec,
    StaticDiscovery,
    Swim,
)


async def main(node_id: int) -> None:
    # Two nodes on localhost; pick a port per node.
    addrs = [("127.0.0.1", 7000), ("127.0.0.1", 7001)]
    bind_addr = addrs[node_id]

    # Caller owns the transport's bind lifecycle; Swim only does
    # send/recv on it.
    transport = AsyncIoUdpTransport(bind_addr)
    await transport.start()
    try:
        local_addr = transport.local_addr()
        assert local_addr is not None  # transport.start() succeeded

        swim = Swim(
            identity=f"node-{node_id}".encode(),
            config=Config(),
            codec=ProtobufCodec(),
            transport=transport,
            discovery=StaticDiscovery(addrs),
            local_addr=local_addr,
        )

        # Print membership events as they arrive.
        async def watch_events() -> None:
            async for event in swim.events:
                kind = EventKind(event.kind).name
                ident = event.member.identity.decode(errors="replace")
                print(f"[{node_id}] {kind} {ident}")

        # asyncio.gather works on Python 3.10+; asyncio.TaskGroup is
        # 3.11+ and would narrow the supported floor unnecessarily.
        await asyncio.gather(swim.run(), watch_events())
    finally:
        transport.close()


if __name__ == "__main__":
    asyncio.run(main(int(sys.argv[1])))
```

```bash
# terminal 1
python quickstart.py 0

# terminal 2
python quickstart.py 1
```

Each node prints `MEMBER_UP node-N` once it learns about its peer. Kill one with `Ctrl+C`; the other prints `MEMBER_SUSPECT` then `MEMBER_DOWN` after the suspicion period (~3 s with default config).

`tests/test_v0_smoke.py` is the canonical end-to-end example covering all four v0 phases (convergence, single-node failure, rejoin with fresh identity, broadcast end-to-end) over real UDP datagrams on localhost.

## Lifecycle

The `Transport` protocol is intentionally minimal (`send` + `recv`); it does not own start/close hooks. The caller owns the transport's bind lifecycle:

```python
transport = AsyncIoUdpTransport(("0.0.0.0", 7777))
await transport.start()       # bind socket
try:
    swim = Swim(..., transport=transport, ...)
    await swim.run()          # blocks until swim.stop() or transport closes
finally:
    transport.close()         # release socket
```

`Swim.run()` is reusable on the same instance: `run` -> `stop` -> `run` re-bootstraps and re-spawns the internal child tasks. `Swim.stop()` is idempotent.

The `local_addr` constructor parameter is the address peers should use to reach this node. It can differ from the transport's bind address (e.g., bind `0.0.0.0` and advertise a routable IP, or bind an ephemeral port and resolve the OS-chosen value via `transport.local_addr()` after `start()`).

## Goals

- **Pure Python.** No native dependencies. Ships as a small wheel that pip can install on any Python 3.10+ environment.
- **Codec-agnostic core.** SWIM message types are Python dataclasses; wire encoding is pluggable. The default `ProtobufCodec` uses `proto/swim.proto`, designed for wire compatibility with foca configured to use the same schema.
- **Transport-agnostic core.** I/O is delegated to a `Transport` interface. The default `AsyncIoUdpTransport` is asyncio-based; users can swap it (TCP framing, in-memory for tests, custom IPC).
- **Discovery-delegated.** Bootstrap peer discovery is a user-supplied interface; the default `StaticDiscovery` takes a list of peers at construction (v0 uses initial-peers only; the `watch` async iterator is reserved for future use). Consumers plug in environment-specific implementations (k8s Endpoints, slurm-nodelist, multicast, etc).
- **Application-domain neutral.** The library knows about members, broadcasts, and gossip. Application semantics live entirely in user-supplied `BroadcastHandler` implementations; the library does not ship a default handler.

## Why

Existing pure-Python SWIM implementations are research-grade or unmaintained. The Rust ecosystem has battle-tested options (foca, memberlist-rs, chitchat) but bundling native code in a Python plugin context (e.g., a vLLM plugin) introduces deployment friction. swim-py fills the gap: a pure-Python SWIM+ implementation suitable for production use, designed for in-process integration into Python-shaped distributed systems.

## License

Apache-2.0. See LICENSE.

## Acknowledgments

Design is foca-inspired: the algorithmic choices and engineering tradeoffs in foca informed this library's architecture. swim-py is a clean-room reimplementation, not a port. The protobuf wire schema is designed for cross-language compatibility with foca configured to use the same codec.
