Metadata-Version: 2.4
Name: erlc-api.py
Version: 2.3.1
Summary: Lightweight sync and async Python wrapper for the ER:LC PRC API
Author: Avi Sehrawat
Maintainer: Avi Sehrawat
License-Expression: LicenseRef-ERLC-API-Wrapper-Attribution-License-1.0
Project-URL: Homepage, https://github.com/Fortune1243/erlc-api
Project-URL: Repository, https://github.com/Fortune1243/erlc-api
Project-URL: Issues, https://github.com/Fortune1243/erlc-api/issues
Project-URL: Documentation, https://github.com/Fortune1243/erlc-api/tree/master/docs/wiki
Keywords: erlc,er:lc,prc,prc-api,roblox,api-wrapper,async,sync
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: mypy>=1.8; extra == "dev"
Requires-Dist: ruff>=0.3; extra == "dev"
Provides-Extra: webhooks
Requires-Dist: cryptography>=42.0; extra == "webhooks"
Provides-Extra: export
Requires-Dist: openpyxl>=3.1; extra == "export"
Provides-Extra: time
Requires-Dist: python-dateutil>=2.9; extra == "time"
Provides-Extra: rich
Requires-Dist: rich>=13.0; extra == "rich"
Provides-Extra: scheduling
Requires-Dist: apscheduler>=3.10; extra == "scheduling"
Provides-Extra: location
Requires-Dist: Pillow>=10.0; extra == "location"
Provides-Extra: utils
Requires-Dist: openpyxl>=3.1; extra == "utils"
Requires-Dist: python-dateutil>=2.9; extra == "utils"
Requires-Dist: rich>=13.0; extra == "utils"
Requires-Dist: apscheduler>=3.10; extra == "utils"
Requires-Dist: Pillow>=10.0; extra == "utils"
Provides-Extra: all
Requires-Dist: cryptography>=42.0; extra == "all"
Requires-Dist: openpyxl>=3.1; extra == "all"
Requires-Dist: python-dateutil>=2.9; extra == "all"
Requires-Dist: rich>=13.0; extra == "all"
Requires-Dist: apscheduler>=3.10; extra == "all"
Requires-Dist: Pillow>=10.0; extra == "all"
Dynamic: license-file

# erlc-api.py

