From c539563b076afe33985d82e967cb5b21181a3136 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 13:57:30 +0300 Subject: [PATCH 1/6] build: disable local version suffix for PyPI compatibility --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d4a23b8..02b34e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ fognode = "fognode.cli.entrypoint:main" [tool.hatch.version] source = "vcs" +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" + [tool.hatch.build.targets.wheel] packages = ["src/fognode"] From 6e4087c94e76a5fa359445ef1a4e853f4ba8483a Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 13:59:58 +0300 Subject: [PATCH 2/6] ci: trigger release workflow on tag push instead of main branch --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb9a8d0..cdbb350 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,8 +2,8 @@ name: Release on: push: - branches: - - main + tags: + - "v*" permissions: contents: write @@ -40,12 +40,12 @@ jobs: - name: Verify package run: twine check dist/* - - name: Get latest tag + - name: Get current tag id: get_tag run: | - TAG=$(git describe --tags --abbrev=0) + TAG=${GITHUB_REF#refs/tags/} echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Latest tag: $TAG" + echo "Tag: $TAG" - name: Generate release notes id: notes @@ -79,7 +79,7 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" - - name: Create or update GitHub release + - name: Create GitHub release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_tag.outputs.tag }} From 2d6b8f22b067cdf68a115217b83a37dfd47cc1cc Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 14:02:06 +0300 Subject: [PATCH 3/6] ci: run release on main/tag push only when HEAD is tagged --- .github/workflows/build.yml | 47 +++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cdbb350..5099a9c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,8 @@ name: Release on: push: + branches: + - main tags: - "v*" @@ -13,6 +15,7 @@ jobs: release: name: Release runs-on: ubuntu-latest + if: github.ref_type == 'tag' || github.event.base_ref == 'refs/heads/main' environment: name: pypi @@ -23,44 +26,62 @@ jobs: with: fetch-depth: 0 + - name: Check if HEAD is tagged + id: check_tag + run: | + TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "") + if [ -z "$TAG" ]; then + echo "No tag on HEAD, skipping release" + echo "has_tag=false" >> "$GITHUB_OUTPUT" + else + echo "Tag on HEAD: $TAG" + echo "has_tag=true" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + fi + + - name: Skip if no tag + if: steps.check_tag.outputs.has_tag == 'false' + run: | + echo "Skipping release build — no tag on current commit" + exit 0 + - name: Setup Python + if: steps.check_tag.outputs.has_tag == 'true' uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - name: Install build dependencies + if: steps.check_tag.outputs.has_tag == 'true' run: | python -m pip install --upgrade pip pip install build twine hatchling - name: Build package + if: steps.check_tag.outputs.has_tag == 'true' run: python -m build - name: Verify package + if: steps.check_tag.outputs.has_tag == 'true' run: twine check dist/* - - name: Get current tag - id: get_tag - run: | - TAG=${GITHUB_REF#refs/tags/} - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Tag: $TAG" - - name: Generate release notes + if: steps.check_tag.outputs.has_tag == 'true' id: notes run: | - PREV_TAG=$(git describe --tags --abbrev=0 --exclude="${{ steps.get_tag.outputs.tag }}" 2>/dev/null || echo "") + TAG="${{ steps.check_tag.outputs.tag }}" + PREV_TAG=$(git describe --tags --abbrev=0 --exclude="$TAG" 2>/dev/null || echo "") if [ -z "$PREV_TAG" ]; then LOG=$(git log --max-count=50 --pretty=format:"- %s") else - LOG=$(git log ${PREV_TAG}..${{ steps.get_tag.outputs.tag }} --max-count=100 --pretty=format:"- %s") + LOG=$(git log ${PREV_TAG}..${TAG} --max-count=100 --pretty=format:"- %s") fi { echo 'notes<> "$GITHUB_OUTPUT" - name: Create GitHub release + if: steps.check_tag.outputs.has_tag == 'true' uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.get_tag.outputs.tag }} - name: fognode ${{ steps.get_tag.outputs.tag }} + tag_name: ${{ steps.check_tag.outputs.tag }} + name: fognode ${{ steps.check_tag.outputs.tag }} body: ${{ steps.notes.outputs.notes }} generate_release_notes: false files: | dist/* - name: Publish to PyPI + if: steps.check_tag.outputs.has_tag == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: attestations: false From 18bef8c63a3ecd4b8b568960a5d46bd31ed6baa0 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 14:13:08 +0300 Subject: [PATCH 4/6] feat: add event classes in core/events.py --- src/fognode/core/events.py | 56 ++++++++++++++++++++++++++++++++++++++ src/fognode/core/server.py | 19 +++++++------ 2 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 src/fognode/core/events.py diff --git a/src/fognode/core/events.py b/src/fognode/core/events.py new file mode 100644 index 0000000..ed67a01 --- /dev/null +++ b/src/fognode/core/events.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from fognode.crypto.channel import SecureChannel + + +@dataclass +class BaseEvent: + channel: SecureChannel | None = None + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class StartEvent(BaseEvent): + pass + + +@dataclass +class ConnectEvent(BaseEvent): + pass + + +@dataclass +class DisconnectEvent(BaseEvent): + pass + + +@dataclass +class MessageEvent(BaseEvent): + type: str = "" + text: str = "" + ts: float = 0.0 + + @classmethod + def from_dict( + cls, data: dict[str, Any], channel: SecureChannel | None = None + ) -> MessageEvent: + return cls( + channel=channel, + data=data, + type=data.get("type", ""), + text=data.get("text", ""), + ts=data.get("ts", 0.0), + ) + + +@dataclass +class ClosedEvent(BaseEvent): + pass + + +@dataclass +class ErrorEvent(BaseEvent): + exception: Exception | None = None diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index a00ea1e..09450fa 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -13,7 +13,7 @@ from fognode.crypto.channel import SecureChannel from fognode.crypto.password import store_password from fognode.types.exceptions import AuthError, SecurityError -from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, Port +from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, OnMessage, Port from fognode.utils.ipwords import ip_to_name from fognode.utils.net import local_ip from fognode.utils.ratelimit import RateLimiter @@ -26,15 +26,12 @@ def _session_loop( ip: IPAddress, on_connect: OnConnect | None, on_disconnect: OnDisconnect | None, + on_message: OnMessage | None, ) -> None: if on_connect: on_connect() - ch.send( - { - "type": "welcome", - } - ) + ch.send({"type": "welcome"}) try: while True: @@ -43,6 +40,8 @@ def _session_loop( if mtype == "cmd": _handle_cmd(ch, msg) + elif on_message: + on_message(msg) except (ConnectionError, OSError, SecurityError): pass finally: @@ -51,7 +50,7 @@ def _session_loop( on_disconnect() -def _handle_cmd(ch: SecureChannel, msg: dict) -> None: # type: ignore[type-arg] +def _handle_cmd(ch: SecureChannel, msg: dict) -> None: cmd = msg.get("cmd", "") if cmd == "info": ch.send( @@ -71,6 +70,7 @@ def _handle_client( ctx: ssl.SSLContext, on_connect: OnConnect | None, on_disconnect: OnDisconnect | None, + on_message: OnMessage | None, ) -> None: if not _rl.check(client_ip): raw.close() @@ -79,7 +79,7 @@ def _handle_client( with ctx.wrap_socket(raw, server_side=True) as tls: tls.settimeout(30) ch = server_handshake(tls, client_ip) - _session_loop(ch, client_ip, on_connect, on_disconnect) + _session_loop(ch, client_ip, on_connect, on_disconnect, on_message) except ssl.SSLError: _rl.fail(client_ip) except AuthError: @@ -97,6 +97,7 @@ def start_server( password: str, on_connect: OnConnect | None = None, on_disconnect: OnDisconnect | None = None, + on_message: OnMessage | None = None, ) -> tuple[IPAddress, CodeName, str]: local = local_ip() display_ip = local if host == "0.0.0.0" else host @@ -133,7 +134,7 @@ def _serve() -> None: break threading.Thread( target=_handle_client, - args=(conn, addr[0], ctx, on_connect, on_disconnect), + args=(conn, addr[0], ctx, on_connect, on_disconnect, on_message), daemon=True, ).start() From bf132b5ee0085e365ddfd8bd2523d2740292aa6c Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 14:14:42 +0300 Subject: [PATCH 5/6] feat: add on_event decorator and event-driven Server/Client API --- README.md | 80 ++++++++-------- examples/headless_server.py | 25 +++-- src/fognode/__init__.py | 17 +++- src/fognode/app.py | 185 +++++++++++++++++------------------- 4 files changed, 160 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index f1601b6..4aaa620 100644 --- a/README.md +++ b/README.md @@ -25,82 +25,86 @@ Stack: TLS 1.2+ · X25519 · AESGCM-256 · HMAC-SHA256 · PBKDF2 · HKDF pip install fognode ``` -## Quick start (aiogram-style) +## Quick start ### Server ```python -from fognode import App, Cipher +from fognode import Server, MessageEvent -app = App( - host="0.0.0.0", - port=9443, - user="alice", - password="secret", - cipher=Cipher.AESGCM, -) +server = Server(host="0.0.0.0", port=9443, password="secret") -@app.on_message() +@server.on_event(MessageEvent) async def echo(ctx): - await ctx.answer(f"echo: {ctx.message.text}") + if ctx.event.text: + await ctx.answer(f"echo: {ctx.event.text}") -@app.on_command("ping") -async def ping(ctx): - await ctx.answer("pong") - -@app.on_connect() +@server.on_event(ConnectEvent) async def on_connect(ctx): - print(f"+ {ctx.user}") + print("+ peer connected") + +@server.on_event(DisconnectEvent) +async def on_disconnect(ctx): + print("- peer disconnected") if __name__ == "__main__": - app.run() + server.run() ``` ### Client ```python -from fognode import App, Cipher +from fognode import Client, MessageEvent, ClosedEvent -app = App.client( - connect_string="alice@oak-pine-stone-field:9443", - password="secret", - cipher=Cipher.AESGCM, -) +client = Client(connect_string="oak-pine-stone-field:9443", password="secret") -@app.on_message() +@client.on_event(MessageEvent) async def on_message(ctx): - print(f"{ctx.message.user}: {ctx.message.text}") + print(f"msg: {ctx.event.text}") + +@client.on_event(ClosedEvent) +async def on_closed(ctx): + print("connection closed") if __name__ == "__main__": - app.run() + client.connect() ``` +## Events + +| Event | Server | Client | Description | +|---|---|---|---| +| `StartEvent` | ✅ | ✅ | Server/client started | +| `ConnectEvent` | ✅ | ✅ | Peer connected | +| `DisconnectEvent` | ✅ | ✅ | Peer disconnected | +| `MessageEvent` | ✅ | ✅ | Message received | +| `ClosedEvent` | ❌ | ✅ | Connection closed | +| `ErrorEvent` | ❌ | ✅ | Error occurred | + ## Classic API ```python from fognode import start_server, client_connect -ip, code, fp = start_server("0.0.0.0", 9443, "alice", "secret") -print(f"Connect: alice@{code}:9443") +ip, code, fp = start_server("0.0.0.0", 9443, "secret") +print(f"Connect: {code}:9443") ``` ## Structure ``` src/fognode/ -├── app.py # App class (aiogram-style) -├── cipher.py # Cipher enum -├── context.py # Context for handlers -├── message.py # Message dataclass -├── router.py # Router for handlers -├── filters/ # Command, Text filters -├── handlers/ # HandlerObject -├── types/ # exceptions, constants, protocol +├── app.py # Server, Client, Context +├── core/ +│ ├── events.py # Event classes +│ ├── server.py # start_server() +│ ├── client.py # client_connect() +│ └── probe.py # probe_server() ├── crypto/ # primitives, kdf, cert, channel ├── ciphers/ # aesgcm, chacha20, x25519, hkdf, pbkdf2, hmac ├── wire/ # framing ├── auth/ # handshake -├── core/ # server, client, session, state +├── types/ # exceptions, constants, protocol ├── decorators.py # retry, rate_limited, timed ├── exceptions.py # errors ├── utils/ # ipwords, ratelimit, net diff --git a/examples/headless_server.py b/examples/headless_server.py index 7824228..34a0002 100644 --- a/examples/headless_server.py +++ b/examples/headless_server.py @@ -1,14 +1,21 @@ from __future__ import annotations -from fognode import start_server +from fognode import Server, ConnectEvent, DisconnectEvent, MessageEvent -ip, code, fp = start_server("0.0.0.0", 9443, "alice", "secret") -print(f"Server running at {ip} ({code}) port 9443") -print(f"Fingerprint: {fp}") +server = Server(host="0.0.0.0", port=9443, password="secret") -import signal +@server.on_event(ConnectEvent) +async def on_connect(ctx): + print("+ peer connected") -try: - signal.pause() -except (KeyboardInterrupt, AttributeError): - pass +@server.on_event(DisconnectEvent) +async def on_disconnect(ctx): + print("- peer disconnected") + +@server.on_event(MessageEvent) +async def on_message(ctx): + if ctx.event.text: + await ctx.answer(f"echo: {ctx.event.text}") + +if __name__ == "__main__": + server.run() diff --git a/src/fognode/__init__.py b/src/fognode/__init__.py index 1803867..ae45056 100644 --- a/src/fognode/__init__.py +++ b/src/fognode/__init__.py @@ -1,8 +1,17 @@ from __future__ import annotations from fognode import ciphers, decorators, exceptions, filters, types -from fognode.app import Client, Context, Message, Server +from fognode.app import Client, Context, Server from fognode.core.client import client_connect +from fognode.core.events import ( + BaseEvent, + ClosedEvent, + ConnectEvent, + DisconnectEvent, + ErrorEvent, + MessageEvent, + StartEvent, +) from fognode.core.server import start_server from fognode.crypto.channel import SecureChannel from fognode.types import ( @@ -57,18 +66,22 @@ "CodeName", "ConnectString", "ConnectionInfo", + "ConnectEvent", "Context", "DEFAULT_HOST", "DEFAULT_PORT", + "DisconnectEvent", + "ErrorEvent", "Fingerprint", "FrameError", "HandshakeError", "InfoMsg", "IPAddress", "MAX_MESSAGE_SIZE", - "Message", + "MessageEvent", "MessageHandler", "NONCE_LENGTH", + "StartEvent", "OnConnect", "OnDisconnect", "OnMessage", diff --git a/src/fognode/app.py b/src/fognode/app.py index 27d355c..3067322 100644 --- a/src/fognode/app.py +++ b/src/fognode/app.py @@ -2,10 +2,18 @@ import asyncio import threading -from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable from fognode.core.client import client_connect +from fognode.core.events import ( + BaseEvent, + ClosedEvent, + ConnectEvent, + DisconnectEvent, + ErrorEvent, + MessageEvent, + StartEvent, +) from fognode.core.server import start_server from fognode.crypto.channel import SecureChannel from fognode.handlers import HandlerObject @@ -15,28 +23,16 @@ from fognode.app import Client, Server -@dataclass(slots=True, frozen=True) -class Message: - type: str - text: str - ts: float - raw: dict[str, Any] - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> Message: - return cls( - type=data.get("type", ""), - text=data.get("text", ""), - ts=data.get("ts", 0.0), - raw=data, - ) - - -@dataclass(slots=True) class Context: - app: Server | Client - channel: SecureChannel | None - message: Message | None = None + def __init__( + self, + app: Server | Client, + channel: SecureChannel | None, + event: BaseEvent | None = None, + ) -> None: + self.app = app + self.channel = channel + self.event = event async def answer(self, text: str) -> None: if self.channel is None: @@ -59,34 +55,25 @@ def __init__( self.host = host self.port = port self.password = password - self._handlers: dict[str, list[HandlerObject]] = { - "connect": [], - "disconnect": [], - "message": [], - } + self._handlers: dict[type[BaseEvent], list[HandlerObject]] = {} self._loop: asyncio.AbstractEventLoop | None = None self._channel: SecureChannel | None = None - def on_connect(self) -> Callable[..., Any]: + def on_event(self, event_class: type[BaseEvent]) -> Callable[..., Any]: def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["connect"].append(HandlerObject(callback)) + self._handlers.setdefault(event_class, []).append(HandlerObject(callback)) return callback return decorator - def on_disconnect(self) -> Callable[..., Any]: - def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["disconnect"].append(HandlerObject(callback)) - return callback + def on_connect(self) -> Callable[..., Any]: + return self.on_event(ConnectEvent) - return decorator + def on_disconnect(self) -> Callable[..., Any]: + return self.on_event(DisconnectEvent) def on_message(self) -> Callable[..., Any]: - def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["message"].append(HandlerObject(callback)) - return callback - - return decorator + return self.on_event(MessageEvent) def run(self) -> None: if not self.password: @@ -97,11 +84,16 @@ def run(self) -> None: def _on_connect() -> None: if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_connect) + self._loop.call_soon_threadsafe(self._process_event, ConnectEvent()) def _on_disconnect() -> None: if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_disconnect) + self._loop.call_soon_threadsafe(self._process_event, DisconnectEvent()) + + def _on_msg(msg: dict[str, Any]) -> None: + if self._loop is not None: + event = MessageEvent.from_dict(msg) + self._loop.call_soon_threadsafe(self._process_event, event) start_server( self.host, @@ -109,8 +101,11 @@ def _on_disconnect() -> None: self.password, on_connect=_on_connect, on_disconnect=_on_disconnect, + on_message=_on_msg, ) + self._process_event(StartEvent()) + try: self._loop.run_forever() except KeyboardInterrupt: @@ -118,27 +113,18 @@ def _on_disconnect() -> None: finally: self._loop.close() - def _process_connect(self) -> None: - ctx = Context(self, self._channel) - for handler in self._handlers["connect"]: - asyncio.create_task(handler.call(ctx)) - - def _process_disconnect(self) -> None: - ctx = Context(self, self._channel) - for handler in self._handlers["disconnect"]: - asyncio.create_task(handler.call(ctx)) - - def _process_message(self, msg: dict[str, Any]) -> None: - message = Message.from_dict(msg) - ctx = Context(self, self._channel, message) - for handler in self._handlers["message"]: - asyncio.create_task(self._run_handler(handler, msg, ctx)) + def _process_event(self, event: BaseEvent) -> None: + ctx = Context(self, self._channel, event) + for handler in self._handlers.get(type(event), []): + asyncio.create_task(self._run_handler(handler, event, ctx)) async def _run_handler( - self, handler: HandlerObject, data: dict[str, Any], ctx: Context + self, handler: HandlerObject, event: BaseEvent, ctx: Context ) -> None: - if await handler.check(data): - await handler.call(ctx) + if hasattr(event, "data") and isinstance(event.data, dict): + if not await handler.check(event.data): + return + await handler.call(ctx) class Client: @@ -149,34 +135,31 @@ def __init__( ) -> None: self.connect_string = connect_string self.password = password - self._handlers: dict[str, list[HandlerObject]] = { - "connect": [], - "disconnect": [], - "message": [], - } + self._handlers: dict[type[BaseEvent], list[HandlerObject]] = {} self._loop: asyncio.AbstractEventLoop | None = None self._channel: SecureChannel | None = None - def on_connect(self) -> Callable[..., Any]: + def on_event(self, event_class: type[BaseEvent]) -> Callable[..., Any]: def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["connect"].append(HandlerObject(callback)) + self._handlers.setdefault(event_class, []).append(HandlerObject(callback)) return callback return decorator - def on_disconnect(self) -> Callable[..., Any]: - def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["disconnect"].append(HandlerObject(callback)) - return callback + def on_connect(self) -> Callable[..., Any]: + return self.on_event(ConnectEvent) - return decorator + def on_disconnect(self) -> Callable[..., Any]: + return self.on_event(DisconnectEvent) def on_message(self) -> Callable[..., Any]: - def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["message"].append(HandlerObject(callback)) - return callback + return self.on_event(MessageEvent) - return decorator + def on_closed(self) -> Callable[..., Any]: + return self.on_event(ClosedEvent) + + def on_error(self) -> Callable[..., Any]: + return self.on_event(ErrorEvent) def connect(self) -> None: if not self.connect_string or not self.password: @@ -185,22 +168,37 @@ def connect(self) -> None: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) - ch, _fp = client_connect(self.connect_string, self.password) + try: + ch, _fp = client_connect(self.connect_string, self.password) + except Exception as exc: + self._process_event(ErrorEvent(exception=exc)) + return + self._channel = ch welcome = ch.recv() if welcome.get("type") == "welcome": - self._process_connect() + self._process_event(StartEvent(self._channel)) + self._process_event(ConnectEvent(self._channel)) def _recv() -> None: while True: try: msg = ch.recv() if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_raw_msg, msg) - except Exception: + event = MessageEvent.from_dict(msg, ch) + self._loop.call_soon_threadsafe(self._process_event, event) + except Exception as exc: if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_disconnect) + self._loop.call_soon_threadsafe( + self._process_event, ErrorEvent(ch, exception=exc) + ) + self._loop.call_soon_threadsafe( + self._process_event, ClosedEvent(ch) + ) + self._loop.call_soon_threadsafe( + self._process_event, DisconnectEvent(ch) + ) break threading.Thread(target=_recv, daemon=True).start() @@ -217,24 +215,15 @@ def send(self, data: dict[str, Any]) -> None: raise RuntimeError("not connected") self._channel.send(data) - def _process_connect(self) -> None: - ctx = Context(self, self._channel) - for handler in self._handlers["connect"]: - asyncio.create_task(handler.call(ctx)) - - def _process_disconnect(self) -> None: - ctx = Context(self, self._channel) - for handler in self._handlers["disconnect"]: - asyncio.create_task(handler.call(ctx)) - - def _process_raw_msg(self, msg: dict[str, Any]) -> None: - message = Message.from_dict(msg) - ctx = Context(self, self._channel, message) - for handler in self._handlers["message"]: - asyncio.create_task(self._run_handler(handler, msg, ctx)) + def _process_event(self, event: BaseEvent) -> None: + ctx = Context(self, self._channel, event) + for handler in self._handlers.get(type(event), []): + asyncio.create_task(self._run_handler(handler, event, ctx)) async def _run_handler( - self, handler: HandlerObject, data: dict[str, Any], ctx: Context + self, handler: HandlerObject, event: BaseEvent, ctx: Context ) -> None: - if await handler.check(data): - await handler.call(ctx) + if hasattr(event, "data") and isinstance(event.data, dict): + if not await handler.check(event.data): + return + await handler.call(ctx) From 9b7bb0abc4efa5e24f888f6ef2faf09ed2535147 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 12:03:51 +0000 Subject: [PATCH 6/6] docs: update CHANGELOG.md for PR #2 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4807d4f..6c581ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, +and this project adheres to Semantic Versioning. + + + +## [dev] - 2026-05-16 + +### Added + +- add event classes in core/events.py ([`18bef8c`](https://github.com/reekeer/fognode/commit/18bef8c63a3ecd4b8b568960a5d46bd31ed6baa0)) +- add on_event decorator and event-driven Server/Client API ([`bf132b5`](https://github.com/reekeer/fognode/commit/bf132b5ee0085e365ddfd8bd2523d2740292aa6c)) + +### Changed + +- trigger release workflow on tag push instead of main branch ([`6e4087c`](https://github.com/reekeer/fognode/commit/6e4087c94e76a5fa359445ef1a4e853f4ba8483a)) +- run release on main/tag push only when HEAD is tagged ([`2d6b8f2`](https://github.com/reekeer/fognode/commit/2d6b8f22b067cdf68a115217b83a37dfd47cc1cc)) + +### Changed + +- disable local version suffix for PyPI compatibility ([`c539563`](https://github.com/reekeer/fognode/commit/c539563b076afe33985d82e967cb5b21181a3136)) + + ## [dev] - 2026-05-16