Metadata-Version: 2.5
Name: sillydev
Version: 1.2.0
Summary: Python client, CLI and MCP server for the Silly Development panel API, and opt-in application monitoring.
Project-URL: Homepage, https://sillydev.co.uk
Project-URL: Documentation, https://sillydev.co.uk/docs/panel
Author: Silly Development
License-Expression: MIT
License-File: LICENSE
Keywords: api,bot hosting,mcp,monitoring,sdk,silly development,sillydev
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
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: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.23
Provides-Extra: console
Requires-Dist: websockets>=13; extra == 'console'
Provides-Extra: dev
Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: respx>=0.20; extra == 'dev'
Requires-Dist: websockets>=13; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'mcp'
Provides-Extra: monitoring-discord
Requires-Dist: discord-py>=2; extra == 'monitoring-discord'
Provides-Extra: monitoring-psutil
Requires-Dist: psutil>=5; extra == 'monitoring-psutil'
Description-Content-Type: text/markdown

# sillydev

The Python client for the [Silly Development](https://sillydev.co.uk) panel API: start, stop and
restart your bots, send console commands, manage files and backups, and deploy from Git, all from
your own code. It also comes with a `sillydev` command line tool, an MCP server for AI assistants
and opt-in [application monitoring](#monitoring).

Full API docs: <https://sillydev.co.uk/docs/panel>

## Install

```sh
pip install sillydev
```

Python 3.9 or later. The only dependency is [httpx](https://www.python-httpx.org/). For the
console's websocket, add the `console` extra (`pip install 'sillydev[console]'`); without it the
console streams or polls over plain HTTPS instead.

## Quick start

Create an API key in the panel under **Account, API keys** and put it in the `SILLYDEV_API_KEY`
environment variable. Keys start with `sdevc_` (older `ptlc_` keys keep working).

```python
from sillydev import SillyDev

client = SillyDev()  # or SillyDev(api_key="sdevc_...")

for server in client.panel.servers.list():
    print(server["identifier"], server["name"])

client.panel.servers.power("1a7ce997", "restart")
client.panel.servers.command("1a7ce997", "say Back in a minute")
```

Each server has an 8-character identifier, shown in the panel and in the `identifier` field of
every server. Every method takes it (or the full UUID) as its first argument.

## Examples

```python
from sillydev import SillyDev

client = SillyDev()
panel = client.panel
server = "1a7ce997"

# Live state and resource use
stats = panel.servers.resources(server)
print(stats["current_state"], stats["resources"]["memory_bytes"])

# Restart and wait until it's running again (raises WaitTimeoutError after 120 seconds)
panel.servers.restart(server)
panel.servers.wait_for_state(server, "running", timeout=120)

# Files
for f in panel.files.list(server, directory="/"):
    print(f["name"], f["size"])
config = panel.files.read(server, "/config.json")
panel.files.write(server, "/config.json", config.replace("debug", "info"))

# Backups
backup = panel.backups.create(server, name="before-update")
print(backup["uuid"])

# Deploy the linked Git repository
deployment = panel.git.deploy(server)
print(deployment["status"])  # "queued"

# Startup variables
panel.startup.set_variable(server, "MAIN_FILE", "bot.py")
```

### Responses

Methods return plain dicts and lists. The API's `{"object": ..., "attributes": {...}}` envelopes
are removed, so `panel.servers.get(server)["name"]` just works.

### Pagination

`panel.servers.list()`, `panel.backups.list()`, `panel.servers.activity()` and
`panel.account.activity()` return iterators that fetch the next page as you go. For one page at a
time, with the list's `meta`, use the `*_page` methods:

```python
page = panel.servers.list_page(page=1, per_page=25)
print(page.total, page.has_next, page.meta.get("pending_orders"))
```

### Async

```python
import asyncio
from sillydev import AsyncSillyDev

async def main():
    async with AsyncSillyDev() as client:
        async for server in client.panel.servers.list():
            print(server["name"])
        await client.panel.servers.restart("1a7ce997")

asyncio.run(main())
```

### Errors

Every error is a `sillydev.SillyDevError`. Errors the API answers with have their own class:

| Status | Exception |
| --- | --- |
| 400 | `BadRequestError` |
| 401 | `AuthenticationError` |
| 403 | `PermissionDeniedError` |
| 404 | `NotFoundError` |
| 409 | `ConflictError` |
| 422 | `ValidationError` (`.errors` has every problem, `.fields` groups them by field) |
| 429 | `RateLimitError` (`.retry_after` in seconds) |
| 5xx | `ServerError` |

If the API cannot be reached at all you get `APIConnectionError`.

```python
from sillydev import ConflictError, NotFoundError

try:
    panel.servers.restart(server)
except ConflictError:
    print("The server is installing or being transferred; try again later")
except NotFoundError:
    print("No such server, or the key cannot reach it")
```

### Retries and limits

Each account may make 768 requests a minute. Requests answered 429 are retried
twice by default, and reads answered 502, 503 or 504 too (a command or power action is not
repeated, since the server may already have run it), waiting as long as the `Retry-After` header asks. Change it with
`SillyDev(max_retries=...)`; `0` turns retries off.

Ordering a paid server (`panel.billing.checkout(...)`) always sends an `Idempotency-Key`, so a
retried request never buys twice. Pass `idempotency_key=` yourself to make retries safe across runs.

### Everything else

`client.panel` has one attribute for each area of the API:

| Attribute | What it covers |
| --- | --- |
| `servers` | list, get, delete, resources, activity, permissions, power, command, share logs, console credentials and relay |
| `console` | the live console and its recent output ([below](#console)) |
| `settings` | rename, reinstall, Docker image, status badge |
| `startup` | startup command and variables |
| `files` | list, read, write, rename, copy, compress, decompress, delete, folders, chmod, pull, signed URLs |
| `backups` | list, create, get, delete, download, lock, restore |
| `schedules` | schedules and their tasks |
| `network` | allocations (ports) |
| `subusers` | other accounts with access to a server |
| `git` | link a repository, deploy, deployments, `sillydev.conf` |
| `account` | the key's account and its activity |
| `server_creation` | create free servers |
| `databases` | free MariaDB and Postgres databases |
| `billing` | paid servers and subscriptions |
| `monitoring` | monitoring projects, keys, issues, events and usage |

Every method's docstring names the API operation it calls, so you can look it up in the
[API reference](https://sillydev.co.uk/docs/panel).

## Console

`client.panel.console` connects to a server's live console: its output, state and resource use as
they happen, with commands and power actions sent back.

```python
with client.panel.console.connect("1a7ce997") as console:
    console.command("say Hello")
    for message in console:  # Every message since the node accepted the token
        if message.event == "console output":
            print(message.args[0])
```

`console.messages(timeout=5)` stops after five quiet seconds instead of waiting for ever.
Listeners work too: `console.on("console output", lambda m: print(m.args[0]))`. With the sync
client the connection runs on a background thread, which is where listeners are called. With
`AsyncSillyDev` it is a coroutine away: `console = await client.panel.console.connect(...)`, then
`async for message in console`, `await console.command(...)` and `await console.close()`.

The recent output (about the last 150 lines; empty while the server is offline):

```python
lines = client.panel.console.history("1a7ce997")
```

There are four ways to reach a console, and by default (`transport="auto"`) each is tried in turn
until one works:

| `transport` | How |
| --- | --- |
| `websocket` | A websocket to the server's node. The quickest. Needs `sillydev[console]`; without it `auto` skips it. |
| `stream` | The same messages streamed over plain HTTPS from a session on the node, for networks that block websockets. |
| `poll` | The same session read by long polling, for proxies that hold streams back. |
| `relay` | The panel reads a session on the node for you, for networks that can't reach the node at all. A second or two slower. |

Name one, as in `connect(server, transport="poll")`, to use only that. `console.transport` says
which is in use. Messages are the websocket's on every way: `ConsoleMessage(event, args)` with
events such as `console output`, `status`, `stats` (`args[0]` is JSON), `daemon message` and
`install output`; see [the API reference](https://sillydev.co.uk/docs/panel/servers) for the full
list. `send(event, *args)` sends any of them.

The connection renews its token before it expires and reconnects (trying each way again) if it
drops, up to `max_reconnect_attempts` times in a row (default 5; `reconnect=False` turns it off).
When it gives up, or the server is suspended or deleted, the console ends with a `ConsoleError`
whose `reason` says why (`suspended`, `booting`, `token`, `timeout`, `network`, `deleted`,
`throttled` or `unavailable`): iterating raises it, `on_close(listener)` receives it, and
`console.close_reason` holds it. When `connect()` itself fails with `auto`, `error.attempts` says
why each way failed.

A console counts towards the 30 console connections a server may have open, and connecting gets a
token from the API (limited to 5 a minute per server), so keep one open rather than connecting
again and again.

## Command line

```sh
export SILLYDEV_API_KEY="sdevc_..."

sillydev servers list
sillydev status 1a7ce997
sillydev power 1a7ce997 restart
sillydev command 1a7ce997 "say hi"
sillydev logs 1a7ce997
sillydev console 1a7ce997
sillydev deploy 1a7ce997
sillydev files ls 1a7ce997 /src
sillydev files cat 1a7ce997 /config.json
sillydev backups list 1a7ce997
sillydev backups create 1a7ce997 --name before-update
```

Add `--json` to any command for JSON output. `python -m sillydev` works too. `console` follows the
console live and sends each line you type as a command; Ctrl+C stops it. `logs` and `console` take
`--transport` (as above).

## MCP server

The MCP server lets an AI assistant (Claude Desktop, Claude Code, Cursor and others) check on and
control your servers. It needs Python 3.10 or later:

```sh
pip install 'sillydev[mcp]'
```

Then add it to your assistant's MCP settings, for example:

```json
{
  "mcpServers": {
    "sillydev": {
      "command": "sillydev-mcp",
      "env": { "SILLYDEV_API_KEY": "sdevc_..." }
    }
  }
}
```

Its tools are: list servers, server status, power action, send command, read console (recent
output), list files, read file, Git deploy and list backups. There is nothing for billing or for deleting anything. Give it a key
limited to the servers and permissions you want the assistant to have.

## Monitoring

`sillydev.monitoring` sends your application's metrics, errors, events and slow operations to
[Silly Development monitoring](https://sillydev.co.uk/docs/monitoring). It is **opt-in**: using
the SDK never sends monitoring data by itself. `import sillydev` does not load it, and importing
`sillydev.monitoring` starts no thread, installs no hook and reads no environment variable. Only
`monitoring.init()` does, and only when it is given a key.

1. In the panel, create a monitoring project and a **server key** (`sdevm_<n>_…`, shown once).
2. Put it in `SILLYDEV_MONITORING_KEY` (servers linked to a project get it automatically) and call
   `init()` once at start-up:

```python
from sillydev import monitoring

monitoring.init()  # reads SILLYDEV_MONITORING_KEY; without a key it does nothing

monitoring.increment("orders.created", tags={"plan": "free"})
monitoring.gauge("queue.size", 12)
monitoring.timing("payments.charge", 182.5)          # milliseconds
with monitoring.timer("reports.render"):
    render()

@monitoring.timed                                     # sync or async functions
async def sync_inventory(): ...

monitoring.event("deploy", {"version": "1.4.2"})
monitoring.record_slow("nightly-export", 42_000, kind="task")
try:
    risky()
except Exception:
    monitoring.capture_exception(tags={"job": "import"})   # the exception being handled
monitoring.capture_message("Disk almost full", level="warning")
```

Every function is a no-op that never raises until `init()` has a key, so libraries can call them
freely. Unhandled exceptions (`sys.excepthook`, `threading.excepthook` and the asyncio exception
handler of instrumented loops) are reported and then passed to the previous hook, so tracebacks are
still printed and the process still exits as before.

Metrics are added up in 10-second windows in your process and sent by a background thread every
`flush_interval` seconds (gzipped, at most 500 items a request), along with process metrics:
`process.cpu.percent`, `process.memory.rss`, `process.threads`, `process.uptime`,
`runtime.gc.count`, `runtime.gc.pause` and, for asyncio apps, `runtime.event_loop.lag` (the loop
running when `init()` is called, or any loop passed to `monitoring.instrument_loop(loop)`).
Buffered data is sent on exit (for at most 2 seconds). If the ingest is down or rate limits, data is
kept (up to `max_buffer_bytes`, oldest dropped first) and retried with backoff; if the key is
revoked (401/403) the SDK logs one warning and stops.

| Option | Environment variable | Default |
| --- | --- | --- |
| `key` | `SILLYDEV_MONITORING_KEY` | none: monitoring stays off |
| `endpoint` | `SILLYDEV_MONITORING_URL` | `https://ingest<n>.sillydev.co.uk`, from the key |
| `service` | `SILLYDEV_MONITORING_SERVICE` | `default` |
| `environment` | `SILLYDEV_MONITORING_ENVIRONMENT` | `production` |
| `release` | `SILLYDEV_MONITORING_RELEASE` | none |
| `enabled` | `SILLYDEV_MONITORING_DISABLED=1` turns it off | `True` |
| `runtime_metrics` | | `True` |
| `capture_unhandled` | | `True` |
| `sample_rate` | | `1.0` (share of errors and events kept; metrics are never sampled) |
| `before_send` | | none: `before_send(item_type, item)` returns the item, changed or not, or `None` to drop it (`"error"`, `"event"`, `"slow"`) |
| `flush_interval` | | 10 seconds |
| `max_buffer_bytes` | | 1,000,000 |
| `slow_threshold_ms` | | 1000 |
| `debug` | | `False` (logs every payload on the `sillydev.monitoring` logger) |

**Privacy.** No local variables are ever sent. Tag, context and attribute keys that look like
`password`, `secret`, `token`, `authorization`, `cookie` or `api_key` have their values replaced
with `[redacted]`. Use `before_send` to remove anything else.

### Integrations

Each one is imported on its own and needs nothing installed beyond the framework you already use:

```python
# FastAPI / Starlette: http.server.duration by route template, method and status class
from sillydev.monitoring.asgi import MonitoringMiddleware
app.add_middleware(MonitoringMiddleware)

# Flask (or Django: wrap get_wsgi_application(), and add
# "sillydev.monitoring.wsgi.DjangoRouteMiddleware" to MIDDLEWARE for route templates)
from sillydev.monitoring.wsgi import MonitoringMiddleware
app.wsgi_app = MonitoringMiddleware(app.wsgi_app)

# aiohttp
from sillydev.monitoring.aiohttp import middleware
app = web.Application(middlewares=[middleware])

# discord.py: discord.command.duration (command, outcome), command and event errors,
# discord.gateway.latency and discord.guilds
from sillydev.monitoring.discord import monitor_bot
bot = monitor_bot(commands.Bot(command_prefix="!", intents=intents))
```

Requests and commands slower than `slow_threshold_ms` are also reported as slow operations.
Keep routes few: each distinct route (and tag value) is a separate series, and projects have a
series limit. Paths without a route template have their ids replaced with `:id`.

## Configuration

| Option | Environment variable | Default |
| --- | --- | --- |
| `api_key` | `SILLYDEV_API_KEY` | (required) |
| `base_url` | `SILLYDEV_BASE_URL` | `https://sillydev.co.uk/panel/api/client` |
| `timeout` | | 60 seconds |
| `max_retries` | | 2 |

## License

MIT
