Metadata-Version: 2.4
Name: merlynx
Version: 0.1.0
Summary: Merlynx Config SDK for Python bots — live configuration over the merlynx.v1 protocol.
Project-URL: Homepage, https://github.com/NickTacke/merlynx
Project-URL: Documentation, https://github.com/NickTacke/merlynx/blob/main/packages/sdk-py/README.md
Project-URL: Source, https://github.com/NickTacke/merlynx
Project-URL: Issues, https://github.com/NickTacke/merlynx/issues
Author: Merlynx
License-Expression: MIT
Keywords: config,discord,discord.py,merlynx,websocket
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: websockets<16,>=13
Description-Content-Type: text/markdown

# merlynx

Live configuration for Python Discord bots running on [Merlynx](https://github.com/NickTacke/merlynx).

Your bot connects once and reads config values that server admins change from the
Merlynx dashboard — no restart, no redeploy, no polling. The same code runs on your
laptop, where there is no control plane to talk to.

```bash
pip install merlynx
```

Requires CPython 3.11+ and works with `discord.py` 2.x (or anything else — the SDK
does not touch your Discord client).

## Quickstart

```python
import asyncio
import os

import discord
import merlynx

SCHEMA = merlynx.define_config(
    prefix=merlynx.string_field(label="Command prefix", default="!"),
    greeting=merlynx.string_field(label="Welcome message", description="Sent in #general"),
    max_warns=merlynx.number_field(label="Warnings before a kick", default=3, min=1, max=10),
    verbose=merlynx.boolean_field(label="Verbose logging"),
    level=merlynx.enum_field(
        label="Log level",
        choices=[
            merlynx.Choice(value="info", label="Info"),
            merlynx.Choice(value="warn", label="Warn"),
        ],
        default="info",
    ),
)


class Bot(discord.Client):
    async def setup_hook(self) -> None:
        self.config = await merlynx.connect(schema=SCHEMA)

        @self.config.on_change
        async def _(change: merlynx.ConfigChange) -> None:
            print(f"{change.guild_id}: {change.key} is now {change.value!r}")

    async def on_ready(self) -> None:
        self.config.report_gateway("connected")

    async def on_disconnect(self) -> None:
        self.config.report_gateway("disconnected")

    async def on_message(self, message: discord.Message) -> None:
        prefix = self.config.get(str(message.guild.id), "prefix", default="!")
        ...


asyncio.run(Bot(intents=discord.Intents.default()).start(os.environ["DISCORD_TOKEN"]))
```

## Authentication

`connect()` reads the environment Merlynx injects when it deploys your bot:

| Variable | What it is |
| --- | --- |
| `MERLYNX_WS_URL` | The control plane's config socket (`wss://…`) |
| `MERLYNX_BOT_ID` | Which bot this workload is |
| `MERLYNX_CONNECT_TOKEN` | Your bot's connect credential |

The token is offered as a WebSocket subprotocol during the handshake, and the control
plane resolves it to your bot. **The SDK never sends a bot id as an authorization
claim** — identity comes from the credential, not from anything your process says.

You can pass `url=` and `token=` explicitly instead, but there is rarely a reason to:
treat the token as a secret, keep it out of your repository, and never log it.

All three go together. With **some** of them set and the rest missing, `connect()`
raises `ConnectEnvIncomplete` naming what is absent, rather than starting a bot that
would quietly read defaults forever: a partial environment is a deployment that went
wrong, not a laptop.

### Running locally

With **none** of those variables set, `connect()` returns the same object backed by a
local, in-process source. Nothing fails, nothing is networked, and every call —
including `report_gateway` — still works. Seed values in tests with `InMemorySource`:

```python
config = merlynx.MerlynxConfig(merlynx.InMemorySource({"123": {"prefix": "?"}}))
assert config.get("123", "prefix") == "?"
```

## Reading config

```python
config.get(guild_id, key)  # None if the bot has no value for it
config.get(guild_id, key, default="!")  # default answers only for an absent key
config.all(guild_id)  # every value held for one guild
```

A key an admin cleared reads as `None` — that is a value, not an absence, so
`default=` does not paper over it.

Values are `str`, `int`/`float`, `bool`, or `None`. Nothing else crosses the wire.

## Reacting to changes

```python
@config.on_change
def handler(change: merlynx.ConfigChange) -> None: ...
```

Handlers may be plain functions or coroutines. One handler raising never stops the
others. Events fire only for values that **actually changed**, including after a
reconnect: the SDK reconciles each fresh snapshot against what it already holds.

## Declaring a schema

`define_config` describes the settings the dashboard should render. Keyword names
become the stored keys, and the schema is registered when you pass it to `connect`.
Declaring one without connecting registers nothing.

Registration is non-destructive and idempotent: re-registering never touches stored
values, and dropping or renaming a key leaves its values orphaned but intact. A
schema that breaks a protocol limit raises `SchemaError` where you wrote it.

## Gateway status

Only your bot can know whether it holds a Discord Gateway session, so Merlynx shows
it as never-reported until you say otherwise:

```python
config.report_gateway("connected")  # from on_ready / on_resumed
config.report_gateway("disconnected")  # from on_disconnect
```

Report transitions, not heartbeats — the SDK re-sends your last status on an interval
and on every reconnect.

## Reconnects and recovery

The SDK reconnects on its own with exponential backoff, and every reopen brings a
fresh snapshot, so no config change is lost while you were away. There is no replay
queue and no duplicate delivery.

What it does **not** do is retry a credential that cannot work:

```python
config.on_connection_failure(lambda err: logging.error("merlynx: %s", err))
```

- **Unknown or revoked token** — `connect()` raises `UpgradeRejected`, and a token
  revoked mid-session raises it to `on_connection_failure` handlers instead of looping
  in silence. Recover by deploying the bot again from the dashboard, which injects a
  fresh `MERLYNX_CONNECT_TOKEN`.
- **Superseded credential** — when Merlynx replaces a credential, the newer workload
  is already connected before the older socket closes. Nothing is required of the new
  bot. The old one is told: its socket closes as superseded, and it reports
  `UpgradeRejected` to `on_connection_failure` rather than fighting the new workload
  for a socket it can no longer hold.
- **Unreachable control plane** — if the socket opens but no snapshot arrives,
  `connect()` raises `ConnectTimeout` after 30s (`connect_timeout_seconds=`) rather
  than waiting forever.
- **Oversized schema** — a schema too large to put on the wire is never sent. The bot
  stays connected and keeps receiving config, and `config.schema_error` holds the
  `SchemaTooLargeError` saying why nothing was registered. It is deliberately not a
  connection failure: the connection is fine, the schema is not.

## Supported versions and compatibility

- **Python** — every CPython minor still receiving upstream security fixes (3.11
  through 3.14 today). A version leaves support when its upstream does, announced one
  release ahead.
- **This package** — SemVer. The wire protocol versions separately, as the
  `merlynx.v1` subprotocol; a package major never silently changes the wire.
- **Compatibility ownership** — the control plane stays backward compatible with every
  published SDK version speaking a supported protocol version. You are never forced to
  upgrade to keep a running bot working. In return the SDK ignores frames and fields it
  does not recognise rather than failing on them.

The TypeScript SDK (`@merlynx/sdk`) implements the same protocol and passes the same
conformance suite. Parity is behavioural, not matching version numbers or method names.

## What this SDK is not

Config only. Deploys, Secrets, logs, rollback and Preview environments are product
surfaces you reach through the Merlynx dashboard and API — not methods on this package.

## License

MIT
