diff --git a/docs/lowlevel.md b/docs/lowlevel.md new file mode 100644 index 0000000..58c6f44 --- /dev/null +++ b/docs/lowlevel.md @@ -0,0 +1,180 @@ +# Low-level reference + +The high-level API (`SFSClient` / `SFSServer` / `SFSModel`) covers almost +every use case. When you need to talk to the wire directly — interoperate +with a custom SFS dialect, ship raw bytes, or write your own +acceptor — this page documents the building blocks underneath. + +- [`sfs2x.core`](#sfs2xcore) — typed primitives and `SFSObject`/`SFSArray`. +- [`sfs2x.protocol`](#sfs2xprotocol) — `Message`, the wire codec, compression and encryption. +- [`sfs2x.transport`](#sfs2xtransport) — `Transport`/`Acceptor`, TCP and WebSocket implementations. + +--- + +## `sfs2x.core` + +The data model. Every value that travels on the wire is a `Field` +subclass: a typed wrapper around a raw Python value that knows how to +serialise/deserialise itself. + +### Primitives & arrays + +```python +from sfs2x.core import ( + Bool, Byte, Short, Int, Long, Float, Double, UtfString, Text, + BoolArray, ByteArray, ShortArray, IntArray, LongArray, + FloatArray, DoubleArray, UtfStringArray, +) + +i = Int(42) # i.value == 42, i.type_code is TypeCode.INT +arr = IntArray([1, 2]) # arr.value == [1, 2] +``` + +### `SFSObject` / `SFSArray` + +```python +from sfs2x.core import SFSObject, SFSArray, Int, UtfString + +obj = SFSObject() +obj.put_int("score", 1200) \ + .put_utf_string("name", "Zewsic") \ + .put_double_array("history", [3.14, -4.5]) + +# Declarative form — raw dicts/lists are auto-wrapped: +obj = SFSObject({ + "name": UtfString("Zewsic"), + "score": Int(2022), + "items": [UtfString("Sword"), UtfString("Shield")], + "object": {"some": UtfString("thing")}, +}) + +# Access (auto-unwraps Field → raw Python value): +obj["score"] # 1200 +obj.get("missing", 0) # 0 +obj.value["score"] # Int(1200) — the actual Field instance +``` + +### Round-tripping bytes + +```python +from sfs2x.core import decode, SFSObject, Int + +obj = SFSObject({"example": Int(42)}) +raw = obj.to_bytes() +restored: SFSObject = decode(raw) +print(restored.get("example")) # 42 +``` + +--- + +## `sfs2x.protocol` + +### `Message` + +```python +from sfs2x.protocol import Message, ControllerID, SysAction +from sfs2x.core import SFSObject, UtfString + +msg = Message( + controller=ControllerID.SYSTEM, + action=SysAction.PUBLIC_MESSAGE, + payload=SFSObject({"text": UtfString("Hello!")}), +) + +# Extension calls have a convenience factory: +ext = Message.extension( + "myCmd", + SFSObject({"x": UtfString("y")}), + request_id=42, +) +``` + +### Encode / decode + +```python +from sfs2x.protocol import encode, decode + +packet = encode( + msg, + compress_threshold=512, # zlib-compress payloads > 512 bytes + encryption_key=b"my_secret_16byte", # AES-128-CBC +) +back = decode(packet, encryption_key=b"my_secret_16byte") +``` + +### Flag bits + +| Bit | Meaning | +| ------------ | -------------------------------- | +| `BINARY` | Always set on SFS2X binary packets | +| `COMPRESSED` | `zlib` compression applied | +| `ENCRYPTED` | AES-128-CBC encryption applied | +| `BIG_SIZE` | Payload length > 65535 bytes | +| `BLUEBOX` | Reserved (BlueBox transport) | + +### `AESCipher` + +`AES-128-CBC` with random IV per encryption, PKCS#7 padding. Requires +`pycryptodome`. Key must be exactly 16 bytes. + +```python +from sfs2x.protocol import AESCipher +c = AESCipher(b"my_secret_16byte") +encrypted = c.encrypt(b"hello") +plain = c.decrypt(encrypted) +``` + +### `ControllerID` / `SysAction` + +See [protocol.md](protocol.md) for the full enum tables and SFS2X +payload-key conventions (`c`/`a`/`p`/`r`/`zn`/`un`/…). + +--- + +## `sfs2x.transport` + +### `Transport` lifecycle + +```python +from sfs2x.transport import client_from_url + +async with client_from_url("tcp://localhost:9933") as t: + await t.send(msg) + reply = await t.recv() + async for m in t.listen(): + ... +``` + +### `Acceptor` lifecycle + +```python +from sfs2x.transport import server_from_url + +async with server_from_url("tcp://0.0.0.0:9933") as acc: + async for client in acc: + asyncio.create_task(handle(client)) +``` + +### URL → transport mapping + +| Scheme | Client | Server | Default port | Default path | +| ------- | ------------------ | -------------------- | ------------ | ---------------------- | +| `tcp` | `TCPTransport` | `TCPAcceptor` | `9933` | — | +| `ws` | `WSTransport` | `WSAcceptor` | `8080` | `/BlueBox/websocket` | +| `wss` | `WSTransport(TLS)` | `WSAcceptor(TLS)` | `443` | `/BlueBox/websocket` | + +Pass `ssl=ssl.SSLContext(...)` to the factory for `wss://`. + +### Concrete classes + +| Symbol | Purpose | +| --------------------- | --------------------------------------------------------- | +| `Transport` | Abstract base for client-side connections. | +| `Acceptor` | Abstract base for server-side acceptors. | +| `TransportClosedError`| Raised when `send`/`recv` is attempted on a closed transport. | +| `TCPTransport` | TCP client (`asyncio.open_connection`). | +| `TCPAcceptor` | TCP server (`asyncio.start_server`). | +| `WSTransport` | WebSocket client (`ws://` / `wss://`). | +| `WSAcceptor` | WebSocket server (default path `/BlueBox/websocket`). | +| `client_from_url` | URL → `Transport`. | +| `server_from_url` | URL → `Acceptor`. | diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..b29748e --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,98 @@ +# SFS2X protocol reference + +Controller IDs, system action codes and the SFSObject-key conventions +used by the wire format. These are mainly useful when you need to call +`Message(...)` directly or define your own custom `SystemRequest` / +`SystemResponse` models. + +## `ControllerID` + +| Value | Name | Purpose | +|-------|----------------|------------------------------------------| +| 0 | `SYSTEM` | Handshake, login, rooms, messages, users | +| 1 | `EXTENSION` | Custom extension commands | +| 2 | `SECURITY` | UDP / encryption negotiation | +| 3 | `BUDDY` | Buddy list management | +| 4 | `ROOM_MANAGER` | Room creation / destruction | +| 5 | `ADMIN` | Admin tool & console commands | + +## `SysAction` + +| Code | Name | +|------|------------------------------| +| 0 | `HANDSHAKE` | +| 1 | `LOGIN` | +| 2 | `LOGOUT` | +| 3 | `GET_ROOM_LIST` | +| 4 | `JOIN_ROOM` | +| 5 | `AUTO_JOIN` | +| 6 | `CREATE_ROOM` | +| 7 | `GENERIC_MESSAGE` | +| 8 | `CHANGE_ROOM_NAME` | +| 9 | `CHANGE_ROOM_PASSWORD` | +| 10 | `OBJECT_MESSAGE` | +| 11 | `SET_ROOM_VARIABLES` | +| 12 | `SET_USER_VARIABLES` | +| 13 | `CALL_EXTENSION` | +| 14 | `LEAVE_ROOM` | +| 15 | `SUBSCRIBE_ROOM_GROUP` | +| 16 | `UNSUBSCRIBE_ROOM_GROUP` | +| 17 | `SPECTATOR_TO_PLAYER` | +| 18 | `PLAYER_TO_SPECTATOR` | +| 19 | `CHANGE_ROOM_CAPACITY` | +| 20 | `PUBLIC_MESSAGE` | +| 21 | `PRIVATE_MESSAGE` | +| 22 | `MODERATOR_MESSAGE` | +| 23 | `ADMIN_MESSAGE` | +| 24 | `KICK_USER` | +| 25 | `BAN_USER` | +| 26 | `MANUAL_DISCONNECTION` | +| 27 | `FIND_ROOMS` | +| 28 | `FIND_USERS` | +| 29 | `PING_PONG` | +| 30 | `SET_USER_POSITION` | +| 31 | `QUICK_JOIN_OR_CREATE_ROOM` | +| 200+ | Buddy actions | +| 300+ | Game / invite actions | +| 500+ | Cluster actions | +| 1000+| Server-pushed event actions | + +## Wire envelope + +Every `Message` is encoded as an `SFSObject` with these top-level keys: + +| Key | Type | Meaning | +|-----|-----------|--------------------------------------------| +| `c` | `Byte` | Controller ID | +| `a` | `Short` | Action code (`SysAction` for `SYSTEM`) | +| `p` | `SFSObject` | Action-specific parameters | + +## Extension envelope + +Extension messages set `controller = EXTENSION` and `action = 12`. The +payload (`p` above) is itself an `SFSObject` with these keys: + +| Key | Type | Meaning | +|-----|-------------|-------------------------------------------------------------| +| `c` | `UtfString` | Extension command name (`"gs_pussy"`, `"join"`, …) | +| `r` | `Int` | Request id — echo it back in your reply for correlation. `-1` = no correlation. | +| `p` | `SFSObject` | Command-specific parameters | + +## Common payload keys (SFS2X conventions) + +| Key | Usual meaning | Example actions | +|-------|---------------------------|------------------------| +| `zn` | Zone name | `LOGIN`, `LOGOUT` | +| `un` | Username | `LOGIN` | +| `pw` | Password | `LOGIN` | +| `id` | User id | `LOGIN` response | +| `pi` | Privilege id | `LOGIN` response | +| `ec` | Error code | error responses | +| `em` | Error message | error responses | +| `epr` | Error params | error responses | +| `api` | SFS API version | `HANDSHAKE` | +| `cl` | Client identifier | `HANDSHAKE` | +| `bin` | Binary mode flag | `HANDSHAKE` | +| `tk` | Session token | `HANDSHAKE` response | +| `ct` | Compression threshold | `HANDSHAKE` response | +| `ms` | Max message size | `HANDSHAKE` response | diff --git a/pyproject.toml b/pyproject.toml index d63804e..804fa78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ build-backend = "setuptools.build_meta" [project] name = "sfs2x" -version = "0.1.0.1" -description = "Python-Based implementation of SmartFoxServer2X (SFS2X) Protocol." +version = "0.2.0" +description = "High-level SmartFoxServer 2X client & server for Python." authors = [{name="Zewsic", email="me@zewsic.pro"}] readme = "readme.md" license = {text = "MIT"} diff --git a/readme.md b/readme.md index 768349a..4ecbb67 100644 --- a/readme.md +++ b/readme.md @@ -1,62 +1,63 @@ # ZewSFS -**ZewSFS** is a Python implementation of the -[SmartFoxServer 2X (SFS2X)](https://www.smartfoxserver.com/) binary protocol — -both the client and the server side. It ships the rich SFS2X data model -(`SFSObject`, `SFSArray`, typed fields), a message codec with optional -compression and AES encryption, and pluggable transports for raw TCP and -WebSockets (the standard binary sub-protocol introduced in SFS2X v2.13). +**High-level SmartFoxServer 2X client & server for Python.** + +Decorator-driven handlers, typed request/response models, and async +client/server lifecycle on top of a complete pure-Python +SmartFoxServer 2X (SFS2X) binary protocol stack — TCP and WebSocket, +optional zlib compression and AES-128 encryption. + +```python +from sfs2x import SFSClient, ExtensionRequest, ExtensionResponse, field +from sfs2x.app import Int, UtfString + + +class PingRequest(ExtensionRequest, command="ping"): + n: Int = field("n") -``` - ┌──────────────────────────┐ - │ Application │ - └────────────┬─────────────┘ - Message(controller, action, payload) - ┌────────────┴─────────────┐ - │ sfs2x.protocol (codec) │ ← compression, AES - └────────────┬─────────────┘ - on-wire bytes - ┌────────────┴─────────────┐ - │ sfs2x.transport (TCP/WS) │ - └──────────────────────────┘ -``` -## Contents - -- [Features](#features) -- [Installation](#installation) -- [Quick start](#quick-start) - - [TCP client](#tcp-client) - - [TCP server](#tcp-server) - - [WebSocket client](#websocket-client) - - [WebSocket server](#websocket-server) -- [Modules overview](#modules-overview) -- [Working with `SFSObject` / `SFSArray`](#working-with-sfsobject--sfsarray) -- [Compression and encryption](#compression-and-encryption) -- [URL → transport mapping](#url--transport-mapping) -- [Status and roadmap](#status-and-roadmap) -- [Contributing](#contributing) -- [License](#license) - ---- +class PongResponse(ExtensionResponse, command="ping"): + n: Int = field("n") + name: UtfString = field("name") + + +client = SFSClient("tcp://localhost:9933") + + +@client.on_extension(command="ping") +async def on_pong(reply: PongResponse): + print(f"pong: {reply.n} from {reply.name}") + + +async with client: + await client.handshake() + await client.login(zone="MyZone", username="alice") + await client.call_extension("ping", PingRequest(n=42)) + await client.run_forever() +``` ## Features -- **Core data model** — `SFSObject`, `SFSArray` and all the typed primitives - (`Bool`, `Byte`, `Short`, `Int`, `Long`, `Float`, `Double`, `UtfString`, - `Text`, and their array variants) with byte-perfect round-trip encoding. -- **Protocol codec** — `Message ↔ bytes` with optional `zlib` compression - over a configurable threshold and optional AES-128-CBC encryption. -- **TCP transport** — async client (`TCPTransport`) and acceptor - (`TCPAcceptor`) built on `asyncio.open_connection` / - `asyncio.start_server`. -- **WebSocket transport** — async client (`WSTransport`) and acceptor - (`WSAcceptor`) speaking SFS2X v2.13+'s binary WebSocket sub-protocol over - `ws://` and `wss://`. Each WebSocket binary frame carries one complete - SFS2X packet so the codec is shared with the TCP transport. -- **URL factories** — `client_from_url("tcp://...")` / - `server_from_url("ws://.../BlueBox/websocket")` pick the right transport - for you. +- **Decorator handlers** — `@client.on_login()`, `@server.on_handshake()`, + `@server.on_extension(command="…")`, `@client.on_message()` catch-all, + plus `on_connect` / `on_disconnect` / `on_error` lifecycle hooks. +- **Typed models** — annotate fields with SFS types (`UtfString`, `Int`, + `Long`, …) and the model serialises/parses against `SFSObject` + automatically. Nested models supported. +- **`raise` to reply** — `raise PongResponse(n=42)` sends the answer. + `return PongResponse(...)` works too. `raise Reply(model)` if you want + the explicit wrapper. +- **Union dispatch** — handlers can declare + `req: LoginResponse | LoginErrorResponse`; the first matching schema + wins. +- **Built-in system models** — `HandshakeRequest`, `LoginRequest`, + `LogoutRequest`, `PingPongRequest` and their responses come ready + to use. +- **Sessions** — server handlers get a `ServerSession` per connection + with `state: dict`, `zone`/`username`/`user_id` slots, `send()` and + `kick()`. +- **TCP & WebSocket** transports with the same API. Optional `zlib` + compression and AES-128-CBC encryption. ## Installation @@ -64,294 +65,274 @@ WebSockets (the standard binary sub-protocol introduced in SFS2X v2.13). pip install sfs2x # or: uv pip install sfs2x ``` -`websockets` is pulled in as a hard dependency. AES encryption is optional -and requires PyCryptodome: +AES encryption requires PyCryptodome: ```bash -pip install sfs2x[crypto] # or: pip install pycryptodome +pip install sfs2x[crypto] ``` ## Quick start -### TCP client +### Client ```python import asyncio -from sfs2x.transport import client_from_url -from sfs2x.protocol import Message, ControllerID, SysAction -from sfs2x.core import SFSObject +from sfs2x import SFSClient, ExtensionRequest, ExtensionResponse, field +from sfs2x import LoginErrorResponse, LoginResponse +from sfs2x.app import Int, UtfString -async def main() -> None: - async with client_from_url("tcp://localhost:9933") as client: - payload = SFSObject().put_utf_string("message", "Hello from ZewSFS!") - await client.send(Message( - controller=ControllerID.SYSTEM, - action=SysAction.PUBLIC_MESSAGE, - payload=payload, - )) - print("Reply:", await client.recv()) +class JoinGameRequest(ExtensionRequest, command="joinGame"): + room: UtfString = field("r") -asyncio.run(main()) -``` +class JoinGameResponse(ExtensionResponse, command="joinGame"): + game_id: Int = field("gid") + seat: Int = field("s") -### TCP server -```python -import asyncio -from sfs2x.transport import server_from_url, Transport -from sfs2x.protocol import Message -from sfs2x.core import SFSObject +client = SFSClient("tcp://localhost:9933") -async def handle(client: Transport) -> None: - async for msg in client.listen(): - await client.send(Message( - controller=msg.controller, - action=msg.action, - payload=SFSObject({"echo": msg.payload}), - )) +@client.on_login() +async def on_login(reply: LoginErrorResponse | LoginResponse): + if isinstance(reply, LoginErrorResponse): + print(f"login failed: {reply.error_message}") + return + print(f"logged in as {reply.username} (id={reply.user_id})") + await client.call_extension("joinGame", JoinGameRequest(room="main")) -async def main() -> None: - async with server_from_url("tcp://0.0.0.0:9933") as acceptor: - async for client in acceptor: - print(f"Client connected: {client.host}:{client.port}") - asyncio.create_task(handle(client)) +@client.on_extension(command="joinGame") +async def on_joined(reply: JoinGameResponse): + print(f"joined game {reply.game_id}, seat {reply.seat}") + + +async def main(): + async with client: + await client.handshake() + await client.login(zone="MyZone", username="alice", password="secret") + await client.run_forever() asyncio.run(main()) ``` -### WebSocket client - -The default SFS2X WebSocket endpoint path is `/BlueBox/websocket`. +### Server ```python import asyncio -from sfs2x.transport import client_from_url -from sfs2x.protocol import Message, ControllerID, SysAction -from sfs2x.core import SFSObject +from sfs2x import ( + SFSServer, LoginRequest, LoginResponse, LoginErrorResponse, + ExtensionRequest, ExtensionResponse, ServerSession, field, +) +from sfs2x.app import Int, UtfString -async def main() -> None: - url = "ws://localhost:8080/BlueBox/websocket" - async with client_from_url(url) as client: - await client.send(Message( - controller=ControllerID.SYSTEM, - action=SysAction.HANDSHAKE, - payload=SFSObject() - .put_utf_string("api", "1.7.3") - .put_utf_string("cl", "Python/ZewSFS") - .put_bool("bin", True), - )) - print("Handshake reply:", await client.recv()) +class JoinGameRequest(ExtensionRequest, command="joinGame"): + room: UtfString = field("r") -asyncio.run(main()) -``` +class JoinGameResponse(ExtensionResponse, command="joinGame"): + game_id: Int = field("gid") + seat: Int = field("s") -For TLS, point the client at `wss://...` and (optionally) pass an -`ssl.SSLContext`: -```python -import ssl -client_from_url("wss://game.example.com/BlueBox/websocket", ssl=ssl.create_default_context()) -``` +server = SFSServer("tcp://0.0.0.0:9933") -### WebSocket server -```python -import asyncio -from sfs2x.transport import server_from_url, Transport -from sfs2x.protocol import Message +@server.on_login() +async def on_login(req: LoginRequest, session: ServerSession): + if req.password != "secret": + raise LoginErrorResponse(error_code=1, error_message="wrong password") + session.zone = req.zone + session.username = req.username + session.user_id = 42 + raise LoginResponse(zone=req.zone, username=req.username, user_id=42) -async def handle(client: Transport) -> None: - async for msg in client.listen(): - await client.send(msg) # echo +@server.on_extension(command="joinGame") +async def on_join(req: JoinGameRequest, session: ServerSession): + print(f"{session.username} joining {req.room}") + raise JoinGameResponse(game_id=1, seat=3) -async def main() -> None: - async with server_from_url("ws://0.0.0.0:8080/BlueBox/websocket") as acceptor: - async for client in acceptor: - print(f"WS client connected: {client.host}:{client.port}") - asyncio.create_task(handle(client)) +asyncio.run(server.serve_forever()) +``` +## Defining models -asyncio.run(main()) -``` +A model is a class whose annotations are SFS types — never plain Python +types. The annotation tells the wire format what to encode; the attribute +itself stores the raw Python value, so the model behaves like a dataclass +at runtime. -## Modules overview +```python +from sfs2x import SFSModel, field +from sfs2x.app import UtfString, Int, Long, SFSObject, UtfStringArray -### `sfs2x.core` -Low-level data model and binary encoding/decoding: +class PlayerProfile(SFSModel): + name: UtfString = field("n") + score: Long = field("s") # 64-bit on the wire + level: Int = field("lvl") + nicknames: UtfStringArray = field("alts") # list of strings + extras: SFSObject = field("x", default_factory=SFSObject) + bio: UtfString | None = field("bio", default=None) # optional -- **Primitives**: `Bool`, `Byte`, `Short`, `Int`, `Long`, `Float`, `Double`, - `UtfString`, `Text`. -- **Arrays**: `BoolArray`, `ByteArray`, `ShortArray`, `IntArray`, - `LongArray`, `FloatArray`, `DoubleArray`, `UtfStringArray`. -- **Containers**: `SFSObject` (keyed) and `SFSArray` (ordered). -- **Helpers**: `Buffer` (byte cursor), `decode` / `register` (raw - bytes ↔ typed values). -### `sfs2x.protocol` +p = PlayerProfile(name="alice", score=99999, level=12, nicknames=["a", "al"]) +obj = p.to_sfs_object() # → SFSObject ready to send +back = PlayerProfile.from_sfs_object(obj) +assert back == p +``` -Wire-format codec: +Nested models work the same way — just use another `SFSModel` subclass +as the annotation: -- `Message(controller, action, payload)` — high-level packet. -- `Flag` — header bit flags (`BINARY`, `COMPRESSED`, `ENCRYPTED`, - `BIG_SIZE`, `BLUEBOX`). -- `encode(msg, *, compress_threshold, encryption_key)` and - `decode(buf_or_bytes, *, encryption_key)`. -- `AESCipher` — AES-128-CBC helper (requires `pycryptodome`). -- `ControllerID`, `SysAction` — protocol-level enums. +```python +class Vec2(SFSModel): + x: Int = field("x") + y: Int = field("y") -### `sfs2x.transport` -Connection-level abstractions and concrete implementations: +class Move(SFSModel): + player: UtfString = field("p") + target: Vec2 = field("t") # nested SFSObject on the wire +``` -| Symbol | Purpose | -| --------------------- | --------------------------------------------- | -| `Transport` | Abstract base for client-side connections. | -| `Acceptor` | Abstract base for server-side acceptors. | -| `TransportClosedError`| Raised when the connection is no longer open. | -| `TCPTransport` | TCP client via `asyncio.open_connection`. | -| `TCPAcceptor` | TCP server via `asyncio.start_server`. | -| `WSTransport` | WebSocket client (`ws://` / `wss://`). | -| `WSAcceptor` | WebSocket server (default path `/BlueBox/websocket`). | -| `client_from_url` | URL → `Transport`. | -| `server_from_url` | URL → `Acceptor`. | +For one-off payloads where you don't want a model, declare the field as +`SFSObject` / `SFSArray` and pass them through directly. -A `Transport` lifecycle is: +## Handler signatures -```python -async with client_from_url(url) as t: - await t.send(msg) - reply = await t.recv() - async for msg in t.listen(): - ... -``` +The dispatcher inspects each handler's signature and injects whatever +you annotate. Pick any subset: -An `Acceptor` lifecycle mirrors it: +| Annotation | What gets injected | +|-------------------------------------|----------------------------------------------| +| `Message` | the raw `Message` packet | +| `SFSObject` | extension `"p"` sub-object, or system payload| +| `ServerSession` / `ClientSession` | the per-connection session | +| `SFSClient` / `SFSServer` | the owning client or server | +| `MyModel` (an `SFSModel` subclass) | the parsed model | +| `A \| B` (union of models) | first candidate that parses successfully | ```python -async with server_from_url(url) as acceptor: - async for client in acceptor: +@server.on_extension(command="x") +async def h(req: RequestA | RequestB, + raw: SFSObject, + session: ServerSession, + msg: Message): + if isinstance(req, RequestA): ... ``` -## Working with `SFSObject` / `SFSArray` +## Sending responses -**Fluent / imperative**: +Three equivalent forms, pick whichever reads best: ```python -from sfs2x.core import SFSObject, SFSArray +@server.on_extension(command="x") +async def h(req: XRequest): + raise XResponse(value=1) # raise the response model + +@server.on_extension(command="x") +async def h(req: XRequest): + return XResponse(value=1) # or return it + +@server.on_extension(command="x") +async def h(req: XRequest, session): + raise Reply(XResponse(value=1)) # or wrap in Reply + # equivalent: await session.send(XResponse(value=1)) +``` -obj = SFSObject() -obj.put_int("score", 1200) \ - .put_double_array("history", [3.14, -4.5, 2.7]) \ - .put_bool("isAdmin", True) +`raise` is convenient because it short-circuits nested logic; the +dispatcher catches it, builds the reply `Message` (echoing the original +extension request id so the peer can correlate), and sends it. -arr = SFSArray() -arr.add_utf_string("item1").add_utf_string("item2") -obj["items"] = arr -``` +## Built-in system models + +| Action | Request | Response | +|-------------------|--------------------------|------------------------------------------------| +| `HANDSHAKE` | `HandshakeRequest` | `HandshakeResponse` | +| `LOGIN` | `LoginRequest` | `LoginResponse` / `LoginErrorResponse` | +| `LOGOUT` | `LogoutRequest` | `LogoutResponse` | +| `PING_PONG` | `PingPongRequest` | `PingPongResponse` | -**Declarative**: +Convenience helpers on `SFSClient`: ```python -from sfs2x.core import UtfString, Int, SFSObject - -obj = SFSObject({ - "name": UtfString("Zewsic"), - "score": Int(2022), - "items": [ - UtfString("Sword"), - UtfString("Shield"), - SFSObject({"key": UtfString("value")}), - ], - "object": {"some": UtfString("thing")}, -}) +await client.handshake(api="1.7.3", cl="Python/ZewSFS") +await client.login(zone="MyZone", username="alice", password="secret") +await client.logout() +await client.call_extension("cmd", MyRequest(x=1)) ``` -**Round-tripping bytes**: +## WebSocket & TLS + +Swap the URL — everything else stays identical. ```python -from sfs2x.core import decode, SFSObject, Int +SFSClient("ws://localhost:8080/BlueBox/websocket") +SFSClient("wss://game.example.com/BlueBox/websocket", ssl=ssl.create_default_context()) -obj = SFSObject({"example": Int(42)}) -raw = obj.to_bytes() -restored: SFSObject = decode(raw) -print(restored.get("example")) # 42 +SFSServer("ws://0.0.0.0:8080/BlueBox/websocket") ``` -## Compression and encryption +The default WebSocket path is `/BlueBox/websocket` (SFS2X v2.13+). + +## Compression & encryption -Both transports accept the same kwargs and forward them straight to the +Both transports take the same kwargs and forward them straight to the codec: ```python -from sfs2x.transport import client_from_url - -client = client_from_url( +client = SFSClient( "ws://game.example.com/BlueBox/websocket", - compress_threshold=512, # zlib-compress payloads > 512 bytes - encryption_key=b"my_secret_16byte", # AES-128-CBC + compress_threshold=512, # zlib-compress payloads > 512 bytes + encryption_key=b"my_secret_16byte", # AES-128-CBC ) ``` -Or, using the codec directly: - -```python -from sfs2x.protocol import Message, encode, decode, ControllerID -from sfs2x.core import SFSObject, UtfString - -msg = Message(controller=ControllerID.EXTENSION, action=18, - payload={"secret": UtfString("HideMe")}) -packet = encode(msg, compress_threshold=512, encryption_key=b"my_secret_16byte") -restored = decode(packet, encryption_key=b"my_secret_16byte") -``` +## Going lower-level -## URL → transport mapping +For direct access to the wire format — raw `Message`, `encode`/`decode`, +`SFSObject` construction, custom `Transport` implementations — see: -| Scheme | Client | Server | Default port | Default path | -| ------- | ------------------ | -------------------- | ------------ | ---------------------- | -| `tcp` | `TCPTransport` | `TCPAcceptor` | `9933` | — | -| `ws` | `WSTransport` | `WSAcceptor` | `8080` | `/BlueBox/websocket` | -| `wss` | `WSTransport(TLS)` | `WSAcceptor(TLS)` | `443` | `/BlueBox/websocket` | +- [`docs/lowlevel.md`](docs/lowlevel.md) — `sfs2x.core`, `sfs2x.protocol`, + `sfs2x.transport`. +- [`docs/protocol.md`](docs/protocol.md) — `ControllerID` / `SysAction` + tables, wire envelope, SFS2X payload-key conventions. -Path defaults are the SFS2X conventions — set an explicit path in the URL -to override (e.g. `ws://host:8080/custom/socket`). For `wss://`, pass -`ssl=ssl.SSLContext(...)` to the factory. +All low-level symbols are re-exported from `sfs2x.app`: -## Status and roadmap +```python +from sfs2x.app import Message, SFSObject, encode, decode, client_from_url, ... +``` -This library covers the **binary protocol** and the **two main transports** -(TCP and WebSocket). It is intended as a low-level building block — there -is no high-level SmartFox-style session yet. +## Status & roadmap -Planned / in development: +The high-level API covers handshake, login/logout, ping-pong and +arbitrary extension calls on both TCP and WebSocket. Planned: -- `SFSClient` / `SFSServer` high-level wrappers (handshake, login, room and - user variables, extension call helpers). -- BlueBox HTTP tunneling transport. -- UDP transport for unreliable high-frequency channels. +- Room and user-variable helpers. +- Request/response correlation for `await client.call_extension(...) -> Response`. +- BlueBox HTTP tunnelling. +- UDP transport for high-frequency channels. Contributions and protocol-edge bug reports are very welcome. ## Contributing -Issues and pull requests are tracked on -[GitHub](https://github.com/zewmsm/ZewSFS). Run the tests with: - ```bash pip install -e . websockets pytest pytest-asyncio pycryptodome pytest ``` +Issues and pull requests are tracked on +[GitHub](https://github.com/zewmsm/ZewSFS). + ## License MIT — see [LICENSE](LICENSE). diff --git a/sfs2x/__init__.py b/sfs2x/__init__.py index e69de29..2bc72bb 100644 --- a/sfs2x/__init__.py +++ b/sfs2x/__init__.py @@ -0,0 +1,53 @@ +"""ZewSFS — SmartFoxServer 2X for Python. + +High-level API (``SFSClient``, ``SFSServer``, ``SFSModel`` and friends) +lives in :mod:`sfs2x.app`. The lower layers (:mod:`sfs2x.core`, +:mod:`sfs2x.protocol`, :mod:`sfs2x.transport`) are still available for +direct use. +""" + +from sfs2x.app import ( + ClientSession, + ExtensionRequest, + ExtensionResponse, + HandshakeRequest, + HandshakeResponse, + LoginErrorResponse, + LoginRequest, + LoginResponse, + LogoutRequest, + LogoutResponse, + PingPongRequest, + PingPongResponse, + Reply, + SFSClient, + SFSModel, + SFSServer, + ServerSession, + SystemRequest, + SystemResponse, + field, +) + +__all__ = [ + "ClientSession", + "ExtensionRequest", + "ExtensionResponse", + "HandshakeRequest", + "HandshakeResponse", + "LoginErrorResponse", + "LoginRequest", + "LoginResponse", + "LogoutRequest", + "LogoutResponse", + "PingPongRequest", + "PingPongResponse", + "Reply", + "SFSClient", + "SFSModel", + "SFSServer", + "ServerSession", + "SystemRequest", + "SystemResponse", + "field", +] diff --git a/sfs2x/app/__init__.py b/sfs2x/app/__init__.py new file mode 100644 index 0000000..b5f00cb --- /dev/null +++ b/sfs2x/app/__init__.py @@ -0,0 +1,122 @@ +"""High-level SmartFoxServer 2X application layer. + +Provides decorator-style handlers, typed request/response models, and +session-aware client/server classes built on top of the raw transport +and protocol stacks. Low-level building blocks from ``sfs2x.core``, +``sfs2x.protocol`` and ``sfs2x.transport`` are re-exported here for +convenience. +""" + +from sfs2x.app.client import SFSClient +from sfs2x.app.models import MISSING, ParseError, SFSModel, field +from sfs2x.app.responses import ( + ExtensionRequest, + ExtensionResponse, + Reply, + SystemRequest, + SystemResponse, +) +from sfs2x.app.server import SFSServer +from sfs2x.app.session import ClientSession, ServerSession +from sfs2x.app.system_models import ( + HandshakeRequest, + HandshakeResponse, + LoginErrorResponse, + LoginRequest, + LoginResponse, + LogoutRequest, + LogoutResponse, + PingPongRequest, + PingPongResponse, +) +from sfs2x.core import ( + Bool, + BoolArray, + Byte, + ByteArray, + Double, + DoubleArray, + Float, + FloatArray, + Int, + IntArray, + Long, + LongArray, + SFSArray, + SFSObject, + Short, + ShortArray, + Text, + UtfString, + UtfStringArray, +) +from sfs2x.protocol import ( + ControllerID, + Flag, + Message, + SysAction, + decode, + encode, +) +from sfs2x.transport import ( + Acceptor, + Transport, + TransportClosedError, + client_from_url, + server_from_url, +) + +__all__ = [ + "MISSING", + "Acceptor", + "Bool", + "BoolArray", + "Byte", + "ByteArray", + "ClientSession", + "ControllerID", + "Double", + "DoubleArray", + "ExtensionRequest", + "ExtensionResponse", + "Flag", + "Float", + "FloatArray", + "HandshakeRequest", + "HandshakeResponse", + "Int", + "IntArray", + "LoginErrorResponse", + "LoginRequest", + "LoginResponse", + "LogoutRequest", + "LogoutResponse", + "Long", + "LongArray", + "Message", + "ParseError", + "PingPongRequest", + "PingPongResponse", + "Reply", + "SFSArray", + "SFSClient", + "SFSModel", + "SFSObject", + "SFSServer", + "ServerSession", + "Short", + "ShortArray", + "SysAction", + "SystemRequest", + "SystemResponse", + "Text", + "Transport", + "TransportClosedError", + "UtfString", + "UtfStringArray", + "client_from_url", + "decode", + "encode", + "field", + "server_from_url", +] diff --git a/sfs2x/app/client.py b/sfs2x/app/client.py new file mode 100644 index 0000000..932bcd7 --- /dev/null +++ b/sfs2x/app/client.py @@ -0,0 +1,206 @@ +"""High-level SmartFox client. + +Wraps a ``Transport`` with decorator-style message handlers, typed +request/response models and lifecycle helpers. + + client = SFSClient("tcp://localhost:9933") + + @client.on_extension(command="gs_pussy") + async def on_pussy(req: PussyRequest): + raise PussyResponse(length=12) + + async with client: + await client.handshake() + await client.login(zone="MyZone", username="user") + await client.run_forever() +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from types import TracebackType +from typing import Any, Self + +from sfs2x.app.dispatcher import Dispatcher +from sfs2x.app.handlers import HandlerRegistry +from sfs2x.app.models import SFSModel +from sfs2x.app.responses import Reply +from sfs2x.app.session import ClientSession +from sfs2x.app.system_models import ( + HandshakeRequest, + LoginRequest, + LogoutRequest, +) +from sfs2x.core import SFSObject +from sfs2x.protocol import Message, SysAction +from sfs2x.transport import Transport, client_from_url + +HandlerFn = Callable[..., Awaitable[Any]] + + +class SFSClient: + """Decorator-driven SFS2X client.""" + + def __init__(self, url: str, **transport_kwargs: Any) -> None: + self._url = url + self._transport_kwargs = transport_kwargs + self._registry = HandlerRegistry() + self._transport: Transport | None = None + self._session: ClientSession | None = None + self._dispatch_task: asyncio.Task[None] | None = None + + @property + def transport(self) -> Transport | None: + return self._transport + + @property + def session(self) -> ClientSession | None: + return self._session + + @property + def connected(self) -> bool: + return self._transport is not None and not self._transport.closed + + def on_system(self, action: int | SysAction) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_system(int(action), fn) + return fn + + return wrap + + def on_handshake(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.HANDSHAKE) + + def on_login(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.LOGIN) + + def on_logout(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.LOGOUT) + + def on_ping_pong(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.PING_PONG) + + def on_extension(self, command: str) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_extension(command, fn) + return fn + + return wrap + + def on_message(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_catchall(fn) + return fn + + return wrap + + def on_connect(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_lifecycle("connect", fn) + return fn + + return wrap + + def on_disconnect(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_lifecycle("disconnect", fn) + return fn + + return wrap + + def on_error(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_lifecycle("error", fn) + return fn + + return wrap + + async def connect(self) -> None: + if self.connected: + return + self._transport = client_from_url(self._url, **self._transport_kwargs) + await self._transport.open() + self._session = ClientSession(self._transport, self) + dispatcher = Dispatcher( + self._registry, self._transport, self._session, owner=self + ) + self._dispatch_task = asyncio.create_task(dispatcher.run()) + + async def disconnect(self) -> None: + if self._transport is None: + return + await self._transport.close() + if self._dispatch_task is not None: + try: + await self._dispatch_task + except Exception: # noqa: BLE001 + pass + self._transport = None + self._session = None + self._dispatch_task = None + + async def run_forever(self) -> None: + if not self.connected: + await self.connect() + assert self._dispatch_task is not None + await self._dispatch_task + + async def __aenter__(self) -> Self: + await self.connect() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.disconnect() + + async def send(self, item: Message | SFSModel | Reply) -> None: + if self._session is None: + msg = "Client is not connected" + raise RuntimeError(msg) + await self._session.send(item) + + async def handshake( + self, + api: str = "1.7.3", + cl: str = "Python/ZewSFS", + bin: bool = True, # noqa: A002 + ) -> None: + await self.send(HandshakeRequest(api=api, cl=cl, bin=bin)) + + async def login( + self, + zone: str, + username: str = "", + password: str = "", + params: SFSObject | dict | None = None, + ) -> None: + if params is None: + params = SFSObject() + elif isinstance(params, dict): + params = SFSObject(params) + await self.send( + LoginRequest(zone=zone, username=username, password=password, params=params) + ) + + async def logout(self) -> None: + await self.send(LogoutRequest()) + + async def call_extension( + self, + command: str, + params: SFSModel | SFSObject | dict | None = None, + ) -> None: + if params is None: + obj: SFSObject = SFSObject() + elif isinstance(params, SFSModel): + obj = params.to_sfs_object() + elif isinstance(params, dict): + obj = SFSObject(params) + else: + obj = params + await self.send(Message.extension(command, obj)) diff --git a/sfs2x/app/dispatcher.py b/sfs2x/app/dispatcher.py new file mode 100644 index 0000000..9226d7c --- /dev/null +++ b/sfs2x/app/dispatcher.py @@ -0,0 +1,177 @@ +"""Routes incoming Messages to registered handlers. + +The dispatcher is constructed per-connection (one for each ``SFSClient`` +and one for each ``ServerSession``). It pulls messages from +``Transport.listen()``, looks up handlers by ``(controller, action)`` or +extension command, binds parameters from the message + session, calls +the handler, and turns any raised/returned response model back into a +``Message`` on the wire. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from sfs2x.app.handlers import HandlerEntry, HandlerRegistry, ParamKind +from sfs2x.app.models import ParseError, SFSModel +from sfs2x.app.responses import ( + ExtensionRequest, + ExtensionResponse, + Reply, + SystemRequest, + SystemResponse, + _RaisableMixin, +) +from sfs2x.core import SFSObject +from sfs2x.protocol import ControllerID, Message +from sfs2x.transport import Transport + +if TYPE_CHECKING: + from sfs2x.app.session import ClientSession, ServerSession + +log = logging.getLogger("sfs2x.app") + + +class Dispatcher: + """Drives the receive-loop for a single transport.""" + + def __init__( + self, + registry: HandlerRegistry, + transport: Transport, + session: ClientSession | ServerSession, + owner: Any, + ) -> None: + self._registry = registry + self._transport = transport + self._session = session + self._owner = owner + + async def run(self) -> None: + await self._fire_lifecycle("connect") + try: + async for msg in self._transport.listen(): + await self._dispatch_one(msg) + finally: + await self._fire_lifecycle("disconnect") + + async def _fire_lifecycle(self, name: str, *extra: Any) -> None: + for entry in self._registry.lifecycle.get(name, []): + try: + kwargs = self._bind_lifecycle(entry, extra) + await entry.fn(**kwargs) + except Exception: # noqa: BLE001 + log.exception("lifecycle handler %s raised", name) + + def _bind_lifecycle(self, entry: HandlerEntry, extra: tuple[Any, ...]) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + extra_iter = iter(extra) + for spec in entry.params: + if spec.kind == ParamKind.SESSION: + kwargs[spec.name] = self._session + elif spec.kind == ParamKind.CLIENT or spec.kind == ParamKind.SERVER: + kwargs[spec.name] = self._owner + else: + try: + kwargs[spec.name] = next(extra_iter) + except StopIteration: + kwargs[spec.name] = None + return kwargs + + async def _dispatch_one(self, msg: Message) -> None: + handlers = self._lookup(msg) + if not handlers: + return + for entry in handlers: + try: + kwargs = self._bind(entry, msg) + except ParseError as e: + log.debug("skipping handler %s: %s", entry.fn.__qualname__, e) + continue + try: + result = await entry.fn(**kwargs) + except _RaisableMixin as resp: + await self._send_reply(resp, msg) + return + except Reply as r: + await self._send_reply(r.model, msg) + return + except Exception as e: # noqa: BLE001 + await self._fire_lifecycle("error", e, msg) + continue + if isinstance(result, SFSModel): + await self._send_reply(result, msg) + return + + def _lookup(self, msg: Message) -> list[HandlerEntry]: + handlers: list[HandlerEntry] = [] + if msg.controller == ControllerID.SYSTEM: + handlers.extend(self._registry.system.get(int(msg.action), [])) + elif msg.controller == ControllerID.EXTENSION: + cmd = msg.payload.get("c") + if cmd is not None: + handlers.extend(self._registry.extension.get(str(cmd), [])) + handlers.extend(self._registry.catchall) + return handlers + + def _extract_payload(self, msg: Message) -> SFSObject: + if msg.controller == ControllerID.EXTENSION: + params = msg.payload.value.get("p") + if isinstance(params, SFSObject): + return params + return SFSObject() + return msg.payload + + def _bind(self, entry: HandlerEntry, msg: Message) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + payload: SFSObject | None = None + for spec in entry.params: + if spec.kind == ParamKind.MESSAGE: + kwargs[spec.name] = msg + elif spec.kind == ParamKind.PAYLOAD: + if payload is None: + payload = self._extract_payload(msg) + kwargs[spec.name] = payload + elif spec.kind == ParamKind.SESSION: + kwargs[spec.name] = self._session + elif spec.kind == ParamKind.CLIENT or spec.kind == ParamKind.SERVER: + kwargs[spec.name] = self._owner + elif spec.kind == ParamKind.MODEL: + if payload is None: + payload = self._extract_payload(msg) + kwargs[spec.name] = spec.candidates[0].from_sfs_object(payload) + elif spec.kind == ParamKind.UNION_MODEL: + if payload is None: + payload = self._extract_payload(msg) + chosen: SFSModel | None = None + for cand in spec.candidates: + chosen = cand.try_from_sfs_object(payload) + if chosen is not None: + break + if chosen is None: + names = ", ".join(c.__name__ for c in spec.candidates) + msg_err = f"Payload did not match any of: {names}" + raise ParseError(msg_err) + kwargs[spec.name] = chosen + elif spec.kind == ParamKind.POSITIONAL: + kwargs[spec.name] = None + return kwargs + + async def _send_reply(self, model: SFSModel, original: Message) -> None: + if isinstance(model, (SystemResponse, SystemRequest)): + reply_msg = model.to_message() + elif isinstance(model, (ExtensionResponse, ExtensionRequest)): + request_id = -1 + if original.controller == ControllerID.EXTENSION: + r = original.payload.get("r") + if isinstance(r, int): + request_id = r + reply_msg = model.to_message(request_id=request_id) + else: + msg_err = ( + f"Cannot turn {type(model).__name__} into a reply Message " + f"— inherit from SystemResponse or ExtensionResponse." + ) + raise TypeError(msg_err) + await self._transport.send(reply_msg) diff --git a/sfs2x/app/handlers.py b/sfs2x/app/handlers.py new file mode 100644 index 0000000..856f4e3 --- /dev/null +++ b/sfs2x/app/handlers.py @@ -0,0 +1,137 @@ +"""Handler registry and signature introspection. + +Decorators on ``SFSClient`` / ``SFSServer`` register async functions +here. At registration time we introspect the function's signature and +remember how to fill each parameter (Message / SFSObject / Session / +Model / Union of models). The dispatcher uses the precomputed +``ParamSpec`` tuple to bind args at call time. +""" + +from __future__ import annotations + +import inspect +import typing +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field as dc_field +from enum import Enum, auto +from typing import Any, get_args, get_origin + +from sfs2x.app.models import SFSModel +from sfs2x.core import SFSObject +from sfs2x.protocol import Message + +# Forward references — these classes live in client.py / server.py / session.py +# but we only need to identify them by name at registration time. + + +class ParamKind(Enum): + MESSAGE = auto() + PAYLOAD = auto() + SESSION = auto() + CLIENT = auto() + SERVER = auto() + MODEL = auto() + UNION_MODEL = auto() + POSITIONAL = auto() # opaque slot, filled positionally for lifecycle hooks + + +@dataclass(slots=True, frozen=True) +class ParamSpec: + name: str + kind: ParamKind + candidates: tuple[type[SFSModel], ...] = () + + +@dataclass(slots=True, frozen=True) +class HandlerEntry: + fn: Callable[..., Awaitable[Any]] + params: tuple[ParamSpec, ...] + + +def _is_model_class(t: Any) -> bool: + return isinstance(t, type) and issubclass(t, SFSModel) + + +def _classify(name: str, ann: Any) -> ParamSpec: + # Lazy imports to avoid cycles + from sfs2x.app.client import SFSClient + from sfs2x.app.server import SFSServer + from sfs2x.app.session import ClientSession, ServerSession + + if ann is inspect.Parameter.empty or ann is None: + if name in {"msg", "message"}: + return ParamSpec(name, ParamKind.MESSAGE) + if name in {"payload", "raw"}: + return ParamSpec(name, ParamKind.PAYLOAD) + if name in {"session", "ctx", "ses"}: + return ParamSpec(name, ParamKind.SESSION) + if name == "client": + return ParamSpec(name, ParamKind.CLIENT) + if name == "server": + return ParamSpec(name, ParamKind.SERVER) + return ParamSpec(name, ParamKind.POSITIONAL) + + if ann is Message: + return ParamSpec(name, ParamKind.MESSAGE) + if ann is SFSObject: + return ParamSpec(name, ParamKind.PAYLOAD) + if ann in (ClientSession, ServerSession): + return ParamSpec(name, ParamKind.SESSION) + if ann is SFSClient: + return ParamSpec(name, ParamKind.CLIENT) + if ann is SFSServer: + return ParamSpec(name, ParamKind.SERVER) + + origin = get_origin(ann) + if origin is typing.Union or origin is type(int | str): + args = tuple(a for a in get_args(ann) if a is not type(None)) + if all(_is_model_class(a) for a in args) and args: + if len(args) == 1: + return ParamSpec(name, ParamKind.MODEL, candidates=(args[0],)) + return ParamSpec(name, ParamKind.UNION_MODEL, candidates=args) + msg = f"Union parameter {name!r} must contain only SFSModel subclasses" + raise TypeError(msg) + + if _is_model_class(ann): + return ParamSpec(name, ParamKind.MODEL, candidates=(ann,)) + + return ParamSpec(name, ParamKind.POSITIONAL) + + +def inspect_handler(fn: Callable[..., Any]) -> tuple[ParamSpec, ...]: + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + out: list[ParamSpec] = [] + for name, p in sig.parameters.items(): + if p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + ann = hints.get(name, p.annotation) + out.append(_classify(name, ann)) + return tuple(out) + + +@dataclass(slots=True) +class HandlerRegistry: + system: dict[int, list[HandlerEntry]] = dc_field(default_factory=dict) + extension: dict[str, list[HandlerEntry]] = dc_field(default_factory=dict) + catchall: list[HandlerEntry] = dc_field(default_factory=list) + lifecycle: dict[str, list[HandlerEntry]] = dc_field(default_factory=dict) + + def register_system(self, action: int, fn: Callable[..., Awaitable[Any]]) -> None: + entry = HandlerEntry(fn=fn, params=inspect_handler(fn)) + self.system.setdefault(int(action), []).append(entry) + + def register_extension(self, command: str, fn: Callable[..., Awaitable[Any]]) -> None: + entry = HandlerEntry(fn=fn, params=inspect_handler(fn)) + self.extension.setdefault(str(command), []).append(entry) + + def register_catchall(self, fn: Callable[..., Awaitable[Any]]) -> None: + entry = HandlerEntry(fn=fn, params=inspect_handler(fn)) + self.catchall.append(entry) + + def register_lifecycle(self, name: str, fn: Callable[..., Awaitable[Any]]) -> None: + entry = HandlerEntry(fn=fn, params=inspect_handler(fn)) + self.lifecycle.setdefault(name, []).append(entry) diff --git a/sfs2x/app/models.py b/sfs2x/app/models.py new file mode 100644 index 0000000..0a81d03 --- /dev/null +++ b/sfs2x/app/models.py @@ -0,0 +1,353 @@ +"""Typed payload models on top of SFSObject. + +Field annotations are SFS-types (``UtfString``, ``Int``, ``Long``, nested +``SFSModel`` subclasses, ``SFSObject``/``SFSArray``) — never plain Python +types. The annotation tells the framework how to encode the value on the +wire; the attribute itself stores the raw Python value (``str``/``int``/…) +so the model behaves like a plain dataclass at runtime. +""" + +from __future__ import annotations + +import typing +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, ClassVar, get_args, get_origin + +from sfs2x.core import SFSArray, SFSObject +from sfs2x.core.field import Field + + +class _MissingType: + """Sentinel for "no default given".""" + + _instance: _MissingType | None = None + + def __new__(cls) -> _MissingType: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "MISSING" + + def __bool__(self) -> bool: + return False + + +MISSING = _MissingType() + + +class ParseError(ValueError): + """Failed to construct an SFSModel from an SFSObject.""" + + +@dataclass(slots=True, frozen=True) +class _FieldMarker: + """Sentinel placed into the class body via ``field(...)``.""" + + sfs_key: str + default: Any + factory: Callable[[], Any] | None + + +def field( + sfs_key: str, + *, + default: Any = MISSING, + default_factory: Callable[[], Any] | None = None, +) -> Any: + """Mark an SFSModel attribute and bind it to an SFSObject key. + + Example: ``zone: UtfString = field("zn")``. + """ + if default is not MISSING and default_factory is not None: + msg = "Cannot specify both default and default_factory" + raise ValueError(msg) + return _FieldMarker(sfs_key=sfs_key, default=default, factory=default_factory) + + +@dataclass(slots=True, frozen=True) +class FieldSpec: + """Compiled metadata for one model attribute.""" + + py_name: str + sfs_key: str + field_cls: type + optional: bool + default: Any + factory: Callable[[], Any] | None + + @property + def has_default(self) -> bool: + return self.default is not MISSING or self.factory is not None + + +def _resolve_annotation(ann: Any) -> tuple[type, bool]: + """Strip ``Optional`` / ``T | None`` and return ``(inner, optional)``.""" + origin = get_origin(ann) + if origin is typing.Union or origin is type(int | str): + args = [a for a in get_args(ann) if a is not type(None)] + if len(args) != 1: + msg = f"Unsupported Union annotation: {ann!r}" + raise TypeError(msg) + return args[0], True + return ann, False + + +def _validate_field_class(cls: type) -> None: + """Allow only Field subclasses, SFSModel subclasses, SFSObject, SFSArray.""" + if cls is SFSObject or cls is SFSArray: + return + if isinstance(cls, type) and issubclass(cls, Field): + return + if isinstance(cls, type) and issubclass(cls, SFSModel): + return + msg = ( + f"SFSModel field annotation must be an SFS type (Field subclass, " + f"SFSObject, SFSArray, or another SFSModel). Got: {cls!r}" + ) + raise TypeError(msg) + + +def _collect_fields(cls: type) -> dict[str, FieldSpec]: + """Walk MRO and merge per-class field specs (subclass overrides base).""" + out: dict[str, FieldSpec] = {} + for base in reversed(cls.__mro__): + own = base.__dict__.get("__sfs_fields_own__") + if own: + out.update(own) + return out + + +def _build_own_fields(ns: dict[str, Any], hints: dict[str, Any]) -> dict[str, FieldSpec]: + """Compile this class body's annotated attributes into FieldSpecs.""" + fields: dict[str, FieldSpec] = {} + annotations = ns.get("__annotations__", {}) + for attr_name, raw_ann in annotations.items(): + if attr_name.startswith("_"): + continue + if attr_name in {"__action__", "__command__"}: + continue + ann = hints.get(attr_name, raw_ann) + inner_cls, optional = _resolve_annotation(ann) + _validate_field_class(inner_cls) + + marker = ns.get(attr_name, MISSING) + if isinstance(marker, _FieldMarker): + sfs_key = marker.sfs_key + default = marker.default + factory = marker.factory + else: + sfs_key = attr_name + default = MISSING if marker is MISSING else marker + factory = None + + fields[attr_name] = FieldSpec( + py_name=attr_name, + sfs_key=sfs_key, + field_cls=inner_cls, + optional=optional, + default=default, + factory=factory, + ) + return fields + + +def _make_init(fields: dict[str, FieldSpec]) -> Callable[..., None]: + names = list(fields.keys()) + + def __init__(self: Any, *args: Any, **kwargs: Any) -> None: + if len(args) > len(names): + msg = f"Too many positional arguments (max {len(names)})" + raise TypeError(msg) + for i, val in enumerate(args): + n = names[i] + if n in kwargs: + msg = f"Multiple values for argument {n!r}" + raise TypeError(msg) + kwargs[n] = val + extra = set(kwargs) - set(fields) + if extra: + msg = f"Unknown fields for {type(self).__name__}: {sorted(extra)}" + raise TypeError(msg) + for name, spec in fields.items(): + if name in kwargs: + value = kwargs[name] + elif spec.factory is not None: + value = spec.factory() + elif spec.default is not MISSING: + value = spec.default + elif spec.optional: + value = None + else: + msg = f"Missing required field {name!r} for {type(self).__name__}" + raise TypeError(msg) + object.__setattr__(self, name, value) + + return __init__ + + +def _make_repr(name: str, fields: dict[str, FieldSpec]) -> Callable[[Any], str]: + field_names = list(fields.keys()) + + def __repr__(self: Any) -> str: + parts = ", ".join(f"{n}={getattr(self, n)!r}" for n in field_names) + return f"{name}({parts})" + + return __repr__ + + +def _make_eq(fields: dict[str, FieldSpec]) -> Callable[[Any, Any], bool]: + field_names = list(fields.keys()) + + def __eq__(self: Any, other: Any) -> bool: + if type(self) is not type(other): + return NotImplemented + return all(getattr(self, n) == getattr(other, n) for n in field_names) + + return __eq__ + + +class SFSModelMeta(type): + """Metaclass: collect fields, install ``__init__``/``__repr__``/``__eq__``.""" + + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + ns: dict[str, Any], + **kwargs: Any, + ) -> SFSModelMeta: + cls = super().__new__(mcs, name, bases, ns) + + try: + hints = typing.get_type_hints(cls) + except Exception: + hints = ns.get("__annotations__", {}) + + own = _build_own_fields(ns, hints) + cls.__sfs_fields_own__ = own + + for attr_name in own: + if attr_name in cls.__dict__ and isinstance(cls.__dict__[attr_name], _FieldMarker): + try: + delattr(cls, attr_name) + except AttributeError: + pass + + cls.__sfs_fields__ = _collect_fields(cls) + + action = kwargs.get("action") + command = kwargs.get("command") + if action is not None: + cls.__action__ = int(action) + if command is not None: + cls.__command__ = str(command) + + if cls.__sfs_fields__ and "__init__" not in ns: + cls.__init__ = _make_init(cls.__sfs_fields__) + if "__repr__" not in ns and cls.__sfs_fields__: + cls.__repr__ = _make_repr(name, cls.__sfs_fields__) + if "__eq__" not in ns and cls.__sfs_fields__: + cls.__eq__ = _make_eq(cls.__sfs_fields__) + + return cls + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + ns: dict[str, Any], + **kwargs: Any, + ) -> None: + super().__init__(name, bases, ns) + + +class SFSModel(metaclass=SFSModelMeta): + """Base class for typed SFS-payload models. + + Subclasses declare annotated attributes whose types are SFS Field + classes. The metaclass collects them, generates a keyword-friendly + ``__init__`` and binds the SFS key for each via ``field("...")``. + """ + + __sfs_fields__: ClassVar[dict[str, FieldSpec]] = {} + __sfs_fields_own__: ClassVar[dict[str, FieldSpec]] = {} + + def to_sfs_object(self) -> SFSObject: + out = SFSObject() + for spec in type(self).__sfs_fields__.values(): + raw = getattr(self, spec.py_name) + if raw is None: + if spec.optional: + continue + msg = f"Field {spec.py_name!r} is required but None" + raise ParseError(msg) + out.value[spec.sfs_key] = _wrap(raw, spec.field_cls) + return out + + @classmethod + def from_sfs_object(cls, obj: SFSObject) -> SFSModel: + if not isinstance(obj, SFSObject): + msg = f"Expected SFSObject, got {type(obj).__name__}" + raise ParseError(msg) + kwargs: dict[str, Any] = {} + for spec in cls.__sfs_fields__.values(): + if spec.sfs_key not in obj.value: + if spec.has_default or spec.optional: + continue + msg = f"Missing required key {spec.sfs_key!r} for {cls.__name__}" + raise ParseError(msg) + raw_field = obj.value[spec.sfs_key] + kwargs[spec.py_name] = _unwrap(raw_field, spec.field_cls) + try: + return cls(**kwargs) + except TypeError as e: + raise ParseError(str(e)) from e + + @classmethod + def try_from_sfs_object(cls, obj: SFSObject) -> SFSModel | None: + try: + return cls.from_sfs_object(obj) + except (ParseError, TypeError, ValueError): + return None + + +def _wrap(raw: Any, field_cls: type) -> Any: + """Encode a raw Python value into the declared SFS field instance.""" + if field_cls is SFSObject or field_cls is SFSArray: + if isinstance(raw, (SFSObject, SFSArray)): + return raw + if field_cls is SFSObject and isinstance(raw, dict): + return SFSObject(raw) + if field_cls is SFSArray and isinstance(raw, list): + return SFSArray(raw) + msg = f"Cannot pack {type(raw).__name__} as {field_cls.__name__}" + raise ParseError(msg) + if isinstance(field_cls, type) and issubclass(field_cls, SFSModel): + if isinstance(raw, field_cls): + return raw.to_sfs_object() + if isinstance(raw, SFSObject): + return raw + msg = f"Expected {field_cls.__name__} or SFSObject, got {type(raw).__name__}" + raise ParseError(msg) + if isinstance(raw, Field): + return raw + return field_cls(raw) + + +def _unwrap(raw_field: Any, field_cls: type) -> Any: + """Decode a Field instance from the wire back into the runtime value.""" + if field_cls is SFSObject or field_cls is SFSArray: + return raw_field + if isinstance(field_cls, type) and issubclass(field_cls, SFSModel): + if isinstance(raw_field, SFSObject): + return field_cls.from_sfs_object(raw_field) + if isinstance(raw_field, Field) and isinstance(raw_field.value, SFSObject): + return field_cls.from_sfs_object(raw_field.value) + msg = f"Expected SFSObject for {field_cls.__name__}, got {type(raw_field).__name__}" + raise ParseError(msg) + if isinstance(raw_field, Field): + return raw_field.value + return raw_field diff --git a/sfs2x/app/responses.py b/sfs2x/app/responses.py new file mode 100644 index 0000000..9c6ff75 --- /dev/null +++ b/sfs2x/app/responses.py @@ -0,0 +1,73 @@ +"""Request/response base classes for the high-level SFS app layer. + +``SystemRequest`` / ``SystemResponse`` correspond to controller=SYSTEM +messages (``SysAction.LOGIN`` etc). ``ExtensionRequest`` / +``ExtensionResponse`` correspond to controller=EXTENSION extension calls +keyed by a command string. + +Response classes additionally inherit from ``_RaisableMixin`` (a tiny +``Exception`` subclass) so handlers can write ``raise PussyResponse(...)`` +and the dispatcher catches it and sends the reply. ``Reply(model)`` is +the more explicit wrapper for non-Response models. +""" + +from __future__ import annotations + +from typing import ClassVar + +from sfs2x.app.models import SFSModel +from sfs2x.core import SFSObject +from sfs2x.protocol import ControllerID, Message + + +class Reply(Exception): + """Wrap an SFSModel into an exception so handlers can ``raise`` it.""" + + def __init__(self, model: SFSModel) -> None: + self.model = model + super().__init__(repr(model)) + + +class _RaisableMixin(Exception): + """Mixin so ``raise SomeResponse(...)`` works inside handlers.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + Exception.__init__(self) + + +class SystemRequest(SFSModel): + """Base for outbound/inbound system messages (HANDSHAKE/LOGIN/...).""" + + __action__: ClassVar[int] + + def to_message(self) -> Message: + return Message(ControllerID.SYSTEM, self.__action__, self.to_sfs_object()) + + +class SystemResponse(SFSModel, _RaisableMixin): + """Base for system replies — raisable so ``raise LoginResponse(...)`` works.""" + + __action__: ClassVar[int] + + def to_message(self) -> Message: + return Message(ControllerID.SYSTEM, self.__action__, self.to_sfs_object()) + + +class ExtensionRequest(SFSModel): + """Base for outbound/inbound extension commands.""" + + __command__: ClassVar[str] + + def to_message(self, request_id: int = -1) -> Message: + params: SFSObject = self.to_sfs_object() + return Message.extension(self.__command__, params, request_id=request_id) + + +class ExtensionResponse(SFSModel, _RaisableMixin): + """Base for extension replies — also raisable.""" + + __command__: ClassVar[str] + + def to_message(self, request_id: int = -1) -> Message: + params: SFSObject = self.to_sfs_object() + return Message.extension(self.__command__, params, request_id=request_id) diff --git a/sfs2x/app/server.py b/sfs2x/app/server.py new file mode 100644 index 0000000..df3b44d --- /dev/null +++ b/sfs2x/app/server.py @@ -0,0 +1,157 @@ +"""High-level SmartFox server. + + server = SFSServer("tcp://0.0.0.0:9933") + + @server.on_login() + async def on_login(req: LoginRequest, session: ServerSession): + if req.password != "secret": + raise LoginErrorResponse(error_code=1, error_message="bad pw") + session.zone = req.zone + session.username = req.username + raise LoginResponse(zone=req.zone, username=req.username, user_id=42) + + @server.on_extension(command="gs_pussy") + async def on_pussy(req: PussyRequest): + raise PussyResponse(length=12) + + async with server: + await server.serve_forever() +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from types import TracebackType +from typing import Any, Self + +from sfs2x.app.dispatcher import Dispatcher +from sfs2x.app.handlers import HandlerRegistry +from sfs2x.app.session import ServerSession +from sfs2x.protocol import SysAction +from sfs2x.transport import Acceptor, Transport, server_from_url + +HandlerFn = Callable[..., Awaitable[Any]] + + +class SFSServer: + """Decorator-driven SFS2X server.""" + + def __init__(self, url: str, **acceptor_kwargs: Any) -> None: + self._url = url + self._acceptor_kwargs = acceptor_kwargs + self._registry = HandlerRegistry() + self._acceptor: Acceptor | None = None + self._sessions: set[ServerSession] = set() + self._tasks: set[asyncio.Task[None]] = set() + + @property + def sessions(self) -> frozenset[ServerSession]: + return frozenset(self._sessions) + + def on_system(self, action: int | SysAction) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_system(int(action), fn) + return fn + + return wrap + + def on_handshake(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.HANDSHAKE) + + def on_login(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.LOGIN) + + def on_logout(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.LOGOUT) + + def on_ping_pong(self) -> Callable[[HandlerFn], HandlerFn]: + return self.on_system(SysAction.PING_PONG) + + def on_extension(self, command: str) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_extension(command, fn) + return fn + + return wrap + + def on_message(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_catchall(fn) + return fn + + return wrap + + def on_connect(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_lifecycle("connect", fn) + return fn + + return wrap + + def on_disconnect(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_lifecycle("disconnect", fn) + return fn + + return wrap + + def on_error(self) -> Callable[[HandlerFn], HandlerFn]: + def wrap(fn: HandlerFn) -> HandlerFn: + self._registry.register_lifecycle("error", fn) + return fn + + return wrap + + async def start(self) -> None: + if self._acceptor is not None: + return + self._acceptor = server_from_url(self._url, **self._acceptor_kwargs) + await self._acceptor.start() + + async def stop(self) -> None: + if self._acceptor is None: + return + await self._acceptor.close() + for s in list(self._sessions): + try: + await s.transport.close() + except Exception: # noqa: BLE001 + pass + for t in list(self._tasks): + t.cancel() + self._acceptor = None + + async def serve_forever(self) -> None: + if self._acceptor is None: + await self.start() + assert self._acceptor is not None + async for transport in self._acceptor: + task = asyncio.create_task(self._serve_one(transport)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _serve_one(self, transport: Transport) -> None: + session = ServerSession(transport, self) + self._sessions.add(session) + try: + dispatcher = Dispatcher(self._registry, transport, session, owner=self) + await dispatcher.run() + finally: + self._sessions.discard(session) + try: + await transport.close() + except Exception: # noqa: BLE001 + pass + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.stop() diff --git a/sfs2x/app/session.py b/sfs2x/app/session.py new file mode 100644 index 0000000..eb394cf --- /dev/null +++ b/sfs2x/app/session.py @@ -0,0 +1,81 @@ +"""Per-connection session objects passed into handlers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sfs2x.app.models import SFSModel +from sfs2x.app.responses import ( + ExtensionRequest, + ExtensionResponse, + Reply, + SystemRequest, + SystemResponse, +) +from sfs2x.protocol import Message +from sfs2x.transport import Transport + +if TYPE_CHECKING: + from sfs2x.app.client import SFSClient + from sfs2x.app.server import SFSServer + + +def _to_message(item: Message | SFSModel | Reply) -> Message: + if isinstance(item, Message): + return item + if isinstance(item, Reply): + item = item.model + if isinstance(item, (SystemRequest, SystemResponse, ExtensionRequest, ExtensionResponse)): + return item.to_message() + msg = f"Cannot send {type(item).__name__} — must be Message, request/response model, or Reply" + raise TypeError(msg) + + +class ClientSession: + """Wraps the client-side transport for handler injection.""" + + __slots__ = ("client", "state", "transport") + + def __init__(self, transport: Transport, client: SFSClient) -> None: + self.transport = transport + self.client = client + self.state: dict[str, Any] = {} + + async def send(self, item: Message | SFSModel | Reply) -> None: + await self.transport.send(_to_message(item)) + + @property + def closed(self) -> bool: + return self.transport.closed + + +class ServerSession: + """Wraps one accepted client connection.""" + + __slots__ = ("server", "state", "transport", "user_id", "username", "zone") + + def __init__(self, transport: Transport, server: SFSServer) -> None: + self.transport = transport + self.server = server + self.state: dict[str, Any] = {} + self.user_id: int | None = None + self.username: str | None = None + self.zone: str | None = None + + async def send(self, item: Message | SFSModel | Reply) -> None: + await self.transport.send(_to_message(item)) + + async def kick(self) -> None: + await self.transport.close() + + @property + def host(self) -> str: + return self.transport.host + + @property + def port(self) -> int: + return self.transport.port + + @property + def closed(self) -> bool: + return self.transport.closed diff --git a/sfs2x/app/system_models.py b/sfs2x/app/system_models.py new file mode 100644 index 0000000..768121d --- /dev/null +++ b/sfs2x/app/system_models.py @@ -0,0 +1,72 @@ +"""Built-in models for the standard SFS2X system actions. + +Field keys mirror the SFS2X SDK conventions (``zn`` = zone, ``un`` = +username, etc.). Optional fields are declared as ``T | None`` so a +missing key parses as ``None`` instead of raising. +""" + +from __future__ import annotations + +from sfs2x.app.models import field +from sfs2x.app.responses import ( + SystemRequest, + SystemResponse, +) +from sfs2x.core import ( + Bool, + Int, + Short, + SFSObject, + UtfString, + UtfStringArray, +) +from sfs2x.protocol import SysAction + + +class HandshakeRequest(SystemRequest, action=SysAction.HANDSHAKE): + api: UtfString = field("api", default="1.7.3") + cl: UtfString = field("cl", default="Python/ZewSFS") + bin: Bool = field("bin", default=True) + + +class HandshakeResponse(SystemResponse, action=SysAction.HANDSHAKE): + token: UtfString | None = field("tk", default=None) + compression_threshold: Int | None = field("ct", default=None) + max_message_size: Int | None = field("ms", default=None) + + +class LoginRequest(SystemRequest, action=SysAction.LOGIN): + zone: UtfString = field("zn") + username: UtfString = field("un", default="") + password: UtfString = field("pw", default="") + params: SFSObject = field("p", default_factory=SFSObject) + + +class LoginResponse(SystemResponse, action=SysAction.LOGIN): + zone: UtfString | None = field("zn", default=None) + username: UtfString | None = field("un", default=None) + user_id: Int | None = field("id", default=None) + privilege_id: Int = field("pi", default=0) + params: SFSObject | None = field("p", default=None) + + +class LoginErrorResponse(SystemResponse, action=SysAction.LOGIN): + error_code: Short = field("ec") + error_message: UtfString | None = field("em", default=None) + error_params: UtfStringArray | None = field("epr", default=None) + + +class LogoutRequest(SystemRequest, action=SysAction.LOGOUT): + pass + + +class LogoutResponse(SystemResponse, action=SysAction.LOGOUT): + zone: UtfString | None = field("zn", default=None) + + +class PingPongRequest(SystemRequest, action=SysAction.PING_PONG): + pass + + +class PingPongResponse(SystemResponse, action=SysAction.PING_PONG): + pass diff --git a/tests/test_app_dispatcher.py b/tests/test_app_dispatcher.py new file mode 100644 index 0000000..37a2343 --- /dev/null +++ b/tests/test_app_dispatcher.py @@ -0,0 +1,289 @@ +import asyncio +from dataclasses import dataclass, field as dc_field +from typing import Any + +import pytest + +from sfs2x.app import ( + ExtensionRequest, + ExtensionResponse, + Int, + Message, + Reply, + SFSModel, + SFSObject, + ServerSession, + Short, + SystemRequest, + SystemResponse, + UtfString, + field, +) +from sfs2x.app.dispatcher import Dispatcher +from sfs2x.app.handlers import HandlerRegistry, inspect_handler, ParamKind +from sfs2x.protocol import ControllerID, SysAction + + +class _FakeTransport: + """In-memory transport that records sent messages and feeds queued ones.""" + + def __init__(self): + self.host = "test" + self.port = 0 + self._sent: list[Message] = [] + self._incoming: asyncio.Queue[Message | None] = asyncio.Queue() + self._opened = True + + @property + def closed(self): + return not self._opened + + async def send(self, msg): + self._sent.append(msg) + + async def close(self): + self._opened = False + await self._incoming.put(None) + + async def listen(self): + while True: + item = await self._incoming.get() + if item is None: + break + yield item + + def push(self, msg): + self._incoming.put_nowait(msg) + + +# ---------- handler signature introspection ---------- + + +def test_inspect_message_param(): + async def h(msg: Message): pass + specs = inspect_handler(h) + assert specs[0].kind == ParamKind.MESSAGE + + +def test_inspect_payload_param(): + async def h(payload: SFSObject): pass + specs = inspect_handler(h) + assert specs[0].kind == ParamKind.PAYLOAD + + +def test_inspect_session_param(): + async def h(session: ServerSession): pass + specs = inspect_handler(h) + assert specs[0].kind == ParamKind.SESSION + + +def test_inspect_model_param(): + class M(SFSModel): + v: Int = field("v") + + async def h(m: M): pass + specs = inspect_handler(h) + assert specs[0].kind == ParamKind.MODEL + assert specs[0].candidates == (M,) + + +def test_inspect_union_model_param(): + class A(SFSModel): + a: Int = field("a") + class B(SFSModel): + b: Int = field("b") + + async def h(x: A | B): pass + specs = inspect_handler(h) + assert specs[0].kind == ParamKind.UNION_MODEL + assert specs[0].candidates == (A, B) + + +# ---------- registration ---------- + + +def test_register_system_handler(): + reg = HandlerRegistry() + + async def h(msg: Message): pass + + reg.register_system(SysAction.LOGIN, h) + assert SysAction.LOGIN in reg.system + + +def test_register_extension_handler(): + reg = HandlerRegistry() + + async def h(msg: Message): pass + + reg.register_extension("cmd", h) + assert "cmd" in reg.extension + + +# ---------- dispatch ---------- + + +class LoginReq(SystemRequest, action=SysAction.LOGIN): + zone: UtfString = field("zn") + + +class LoginOk(SystemResponse, action=SysAction.LOGIN): + uid: Int = field("uid") + + +class LoginErr(SystemResponse, action=SysAction.LOGIN): + ec: Short = field("ec") + + +class PingReq(ExtensionRequest, command="ping"): + n: Int = field("n") + + +class PongResp(ExtensionResponse, command="ping"): + n: Int = field("n") + + +@pytest.mark.asyncio +async def test_dispatch_system_action(): + reg = HandlerRegistry() + received = [] + + @lambda fn: reg.register_system(SysAction.LOGIN, fn) or fn + async def on_login(req: LoginReq): + received.append(req) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(LoginReq(zone="z").to_message()) + await transport.close() + await dispatcher.run() + assert received[0].zone == "z" + + +@pytest.mark.asyncio +async def test_dispatch_extension_by_command(): + reg = HandlerRegistry() + received = [] + + @lambda fn: reg.register_extension("ping", fn) or fn + async def on_ping(req: PingReq): + received.append(req) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(PingReq(n=5).to_message(request_id=11)) + await transport.close() + await dispatcher.run() + assert received[0].n == 5 + + +@pytest.mark.asyncio +async def test_raise_response_sends_reply(): + reg = HandlerRegistry() + + @lambda fn: reg.register_extension("ping", fn) or fn + async def on_ping(req: PingReq): + raise PongResp(n=req.n * 2) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(PingReq(n=3).to_message(request_id=99)) + await transport.close() + await dispatcher.run() + assert len(transport._sent) == 1 + sent = transport._sent[0] + assert sent.controller == ControllerID.EXTENSION + assert sent.payload.value["c"].value == "ping" + assert sent.payload.value["r"].value == 99 + assert sent.payload.value["p"].value["n"].value == 6 + + +@pytest.mark.asyncio +async def test_return_response_sends_reply(): + reg = HandlerRegistry() + + @lambda fn: reg.register_extension("ping", fn) or fn + async def on_ping(req: PingReq): + return PongResp(n=req.n + 1) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(PingReq(n=4).to_message(request_id=5)) + await transport.close() + await dispatcher.run() + assert transport._sent[0].payload.value["p"].value["n"].value == 5 + + +@pytest.mark.asyncio +async def test_reply_wrapper_sends_reply(): + reg = HandlerRegistry() + + @lambda fn: reg.register_extension("ping", fn) or fn + async def on_ping(req: PingReq): + raise Reply(PongResp(n=42)) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(PingReq(n=1).to_message()) + await transport.close() + await dispatcher.run() + assert transport._sent[0].payload.value["p"].value["n"].value == 42 + + +@pytest.mark.asyncio +async def test_union_dispatch_picks_first_match(): + reg = HandlerRegistry() + received = [] + + @lambda fn: reg.register_system(SysAction.LOGIN, fn) or fn + async def on_login(req: LoginErr | LoginOk): + received.append(req) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + # No "ec" field → should pick LoginOk + transport.push(LoginOk(uid=1).to_message()) + # With "ec" → should pick LoginErr + transport.push(LoginErr(ec=42).to_message()) + await transport.close() + await dispatcher.run() + assert isinstance(received[0], LoginOk) + assert isinstance(received[1], LoginErr) + + +@pytest.mark.asyncio +async def test_catchall_handler(): + reg = HandlerRegistry() + received = [] + + @lambda fn: reg.register_catchall(fn) or fn + async def fallback(msg: Message): + received.append(msg) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(Message(ControllerID.SYSTEM, 99, SFSObject())) + await transport.close() + await dispatcher.run() + assert len(received) == 1 + + +@pytest.mark.asyncio +async def test_handler_exception_fires_on_error(): + reg = HandlerRegistry() + errors = [] + + @lambda fn: reg.register_extension("ping", fn) or fn + async def on_ping(req: PingReq): + raise ValueError("boom") + + @lambda fn: reg.register_lifecycle("error", fn) or fn + async def on_err(exc: Exception, msg: Message): + errors.append(exc) + + transport = _FakeTransport() + dispatcher = Dispatcher(reg, transport, session=None, owner=None) + transport.push(PingReq(n=1).to_message()) + await transport.close() + await dispatcher.run() + assert any(isinstance(e, ValueError) for e in errors) diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py new file mode 100644 index 0000000..80e4ffc --- /dev/null +++ b/tests/test_app_integration.py @@ -0,0 +1,128 @@ +import asyncio + +import pytest +import pytest_asyncio + +from sfs2x.app import ( + ExtensionRequest, + ExtensionResponse, + Int, + LoginErrorResponse, + LoginRequest, + LoginResponse, + SFSClient, + SFSObject, + SFSServer, + ServerSession, + Short, + UtfString, + field, +) + + +class EchoRequest(ExtensionRequest, command="echo"): + text: UtfString = field("t") + + +class EchoResponse(ExtensionResponse, command="echo"): + text: UtfString = field("t") + length: Int = field("len") + + +@pytest_asyncio.fixture +async def tcp_server(): + server = SFSServer("tcp://127.0.0.1:29933") + + @server.on_extension(command="echo") + async def on_echo(req: EchoRequest): + raise EchoResponse(text=req.text, length=len(req.text)) + + @server.on_login() + async def on_login(req: LoginRequest, session: ServerSession): + if req.password == "bad": + raise LoginErrorResponse(error_code=1, error_message="wrong password") + session.zone = req.zone + session.username = req.username + raise LoginResponse(zone=req.zone, username=req.username, user_id=42) + + await server.start() + serve_task = asyncio.create_task(server.serve_forever()) + await asyncio.sleep(0.05) + yield server + serve_task.cancel() + await server.stop() + + +@pytest.mark.asyncio +async def test_extension_roundtrip_via_raise(tcp_server): + captured = {} + client = SFSClient("tcp://127.0.0.1:29933") + + @client.on_extension(command="echo") + async def on_echo(resp: EchoResponse): + captured["reply"] = resp + + async with client: + await client.call_extension("echo", EchoRequest(text="hello")) + await asyncio.sleep(0.2) + + assert captured["reply"].text == "hello" + assert captured["reply"].length == 5 + + +@pytest.mark.asyncio +async def test_login_success_roundtrip(tcp_server): + captured = {} + client = SFSClient("tcp://127.0.0.1:29933") + + @client.on_login() + async def on_login(resp: LoginErrorResponse | LoginResponse): + captured["reply"] = resp + + async with client: + await client.login(zone="MyZone", username="alice", password="ok") + await asyncio.sleep(0.2) + + reply = captured["reply"] + assert isinstance(reply, LoginResponse) + assert reply.zone == "MyZone" + assert reply.username == "alice" + assert reply.user_id == 42 + + +@pytest.mark.asyncio +async def test_login_error_union_dispatch(tcp_server): + captured = {} + client = SFSClient("tcp://127.0.0.1:29933") + + @client.on_login() + async def on_login(resp: LoginErrorResponse | LoginResponse): + captured["reply"] = resp + + async with client: + await client.login(zone="MyZone", username="bob", password="bad") + await asyncio.sleep(0.2) + + reply = captured["reply"] + assert isinstance(reply, LoginErrorResponse) + assert reply.error_code == 1 + assert reply.error_message == "wrong password" + + +@pytest.mark.asyncio +async def test_handler_receives_session_and_raw(tcp_server): + seen = {} + client = SFSClient("tcp://127.0.0.1:29933") + + @client.on_extension(command="echo") + async def on_echo(resp: EchoResponse, raw: SFSObject): + seen["resp"] = resp + seen["raw_keys"] = list(raw.value.keys()) + + async with client: + await client.call_extension("echo", EchoRequest(text="hi")) + await asyncio.sleep(0.2) + + assert seen["resp"].length == 2 + assert "t" in seen["raw_keys"] + assert "len" in seen["raw_keys"] diff --git a/tests/test_app_models.py b/tests/test_app_models.py new file mode 100644 index 0000000..e84ebe1 --- /dev/null +++ b/tests/test_app_models.py @@ -0,0 +1,218 @@ +import pytest + +from sfs2x.app import ( + ExtensionRequest, + ExtensionResponse, + Int, + Long, + ParseError, + SFSArray, + SFSModel, + SFSObject, + SystemRequest, + SystemResponse, + UtfString, + UtfStringArray, + field, +) +from sfs2x.protocol import ControllerID, SysAction + + +def test_declaration_uses_sfs_annotations(): + class M(SFSModel): + name: UtfString = field("n") + score: Int = field("s") + + m = M(name="alice", score=42) + assert m.name == "alice" + assert m.score == 42 + + +def test_to_and_from_sfs_object_roundtrip(): + class M(SFSModel): + name: UtfString = field("n") + score: Int = field("s") + + m = M(name="alice", score=42) + obj = m.to_sfs_object() + assert obj.value["n"].value == "alice" + assert obj.value["s"].value == 42 + back = M.from_sfs_object(obj) + assert back == m + + +def test_long_vs_int_annotation_distinguished_on_wire(): + class A(SFSModel): + x: Int = field("x") + + class B(SFSModel): + x: Long = field("x") + + a_obj = A(x=1).to_sfs_object() + b_obj = B(x=1).to_sfs_object() + assert type(a_obj.value["x"]).__name__ == "Int" + assert type(b_obj.value["x"]).__name__ == "Long" + + +def test_missing_required_raises(): + class M(SFSModel): + name: UtfString = field("n") + + with pytest.raises(TypeError): + M() + with pytest.raises(ParseError): + M.from_sfs_object(SFSObject()) + + +def test_optional_field_defaults_to_none(): + class M(SFSModel): + name: UtfString | None = field("n", default=None) + + m = M() + assert m.name is None + obj = m.to_sfs_object() + assert "n" not in obj.value + back = M.from_sfs_object(SFSObject()) + assert back.name is None + + +def test_default_factory(): + class M(SFSModel): + params: SFSObject = field("p", default_factory=SFSObject) + + m1 = M() + m2 = M() + assert m1.params is not m2.params + assert isinstance(m1.params, SFSObject) + + +def test_nested_model_roundtrip(): + class Inner(SFSModel): + v: Int = field("v") + + class Outer(SFSModel): + inner: Inner = field("in") + + o = Outer(inner=Inner(v=7)) + obj = o.to_sfs_object() + inner_obj = obj.value["in"] + assert isinstance(inner_obj, SFSObject) + assert inner_obj.value["v"].value == 7 + + back = Outer.from_sfs_object(obj) + assert isinstance(back.inner, Inner) + assert back.inner.v == 7 + + +def test_sfs_object_passthrough(): + class M(SFSModel): + data: SFSObject = field("d") + + inner = SFSObject({"x": Int(1)}) + m = M(data=inner) + obj = m.to_sfs_object() + assert obj.value["d"] is inner + back = M.from_sfs_object(obj) + assert isinstance(back.data, SFSObject) + assert back.data.value["x"].value == 1 + + +def test_sfs_array_passthrough(): + class M(SFSModel): + items: SFSArray = field("i") + + arr = SFSArray([Int(1), Int(2)]) + m = M(items=arr) + obj = m.to_sfs_object() + back = M.from_sfs_object(obj) + assert isinstance(back.items, SFSArray) + + +def test_try_from_sfs_object_returns_none_on_mismatch(): + class M(SFSModel): + required: Int = field("r") + + assert M.try_from_sfs_object(SFSObject()) is None + + +def test_try_from_sfs_object_returns_instance_on_match(): + class M(SFSModel): + v: Int = field("v") + + parsed = M.try_from_sfs_object(SFSObject({"v": Int(3)})) + assert parsed == M(v=3) + + +def test_system_request_to_message(): + class MyReq(SystemRequest, action=SysAction.LOGIN): + zone: UtfString = field("zn") + + msg = MyReq(zone="x").to_message() + assert msg.controller == ControllerID.SYSTEM + assert msg.action == SysAction.LOGIN + assert msg.payload.value["zn"].value == "x" + + +def test_extension_request_to_message_with_request_id(): + class MyExt(ExtensionRequest, command="ping"): + v: Int = field("v") + + msg = MyExt(v=9).to_message(request_id=42) + assert msg.controller == ControllerID.EXTENSION + assert msg.payload.value["c"].value == "ping" + assert msg.payload.value["r"].value == 42 + assert msg.payload.value["p"].value["v"].value == 9 + + +def test_response_is_raisable(): + class R(ExtensionResponse, command="x"): + n: Int = field("n") + + with pytest.raises(R) as ei: + raise R(n=5) + assert ei.value.n == 5 + + +def test_repr_and_eq(): + class M(SFSModel): + a: Int = field("a") + b: UtfString = field("b") + + m1 = M(a=1, b="x") + m2 = M(a=1, b="x") + m3 = M(a=2, b="x") + assert m1 == m2 + assert m1 != m3 + assert "a=1" in repr(m1) + + +def test_unsupported_annotation_rejected(): + with pytest.raises(TypeError): + class Bad(SFSModel): + x: int = field("x") # python type, not SFS type — should fail + + +def test_inheritance_preserves_fields(): + class Base(SFSModel): + a: Int = field("a") + + class Child(Base): + b: UtfString = field("b") + + c = Child(a=1, b="x") + assert c.a == 1 + assert c.b == "x" + obj = c.to_sfs_object() + assert "a" in obj.value + assert "b" in obj.value + + +def test_array_types(): + class M(SFSModel): + names: UtfStringArray = field("ns") + + m = M(names=["alice", "bob"]) + obj = m.to_sfs_object() + assert obj.value["ns"].value == ["alice", "bob"] + back = M.from_sfs_object(obj) + assert back.names == ["alice", "bob"]