Metadata-Version: 2.4
Name: ensemble-client
Version: 0.4.0
Summary: Python client for the Ensemble peer-to-peer messaging daemon.
Project-URL: Homepage, https://github.com/boxsie/ensemble
Project-URL: Repository, https://github.com/boxsie/ensemble.git
Project-URL: Issues, https://github.com/boxsie/ensemble/issues
Author: boxsie
License-Expression: MIT
Keywords: ensemble,grpc,messaging,p2p,tor
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: cryptography>=41.0
Requires-Dist: grpcio>=1.62
Requires-Dist: protobuf>=5.0
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: grpcio-tools>=1.62; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: twine>=4.0; extra == 'dev'
Description-Content-Type: text/markdown

# ensemble-client (Python)

Python client library for the [Ensemble](https://github.com/boxsie/ensemble)
decentralized P2P messaging daemon. Wraps the `RegisterService` bidi gRPC
stream so an external Python process can register a service against a local
daemon and exchange chat messages with peers.

## Installation

```bash
pip install ensemble-client
```

Requires Python 3.11+. Depends on `grpcio`, `protobuf`, `cryptography`.

For local development against an unpublished checkout:

```bash
git clone https://github.com/boxsie/ensemble.git
pip install -e ensemble/clients/python
```

## Quick start

Connect to a daemon and inspect the service handle (`address`, `onion`) the
moment the registration completes:

```python
import asyncio
from ensemble import ACL, Client

async def main():
    async with Client(
        socket_path="/run/ensemble/sock",
        auth_seed="/etc/ensemble/admin.seed",
    ) as client:
        async with await client.register("echo", acl=ACL.CONTACTS) as svc:
            print(f"Registered: {svc.address} (onion={svc.onion})")

asyncio.run(main())
```

## Service registration quickstart

Loop the events iterator and echo every inbound chat back to the sender:

```python
import asyncio
from ensemble import ACL, ChatMessage, Client

async def main():
    async with Client(
        socket_path="/run/ensemble/sock",
        auth_seed="/etc/ensemble/admin.seed",
    ) as client:
        async with await client.register("echo", acl=ACL.CONTACTS) as svc:
            print(f"Registered: {svc.address} {svc.onion}")
            async for ev in svc.events():
                if isinstance(ev, ChatMessage):
                    await svc.send_message(ev.from_addr, f"echo: {ev.text}")

asyncio.run(main())
```

See [`examples/echo.py`](examples/echo.py) for a complete runnable example
with argparse + connection-request handling.

### Running examples against a loopback daemon

For local development the daemon's `--signaling=loopback` mode (Ensemble
T2) skips Tor bootstrap entirely and brings the gRPC + signaling layers
up in &lt;200ms, using a per-host Unix-socket rendezvous directory in
place of the DHT. Two daemons on the same host pointed at the same
rendezvous directory can discover each other and complete a contact
handshake without ever touching Tor. Ideal for iterating on
`examples/echo.py` and `examples/echo_rpc.py`.

```bash
# Terminal 1 — start a daemon in loopback mode.
ensemble --headless --signaling=loopback \
  --data-dir /tmp/echo-daemon \
  --api-addr 127.0.0.1:9090

# Terminal 2 — register the echo service against it.
python examples/echo.py --addr localhost:9090
```

No `--tor-path`, no `ENSEMBLE_ADMIN_KEY`, no onion descriptor. The
daemon's `GetStatus` still reports `tor_state == "ready"` once the
loopback backend is up — that's a compat shim for SDK fixtures, not a
Tor claim. Loopback is Linux-only (uses Unix domain sockets); pass
`--loopback-dir /custom/path` if `$XDG_RUNTIME_DIR/ensemble-loopback/`
isn't a good fit. HTTP-transport services (the built-in `ui` service)
are not supported under loopback — chat and RPC transports work
unchanged.

## Connecting

Pass exactly one of `socket_path` or `addr` to `Client`:

- `socket_path="/run/ensemble/sock"` — Unix socket (the typical k8s sidecar setup).
- `addr="localhost:9090"` — TCP, optionally with `tls=True`.