[![PyPI](https://img.shields.io/pypi/v/erlc-api.py)](https://pypi.org/project/erlc-api.py/)
[![Python](https://img.shields.io/pypi/pyversions/erlc-api.py)](https://pypi.org/project/erlc-api.py/)
[![License](https://img.shields.io/badge/license-Custom_Attribution-blue)](LICENSE)

Lightweight Python wrapper for the **ER:LC PRC API**. Version 2 is a v2-first
release with flat sync and async clients, typed dataclass responses by default,
safe rate-limit handling, flexible commands, and explicit utility modules that
only load when you import them.

Install the PyPI package as `erlc-api.py`; import it in Python as `erlc_api`.

## Install And Extras

```bash
pip install erlc-api.py
```

Development install:

```bash
pip install -e .[dev]
```

Optional extras:

| Extra | Installs | Used by |
| --- | --- | --- |
| `webhooks` | `cryptography` | Event webhook Ed25519 signature verification |
| `export` | `openpyxl` | `Exporter(...).xlsx(...)` |
| `time` | `python-dateutil` | `TimeTools().parse(..., enhanced=True)` |
| `rich` | `rich` | `Formatter().rich_table(...)` |
| `scheduling` | `apscheduler` | Advanced scheduling integrations around watchers |
| `location` | `Pillow` | Optional map overlays through `MapRenderer` |
| `utils` | all utility extras | Export, time, rich, scheduling, and location helpers |
| `all` | webhooks plus utility extras | Everything optional |

Example:

```bash
pip install "erlc-api.py[webhooks,export]"
```

## Package Name And Import Name

| Where | Name |
| --- | --- |
| PyPI install | `pip install erlc-api.py` |
| Python import | `import erlc_api` |
| Core imports | `from erlc_api import AsyncERLC, ERLC, cmd` |

The repository URL still uses `erlc-api`, but the published package name is
`erlc-api.py` to avoid ambiguity with other packages.

## Quickstart

Async apps and bots:

```python
import asyncio
from erlc_api import AsyncERLC, CommandPolicy, cmd


async def main() -> None:
    policy = CommandPolicy(allowed={"h"}, max_length=120)
    async with AsyncERLC("server-key") as api:
        bundle = await api.server(players=True, queue=True, staff=True)
        preview = await api.command(policy.validate(cmd.h("Hello from the API")), dry_run=True)

        print(bundle.name, len(bundle.players or []), preview.raw["command"])


asyncio.run(main())
```

Sync scripts:

```python
from erlc_api import ERLC, CommandPolicy

with ERLC("server-key") as api:
    policy = CommandPolicy(allowed={"h"}, max_length=120)
    players = api.players()
    result = api.command(policy.validate("h Hello"), dry_run=True)
    print(len(players), result.message)
```

## Safe Defaults

- Dynamic process-local rate limiting is enabled by default with
  `rate_limited=True`.
- `retry_429=True` sleeps once and retries once when PRC provides retry timing.
- Commands stay flexible, but bot/web examples should gate execution with
  `CommandPolicy`, permissions, and cooldowns.
- Server keys are never stored or encrypted by the wrapper; keep them in your
  secret manager or environment.

```python
from erlc_api.security import key_fingerprint

print(key_fingerprint("server-key"))  # safe for logs; never log the key itself
```

## Client Reference

`AsyncERLC` is for async frameworks, Discord bots, FastAPI apps, background
workers, and anything already running an event loop.

```python
AsyncERLC(
    server_key: str | None = None,
    *,
    global_key: str | None = None,
    base_url: str = "https://api.policeroleplay.community",
    timeout_s: float = 20.0,
    retry_429: bool = True,
    rate_limited: bool = True,
    user_agent: str | None = None,
)
```

Use it as an async context manager, or call `await api.start()` and
`await api.close()` yourself.

`ERLC` has the same constructor and method names for sync scripts:

```python
ERLC(
    server_key: str | None = None,
    *,
    global_key: str | None = None,
    base_url: str = "https://api.policeroleplay.community",
    timeout_s: float = 20.0,
    retry_429: bool = True,
    rate_limited: bool = True,
    user_agent: str | None = None,
)
```

Every request sends `Server-Key`. If `global_key=` is configured, requests also
send `Authorization`.

Every endpoint method accepts `server_key=` so one client can work with multiple
servers:

```python
api = ERLC("primary-server-key")

primary = api.players()
secondary = api.players(server_key="secondary-server-key")
```

`validate_key()` and `health_check()` return `ValidationResult` instead of
raising common API errors.

## Endpoint Methods

Typed models are returned by default. Pass `raw=True` when you need raw PRC data;
the exact shape depends on the method and is summarized below.

| Method | PRC endpoint | Default return type | Notes |
| --- | --- | --- | --- |
| `server(...)` | `GET /v2/server` | `ServerBundle` | Accepts include flags for v2 sections |
| `players()` | `GET /v2/server?Players=true` | `list[Player]` | Parses `PlayerName:Id` |
| `staff()` | `GET /v2/server?Staff=true` | `StaffList` | Staff object maps plus `.members` |
| `queue()` | `GET /v2/server?Queue=true` | `list[int]` | Queue user IDs in API order |
| `join_logs()` | `GET /v2/server?JoinLogs=true` | `list[JoinLogEntry]` | Includes join/leave flag and timestamp |
| `kill_logs()` | `GET /v2/server?KillLogs=true` | `list[KillLogEntry]` | Includes killer/victim helpers |
| `command_logs()` | `GET /v2/server?CommandLogs=true` | `list[CommandLogEntry]` | Useful with `Finder` and `Analyzer` |
| `mod_calls()` | `GET /v2/server?ModCalls=true` | `list[ModCallEntry]` | Includes caller/moderator helpers |
| `emergency_calls()` | `GET /v2/server?EmergencyCalls=true` | `list[EmergencyCall]` | v2 emergency call payloads |
| `vehicles()` | `GET /v2/server?Vehicles=true` | `list[Vehicle]` | Vehicle model, owner, plate, color |
| `bans()` | `GET /v1/server/bans` | `BanList` | Uses v1 because v2 does not replace it |
| `command(command, ...)` | `POST /v2/server/command` | `CommandResult` | Accepts strings or `cmd` values |
| `request(method, path, ...)` | Any path | raw JSON/text | Low-level escape hatch |

### Endpoint Version Map

| API area | PRC version | Wrapper methods |
| --- | --- | --- |
| Server status and includes | v2 | `server`, `players`, `staff`, `queue`, logs, `vehicles`, `emergency_calls` |
| Command execution | v2 | `command` |
| Bans | v1 | `bans` |
| Custom requests | caller chooses | `request` |

### Support Matrix

| Feature | Built in | Notes |
| --- | --- | --- |
| Async client | Yes | `AsyncERLC` |
| Sync client | Yes | `ERLC` |
| Typed dataclasses | Yes | Default response mode |
| Raw PRC data | Yes | `raw=True` |
| Dynamic rate limiting | Yes | Enabled by default, process-local |
| Event webhook verification | Optional extra | `erlc-api.py[webhooks]` |
| Discord bot framework | No | Docs use `discord.py`; wrapper stays framework-neutral |
| Persistent/distributed cache | No | Bring your own adapter or external store |

`server()` include options:

```python
bundle = await api.server(players=True, queue=True, staff=True)
everything = await api.server(all=True)
custom = await api.server(include=["players", "vehicles"])
raw_payload = await api.server(all=True, raw=True)
```

## Command API

Commands are intentionally flexible:

```python
from erlc_api import CommandPolicy, CommandPolicyError, cmd, normalize_command

await api.command(":h hi")
await api.command("h hi")
await api.command(cmd.h("hi"))
await api.command(cmd.pm("Player", "hello"))
await api.command(cmd("pm", "Player", "hello"))

assert normalize_command("h hi") == ":h hi"
```

Validation is minimal and predictable:

| Rule | Behavior |
| --- | --- |
| Leading colon missing | Added automatically |
| Blank command | Raises `ValueError` |
| Newline in command | Raises `ValueError` |
| Missing command name | Raises `ValueError` |
| `:log` | Not blocked by the wrapper |

Dry-run validates and returns a local `CommandResult` without sending HTTP:

```python
preview = await api.command(cmd.pm("Player", "hello"), dry_run=True)
print(preview.raw["command"], preview.success)
```

For Discord bots, web routes, and custom-command handlers, put an application
policy in front of command execution:

```python
policy = CommandPolicy(allowed={"h", "pm"}, max_length=120)

try:
    safe_command = policy.validate(cmd.h("Short staff announcement"))
except CommandPolicyError as exc:
    print(exc.result.reason)
else:
    await api.command(safe_command)
```

`CommandPolicy.check(...)` returns a `CommandPolicyResult` for previews and UI;
`CommandPolicy.validate(...)` raises `CommandPolicyError` when blocked.

## Raw Response Behavior

`raw=True` returns PRC data before typed model decoding, but wrapper convenience
methods intentionally return the section they are named after:

| Call | `raw=True` returns |
| --- | --- |
| `api.server(raw=True)` | Full `/v2/server` payload |
| `api.server(players=True, raw=True)` | Full `/v2/server` payload including `Players` |
| `api.players(raw=True)` | Raw `Players` list only |
| `api.staff(raw=True)` | Raw `Staff` object only |
| `api.queue(raw=True)` | Raw `Queue` list only |
| log/vehicle/call helpers with `raw=True` | Raw section list only |
| `api.bans(raw=True)` | Full raw v1 bans mapping |
| `api.command(raw=True)` | Raw v2 command response |
| `api.request(...)` | Raw decoded response body |

Model `.to_dict()` output uses wrapper field names and helper shapes. It is
JSON-safe, but it is not guaranteed to be byte-for-byte identical to PRC JSON.

## Models

Models are frozen dataclasses. They preserve the original payload in `.raw`,
unknown fields in `.extra`, and convert back to dictionaries with `.to_dict()`.

Key models:

| Model | Returned by | Useful fields |
| --- | --- | --- |
| `ServerInfo` | `server()` without sections | `name`, `owner_id`, `current_players`, `max_players` |
| `ServerBundle` | `server()` | server fields plus optional `players`, `staff`, logs, queue, vehicles |
| `Player` | `players()` | `player`, `name`, `user_id`, `permission`, `callsign`, `team`, `location` |
| `StaffList` | `staff()` | `co_owners`, `admins`, `mods`, `helpers`, `.members` |
| `CommandLogEntry` | `command_logs()` | `player`, `name`, `user_id`, `timestamp`, `command` |
| `CommandResult` | `command()` | `message`, `success` |

```python
players = await api.players()
first = players[0]

print(first.name, first.user_id)
print(first.extra)
print(first.to_dict())
```

Parse PRC `PlayerName:Id` strings directly:

```python
from erlc_api import parse_player_identifier

name, user_id = parse_player_identifier("Avi:123")
```

## Utility Modules

Utilities are explicit lazy modules. `import erlc_api` only imports clients,
models, errors, and `cmd`.

| Module | Import | Purpose |
| --- | --- | --- |
| Find | `from erlc_api.find import Finder` | Look up players, staff, vehicles, logs, bans, and calls |
| Filter | `from erlc_api.filter import Filter` | Chain filters and return `.all()`, `.first()`, `.count()` |
| Sort | `from erlc_api.sort import Sorter` | Sort by name, timestamp, team, permission, queue position, vehicle fields |
| Group | `from erlc_api.group import Grouper` | Group by team, permission, role, owner, command, day, hour |
| Diff | `from erlc_api.diff import Differ` | Compare lists or full server bundles |
| Wait | `from erlc_api.wait import AsyncWaiter, Waiter` | Poll until joins, leaves, queue changes, logs, or counts occur |
| Watch | `from erlc_api.watch import AsyncWatcher, Watcher` | Stream snapshot diffs as events and callbacks |
| Format | `from erlc_api.format import Formatter` | Compact Discord-safe, console-safe, and rich text formatting |
| Analytics | `from erlc_api.analytics import Analyzer` | Dashboard summaries, distributions, command usage, moderation trends |
| Export | `from erlc_api.export import Exporter` | JSON, CSV, Markdown, HTML, optional XLSX |
| Moderation | `from erlc_api.moderation import AsyncModerator, Moderator` | Safe command composition, previews, audit messages |
| Time | `from erlc_api.time import TimeTools` | Timestamp parsing, age strings, windows, timezone formatting |
| Schema | `from erlc_api.schema import SchemaInspector` | Field discovery, raw/extra inspection, payload diagnostics |
| Snapshot | `from erlc_api.snapshot import SnapshotStore` | JSONL snapshot persistence and latest-state comparisons |
| Audit | `from erlc_api.audit import AuditLog` | JSON-safe audit events for commands, webhooks, watchers, and moderation |
| Idempotency | `from erlc_api.idempotency import MemoryDeduper, FileDeduper` | TTL dedupe for webhook deliveries and watcher restarts |
| Limits | `from erlc_api.limits import poll_plan, safe_interval` | Conservative polling guidance without fake PRC limit claims |
| Rate Limit | `from erlc_api.ratelimit import AsyncRateLimiter, RateLimiter` | Dynamic in-memory limiter used by clients by default |
| Error Codes | `from erlc_api.error_codes import explain_error_code` | Explain PRC error codes and wrapper exception mappings |
| Custom Commands | `from erlc_api.custom_commands import CustomCommandRouter` | Framework-neutral router for PRC webhook messages starting with `;` |
| Location | `from erlc_api.location import LocationTools` | Distances, nearest players, postal/street matching, map URLs, optional overlays |
| Bundle | `from erlc_api.bundle import AsyncBundle, Bundle` | Named/custom v2 bundle presets without changing the client |
| Rules | `from erlc_api.rules import RuleEngine, Conditions` | Evaluate flexible alert rules and return matches/callback results |
| Multi Server | `from erlc_api.multiserver import AsyncMultiServer, MultiServer` | Read and aggregate multiple named servers with bounded concurrency |
| Discord Tools | `from erlc_api.discord_tools import DiscordFormatter` | Dependency-free Discord embed/message payload dictionaries |
| Diagnostics | `from erlc_api.diagnostics import diagnose_error` | User-facing diagnostics from errors, rate limits, command results, and status |
| Cache | `from erlc_api.cache import AsyncCachedClient, CachedClient` | Explicit memory TTL caching for read endpoints plus adapter protocols |
| Status | `from erlc_api.status import AsyncStatus, StatusBuilder` | Typed dashboard status snapshots with `.to_dict()` |
| Command Flows | `from erlc_api.command_flows import CommandFlowBuilder` | Preview and validate command sequences without executing them |

Example:

```python
from erlc_api.find import Finder
from erlc_api.filter import Filter
from erlc_api.export import Exporter
from erlc_api.snapshot import SnapshotStore
from erlc_api.bundle import AsyncBundle
from erlc_api.status import StatusBuilder

bundle = await AsyncBundle(api).dashboard()
player = Finder(bundle).player("Avi")
police = Filter(bundle.players or []).team("Police").all()
csv_text = Exporter(police).csv()
SnapshotStore("snapshots.jsonl").save(bundle)
status = StatusBuilder(bundle).build()
```

Custom in-game commands are received through PRC Event Webhooks. Use
`erlc_api.webhooks` for signature verification and `erlc_api.custom_commands`
for flexible routing:

```python
from erlc_api.custom_commands import CustomCommandRouter

router = CustomCommandRouter(prefix=";")


@router.command("ping", "p")
async def ping(ctx):
    return ctx.reply("pong")


result = await router.dispatch({"Message": ";p"})
```

## Errors

All wrapper exceptions inherit from `ERLCError`.

| Exception | Raised when |
| --- | --- |
| `APIError` | Non-success response without a more specific mapping |
| `BadRequestError` | Request payload, path, or params are invalid |
| `AuthError` | Server key or global key is missing, invalid, banned, or unauthorized |
| `PermissionDeniedError` | A valid key cannot access the resource |
| `NotFoundError` | The requested API path/resource was not found |
| `NetworkError` | Timeout, DNS, connection, or transport failure |
| `RateLimitError` | PRC returns `429` or a rate-limit error code |
| `InvalidCommandError` | Command syntax/payload is rejected by PRC |
| `RestrictedCommandError` | PRC restricts the command from API execution |
| `ProhibitedMessageError` | Command text is prohibited by PRC |
| `ServerOfflineError` | Server is offline or unavailable for the request |
| `RobloxCommunicationError` | PRC cannot communicate with Roblox or the module |
| `ModuleOutdatedError` | In-game module must be updated |
| `ModelDecodeError` | Typed decoding received an unexpected payload shape |

```python
from erlc_api import ERLCError, RateLimitError

try:
    players = await api.players()
except RateLimitError as exc:
    print(exc.retry_after, exc.reset_epoch_s, exc.bucket)
except ERLCError as exc:
    print(exc.status_code, exc.error_code, exc.body_excerpt)
```

## Rate Limits

On `429`, `RateLimitError` exposes:

| Attribute | Meaning |
| --- | --- |
| `retry_after` / `retry_after_s` | Seconds to wait when PRC provides `Retry-After` or body retry data |
| `reset_epoch_s` | Epoch reset time parsed from `X-RateLimit-Reset` |
| `bucket` | Bucket name from `X-RateLimit-Bucket` |
| `error_code` | PRC error code when present |

By default `retry_429=True`, so the transport sleeps once and retries once when
it has retry timing. Set `retry_429=False` to handle rate limits yourself.

Dynamic rate limiting is enabled by default. It learns from PRC rate-limit
headers and waits before avoidable requests:

```python
api = AsyncERLC("server-key")
print(api.rate_limits)
```

Pass `rate_limited=False` only when your application has its own limiter and
you want to disable the wrapper's pre-request waiting.

## Known Limitations

- Built-in rate limiting is process-local. Multiple bot shards, containers, or
  workers need external coordination if they share keys.
- The wrapper does not store, encrypt, rotate, or validate server keys unless
  your app calls `validate_key()`.
- Command execution is powerful. Gate it with Discord permissions, web auth,
  cooldowns, `CommandPolicy`, dry-run previews, and audit logs.
- Discord and FastAPI examples are safe templates, not complete production bot
  or web security systems.
- Optional rendering/export/webhook features load only when explicitly imported
  and installed through extras.

## Documentation Deep Dives

The README is the compact API reference. The full documentation source lives in
`docs/wiki`:

- [Home](docs/wiki/Home.md)
- [Installation and Extras](docs/wiki/Installation-and-Extras.md)
- [Getting Started](docs/wiki/Getting-Started.md)
- [Quickstart: Web Backend](docs/wiki/Quickstart-Web-Backend.md)
- [Quickstart: Discord.py](docs/wiki/Quickstart-Discord.py.md)
- [FAQ](docs/wiki/FAQ.md)
- [Clients and Authentication](docs/wiki/Clients-and-Authentication.md)
- [Endpoint Reference](docs/wiki/Endpoint-Reference.md)
- [Endpoint Usage Cookbook](docs/wiki/Endpoint-Usage-Cookbook.md)
- [Models Reference](docs/wiki/Models-Reference.md)
- [Typed vs Raw Responses](docs/wiki/Typed-vs-Raw-Responses.md)
- [Commands Reference](docs/wiki/Commands-Reference.md)
- [Function List](docs/wiki/Function-List.md)
- [Utilities Reference](docs/wiki/Utilities-Reference.md)
- [Ops Utilities Reference](docs/wiki/Ops-Utilities-Reference.md)
- [Workflow Utilities Reference](docs/wiki/Workflow-Utilities-Reference.md)
- [Formatting, Analytics, and Export](docs/wiki/Formatting-Analytics-and-Export.md)
- [Moderation Helpers](docs/wiki/Moderation-Helpers.md)
- [Waiters and Watchers](docs/wiki/Waiters-and-Watchers.md)
- [Webhooks Reference](docs/wiki/Webhooks-Reference.md)
- [Event Webhooks and Custom Commands](docs/wiki/Event-Webhooks-and-Custom-Commands.md)
- [Custom Commands Reference](docs/wiki/Custom-Commands-Reference.md)
- [Security and Secrets](docs/wiki/Security-and-Secrets.md)
- [Rate Limits, Retries, and Reliability](docs/wiki/Rate-Limits-Retries-and-Reliability.md)
- [Errors and Rate Limits](docs/wiki/Errors-and-Rate-Limits.md)
- [Error Handling and Troubleshooting](docs/wiki/Error-Handling-and-Troubleshooting.md)
- [Testing and Mocking](docs/wiki/Testing-and-Mocking.md)
- [Migration to v2](docs/wiki/Migration-to-v2.md)
- [Comparison and Why erlc-api.py](docs/wiki/Comparison-and-Why-erlc-api.md)

## Development

```powershell
$env:PYTHONPATH = "src"
python -m pytest -q
python -m ruff check src tests scripts
```