The `auth_seed` argument may be either raw bytes or a path to a file
containing the seed (32 raw bytes, or 64 ASCII hex characters). It must match
the daemon's configured admin key (see `ensemble keygen` in the main repo).

### TLS

Pass `tls=True` to use TLS. The client uses the system trust store;
self-signed daemon certs (e.g. behind a LAN CA) need either the CA installed
in the trust store or `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` pointing at it.
There is no clean equivalent to the Go CLI's `--tls-insecure` flag in
grpc-python; the parameter exists for API parity but does not currently
disable verification.

## Events

`ServiceHandle.events()` yields decoded dataclasses, not raw protobuf:

- `ChatMessage(type, from_addr, text, ts)` — inbound chat.
- `ConnectionRequest(type, request_id, from_addr)` — inbound connection
  awaiting accept/reject. Respond with
  `svc.accept_connection(request_id)` or
  `svc.reject_connection(request_id, reason)`.
- `UnknownEvent(type, payload)` — forward-compat fallback for event types
  the client version doesn't recognise.

The daemon enforces backpressure with a 256-deep per-stream queue and drops
oldest events under sustained load (no on-wire signal). Consume events
promptly.

## Public services (RPC transport + introductions)

For services that accept callers beyond the contact list, three primitives
work together (see [`examples/matchmaker_stub.py`](examples/matchmaker_stub.py)):

- `transport=Transport.RPC` on `client.register(...)` opts the service
  into raw protobuf bytes both directions. Reply via
  `svc.send_bytes(to_addr, payload)`; receive via either an
  `svc.on_rpc_message(handler)` callback or `RpcMessage` items from
  `svc.events()`.
- `svc.introduce_peers(to_addr, other_addr, session_id, expires_at_ms,
  role_hint="", payload=b"")` asks the daemon to introduce two peers to
  each other. The receiving peer gets a `PeerIntroduction` event with a
  daemon-attested `from_service_addr` — provenance comes free; replay
  protection (`session_id` + `expires_at`) is consumer-side.
- `max_payload_bytes` and `rate_limit_per_minute` / `rate_limit_burst`
  on the manifest cap inbound abuse. Oversize / throttled inbound
  envelopes surface as `PayloadTooLargeError` / `RateLimitedError` from
  `events()` — branch on the typed exception, do NOT string-match
  `message`.

## Caveats

- `keypair_seed` on the manifest is currently advisory: the daemon's
  keystore is append-only and ignores externally-supplied seeds. Pin to the
  server-issued `address` from `ServiceRegistered` for stability across
  restarts. (T07 limitation; tracked for follow-up.)
- Outbound chat (`send_message`) currently travels under the daemon's
  primary node identity, not the registered service's identity. Inbound
  chat correctly carries the service address. (Also T07.)
- Async-only API. There's no synchronous wrapper; use `asyncio.run` or
  embed in your existing event loop.

## Regenerating the gRPC stubs

The `ensemble/_proto/*.py` files are checked in. Regenerate via the
top-level `Makefile` (canonical entrypoint — regenerates Go and Python
stubs together so they stay in lock-step):

```bash
make proto
```

## Versioning

`ensemble-client` is versioned independently from the daemon and from the
.NET client.

- Tag pattern for PyPI releases: `client-python/v<MAJOR>.<MINOR>.<PATCH>`
  (e.g. `client-python/v0.1.0`). The publish workflow at
  `ci/workflows/python-publish.yml` is wired to trigger only on tags
  matching that prefix, so daemon (`v*.*.*`) and .NET
  (`client-dotnet/v*.*.*`) tags never accidentally cut a Python release.
- Pre-1.0: minor bumps may include breaking changes. Pin the major+minor
  (e.g. `ensemble-client~=0.1.0`) if you depend on this in production.
- Post-1.0: semver. Breaking changes bump the major.

## Links

- Daemon + source: <https://github.com/boxsie/ensemble>
- Issues: <https://github.com/boxsie/ensemble/issues>
- PyPI: <https://pypi.org/project/ensemble-client/>
