From 8c671cbc877617d6d7409b8d90736d3d43074738 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:10:20 +1100 Subject: [PATCH 01/57] Added editorconfig based on that in nvaccess/nvda --- .editorconfig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9127bc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +charset = utf-8 +indent_style = tab +tab_width = 4 +trim_trailing_whitespace = true +max_line_length = 110 + +[*.{yml,yaml}] +indent_style = space +charset = utf-8 +indent_size = 2 +trim_trailing_whitespace = true From 962a23437706dba5500a61abc67656457541555d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:40:07 +1100 Subject: [PATCH 02/57] lf instead of crlf --- server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/server.py b/server.py index 3159508..806a922 100644 --- a/server.py +++ b/server.py @@ -148,7 +148,6 @@ def cleanup(self): self.transport.abortConnection() self.cleanup_timer = None - class User(object): user_id = 0 @@ -236,7 +235,6 @@ def find_or_create_channel(self, name): self.channels[name] = channel return channel - class Options(usage.Options): optParameters = [ ["certificate", "c", "cert", "SSL certificate"], From 497f668773aa6b734f911e68bb91b55e1cb9fea4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:00:25 +1100 Subject: [PATCH 03/57] Add initial ruff configuration, based on that of NVDA --- .ruff.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .ruff.toml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..5a99a54 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,9 @@ +line-length = 110 +target-version = "py311" + +[lint] +fixable = ["ALL"] + +[format] +indent-style = "tab" +line-ending = "lf" From a4f79e9dd2d144cb29e3228e34d33927124143cb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 27 Mar 2025 17:31:10 +1100 Subject: [PATCH 04/57] remove explicit inheritance from `object` --- server.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 806a922..939fb92 100644 --- a/server.py +++ b/server.py @@ -18,10 +18,10 @@ logger = getLogger("remote-server") -PING_INTERVAL = 300 -INITIAL_TIMEOUT = 30 +PING_INTERVAL: int = 300 +INITIAL_TIMEOUT: int = 30 # Expiration time for generated keys, in seconds -GENERATED_KEY_EXPIRATION_TIME = 60 * 60 * 24 # One day +GENERATED_KEY_EXPIRATION_TIME: int = 60 * 60 * 24 # One day class Channel(object): @@ -148,7 +148,8 @@ def cleanup(self): self.transport.abortConnection() self.cleanup_timer = None -class User(object): + +class User: user_id = 0 def __init__(self, protocol): @@ -215,7 +216,7 @@ def ping_connected_clients(self): channel.ping_clients() -class ServerState(object): +class ServerState: def __init__(self): self.channels = {} # Set of already generated keys From bb59a9ea7e7456b58c75cfcbc335d7383413e2a2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 21 May 2025 15:57:57 +1000 Subject: [PATCH 05/57] Initial run ov monkeytype over the code base --- server.pyi | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 server.pyi diff --git a/server.pyi b/server.pyi new file mode 100644 index 0000000..66fc3ce --- /dev/null +++ b/server.pyi @@ -0,0 +1,56 @@ +from twisted.internet.defer import Deferred +from twisted.python.failure import Failure +from typing import ( + Any, + Dict, + Optional, + Union, +) + + +def main() -> Deferred: ... + + +class Channel: + def __init__(self, key: str, server_state: Optional[ServerState] = ...) -> None: ... + def add_client(self, client: User) -> None: ... + def ping_clients(self) -> None: ... + def remove_connection(self, con: User) -> None: ... + def send_to_clients( + self, + obj: Dict[str, Any], + exclude: Optional[User] = ..., + origin: Optional[int] = ... + ) -> None: ... + + +class Handler: + def __init__(self) -> None: ... + def connectionLost(self, reason: Failure) -> None: ... + def connectionMade(self) -> None: ... + def do_generate_key(self, obj: Dict[str, str]) -> None: ... + def do_join(self, obj: Dict[str, str]) -> None: ... + def do_protocol_version(self, obj: Dict[str, Union[int, str]]) -> None: ... + def lineReceived(self, line: bytes) -> None: ... + def send(self, origin: Optional[int] = ..., **msg) -> None: ... + + +class RemoteServerFactory: + def __init__(self, server_state: ServerState) -> None: ... + def ping_connected_clients(self) -> None: ... + + +class ServerState: + def __init__(self) -> None: ... + def find_or_create_channel(self, name: str) -> Channel: ... + def remove_channel(self, channel: str) -> None: ... + + +class User: + def __init__(self, protocol: Handler) -> None: ... + def as_dict(self) -> Dict[str, Union[int, str]]: ... + def connection_lost(self) -> None: ... + def generate_key(self) -> str: ... + def join(self, channel: str, connection_type: str) -> None: ... + def send(self, **obj) -> None: ... + def send_motd(self) -> None: ... From 253d6079afdc263f93db8126acfc45b3215f3180 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 21 May 2025 16:05:01 +1000 Subject: [PATCH 06/57] Remove inheritance from object --- server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.py b/server.py index 939fb92..52f8c66 100644 --- a/server.py +++ b/server.py @@ -24,7 +24,7 @@ GENERATED_KEY_EXPIRATION_TIME: int = 60 * 60 * 24 # One day -class Channel(object): +class Channel: def __init__(self, key, server_state=None): self.clients = OrderedDict() self.key = key From b60378ed54fe15f6f7101dded2dffd6b20bff2e7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 21 May 2025 16:43:25 +1000 Subject: [PATCH 07/57] Fix a couple of type annotations --- server.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.pyi b/server.pyi index 66fc3ce..bc05100 100644 --- a/server.pyi +++ b/server.pyi @@ -32,7 +32,7 @@ class Handler: def do_join(self, obj: Dict[str, str]) -> None: ... def do_protocol_version(self, obj: Dict[str, Union[int, str]]) -> None: ... def lineReceived(self, line: bytes) -> None: ... - def send(self, origin: Optional[int] = ..., **msg) -> None: ... + def send(self, origin: Optional[int] = ..., **msg: dict[str, Any]) -> None: ... class RemoteServerFactory: @@ -52,5 +52,5 @@ class User: def connection_lost(self) -> None: ... def generate_key(self) -> str: ... def join(self, channel: str, connection_type: str) -> None: ... - def send(self, **obj) -> None: ... + def send(self, **obj: dict[str, Any]) -> None: ... def send_motd(self) -> None: ... From 4748695b103c5fe3e582d41577aaa0d5a60e6c96 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 21 May 2025 17:11:31 +1000 Subject: [PATCH 08/57] Rename and improve ruff.toml --- .ruff.toml | 9 --------- ruff.toml | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) delete mode 100644 .ruff.toml create mode 100644 ruff.toml diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 5a99a54..0000000 --- a/.ruff.toml +++ /dev/null @@ -1,9 +0,0 @@ -line-length = 110 -target-version = "py311" - -[lint] -fixable = ["ALL"] - -[format] -indent-style = "tab" -line-ending = "lf" diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..2522a62 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,18 @@ +line-length = 110 +target-version = "py311" +show-fixes = true + +[lint] +fixable = ["ALL"] +extend-select = [ + "UP", # pyupgrade +] + +[format] +indent-style = "tab" +line-ending = "lf" + + +[lint.pyupgrade] +# Preserve types, even if a file imports `from __future__ import annotations`. +keep-runtime-typing = false From 431c23f9354714cdbd33099f89bcc037544f53ce Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 21 May 2025 17:12:52 +1000 Subject: [PATCH 09/57] Fix up type annotations by removing Optional, Union, Dict, List etc --- server.py | 3 +-- server.pyi | 68 ++++++++++++++++++++++-------------------------------- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/server.py b/server.py index 52f8c66..35a9d9d 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,3 @@ -import io import json import os import random @@ -265,7 +264,7 @@ def main(): # pragma: no cover ) state = ServerState() if os.path.exists(config["motd"]): - with io.open(config["motd"], encoding="utf-8") as fp: + with open(config["motd"], encoding="utf-8") as fp: state.motd = fp.read().strip() else: state.motd = None diff --git a/server.pyi b/server.pyi index bc05100..994d9b2 100644 --- a/server.pyi +++ b/server.pyi @@ -1,56 +1,44 @@ from twisted.internet.defer import Deferred from twisted.python.failure import Failure from typing import ( - Any, - Dict, - Optional, - Union, + Any, ) - def main() -> Deferred: ... - class Channel: - def __init__(self, key: str, server_state: Optional[ServerState] = ...) -> None: ... - def add_client(self, client: User) -> None: ... - def ping_clients(self) -> None: ... - def remove_connection(self, con: User) -> None: ... - def send_to_clients( - self, - obj: Dict[str, Any], - exclude: Optional[User] = ..., - origin: Optional[int] = ... - ) -> None: ... - + def __init__(self, key: str, server_state: ServerState | None = ...) -> None: ... + def add_client(self, client: User) -> None: ... + def ping_clients(self) -> None: ... + def remove_connection(self, con: User) -> None: ... + def send_to_clients( + self, obj: dict[str, Any], exclude: User | None = ..., origin: int | None = ... + ) -> None: ... class Handler: - def __init__(self) -> None: ... - def connectionLost(self, reason: Failure) -> None: ... - def connectionMade(self) -> None: ... - def do_generate_key(self, obj: Dict[str, str]) -> None: ... - def do_join(self, obj: Dict[str, str]) -> None: ... - def do_protocol_version(self, obj: Dict[str, Union[int, str]]) -> None: ... - def lineReceived(self, line: bytes) -> None: ... - def send(self, origin: Optional[int] = ..., **msg: dict[str, Any]) -> None: ... - + def __init__(self) -> None: ... + def connectionLost(self, reason: Failure) -> None: ... + def connectionMade(self) -> None: ... + def do_generate_key(self, obj: dict[str, str]) -> None: ... + def do_join(self, obj: dict[str, str]) -> None: ... + def do_protocol_version(self, obj: dict[str, int | str]) -> None: ... + def lineReceived(self, line: bytes) -> None: ... + def send(self, origin: int | None = ..., **msg: dict[str, Any]) -> None: ... class RemoteServerFactory: - def __init__(self, server_state: ServerState) -> None: ... - def ping_connected_clients(self) -> None: ... - + def __init__(self, server_state: ServerState) -> None: ... + def ping_connected_clients(self) -> None: ... class ServerState: - def __init__(self) -> None: ... - def find_or_create_channel(self, name: str) -> Channel: ... - def remove_channel(self, channel: str) -> None: ... - + def __init__(self) -> None: ... + def find_or_create_channel(self, name: str) -> Channel: ... + def remove_channel(self, channel: str) -> None: ... class User: - def __init__(self, protocol: Handler) -> None: ... - def as_dict(self) -> Dict[str, Union[int, str]]: ... - def connection_lost(self) -> None: ... - def generate_key(self) -> str: ... - def join(self, channel: str, connection_type: str) -> None: ... - def send(self, **obj: dict[str, Any]) -> None: ... - def send_motd(self) -> None: ... + def __init__(self, protocol: Handler) -> None: ... + def as_dict(self) -> dict[str, int | str]: ... + def connection_lost(self) -> None: ... + def generate_key(self) -> str: ... + def join(self, channel: str, connection_type: str) -> None: ... + def send(self, **obj: dict[str, Any]) -> None: ... + def send_motd(self) -> None: ... From d46f773c0cc6533db3fe1f85b8a1a053420ce1de Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:42:04 +1000 Subject: [PATCH 10/57] Read certificates as binary --- server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 35a9d9d..136b227 100644 --- a/server.py +++ b/server.py @@ -251,8 +251,8 @@ def main(): # pragma: no cover config = Options() config.parseOptions() privkey = open(config["privkey"]).read() - certData = open(config["certificate"]).read() - chain = open(config["chain"]).read() + certData = open(config["certificate"], "rb").read() + chain = open(config["chain"], "rb").read() log.startLogging(sys.stdout) privkey = crypto.load_privatekey(crypto.FILETYPE_PEM, privkey) certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certData) From 26267e1f55f817188f067c8a7742cccf41914293 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:01:37 +1000 Subject: [PATCH 11/57] Move type hints into server.py --- server.py | 55 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/server.py b/server.py index 136b227..33ac34b 100644 --- a/server.py +++ b/server.py @@ -14,6 +14,9 @@ from twisted.internet.task import LoopingCall from twisted.protocols.basic import LineReceiver from twisted.python import log, usage +from twisted.internet.defer import Deferred +from twisted.python.failure import Failure +from typing import Any logger = getLogger("remote-server") @@ -24,12 +27,12 @@ class Channel: - def __init__(self, key, server_state=None): + def __init__(self, key: str, server_state: "ServerState | None"=None) -> None: self.clients = OrderedDict() self.key = key self.server_state = server_state - def add_client(self, client): + def add_client(self, client: "User") -> None: if client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested ids = [c.user_id for c in self.clients.values()] msg = dict(type="channel_joined", channel=self.key, user_ids=ids, origin=client.user_id) @@ -44,7 +47,7 @@ def add_client(self, client): existing_client.send(type="client_joined", client=client.as_dict()) self.clients[client.user_id] = client - def remove_connection(self, con): + def remove_connection(self, con: "User") -> None: if con.user_id in self.clients: del self.clients[con.user_id] for client in self.clients.values(): @@ -55,10 +58,10 @@ def remove_connection(self, con): if not self.clients: self.server_state.remove_channel(self.key) - def ping_clients(self): + def ping_clients(self) -> None: self.send_to_clients({"type": "ping"}) - def send_to_clients(self, obj, exclude=None, origin=None): + def send_to_clients(self, obj: dict[str, Any], exclude: "User | None"=None, origin: int | None=None) -> None: for client in self.clients.values(): if client is exclude: continue @@ -70,12 +73,12 @@ class Handler(LineReceiver): connection_id = 0 MAX_LENGTH = 20 * 1048576 - def __init__(self): + def __init__(self) -> None: self.connection_id = Handler.connection_id + 1 Handler.connection_id += 1 self.protocol_version = 1 - def connectionMade(self): + def connectionMade(self) -> None: logger.info("Connection %d from %s" % (self.connection_id, self.transport.getPeer())) # We use a non-tcp transport for unit testing, # which doesn't support setTcpNoDelay. @@ -87,7 +90,7 @@ def connectionMade(self): self.cleanup_timer = reactor.callLater(INITIAL_TIMEOUT, self.cleanup) self.user.send_motd() - def connectionLost(self, reason): + def connectionLost(self, reason: Failure) -> None: logger.info( "Connection %d lost, bytes sent: %d received: %d" % (self.connection_id, self.bytes_sent, self.bytes_received), @@ -98,7 +101,7 @@ def connectionLost(self, reason): ): # pragma: no cover - not sure how to trigger this self.cleanup_timer.cancel() - def lineReceived(self, line): + def lineReceived(self, line: bytes) -> None: self.bytes_received += len(line) try: parsed = json.loads(line) @@ -120,22 +123,22 @@ def lineReceived(self, line): return getattr(self, "do_" + parsed["type"])(parsed) - def do_join(self, obj): + def do_join(self, obj: dict[str, str]) -> None: if "channel" not in obj or not obj["channel"]: self.send(type="error", error="invalid_parameters") return self.user.join(obj["channel"], connection_type=obj.get("connection_type")) self.cleanup_timer.cancel() - def do_protocol_version(self, obj): + def do_protocol_version(self, obj: dict[str, int | str]) -> None: if "version" not in obj: return self.protocol_version = obj["version"] - def do_generate_key(self, obj): + def do_generate_key(self, obj: dict[str, str]) -> None: self.user.generate_key() - def send(self, origin=None, **msg): + def send(self, origin: int | None=None, **msg) -> None: if self.protocol_version > 1 and origin: msg["origin"] = origin obj = json.dumps(msg).encode("ascii") @@ -151,7 +154,7 @@ def cleanup(self): class User: user_id = 0 - def __init__(self, protocol): + def __init__(self, protocol: Handler) -> None: self.protocol = protocol self.channel = None self.server_state = self.protocol.factory.server_state @@ -159,10 +162,10 @@ def __init__(self, protocol): self.user_id = User.user_id + 1 User.user_id += 1 - def as_dict(self): + def as_dict(self) -> dict[str, int | str]: return dict(id=self.user_id, connection_type=self.connection_type) - def generate_key(self): + def generate_key(self) -> str: ip = self.protocol.transport.getPeer().host if ip in self.server_state.generated_ips and time.time() - self.server_state.generated_ips[ip] < 1: self.send(type="error", message="too many keys") @@ -178,13 +181,13 @@ def generate_key(self): self.send(type="generate_key", key=key) return key - def connection_lost(self): + def connection_lost(self) -> None: if ( self.channel is not None ): # pragma: no branch - we don't care about the alternative, as it's a no-op self.channel.remove_connection(self) - def join(self, channel, connection_type): + def join(self, channel: str, connection_type: str) -> None: if self.channel: self.send(type="error", error="already_joined") return @@ -198,25 +201,25 @@ def do_generate_key(self): # pragma: no cover if key: self.send(type="generate_key", key=key) - def send(self, **obj): + def send(self, **obj) -> None: self.protocol.send(**obj) - def send_motd(self): + def send_motd(self) -> None: if self.server_state.motd is not None: self.send(type="motd", motd=self.server_state.motd) class RemoteServerFactory(Factory): - def __init__(self, server_state): + def __init__(self, server_state: "ServerState") -> None: self.server_state = server_state - def ping_connected_clients(self): + def ping_connected_clients(self) -> None: for channel in self.server_state.channels.values(): channel.ping_clients() class ServerState: - def __init__(self): + def __init__(self) -> None: self.channels = {} # Set of already generated keys self.generated_keys = set() @@ -224,10 +227,10 @@ def __init__(self): self.generated_ips = {} self.motd: str | None = None - def remove_channel(self, channel): + def remove_channel(self, channel: str) -> None: del self.channels[channel] - def find_or_create_channel(self, name): + def find_or_create_channel(self, name: str) -> Channel: if name in self.channels: channel = self.channels[name] else: @@ -247,7 +250,7 @@ class Options(usage.Options): # Exclude from coverage as it's hard to unit test. -def main(): # pragma: no cover +def main() -> Deferred: # pragma: no cover config = Options() config.parseOptions() privkey = open(config["privkey"]).read() From e8c7cd1fd499cec34a17de1620009a2b7062d8b3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:50:13 +1000 Subject: [PATCH 12/57] Fix logging --- server.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/server.py b/server.py index 33ac34b..4a8f331 100644 --- a/server.py +++ b/server.py @@ -27,7 +27,7 @@ class Channel: - def __init__(self, key: str, server_state: "ServerState | None"=None) -> None: + def __init__(self, key: str, server_state: "ServerState | None" = None) -> None: self.clients = OrderedDict() self.key = key self.server_state = server_state @@ -61,7 +61,9 @@ def remove_connection(self, con: "User") -> None: def ping_clients(self) -> None: self.send_to_clients({"type": "ping"}) - def send_to_clients(self, obj: dict[str, Any], exclude: "User | None"=None, origin: int | None=None) -> None: + def send_to_clients( + self, obj: dict[str, Any], exclude: "User | None" = None, origin: int | None = None + ) -> None: for client in self.clients.values(): if client is exclude: continue @@ -79,7 +81,7 @@ def __init__(self) -> None: self.protocol_version = 1 def connectionMade(self) -> None: - logger.info("Connection %d from %s" % (self.connection_id, self.transport.getPeer())) + logger.info("Connection %d from %s", self.connection_id, self.transport.getPeer()) # We use a non-tcp transport for unit testing, # which doesn't support setTcpNoDelay. if isinstance(self.transport, ITCPTransport): # pragma: no cover @@ -92,8 +94,10 @@ def connectionMade(self) -> None: def connectionLost(self, reason: Failure) -> None: logger.info( - "Connection %d lost, bytes sent: %d received: %d" - % (self.connection_id, self.bytes_sent, self.bytes_received), + "Connection %d lost, bytes sent: %d received: %d", + self.connection_id, + self.bytes_sent, + self.bytes_received, ) self.user.connection_lost() if ( @@ -108,18 +112,18 @@ def lineReceived(self, line: bytes) -> None: if not isinstance(parsed, dict): raise ValueError except ValueError: - logger.warn("Unable to parse %r" % line) + logger.warn("Unable to parse %r", line) self.transport.loseConnection() return if "type" not in parsed: - logger.warning("Invalid object received: %r" % parsed) + logger.warning("Invalid object received: %r", parsed) return parsed.pop("origin", None) # Remove an existing origin, we know where the message comes from. if self.user.channel is not None: self.user.channel.send_to_clients(parsed, exclude=self.user, origin=self.user.user_id) return elif not hasattr(self, "do_" + parsed["type"]): - logger.warning("No function for type %s" % parsed["type"]) + logger.warning("No function for type %s", parsed["type"]) return getattr(self, "do_" + parsed["type"])(parsed) @@ -138,7 +142,7 @@ def do_protocol_version(self, obj: dict[str, int | str]) -> None: def do_generate_key(self, obj: dict[str, str]) -> None: self.user.generate_key() - def send(self, origin: int | None=None, **msg) -> None: + def send(self, origin: int | None = None, **msg) -> None: if self.protocol_version > 1 and origin: msg["origin"] = origin obj = json.dumps(msg).encode("ascii") @@ -146,7 +150,7 @@ def send(self, origin: int | None=None, **msg) -> None: self.sendLine(obj) def cleanup(self): - logger.info("Connection %d timed out" % self.connection_id) + logger.info("Connection %d timed out", self.connection_id) self.transport.abortConnection() self.cleanup_timer = None From f2c7b5e70c8954966dd623c921e6a3aa16d31fae Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:55:54 +1000 Subject: [PATCH 13/57] Fix ambiguous name --- server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 4a8f331..af5d0fa 100644 --- a/server.py +++ b/server.py @@ -275,11 +275,11 @@ def main() -> Deferred: # pragma: no cover state.motd = fp.read().strip() else: state.motd = None - f = RemoteServerFactory(state) - l = LoopingCall(f.ping_connected_clients) - l.start(PING_INTERVAL) - f.protocol = Handler - reactor.listenSSL(int(config["port"]), f, context_factory, interface=config["network-interface"]) + factory = RemoteServerFactory(state) + looper = LoopingCall(factory.ping_connected_clients) + looper.start(PING_INTERVAL) + factory.protocol = Handler + reactor.listenSSL(int(config["port"]), factory, context_factory, interface=config["network-interface"]) reactor.run() return defer.Deferred() From 03a618aa7b1c83698faae277ab58bb126f3f6db3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:16:44 +1000 Subject: [PATCH 14/57] Fix some typing issues --- server.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/server.py b/server.py index af5d0fa..413df11 100644 --- a/server.py +++ b/server.py @@ -10,13 +10,13 @@ from OpenSSL import crypto from twisted.internet import reactor, ssl from twisted.internet.interfaces import ITCPTransport -from twisted.internet.protocol import Factory, defer +from twisted.internet.protocol import Factory, defer, connectionDone from twisted.internet.task import LoopingCall from twisted.protocols.basic import LineReceiver from twisted.python import log, usage from twisted.internet.defer import Deferred from twisted.python.failure import Failure -from typing import Any +from typing import Any, TypedDict logger = getLogger("remote-server") @@ -26,6 +26,12 @@ GENERATED_KEY_EXPIRATION_TIME: int = 60 * 60 * 24 # One day +class UserDict(TypedDict): + """Typed dictionary representing a user.""" + id: int + connection_type: str | None + + class Channel: def __init__(self, key: str, server_state: "ServerState | None" = None) -> None: self.clients = OrderedDict() @@ -92,7 +98,7 @@ def connectionMade(self) -> None: self.cleanup_timer = reactor.callLater(INITIAL_TIMEOUT, self.cleanup) self.user.send_motd() - def connectionLost(self, reason: Failure) -> None: + def connectionLost(self, reason: Failure = connectionDone) -> None: logger.info( "Connection %d lost, bytes sent: %d received: %d", self.connection_id, @@ -128,16 +134,19 @@ def lineReceived(self, line: bytes) -> None: getattr(self, "do_" + parsed["type"])(parsed) def do_join(self, obj: dict[str, str]) -> None: - if "channel" not in obj or not obj["channel"]: + if "channel" not in obj or not obj["channel"] or "connection_type" not in obj or not obj["connection_type"]: self.send(type="error", error="invalid_parameters") return - self.user.join(obj["channel"], connection_type=obj.get("connection_type")) + self.user.join(obj["channel"], connection_type=obj["connection_type"]) self.cleanup_timer.cancel() def do_protocol_version(self, obj: dict[str, int | str]) -> None: if "version" not in obj: return - self.protocol_version = obj["version"] + try: + self.protocol_version = int(obj["version"]) + except ValueError: + return def do_generate_key(self, obj: dict[str, str]) -> None: self.user.generate_key() @@ -166,10 +175,10 @@ def __init__(self, protocol: Handler) -> None: self.user_id = User.user_id + 1 User.user_id += 1 - def as_dict(self) -> dict[str, int | str]: - return dict(id=self.user_id, connection_type=self.connection_type) + def as_dict(self) -> UserDict: + return UserDict(id=self.user_id, connection_type=self.connection_type) - def generate_key(self) -> str: + def generate_key(self) -> str | None: ip = self.protocol.transport.getPeer().host if ip in self.server_state.generated_ips and time.time() - self.server_state.generated_ips[ip] < 1: self.send(type="error", message="too many keys") From 0104e77972589f8e8c73dd6886a78c425a7dc920 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:42:23 +1000 Subject: [PATCH 15/57] Docstrings for channel --- server.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 413df11..cc81e51 100644 --- a/server.py +++ b/server.py @@ -28,17 +28,29 @@ class UserDict(TypedDict): """Typed dictionary representing a user.""" + id: int connection_type: str | None class Channel: + """Collection of connected users in the one "session".""" + def __init__(self, key: str, server_state: "ServerState | None" = None) -> None: - self.clients = OrderedDict() + """_summary_ + + :param key: Unique identifier of this channel. + :param server_state: Server state, defaults to None + """ + self.clients: OrderedDict[int, User] = OrderedDict() self.key = key self.server_state = server_state def add_client(self, client: "User") -> None: + """Joined when a new user wants to join the channel. + + :param client: The new channel member. + """ if client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested ids = [c.user_id for c in self.clients.values()] msg = dict(type="channel_joined", channel=self.key, user_ids=ids, origin=client.user_id) @@ -54,6 +66,10 @@ def add_client(self, client: "User") -> None: self.clients[client.user_id] = client def remove_connection(self, con: "User") -> None: + """Called when a user leaves the channel. + + :param con: The leaving channel member. + """ if con.user_id in self.clients: del self.clients[con.user_id] for client in self.clients.values(): @@ -65,11 +81,21 @@ def remove_connection(self, con: "User") -> None: self.server_state.remove_channel(self.key) def ping_clients(self) -> None: + """Ping clients to ensure they're still connected.""" self.send_to_clients({"type": "ping"}) def send_to_clients( - self, obj: dict[str, Any], exclude: "User | None" = None, origin: int | None = None + self, + obj: dict[str, Any], + exclude: "User | None" = None, + origin: int | None = None, ) -> None: + """Broadcast a message to all users in this channel. + + :param obj: Message to send. + :param exclude: User to exclude from the broadcast, defaults to None + :param origin: Originating user, defaults to None + """ for client in self.clients.values(): if client is exclude: continue @@ -134,7 +160,12 @@ def lineReceived(self, line: bytes) -> None: getattr(self, "do_" + parsed["type"])(parsed) def do_join(self, obj: dict[str, str]) -> None: - if "channel" not in obj or not obj["channel"] or "connection_type" not in obj or not obj["connection_type"]: + if ( + "channel" not in obj + or not obj["channel"] + or "connection_type" not in obj + or not obj["connection_type"] + ): self.send(type="error", error="invalid_parameters") return self.user.join(obj["channel"], connection_type=obj["connection_type"]) From 217d049e4fbc681c3a95c2027e82dfec54dbe1f0 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:52:19 +1000 Subject: [PATCH 16/57] Docstrings for Handler --- server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server.py b/server.py index cc81e51..f27be43 100644 --- a/server.py +++ b/server.py @@ -103,6 +103,8 @@ def send_to_clients( class Handler(LineReceiver): + """Handle sending and receiving messages.""" + delimiter = b"\n" connection_id = 0 MAX_LENGTH = 20 * 1048576 @@ -113,6 +115,7 @@ def __init__(self) -> None: self.protocol_version = 1 def connectionMade(self) -> None: + """Called when a user first connects.""" logger.info("Connection %d from %s", self.connection_id, self.transport.getPeer()) # We use a non-tcp transport for unit testing, # which doesn't support setTcpNoDelay. @@ -125,6 +128,7 @@ def connectionMade(self) -> None: self.user.send_motd() def connectionLost(self, reason: Failure = connectionDone) -> None: + """Called when the connection is dropped.""" logger.info( "Connection %d lost, bytes sent: %d received: %d", self.connection_id, @@ -138,6 +142,10 @@ def connectionLost(self, reason: Failure = connectionDone) -> None: self.cleanup_timer.cancel() def lineReceived(self, line: bytes) -> None: + """Called when a new line (a command) has been received. + + :param line: The incoming line. + """ self.bytes_received += len(line) try: parsed = json.loads(line) @@ -160,6 +168,7 @@ def lineReceived(self, line: bytes) -> None: getattr(self, "do_" + parsed["type"])(parsed) def do_join(self, obj: dict[str, str]) -> None: + """Called when receiving a "join" message.""" if ( "channel" not in obj or not obj["channel"] @@ -172,6 +181,8 @@ def do_join(self, obj: dict[str, str]) -> None: self.cleanup_timer.cancel() def do_protocol_version(self, obj: dict[str, int | str]) -> None: + """Called when a "protocol_version" message is received.""" + # TODO: Why don't we send an error message back? if "version" not in obj: return try: @@ -180,9 +191,14 @@ def do_protocol_version(self, obj: dict[str, int | str]) -> None: return def do_generate_key(self, obj: dict[str, str]) -> None: + """Called when a "generate_key" message is received.""" self.user.generate_key() def send(self, origin: int | None = None, **msg) -> None: + """Send a message + + :param origin: Originating user of the message, defaults to None + """ if self.protocol_version > 1 and origin: msg["origin"] = origin obj = json.dumps(msg).encode("ascii") @@ -190,6 +206,7 @@ def send(self, origin: int | None = None, **msg) -> None: self.sendLine(obj) def cleanup(self): + """Clean up this connection.""" logger.info("Connection %d timed out", self.connection_id) self.transport.abortConnection() self.cleanup_timer = None From b046099747063455cfd4e3a840df1aae23bba330 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:59:53 +1000 Subject: [PATCH 17/57] Docstrings for User --- server.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index f27be43..5b508c5 100644 --- a/server.py +++ b/server.py @@ -195,7 +195,7 @@ def do_generate_key(self, obj: dict[str, str]) -> None: self.user.generate_key() def send(self, origin: int | None = None, **msg) -> None: - """Send a message + """Send a message. :param origin: Originating user of the message, defaults to None """ @@ -213,9 +213,15 @@ def cleanup(self): class User: + """A single connected user.""" + user_id = 0 def __init__(self, protocol: Handler) -> None: + """Initializer. + + :param protocol: The Handler through which this user connected. + """ self.protocol = protocol self.channel = None self.server_state = self.protocol.factory.server_state @@ -224,9 +230,16 @@ def __init__(self, protocol: Handler) -> None: User.user_id += 1 def as_dict(self) -> UserDict: + """Get a representation of this user suitable for sending over the wire.""" return UserDict(id=self.user_id, connection_type=self.connection_type) def generate_key(self) -> str | None: + """Generate a key for the user. + + :return: A channel key, or None if too many keys have been requested. + + :postcondition: The key will be temporarily persisted so that future key generation requests don't result in duplicate keys. + """ ip = self.protocol.transport.getPeer().host if ip in self.server_state.generated_ips and time.time() - self.server_state.generated_ips[ip] < 1: self.send(type="error", message="too many keys") @@ -243,12 +256,18 @@ def generate_key(self) -> str | None: return key def connection_lost(self) -> None: + """Remove this user when they disconnect.""" if ( self.channel is not None ): # pragma: no branch - we don't care about the alternative, as it's a no-op self.channel.remove_connection(self) def join(self, channel: str, connection_type: str) -> None: + """Add this user to a channel. + + :param channel: Key of the channel to join. If no channel with this key exists, a new channel will be created. + :param connection_type: Leader ("master") or follower ("slave"). + """ if self.channel: self.send(type="error", error="already_joined") return @@ -258,14 +277,17 @@ def join(self, channel: str, connection_type: str) -> None: # TODO: Work out if this is ever called. def do_generate_key(self): # pragma: no cover + """Not sure what calls this?""" key = self.generate_key() if key: self.send(type="generate_key", key=key) def send(self, **obj) -> None: + """Send a message to this user.""" self.protocol.send(**obj) def send_motd(self) -> None: + """Send the message of the day to this user.""" if self.server_state.motd is not None: self.send(type="motd", motd=self.server_state.motd) From 856be4539ac88e6b2774760ae9e8f266915af736 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 17:10:01 +1000 Subject: [PATCH 18/57] Docstrings for ServerState --- server.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 5b508c5..e3f22da 100644 --- a/server.py +++ b/server.py @@ -16,6 +16,7 @@ from twisted.python import log, usage from twisted.internet.defer import Deferred from twisted.python.failure import Failure +from twisted.internet.interfaces import IAddress from typing import Any, TypedDict logger = getLogger("remote-server") @@ -293,27 +294,45 @@ def send_motd(self) -> None: class RemoteServerFactory(Factory): + """Factory to add common functionality to connections.""" + def __init__(self, server_state: "ServerState") -> None: + """Initializer. + + :param server_state: Status tracking object. + """ self.server_state = server_state def ping_connected_clients(self) -> None: + """Ping all users in all channels to determine if they're still connected.""" for channel in self.server_state.channels.values(): channel.ping_clients() class ServerState: + """Object that tracks the status of the server.""" + def __init__(self) -> None: - self.channels = {} + self.channels: dict[str, Channel] = {} # Set of already generated keys - self.generated_keys = set() - # Dictionary of ips to generated time for people who have generated keys. - self.generated_ips = {} + self.generated_keys: set[str] = set() + # Mapping of IPs to generated time for people who have generated keys. + self.generated_ips: dict[IAddress, float] = {} self.motd: str | None = None def remove_channel(self, channel: str) -> None: + """Close a channel. + + :param channel: Key of the channel to remove. + """ del self.channels[channel] def find_or_create_channel(self, name: str) -> Channel: + """Find an existing channel, or create one if one doesn't already exist. + + :param name: Key of the channel to find/create. + :return: The found or created channel. + """ if name in self.channels: channel = self.channels[name] else: From 3d479d560782128d4bbcc3c194e2a6cf8a0f4779 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Jun 2025 17:13:19 +1000 Subject: [PATCH 19/57] Add some comments --- server.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server.py b/server.py index e3f22da..26fe1be 100644 --- a/server.py +++ b/server.py @@ -353,12 +353,15 @@ class Options(usage.Options): # Exclude from coverage as it's hard to unit test. def main() -> Deferred: # pragma: no cover + # Read options from CLI. config = Options() config.parseOptions() + # Open SSL keys. privkey = open(config["privkey"]).read() certData = open(config["certificate"], "rb").read() chain = open(config["chain"], "rb").read() log.startLogging(sys.stdout) + # Initialise encryption privkey = crypto.load_privatekey(crypto.FILETYPE_PEM, privkey) certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certData) chain = crypto.load_certificate(crypto.FILETYPE_PEM, chain) @@ -367,16 +370,19 @@ def main() -> Deferred: # pragma: no cover certificate=certificate, extraCertChain=[chain], ) + # Initialise the server state machine state = ServerState() if os.path.exists(config["motd"]): with open(config["motd"], encoding="utf-8") as fp: state.motd = fp.read().strip() else: state.motd = None + # Set up the machinery of the server. factory = RemoteServerFactory(state) looper = LoopingCall(factory.ping_connected_clients) looper.start(PING_INTERVAL) factory.protocol = Handler + # Start running the server. reactor.listenSSL(int(config["port"]), factory, context_factory, interface=config["network-interface"]) reactor.run() return defer.Deferred() From 65c52b3a806fd95fe288b5ccf0be0666a591da15 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:22:36 +1000 Subject: [PATCH 20/57] Enable pyright, with all failing tests disabled --- pyproject.toml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2aad0a5..576e83f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,3 +84,39 @@ fail_under = 100 exclude_also = [ 'if __name__ == .__main__.:', ] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Linux" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "setup.py", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# General config +analyzeUnannotatedFunctions = false +deprecateTypingAliases = true + +reportMissingTypeArgument=false +reportUnknownVariableType=false +reportAttributeAccessIssue=false +reportUnknownMemberType=false +reportUnknownParameterType=false +reportUnknownArgumentType=false +reportMissingParameterType=false +reportUnknownLambdaType=false +reportUnusedVariable=false +reportOptionalMemberAccess=false +reportDeprecated=false From 780ca10ac4ad854fee65bae9a6d5f432b4d9fbd9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:24:11 +1000 Subject: [PATCH 21/57] Fix reportDeprecated --- pyproject.toml | 1 - server.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 576e83f..ce54c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,4 +119,3 @@ reportMissingParameterType=false reportUnknownLambdaType=false reportUnusedVariable=false reportOptionalMemberAccess=false -reportDeprecated=false diff --git a/server.py b/server.py index 26fe1be..8a93eed 100644 --- a/server.py +++ b/server.py @@ -153,7 +153,7 @@ def lineReceived(self, line: bytes) -> None: if not isinstance(parsed, dict): raise ValueError except ValueError: - logger.warn("Unable to parse %r", line) + logger.warning("Unable to parse %r", line) self.transport.loseConnection() return if "type" not in parsed: From b18c973c4968cc233129e23ebc2b2901939cb279 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:12:30 +1000 Subject: [PATCH 22/57] Fix unannotated functions --- pyproject.toml | 35 ----------------------------------- server.py | 13 ++++++++++--- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce54c16..2aad0a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,38 +84,3 @@ fail_under = 100 exclude_also = [ 'if __name__ == .__main__.:', ] - -[tool.pyright] -venvPath = ".venv" -venv = "." -pythonPlatform = "Linux" -typeCheckingMode = "strict" - -include = [ - "**/*.py", -] - -exclude = [ - ".git", - "__pycache__", - ".venv", - "setup.py", - # When excluding concrete paths relative to a directory, - # not matching multiple folders by name e.g. `__pycache__`, - # paths are relative to the configuration file. -] - -# General config -analyzeUnannotatedFunctions = false -deprecateTypingAliases = true - -reportMissingTypeArgument=false -reportUnknownVariableType=false -reportAttributeAccessIssue=false -reportUnknownMemberType=false -reportUnknownParameterType=false -reportUnknownArgumentType=false -reportMissingParameterType=false -reportUnknownLambdaType=false -reportUnusedVariable=false -reportOptionalMemberAccess=false diff --git a/server.py b/server.py index 8a93eed..84043e1 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from twisted.internet.defer import Deferred from twisted.python.failure import Failure from twisted.internet.interfaces import IAddress -from typing import Any, TypedDict +from typing import Any, TypedDict, cast logger = getLogger("remote-server") @@ -34,6 +34,12 @@ class UserDict(TypedDict): connection_type: str | None +class Message(TypedDict): + """Type hints for protocol messages.""" + + type: str + + class Channel: """Collection of connected users in the one "session".""" @@ -156,6 +162,7 @@ def lineReceived(self, line: bytes) -> None: logger.warning("Unable to parse %r", line) self.transport.loseConnection() return + cast(Message, parsed) if "type" not in parsed: logger.warning("Invalid object received: %r", parsed) return @@ -206,7 +213,7 @@ def send(self, origin: int | None = None, **msg) -> None: self.bytes_sent += len(obj) self.sendLine(obj) - def cleanup(self): + def cleanup(self) -> None: """Clean up this connection.""" logger.info("Connection %d timed out", self.connection_id) self.transport.abortConnection() @@ -277,7 +284,7 @@ def join(self, channel: str, connection_type: str) -> None: self.channel.add_client(self) # TODO: Work out if this is ever called. - def do_generate_key(self): # pragma: no cover + def do_generate_key(self) -> None: # pragma: no cover """Not sure what calls this?""" key = self.generate_key() if key: From 2711ecf54a25368f05f89e7edc752bea8cd525af Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:29:30 +1000 Subject: [PATCH 23/57] Resolved several type issues --- server.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/server.py b/server.py index 84043e1..dceb711 100644 --- a/server.py +++ b/server.py @@ -16,7 +16,6 @@ from twisted.python import log, usage from twisted.internet.defer import Deferred from twisted.python.failure import Failure -from twisted.internet.interfaces import IAddress from typing import Any, TypedDict, cast logger = getLogger("remote-server") @@ -162,7 +161,7 @@ def lineReceived(self, line: bytes) -> None: logger.warning("Unable to parse %r", line) self.transport.loseConnection() return - cast(Message, parsed) + cast(dict[str, Any], parsed) if "type" not in parsed: logger.warning("Invalid object received: %r", parsed) return @@ -202,7 +201,7 @@ def do_generate_key(self, obj: dict[str, str]) -> None: """Called when a "generate_key" message is received.""" self.user.generate_key() - def send(self, origin: int | None = None, **msg) -> None: + def send(self, origin: int | None = None, **msg: Any) -> None: """Send a message. :param origin: Originating user of the message, defaults to None @@ -231,8 +230,8 @@ def __init__(self, protocol: Handler) -> None: :param protocol: The Handler through which this user connected. """ self.protocol = protocol - self.channel = None - self.server_state = self.protocol.factory.server_state + self.channel: Channel | None = None + self.server_state: ServerState = self.protocol.factory.server_state self.connection_type = None self.user_id = User.user_id + 1 User.user_id += 1 @@ -248,14 +247,14 @@ def generate_key(self) -> str | None: :postcondition: The key will be temporarily persisted so that future key generation requests don't result in duplicate keys. """ - ip = self.protocol.transport.getPeer().host + ip: str = self.protocol.transport.getPeer().host # type: ignore if ip in self.server_state.generated_ips and time.time() - self.server_state.generated_ips[ip] < 1: self.send(type="error", message="too many keys") self.protocol.transport.loseConnection() return - key = "".join([random.choice(string.digits) for i in range(7)]) + key = "".join([random.choice(string.digits) for _ in range(7)]) while key in self.server_state.generated_keys or key in self.server_state.channels.keys(): - key = "".join([random.choice(string.digits) for i in range(7)]) + key = "".join([random.choice(string.digits) for _ in range(7)]) self.server_state.generated_keys.add(key) self.server_state.generated_ips[ip] = time.time() reactor.callLater(GENERATED_KEY_EXPIRATION_TIME, lambda: self.server_state.generated_keys.remove(key)) @@ -290,7 +289,7 @@ def do_generate_key(self) -> None: # pragma: no cover if key: self.send(type="generate_key", key=key) - def send(self, **obj) -> None: + def send(self, **obj: Any) -> None: """Send a message to this user.""" self.protocol.send(**obj) @@ -324,7 +323,7 @@ def __init__(self) -> None: # Set of already generated keys self.generated_keys: set[str] = set() # Mapping of IPs to generated time for people who have generated keys. - self.generated_ips: dict[IAddress, float] = {} + self.generated_ips: dict[str, float] = {} self.motd: str | None = None def remove_channel(self, channel: str) -> None: @@ -359,7 +358,7 @@ class Options(usage.Options): # Exclude from coverage as it's hard to unit test. -def main() -> Deferred: # pragma: no cover +def main() -> Deferred[None]: # pragma: no cover # Read options from CLI. config = Options() config.parseOptions() From d85160745fc3a406ac7bfeb1b941a15f97cca8fa Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:39:52 +1000 Subject: [PATCH 24/57] Move to pyproject.toml for ruff config --- pyproject.toml | 4 ++++ ruff.toml | 18 ------------------ 2 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 ruff.toml diff --git a/pyproject.toml b/pyproject.toml index 2aad0a5..4ca98c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,11 @@ reportAttributeAccessIssue = false reportUnknownMemberType = false reportOptionalMemberAccess = false # The following option causes problems due to dynamic member access +<<<<<<< HEAD reportUnknownArgumentType = false +======= +reportUnknownArgumentType=false +>>>>>>> 65ec5c5 (Move to pyproject.toml for ruff config) [tool.ruff] line-length = 110 diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 2522a62..0000000 --- a/ruff.toml +++ /dev/null @@ -1,18 +0,0 @@ -line-length = 110 -target-version = "py311" -show-fixes = true - -[lint] -fixable = ["ALL"] -extend-select = [ - "UP", # pyupgrade -] - -[format] -indent-style = "tab" -line-ending = "lf" - - -[lint.pyupgrade] -# Preserve types, even if a file imports `from __future__ import annotations`. -keep-runtime-typing = false From 282581a82cc54609fceed0a166bf180651080de7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:41:22 +1000 Subject: [PATCH 25/57] Remove no longer needed type stubs --- server.pyi | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 server.pyi diff --git a/server.pyi b/server.pyi deleted file mode 100644 index 994d9b2..0000000 --- a/server.pyi +++ /dev/null @@ -1,44 +0,0 @@ -from twisted.internet.defer import Deferred -from twisted.python.failure import Failure -from typing import ( - Any, -) - -def main() -> Deferred: ... - -class Channel: - def __init__(self, key: str, server_state: ServerState | None = ...) -> None: ... - def add_client(self, client: User) -> None: ... - def ping_clients(self) -> None: ... - def remove_connection(self, con: User) -> None: ... - def send_to_clients( - self, obj: dict[str, Any], exclude: User | None = ..., origin: int | None = ... - ) -> None: ... - -class Handler: - def __init__(self) -> None: ... - def connectionLost(self, reason: Failure) -> None: ... - def connectionMade(self) -> None: ... - def do_generate_key(self, obj: dict[str, str]) -> None: ... - def do_join(self, obj: dict[str, str]) -> None: ... - def do_protocol_version(self, obj: dict[str, int | str]) -> None: ... - def lineReceived(self, line: bytes) -> None: ... - def send(self, origin: int | None = ..., **msg: dict[str, Any]) -> None: ... - -class RemoteServerFactory: - def __init__(self, server_state: ServerState) -> None: ... - def ping_connected_clients(self) -> None: ... - -class ServerState: - def __init__(self) -> None: ... - def find_or_create_channel(self, name: str) -> Channel: ... - def remove_channel(self, channel: str) -> None: ... - -class User: - def __init__(self, protocol: Handler) -> None: ... - def as_dict(self) -> dict[str, int | str]: ... - def connection_lost(self) -> None: ... - def generate_key(self) -> str: ... - def join(self, channel: str, connection_type: str) -> None: ... - def send(self, **obj: dict[str, Any]) -> None: ... - def send_motd(self) -> None: ... From 2418759950d4006487626e744b4377134bfb3924 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:01:39 +1000 Subject: [PATCH 26/57] Update pre-commit config for CI --- .pre-commit-config.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 768c8c8..b0817e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ # Configuration for Continuous Integration service # https://pre-commit.ci/ ci: - skip: [pyright] + skip: [pyrightLocal] autoupdate_schedule: monthly autoupdate_commit_msg: "Pre-commit auto-update" autofix_commit_msg: "Pre-commit auto-fix" @@ -81,4 +81,15 @@ repos: rev: v1.1.401 hooks: - id: pyright + alias: pyrightLocal name: Check types with pyright + +- repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.401 + hooks: + - id: pyright + alias: pyrightCI + name: Check types with pyright + # use nodejs version of pyright and install pyproject.toml for CI + additional_dependencies: [".", "pyright[nodejs]"] + stages: [manual] # Only run from CI manually From 96f3da9084ddd992da08580477b12bdc85d1cb6a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:08:37 +1000 Subject: [PATCH 27/57] Pre-commit updates --- .pre-commit-config.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0817e8..6a2eb08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ # Configuration for Continuous Integration service # https://pre-commit.ci/ ci: - skip: [pyrightLocal] + # skip: [pyrightLocal] autoupdate_schedule: monthly autoupdate_commit_msg: "Pre-commit auto-update" autofix_commit_msg: "Pre-commit auto-fix" @@ -84,12 +84,12 @@ repos: alias: pyrightLocal name: Check types with pyright -- repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.401 - hooks: - - id: pyright - alias: pyrightCI - name: Check types with pyright +# - repo: https://github.com/RobertCraigie/pyright-python + # rev: v1.1.401 + # hooks: + # - id: pyright + # alias: pyrightCI + # name: Check types with pyright # use nodejs version of pyright and install pyproject.toml for CI - additional_dependencies: [".", "pyright[nodejs]"] - stages: [manual] # Only run from CI manually + # additional_dependencies: [".", "pyright[nodejs]"] + # stages: [manual] # Only run from CI manually From 8e191e958e5ff16f6f27a1d65a992e75bb4933fb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:25:26 +1000 Subject: [PATCH 28/57] Remove pyrightCI and remove pyrightLocal alias --- .pre-commit-config.yaml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a2eb08..768c8c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ # Configuration for Continuous Integration service # https://pre-commit.ci/ ci: - # skip: [pyrightLocal] + skip: [pyright] autoupdate_schedule: monthly autoupdate_commit_msg: "Pre-commit auto-update" autofix_commit_msg: "Pre-commit auto-fix" @@ -81,15 +81,4 @@ repos: rev: v1.1.401 hooks: - id: pyright - alias: pyrightLocal name: Check types with pyright - -# - repo: https://github.com/RobertCraigie/pyright-python - # rev: v1.1.401 - # hooks: - # - id: pyright - # alias: pyrightCI - # name: Check types with pyright - # use nodejs version of pyright and install pyproject.toml for CI - # additional_dependencies: [".", "pyright[nodejs]"] - # stages: [manual] # Only run from CI manually From f854857e145c3e65d56bdae419d6017cbc366f8e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:58:30 +1000 Subject: [PATCH 29/57] Experimental pyright GHA --- .github/workflows/pyright.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/pyright.yml diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml new file mode 100644 index 0000000..97c25d1 --- /dev/null +++ b/.github/workflows/pyright.yml @@ -0,0 +1,15 @@ +name: Check types with Pyright + +on: push + +jobs: + pyright: + name: Check types with pyright + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + - uses: jakebailey/pyright-action@v2 + with: + version: 1.1.401 From 3543a3f6196937857db9a65e0d125e4eaf6d6fe2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:06:15 +1000 Subject: [PATCH 30/57] Try using uvx to run pyright --- .github/workflows/pyright.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 97c25d1..c3ee866 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -10,6 +10,5 @@ jobs: - uses: actions/checkout@v3 - name: Install the latest version of uv uses: astral-sh/setup-uv@v6 - - uses: jakebailey/pyright-action@v2 - with: - version: 1.1.401 + - name: Run pyright + run: uvx pyright From 521dc54853fe8974172984416bbd20ff56ae0eb0 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:17:51 +1000 Subject: [PATCH 31/57] Update deps --- .github/workflows/pyright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index c3ee866..b0a3bf6 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -7,8 +7,8 @@ jobs: name: Check types with pyright runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4.2.2 - name: Install the latest version of uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v6.1.0 - name: Run pyright run: uvx pyright From ba433e487a9363e9c86cd9b309ecaccbf21916eb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:10:26 +1000 Subject: [PATCH 32/57] Try running with uv run --- .github/workflows/pyright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index b0a3bf6..0b7c753 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -11,4 +11,4 @@ jobs: - name: Install the latest version of uv uses: astral-sh/setup-uv@v6.1.0 - name: Run pyright - run: uvx pyright + run: uv run pyright From e807136ac78e817da5d9c8d5c410f2413c19f3ff Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:15:00 +1000 Subject: [PATCH 33/57] Sync environment then use uvx --- .github/workflows/pyright.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 0b7c753..7fd8483 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -10,5 +10,7 @@ jobs: - uses: actions/checkout@v4.2.2 - name: Install the latest version of uv uses: astral-sh/setup-uv@v6.1.0 + - name: Setup environment + run: uv sync - name: Run pyright - run: uv run pyright + run: uvx pyright From 418bfc21b5acb1e3c8db3acc746e1ce4f308908f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:17:51 +1000 Subject: [PATCH 34/57] Include dev --- .github/workflows/pyright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 7fd8483..0233f5c 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -11,6 +11,6 @@ jobs: - name: Install the latest version of uv uses: astral-sh/setup-uv@v6.1.0 - name: Setup environment - run: uv sync + run: uv sync --dev - name: Run pyright run: uvx pyright From 86805256ff5d2dac97e639a31de8e7a217be5f01 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:18:52 +1000 Subject: [PATCH 35/57] Use uv run --- .github/workflows/pyright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 0233f5c..b56e788 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -13,4 +13,4 @@ jobs: - name: Setup environment run: uv sync --dev - name: Run pyright - run: uvx pyright + run: uv run pyright From 21f0a7976fafedef1f4b50cffb21ca6a4718d7aa Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:36:58 +1000 Subject: [PATCH 36/57] Apply suggestions from code review Co-authored-by: Sean Budd --- pyproject.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4ca98c1..72883e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,11 +47,8 @@ reportAttributeAccessIssue = false reportUnknownMemberType = false reportOptionalMemberAccess = false # The following option causes problems due to dynamic member access -<<<<<<< HEAD reportUnknownArgumentType = false -======= -reportUnknownArgumentType=false ->>>>>>> 65ec5c5 (Move to pyproject.toml for ruff config) + [tool.ruff] line-length = 110 From 1f36a0dafe6b9ab14aa7a0c0a7861cde470eecbe Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Jun 2025 12:17:21 +1000 Subject: [PATCH 37/57] Update .github/workflows/pyright.yml Co-authored-by: Sean Budd --- .github/workflows/pyright.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index b56e788..a3bb195 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -1,6 +1,13 @@ name: Check types with Pyright -on: push +on: + push: + branches: + - main + + pull_request: + branches: + - main jobs: pyright: From 0e4f42523c0d5e6b747b1508ebc89fe4edecb1c9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:36:30 +1000 Subject: [PATCH 38/57] Fix pyright error in server.py --- server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index dceb711..7d5c83e 100644 --- a/server.py +++ b/server.py @@ -126,7 +126,8 @@ def connectionMade(self) -> None: # We use a non-tcp transport for unit testing, # which doesn't support setTcpNoDelay. if isinstance(self.transport, ITCPTransport): # pragma: no cover - self.transport.setTcpNoDelay(True) + # Methods of Zope interfaces don't take self, so pyright thinks this call has too many arguments + self.transport.setTcpNoDelay(True) # pyright: ignore [reportCallIssue] self.bytes_sent = 0 self.bytes_received = 0 self.user = User(protocol=self) @@ -346,6 +347,7 @@ def find_or_create_channel(self, name: str) -> Channel: self.channels[name] = channel return channel + class Options(usage.Options): optParameters = [ ["certificate", "c", "cert", "SSL certificate"], From 5bb8dd50f13ff02951e6940845593114e9c4d251 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:42:39 +1000 Subject: [PATCH 39/57] Fix typ annotations on test.TestGenerateKey._test --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index 8eca766..558762b 100644 --- a/test.py +++ b/test.py @@ -300,7 +300,7 @@ def setUp(self) -> None: self.protocol, self.transport = self._connectClient() random.seed(self.RANDOM_SEED) - def _test(self, serverReceived: bytes, clientReceived: bytes) -> None: + def _test(self, serverReceived: dict[str, Any], clientReceived: dict[str, Any]) -> None: self.protocol.dataReceived(json.dumps(serverReceived).encode() + b"\n") self.assertEqual(json.loads(self.transport.value().decode()), clientReceived) self.transport.clear() From 650df67b3b49b43ae4373152e1bb6e2a9ecdbf2a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:55:29 +1000 Subject: [PATCH 40/57] Silence pyright errors caused by incorrect types in ttwisted/Zope interfaces --- test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test.py b/test.py index 558762b..7c9ae9e 100644 --- a/test.py +++ b/test.py @@ -255,9 +255,13 @@ def tearDown(self) -> None: def _createClient(self) -> Client: """Create a client-server connection.""" - protocol = self.factory.buildProtocol(("127.0.0.1", 0)) + # A (host, port) tuple works fine here. + # Even using twisted.internet.address.IPv4Address` here doesn't work, + # as pyright doesn't understand Zope interfaces. + protocol = self.factory.buildProtocol(("127.0.0.1", 0)) # pyright: ignore [reportArgumentType] transport = StringTransport() protocol.makeConnection(transport) + assert protocol is not None # Needed to shut pyright up return Client(protocol=protocol, transport=transport) def _connectClient(self, protocolVersion: int = 2) -> Client: From 1882501785caea8ce3473e16fcf3b5311c57292c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:02:52 +1000 Subject: [PATCH 41/57] Bring coverage back up to 100% --- server.py | 2 +- test.py | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index 7d5c83e..ac2451e 100644 --- a/server.py +++ b/server.py @@ -43,7 +43,7 @@ class Channel: """Collection of connected users in the one "session".""" def __init__(self, key: str, server_state: "ServerState | None" = None) -> None: - """_summary_ + """Constructor :param key: Unique identifier of this channel. :param server_state: Server state, defaults to None diff --git a/test.py b/test.py index 7c9ae9e..831e9ef 100644 --- a/test.py +++ b/test.py @@ -2,11 +2,11 @@ from itertools import islice import json import random -from typing import Any, Final, NamedTuple +from typing import Any, Final, NamedTuple, cast from unittest import mock from twisted.internet import reactor -from twisted.internet.protocol import Protocol, connectionDone +from twisted.internet.protocol import connectionDone from twisted.internet.task import Clock from twisted.internet.testing import StringTransport from twisted.trial import unittest @@ -25,7 +25,7 @@ class Client(NamedTuple): """Structure representing a client connection to the server.""" - protocol: Protocol + protocol: Handler """Serverside protocol. Write to this to represent the client sending to the server.""" transport: StringTransport @@ -262,7 +262,7 @@ def _createClient(self) -> Client: transport = StringTransport() protocol.makeConnection(transport) assert protocol is not None # Needed to shut pyright up - return Client(protocol=protocol, transport=transport) + return Client(protocol=cast(Handler, protocol), transport=transport) def _connectClient(self, protocolVersion: int = 2) -> Client: """Create and initialize a new connection.""" @@ -448,10 +448,19 @@ def test_join_withoutChannel(self): def test_protocol_version_withoutVersion(self): """Test that sending a 'protocol_version' message without a 'version' returns nothing.""" - client = self._connectClient() - # TODO: Work out how to find the associated Handler and check that its protocol version doesn't change. + client = self._createClient() + oldProtocolVersion = client.protocol.protocol_version self._send(client, {"type": "protocol_version"}) self.assertIsNone(self._receive(client)) + self.assertEqual(client.protocol.protocol_version, oldProtocolVersion) + + def test_protocol_version_withInvalidVersion(self): + """Test that sending a 'protocol_version' message with a non-integer 'version' returns nothing.""" + client = self._createClient() + oldProtocolVersion = client.protocol.protocol_version + self._send(client, {"type": "protocol_version", "version": "NaN"}) + self.assertIsNone(self._receive(client)) + self.assertEqual(client.protocol.protocol_version, oldProtocolVersion) def test_inactivityCausesDisconnection(self): """Test that connecting without joining a channel causes disconnection.""" From d810bb69f1d128d14ecd8eb635d3306b6a17051c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:12:10 +1000 Subject: [PATCH 42/57] camelCase variables, methods and attributes in server.Channel --- server.py | 32 ++++++++++++++++---------------- test.py | 20 ++++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/server.py b/server.py index ac2451e..7b4ff72 100644 --- a/server.py +++ b/server.py @@ -42,7 +42,7 @@ class Message(TypedDict): class Channel: """Collection of connected users in the one "session".""" - def __init__(self, key: str, server_state: "ServerState | None" = None) -> None: + def __init__(self, key: str, serverState: "ServerState | None" = None) -> None: """Constructor :param key: Unique identifier of this channel. @@ -50,9 +50,9 @@ def __init__(self, key: str, server_state: "ServerState | None" = None) -> None: """ self.clients: OrderedDict[int, User] = OrderedDict() self.key = key - self.server_state = server_state + self.serverState = serverState - def add_client(self, client: "User") -> None: + def addClient(self, client: "User") -> None: """Joined when a new user wants to join the channel. :param client: The new channel member. @@ -64,14 +64,14 @@ def add_client(self, client: "User") -> None: clients = [i.as_dict() for i in self.clients.values()] msg = dict(type="channel_joined", channel=self.key, origin=client.user_id, clients=clients) client.send(**msg) - for existing_client in self.clients.values(): - if existing_client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested - existing_client.send(type="client_joined", user_id=client.user_id) + for existingClient in self.clients.values(): + if existingClient.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested + existingClient.send(type="client_joined", user_id=client.user_id) else: - existing_client.send(type="client_joined", client=client.as_dict()) + existingClient.send(type="client_joined", client=client.as_dict()) self.clients[client.user_id] = client - def remove_connection(self, con: "User") -> None: + def removeConnection(self, con: "User") -> None: """Called when a user leaves the channel. :param con: The leaving channel member. @@ -84,13 +84,13 @@ def remove_connection(self, con: "User") -> None: else: client.send(type="client_left", client=con.as_dict()) if not self.clients: - self.server_state.remove_channel(self.key) + self.serverState.remove_channel(self.key) - def ping_clients(self) -> None: + def pingClients(self) -> None: """Ping clients to ensure they're still connected.""" - self.send_to_clients({"type": "ping"}) + self.sendToClients({"type": "ping"}) - def send_to_clients( + def sendToClients( self, obj: dict[str, Any], exclude: "User | None" = None, @@ -168,7 +168,7 @@ def lineReceived(self, line: bytes) -> None: return parsed.pop("origin", None) # Remove an existing origin, we know where the message comes from. if self.user.channel is not None: - self.user.channel.send_to_clients(parsed, exclude=self.user, origin=self.user.user_id) + self.user.channel.sendToClients(parsed, exclude=self.user, origin=self.user.user_id) return elif not hasattr(self, "do_" + parsed["type"]): logger.warning("No function for type %s", parsed["type"]) @@ -268,7 +268,7 @@ def connection_lost(self) -> None: if ( self.channel is not None ): # pragma: no branch - we don't care about the alternative, as it's a no-op - self.channel.remove_connection(self) + self.channel.removeConnection(self) def join(self, channel: str, connection_type: str) -> None: """Add this user to a channel. @@ -281,7 +281,7 @@ def join(self, channel: str, connection_type: str) -> None: return self.connection_type = connection_type self.channel = self.server_state.find_or_create_channel(channel) - self.channel.add_client(self) + self.channel.addClient(self) # TODO: Work out if this is ever called. def do_generate_key(self) -> None: # pragma: no cover @@ -313,7 +313,7 @@ def __init__(self, server_state: "ServerState") -> None: def ping_connected_clients(self) -> None: """Ping all users in all channels to determine if they're still connected.""" for channel in self.server_state.channels.values(): - channel.ping_clients() + channel.pingClients() class ServerState: diff --git a/test.py b/test.py index 831e9ef..b49e865 100644 --- a/test.py +++ b/test.py @@ -81,7 +81,7 @@ def test_addClient(self): oldUsers = [mockUser(id=id) for id in range(3)] self.channel.clients.update({user.user_id: user for user in oldUsers}) newUser = mockUser(id=4) - self.channel.add_client(newUser) + self.channel.addClient(newUser) self.assertEqual(newUser, self.channel.clients[4]) newUser.send.assert_called_once() self.assertEqual(newUser.send.call_args.kwargs["type"], "channel_joined") @@ -96,7 +96,7 @@ def test_removeConnection(self): leftUsers = [user for user in allUsers if user is not leavingUser] self.channel.clients.update({user.user_id: user for user in allUsers}) self.assertIs(self.channel.clients[leavingUser.user_id], leavingUser) - self.channel.remove_connection(leavingUser) + self.channel.removeConnection(leavingUser) self.assertNotIn(leavingUser.user_id, self.channel.clients) self.assertNotIn(leavingUser, self.channel.clients.values()) for leftUser in leftUsers: @@ -109,7 +109,7 @@ def test_removeConnection_notJoined(self): nonmemberUser = memberUsers.pop(2) oldChannelClients = {user.user_id: user for user in memberUsers} self.channel.clients.update(oldChannelClients) - self.channel.remove_connection(nonmemberUser) + self.channel.removeConnection(nonmemberUser) # NOTE: The current implementation sends client_left messages to the remaining clients, # even if the client wasn't in the channel to begin with. # Sending these messages is already covered in another test. @@ -118,15 +118,15 @@ def test_removeConnection_notJoined(self): def test_cleanup(self): """Test removing the last client removes the channel from the server state.""" user = mockUser(id=1) - self.channel.add_client(user) - self.channel.remove_connection(user) + self.channel.addClient(user) + self.channel.removeConnection(user) self.assertNotIn("channel", self.state.channels) def test_sendToClients_all(self): """Test sending to all clients in the channel.""" users = [mockUser(id) for id in range(4)] self.channel.clients.update({user.user_id: user for user in users}) - self.channel.send_to_clients({"this": "is a message"}, origin=99) + self.channel.sendToClients({"this": "is a message"}, origin=99) for user in users: user.send.assert_called_once_with(this="is a message", origin=99) @@ -134,7 +134,7 @@ def test_sendToClients_except(self): """Test sending to all clients but one in the channel.""" users = [mockUser(id) for id in range(4)] self.channel.clients.update({user.user_id: user for user in users}) - self.channel.send_to_clients({"this": "is a message"}, origin=99, exclude=users[2]) + self.channel.sendToClients({"this": "is a message"}, origin=99, exclude=users[2]) for user in users: if user is users[2]: user.send.assert_not_called() @@ -145,7 +145,7 @@ def test_ping(self): """Test pinging the clients in the channel.""" users = [mockUser(id) for id in range(4)] self.channel.clients.update({user.user_id: user for user in users}) - self.channel.ping_clients() + self.channel.pingClients() for user in users: user.send.assert_called_once_with(type="ping", origin=None) @@ -222,7 +222,7 @@ class TestRemoteServerFactory(unittest.TestCase): """Test the RemoteServerFactory class.""" def test_pingClients(self): - """Test that calling ping_connected_clients calls ping_clients on all channels, regardless of size.""" + """Test that calling ping_connected_clients calls pingClients on all channels, regardless of size.""" serverState = ServerState() factory = RemoteServerFactory(serverState) userIterator = (mockUser(id) for id in range(10)) @@ -230,7 +230,7 @@ def test_pingClients(self): serverState.channels.update({channel.key: channel for channel in channels}) factory.ping_connected_clients() for channel in channels: - channel.ping_clients.assert_called_once() + channel.pingClients.assert_called_once() class BaseServerTestCase(unittest.TestCase): From b448bbc1b944a717e0d7ac7e52cab40fe348f67f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:17:35 +1000 Subject: [PATCH 43/57] camelCase attributes, methods and variables in server.Handler --- server.py | 46 +++++++++++++++++++++++----------------------- test.py | 12 ++++++------ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/server.py b/server.py index 7b4ff72..d50d8f8 100644 --- a/server.py +++ b/server.py @@ -57,7 +57,7 @@ def addClient(self, client: "User") -> None: :param client: The new channel member. """ - if client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested + if client.protocol.protocolVersion == 1: # pragma: no cover - protocol v1 is not tested ids = [c.user_id for c in self.clients.values()] msg = dict(type="channel_joined", channel=self.key, user_ids=ids, origin=client.user_id) else: @@ -65,7 +65,7 @@ def addClient(self, client: "User") -> None: msg = dict(type="channel_joined", channel=self.key, origin=client.user_id, clients=clients) client.send(**msg) for existingClient in self.clients.values(): - if existingClient.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested + if existingClient.protocol.protocolVersion == 1: # pragma: no cover - protocol v1 is not tested existingClient.send(type="client_joined", user_id=client.user_id) else: existingClient.send(type="client_joined", client=client.as_dict()) @@ -79,7 +79,7 @@ def removeConnection(self, con: "User") -> None: if con.user_id in self.clients: del self.clients[con.user_id] for client in self.clients.values(): - if client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested + if client.protocol.protocolVersion == 1: # pragma: no cover - protocol v1 is not tested client.send(type="client_left", user_id=con.user_id) else: client.send(type="client_left", client=con.as_dict()) @@ -112,48 +112,48 @@ class Handler(LineReceiver): """Handle sending and receiving messages.""" delimiter = b"\n" - connection_id = 0 + connectionId = 0 MAX_LENGTH = 20 * 1048576 def __init__(self) -> None: - self.connection_id = Handler.connection_id + 1 - Handler.connection_id += 1 - self.protocol_version = 1 + self.connectionId = Handler.connectionId + 1 + Handler.connectionId += 1 + self.protocolVersion = 1 def connectionMade(self) -> None: """Called when a user first connects.""" - logger.info("Connection %d from %s", self.connection_id, self.transport.getPeer()) + logger.info("Connection %d from %s", self.connectionId, self.transport.getPeer()) # We use a non-tcp transport for unit testing, # which doesn't support setTcpNoDelay. if isinstance(self.transport, ITCPTransport): # pragma: no cover # Methods of Zope interfaces don't take self, so pyright thinks this call has too many arguments self.transport.setTcpNoDelay(True) # pyright: ignore [reportCallIssue] - self.bytes_sent = 0 - self.bytes_received = 0 + self.bytesSent = 0 + self.bytesReceived = 0 self.user = User(protocol=self) - self.cleanup_timer = reactor.callLater(INITIAL_TIMEOUT, self.cleanup) + self.cleanupTimer = reactor.callLater(INITIAL_TIMEOUT, self.cleanup) self.user.send_motd() def connectionLost(self, reason: Failure = connectionDone) -> None: """Called when the connection is dropped.""" logger.info( "Connection %d lost, bytes sent: %d received: %d", - self.connection_id, - self.bytes_sent, - self.bytes_received, + self.connectionId, + self.bytesSent, + self.bytesReceived, ) self.user.connection_lost() if ( - self.cleanup_timer is not None and not self.cleanup_timer.cancelled + self.cleanupTimer is not None and not self.cleanupTimer.cancelled ): # pragma: no cover - not sure how to trigger this - self.cleanup_timer.cancel() + self.cleanupTimer.cancel() def lineReceived(self, line: bytes) -> None: """Called when a new line (a command) has been received. :param line: The incoming line. """ - self.bytes_received += len(line) + self.bytesReceived += len(line) try: parsed = json.loads(line) if not isinstance(parsed, dict): @@ -186,7 +186,7 @@ def do_join(self, obj: dict[str, str]) -> None: self.send(type="error", error="invalid_parameters") return self.user.join(obj["channel"], connection_type=obj["connection_type"]) - self.cleanup_timer.cancel() + self.cleanupTimer.cancel() def do_protocol_version(self, obj: dict[str, int | str]) -> None: """Called when a "protocol_version" message is received.""" @@ -194,7 +194,7 @@ def do_protocol_version(self, obj: dict[str, int | str]) -> None: if "version" not in obj: return try: - self.protocol_version = int(obj["version"]) + self.protocolVersion = int(obj["version"]) except ValueError: return @@ -207,17 +207,17 @@ def send(self, origin: int | None = None, **msg: Any) -> None: :param origin: Originating user of the message, defaults to None """ - if self.protocol_version > 1 and origin: + if self.protocolVersion > 1 and origin: msg["origin"] = origin obj = json.dumps(msg).encode("ascii") - self.bytes_sent += len(obj) + self.bytesSent += len(obj) self.sendLine(obj) def cleanup(self) -> None: """Clean up this connection.""" - logger.info("Connection %d timed out", self.connection_id) + logger.info("Connection %d timed out", self.connectionId) self.transport.abortConnection() - self.cleanup_timer = None + self.cleanupTimer = None class User: diff --git a/test.py b/test.py index b49e865..cba2241 100644 --- a/test.py +++ b/test.py @@ -42,11 +42,11 @@ def mockUser(id: int) -> mock.MagicMock: ) -def MockHandler(protocol_version: int = 2, serverState: ServerState | None = None) -> mock.MagicMock: +def MockHandler(protocolVersion: int = 2, serverState: ServerState | None = None) -> mock.MagicMock: """Return a MagicMock representing a Handler.""" return mock.MagicMock( spec=Handler, - protocol_version=protocol_version, + protocolVersion=protocolVersion, factory=mockRemoteServerFactory(serverState=serverState or ServerState()), ) @@ -449,18 +449,18 @@ def test_join_withoutChannel(self): def test_protocol_version_withoutVersion(self): """Test that sending a 'protocol_version' message without a 'version' returns nothing.""" client = self._createClient() - oldProtocolVersion = client.protocol.protocol_version + oldProtocolVersion = client.protocol.protocolVersion self._send(client, {"type": "protocol_version"}) self.assertIsNone(self._receive(client)) - self.assertEqual(client.protocol.protocol_version, oldProtocolVersion) + self.assertEqual(client.protocol.protocolVersion, oldProtocolVersion) def test_protocol_version_withInvalidVersion(self): """Test that sending a 'protocol_version' message with a non-integer 'version' returns nothing.""" client = self._createClient() - oldProtocolVersion = client.protocol.protocol_version + oldProtocolVersion = client.protocol.protocolVersion self._send(client, {"type": "protocol_version", "version": "NaN"}) self.assertIsNone(self._receive(client)) - self.assertEqual(client.protocol.protocol_version, oldProtocolVersion) + self.assertEqual(client.protocol.protocolVersion, oldProtocolVersion) def test_inactivityCausesDisconnection(self): """Test that connecting without joining a channel causes disconnection.""" From 993cb65d8e57f0497fa82aefc8e0e63040940170 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:27:10 +1000 Subject: [PATCH 44/57] camelCase attributes, methods and variables in server.User --- server.py | 74 +++++++++++++++++++++++++++---------------------------- test.py | 34 ++++++++++++------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/server.py b/server.py index d50d8f8..e4290ac 100644 --- a/server.py +++ b/server.py @@ -58,31 +58,31 @@ def addClient(self, client: "User") -> None: :param client: The new channel member. """ if client.protocol.protocolVersion == 1: # pragma: no cover - protocol v1 is not tested - ids = [c.user_id for c in self.clients.values()] - msg = dict(type="channel_joined", channel=self.key, user_ids=ids, origin=client.user_id) + ids = [c.userId for c in self.clients.values()] + msg = dict(type="channel_joined", channel=self.key, user_ids=ids, origin=client.userId) else: - clients = [i.as_dict() for i in self.clients.values()] - msg = dict(type="channel_joined", channel=self.key, origin=client.user_id, clients=clients) + clients = [i.asDict() for i in self.clients.values()] + msg = dict(type="channel_joined", channel=self.key, origin=client.userId, clients=clients) client.send(**msg) for existingClient in self.clients.values(): if existingClient.protocol.protocolVersion == 1: # pragma: no cover - protocol v1 is not tested - existingClient.send(type="client_joined", user_id=client.user_id) + existingClient.send(type="client_joined", user_id=client.userId) else: - existingClient.send(type="client_joined", client=client.as_dict()) - self.clients[client.user_id] = client + existingClient.send(type="client_joined", client=client.asDict()) + self.clients[client.userId] = client def removeConnection(self, con: "User") -> None: """Called when a user leaves the channel. :param con: The leaving channel member. """ - if con.user_id in self.clients: - del self.clients[con.user_id] + if con.userId in self.clients: + del self.clients[con.userId] for client in self.clients.values(): if client.protocol.protocolVersion == 1: # pragma: no cover - protocol v1 is not tested - client.send(type="client_left", user_id=con.user_id) + client.send(type="client_left", user_id=con.userId) else: - client.send(type="client_left", client=con.as_dict()) + client.send(type="client_left", client=con.asDict()) if not self.clients: self.serverState.remove_channel(self.key) @@ -132,7 +132,7 @@ def connectionMade(self) -> None: self.bytesReceived = 0 self.user = User(protocol=self) self.cleanupTimer = reactor.callLater(INITIAL_TIMEOUT, self.cleanup) - self.user.send_motd() + self.user.sendMotd() def connectionLost(self, reason: Failure = connectionDone) -> None: """Called when the connection is dropped.""" @@ -142,7 +142,7 @@ def connectionLost(self, reason: Failure = connectionDone) -> None: self.bytesSent, self.bytesReceived, ) - self.user.connection_lost() + self.user.connectionLost() if ( self.cleanupTimer is not None and not self.cleanupTimer.cancelled ): # pragma: no cover - not sure how to trigger this @@ -168,7 +168,7 @@ def lineReceived(self, line: bytes) -> None: return parsed.pop("origin", None) # Remove an existing origin, we know where the message comes from. if self.user.channel is not None: - self.user.channel.sendToClients(parsed, exclude=self.user, origin=self.user.user_id) + self.user.channel.sendToClients(parsed, exclude=self.user, origin=self.user.userId) return elif not hasattr(self, "do_" + parsed["type"]): logger.warning("No function for type %s", parsed["type"]) @@ -185,7 +185,7 @@ def do_join(self, obj: dict[str, str]) -> None: ): self.send(type="error", error="invalid_parameters") return - self.user.join(obj["channel"], connection_type=obj["connection_type"]) + self.user.join(obj["channel"], connectionType=obj["connection_type"]) self.cleanupTimer.cancel() def do_protocol_version(self, obj: dict[str, int | str]) -> None: @@ -200,7 +200,7 @@ def do_protocol_version(self, obj: dict[str, int | str]) -> None: def do_generate_key(self, obj: dict[str, str]) -> None: """Called when a "generate_key" message is received.""" - self.user.generate_key() + self.user.generateKey() def send(self, origin: int | None = None, **msg: Any) -> None: """Send a message. @@ -223,7 +223,7 @@ def cleanup(self) -> None: class User: """A single connected user.""" - user_id = 0 + userId = 0 def __init__(self, protocol: Handler) -> None: """Initializer. @@ -232,16 +232,16 @@ def __init__(self, protocol: Handler) -> None: """ self.protocol = protocol self.channel: Channel | None = None - self.server_state: ServerState = self.protocol.factory.server_state - self.connection_type = None - self.user_id = User.user_id + 1 - User.user_id += 1 + self.serverState: ServerState = self.protocol.factory.server_state + self.connectionType = None + self.userId = User.userId + 1 + User.userId += 1 - def as_dict(self) -> UserDict: + def asDict(self) -> UserDict: """Get a representation of this user suitable for sending over the wire.""" - return UserDict(id=self.user_id, connection_type=self.connection_type) + return UserDict(id=self.userId, connection_type=self.connectionType) - def generate_key(self) -> str | None: + def generateKey(self) -> str | None: """Generate a key for the user. :return: A channel key, or None if too many keys have been requested. @@ -249,28 +249,28 @@ def generate_key(self) -> str | None: :postcondition: The key will be temporarily persisted so that future key generation requests don't result in duplicate keys. """ ip: str = self.protocol.transport.getPeer().host # type: ignore - if ip in self.server_state.generated_ips and time.time() - self.server_state.generated_ips[ip] < 1: + if ip in self.serverState.generated_ips and time.time() - self.serverState.generated_ips[ip] < 1: self.send(type="error", message="too many keys") self.protocol.transport.loseConnection() return key = "".join([random.choice(string.digits) for _ in range(7)]) - while key in self.server_state.generated_keys or key in self.server_state.channels.keys(): + while key in self.serverState.generated_keys or key in self.serverState.channels.keys(): key = "".join([random.choice(string.digits) for _ in range(7)]) - self.server_state.generated_keys.add(key) - self.server_state.generated_ips[ip] = time.time() - reactor.callLater(GENERATED_KEY_EXPIRATION_TIME, lambda: self.server_state.generated_keys.remove(key)) + self.serverState.generated_keys.add(key) + self.serverState.generated_ips[ip] = time.time() + reactor.callLater(GENERATED_KEY_EXPIRATION_TIME, lambda: self.serverState.generated_keys.remove(key)) if key: # pragma: no cover - I can't work out why this branch is here. When would this be False? self.send(type="generate_key", key=key) return key - def connection_lost(self) -> None: + def connectionLost(self) -> None: """Remove this user when they disconnect.""" if ( self.channel is not None ): # pragma: no branch - we don't care about the alternative, as it's a no-op self.channel.removeConnection(self) - def join(self, channel: str, connection_type: str) -> None: + def join(self, channel: str, connectionType: str) -> None: """Add this user to a channel. :param channel: Key of the channel to join. If no channel with this key exists, a new channel will be created. @@ -279,14 +279,14 @@ def join(self, channel: str, connection_type: str) -> None: if self.channel: self.send(type="error", error="already_joined") return - self.connection_type = connection_type - self.channel = self.server_state.find_or_create_channel(channel) + self.connectionType = connectionType + self.channel = self.serverState.find_or_create_channel(channel) self.channel.addClient(self) # TODO: Work out if this is ever called. def do_generate_key(self) -> None: # pragma: no cover """Not sure what calls this?""" - key = self.generate_key() + key = self.generateKey() if key: self.send(type="generate_key", key=key) @@ -294,10 +294,10 @@ def send(self, **obj: Any) -> None: """Send a message to this user.""" self.protocol.send(**obj) - def send_motd(self) -> None: + def sendMotd(self) -> None: """Send the message of the day to this user.""" - if self.server_state.motd is not None: - self.send(type="motd", motd=self.server_state.motd) + if self.serverState.motd is not None: + self.send(type="motd", motd=self.serverState.motd) class RemoteServerFactory(Factory): diff --git a/test.py b/test.py index cba2241..6cc9b81 100644 --- a/test.py +++ b/test.py @@ -36,7 +36,7 @@ def mockUser(id: int) -> mock.MagicMock: """Create a MagicMock representing a user.""" return mock.MagicMock( spec=User, - user_id=id, + userId=id, protocol=MockHandler(), as_dict=lambda: dict(id=id, connection_type="dummy"), ) @@ -56,7 +56,7 @@ def mockChannel(key: str, clients: Iterable[User]) -> mock.MagicMock: return mock.MagicMock( speck=Channel, key=key, - clients={client.user_id: client for client in clients}, + clients={client.userId: client for client in clients}, ) @@ -79,7 +79,7 @@ def setUp(self) -> None: def test_addClient(self): """Test adding a client to a channel.""" oldUsers = [mockUser(id=id) for id in range(3)] - self.channel.clients.update({user.user_id: user for user in oldUsers}) + self.channel.clients.update({user.userId: user for user in oldUsers}) newUser = mockUser(id=4) self.channel.addClient(newUser) self.assertEqual(newUser, self.channel.clients[4]) @@ -94,10 +94,10 @@ def test_removeConnection(self): allUsers = [mockUser(id=id) for id in range(4)] leavingUser = allUsers[1] leftUsers = [user for user in allUsers if user is not leavingUser] - self.channel.clients.update({user.user_id: user for user in allUsers}) - self.assertIs(self.channel.clients[leavingUser.user_id], leavingUser) + self.channel.clients.update({user.userId: user for user in allUsers}) + self.assertIs(self.channel.clients[leavingUser.userId], leavingUser) self.channel.removeConnection(leavingUser) - self.assertNotIn(leavingUser.user_id, self.channel.clients) + self.assertNotIn(leavingUser.userId, self.channel.clients) self.assertNotIn(leavingUser, self.channel.clients.values()) for leftUser in leftUsers: leftUser.send.assert_called_once() @@ -107,7 +107,7 @@ def test_removeConnection_notJoined(self): """Test removing a client from a channel of which it isn't a member does nothing.""" memberUsers = [mockUser(id=id) for id in range(4)] nonmemberUser = memberUsers.pop(2) - oldChannelClients = {user.user_id: user for user in memberUsers} + oldChannelClients = {user.userId: user for user in memberUsers} self.channel.clients.update(oldChannelClients) self.channel.removeConnection(nonmemberUser) # NOTE: The current implementation sends client_left messages to the remaining clients, @@ -125,7 +125,7 @@ def test_cleanup(self): def test_sendToClients_all(self): """Test sending to all clients in the channel.""" users = [mockUser(id) for id in range(4)] - self.channel.clients.update({user.user_id: user for user in users}) + self.channel.clients.update({user.userId: user for user in users}) self.channel.sendToClients({"this": "is a message"}, origin=99) for user in users: user.send.assert_called_once_with(this="is a message", origin=99) @@ -133,7 +133,7 @@ def test_sendToClients_all(self): def test_sendToClients_except(self): """Test sending to all clients but one in the channel.""" users = [mockUser(id) for id in range(4)] - self.channel.clients.update({user.user_id: user for user in users}) + self.channel.clients.update({user.userId: user for user in users}) self.channel.sendToClients({"this": "is a message"}, origin=99, exclude=users[2]) for user in users: if user is users[2]: @@ -144,7 +144,7 @@ def test_sendToClients_except(self): def test_ping(self): """Test pinging the clients in the channel.""" users = [mockUser(id) for id in range(4)] - self.channel.clients.update({user.user_id: user for user in users}) + self.channel.clients.update({user.userId: user for user in users}) self.channel.pingClients() for user in users: user.send.assert_called_once_with(type="ping", origin=None) @@ -154,15 +154,15 @@ class TestUser(unittest.TestCase): """Test the User class.""" def setUp(self) -> None: - User.user_id = 0 + User.userId = 0 def tearDown(self) -> None: - User.user_id = 0 + User.userId = 0 def test_consecutiveUserCreation(self): """Test that creating several users sequentially creates them with sequential user IDs.""" users = (User(mock.Mock(Handler)) for _ in range(10)) - self.assertSequenceEqual(list(map(lambda user: user.user_id, users)), range(1, 11)) + self.assertSequenceEqual(list(map(lambda user: user.userId, users)), range(1, 11)) def test_join(self): """Test that adding a user to a channel works as expected.""" @@ -171,7 +171,7 @@ def test_join(self): user = User(MockHandler(serverState=serverState)) user.join(CHANNEL_ID, "master") self.assertIs(user.channel, serverState.channels[CHANNEL_ID]) - self.assertIs(user, serverState.channels[CHANNEL_ID].clients[user.user_id]) + self.assertIs(user, serverState.channels[CHANNEL_ID].clients[user.userId]) def test_join_alreadyJoined(self): """Test that adding a user who is already in a channel to a new channel fails.""" @@ -241,8 +241,8 @@ class BaseServerTestCase(unittest.TestCase): def setUp(self) -> None: # Ensure we're starting from a common baseline - self._oldUserId = User.user_id - User.user_id = 0 + self._oldUserId = User.userId + User.userId = 0 self.state = ServerState() self.factory = RemoteServerFactory(self.state) self.factory.protocol = Handler @@ -251,7 +251,7 @@ def setUp(self) -> None: def tearDown(self) -> None: # Put things back how they were when we found them - User.user_id = self._oldUserId + User.userId = self._oldUserId def _createClient(self) -> Client: """Create a client-server connection.""" From 2d90454c6a725724b67c0a7deadcc26705f4d54b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:30:30 +1000 Subject: [PATCH 45/57] camelCase attributes, methods and variables in server.RemoteServerFactory --- server.py | 16 ++++++++-------- test.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server.py b/server.py index e4290ac..fddd8d7 100644 --- a/server.py +++ b/server.py @@ -46,7 +46,7 @@ def __init__(self, key: str, serverState: "ServerState | None" = None) -> None: """Constructor :param key: Unique identifier of this channel. - :param server_state: Server state, defaults to None + :param serverSstate: Server state, defaults to None """ self.clients: OrderedDict[int, User] = OrderedDict() self.key = key @@ -232,7 +232,7 @@ def __init__(self, protocol: Handler) -> None: """ self.protocol = protocol self.channel: Channel | None = None - self.serverState: ServerState = self.protocol.factory.server_state + self.serverState: ServerState = self.protocol.factory.serverState self.connectionType = None self.userId = User.userId + 1 User.userId += 1 @@ -303,16 +303,16 @@ def sendMotd(self) -> None: class RemoteServerFactory(Factory): """Factory to add common functionality to connections.""" - def __init__(self, server_state: "ServerState") -> None: + def __init__(self, serverState: "ServerState") -> None: """Initializer. - :param server_state: Status tracking object. + :param serverState: Status tracking object. """ - self.server_state = server_state + self.serverState = serverState - def ping_connected_clients(self) -> None: + def pingConnectedClients(self) -> None: """Ping all users in all channels to determine if they're still connected.""" - for channel in self.server_state.channels.values(): + for channel in self.serverState.channels.values(): channel.pingClients() @@ -387,7 +387,7 @@ def main() -> Deferred[None]: # pragma: no cover state.motd = None # Set up the machinery of the server. factory = RemoteServerFactory(state) - looper = LoopingCall(factory.ping_connected_clients) + looper = LoopingCall(factory.pingConnectedClients) looper.start(PING_INTERVAL) factory.protocol = Handler # Start running the server. diff --git a/test.py b/test.py index 6cc9b81..c62e343 100644 --- a/test.py +++ b/test.py @@ -64,7 +64,7 @@ def mockRemoteServerFactory(serverState: ServerState) -> mock.MagicMock: """Return a MagicMock representing a RemoteServerFactory.""" return mock.MagicMock( spec=RemoteServerFactory, - server_state=serverState, + serverState=serverState, ) @@ -228,7 +228,7 @@ def test_pingClients(self): userIterator = (mockUser(id) for id in range(10)) channels = tuple(mockChannel(key=chr(n + 65), clients=islice(userIterator, n)) for n in range(5)) serverState.channels.update({channel.key: channel for channel in channels}) - factory.ping_connected_clients() + factory.pingConnectedClients() for channel in channels: channel.pingClients.assert_called_once() From 26288abd8d5eb3636740cf1944ab7dd32a8ccfe1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:32:26 +1000 Subject: [PATCH 46/57] camelCase attributes, methods and variables in server.ServerState --- server.py | 22 +++++++++++----------- test.py | 8 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server.py b/server.py index fddd8d7..4c1adce 100644 --- a/server.py +++ b/server.py @@ -84,7 +84,7 @@ def removeConnection(self, con: "User") -> None: else: client.send(type="client_left", client=con.asDict()) if not self.clients: - self.serverState.remove_channel(self.key) + self.serverState.removeChannel(self.key) def pingClients(self) -> None: """Ping clients to ensure they're still connected.""" @@ -249,16 +249,16 @@ def generateKey(self) -> str | None: :postcondition: The key will be temporarily persisted so that future key generation requests don't result in duplicate keys. """ ip: str = self.protocol.transport.getPeer().host # type: ignore - if ip in self.serverState.generated_ips and time.time() - self.serverState.generated_ips[ip] < 1: + if ip in self.serverState.generatedIps and time.time() - self.serverState.generatedIps[ip] < 1: self.send(type="error", message="too many keys") self.protocol.transport.loseConnection() return key = "".join([random.choice(string.digits) for _ in range(7)]) - while key in self.serverState.generated_keys or key in self.serverState.channels.keys(): + while key in self.serverState.generatedKeys or key in self.serverState.channels.keys(): key = "".join([random.choice(string.digits) for _ in range(7)]) - self.serverState.generated_keys.add(key) - self.serverState.generated_ips[ip] = time.time() - reactor.callLater(GENERATED_KEY_EXPIRATION_TIME, lambda: self.serverState.generated_keys.remove(key)) + self.serverState.generatedKeys.add(key) + self.serverState.generatedIps[ip] = time.time() + reactor.callLater(GENERATED_KEY_EXPIRATION_TIME, lambda: self.serverState.generatedKeys.remove(key)) if key: # pragma: no cover - I can't work out why this branch is here. When would this be False? self.send(type="generate_key", key=key) return key @@ -280,7 +280,7 @@ def join(self, channel: str, connectionType: str) -> None: self.send(type="error", error="already_joined") return self.connectionType = connectionType - self.channel = self.serverState.find_or_create_channel(channel) + self.channel = self.serverState.findOrCreateChannel(channel) self.channel.addClient(self) # TODO: Work out if this is ever called. @@ -322,19 +322,19 @@ class ServerState: def __init__(self) -> None: self.channels: dict[str, Channel] = {} # Set of already generated keys - self.generated_keys: set[str] = set() + self.generatedKeys: set[str] = set() # Mapping of IPs to generated time for people who have generated keys. - self.generated_ips: dict[str, float] = {} + self.generatedIps: dict[str, float] = {} self.motd: str | None = None - def remove_channel(self, channel: str) -> None: + def removeChannel(self, channel: str) -> None: """Close a channel. :param channel: Key of the channel to remove. """ del self.channels[channel] - def find_or_create_channel(self, name: str) -> Channel: + def findOrCreateChannel(self, name: str) -> Channel: """Find an existing channel, or create one if one doesn't already exist. :param name: Key of the channel to find/create. diff --git a/test.py b/test.py index c62e343..55bdf87 100644 --- a/test.py +++ b/test.py @@ -201,7 +201,7 @@ def test_findOrCreateChannel_create(self): extantChannels = self._addChannels() oldChannels = self.serverState.channels.copy() self.assertNotIn("newChannel", self.serverState.channels) - newChannel = self.serverState.find_or_create_channel("newChannel") + newChannel = self.serverState.findOrCreateChannel("newChannel") self.assertIn("newChannel", self.serverState.channels) self.assertIs(self.serverState.channels["newChannel"], newChannel) self.assertNotIn(newChannel, extantChannels) @@ -213,7 +213,7 @@ def test_findOrCreateChannel_find(self): oldChannels = self.serverState.channels.copy() self.assertIn("c", self.serverState.channels) expectedChannel = extantChannels[2] - foundChannel = self.serverState.find_or_create_channel("c") + foundChannel = self.serverState.findOrCreateChannel("c") self.assertIs(expectedChannel, foundChannel) self.assertEqual(oldChannels, self.serverState.channels) @@ -313,9 +313,9 @@ def test_generateKey(self): """Test that requesting the server to generate a key returns the expected result, and temporarily persists the key to avoid collisions.""" key = self.EXPECTED_KEYS[0] self._test({"type": "generate_key"}, {"type": "generate_key", "key": key}) - self.assertIn(key, self.state.generated_keys, "Key was not persisted where expected.") + self.assertIn(key, self.state.generatedKeys, "Key was not persisted where expected.") self.clock.advance(GENERATED_KEY_EXPIRATION_TIME) - self.assertNotIn(key, self.state.generated_keys, "Key was not removed after expiration.") + self.assertNotIn(key, self.state.generatedKeys, "Key was not removed after expiration.") @mock.patch("time.time", return_value=12345) def test_repeated_generateKey_ok(self, mock_time: mock.MagicMock): From 6012376ef0b1c027d10ba2fd90f7a9f8407cc16b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:35:17 +1000 Subject: [PATCH 47/57] Fix some straggling snake_case --- server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 4c1adce..9477ce9 100644 --- a/server.py +++ b/server.py @@ -274,7 +274,7 @@ def join(self, channel: str, connectionType: str) -> None: """Add this user to a channel. :param channel: Key of the channel to join. If no channel with this key exists, a new channel will be created. - :param connection_type: Leader ("master") or follower ("slave"). + :param connectionType: Leader ("master") or follower ("slave"). """ if self.channel: self.send(type="error", error="already_joined") @@ -373,7 +373,7 @@ def main() -> Deferred[None]: # pragma: no cover privkey = crypto.load_privatekey(crypto.FILETYPE_PEM, privkey) certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certData) chain = crypto.load_certificate(crypto.FILETYPE_PEM, chain) - context_factory = ssl.CertificateOptions( + contextFactory = ssl.CertificateOptions( privateKey=privkey, certificate=certificate, extraCertChain=[chain], @@ -391,7 +391,7 @@ def main() -> Deferred[None]: # pragma: no cover looper.start(PING_INTERVAL) factory.protocol = Handler # Start running the server. - reactor.listenSSL(int(config["port"]), factory, context_factory, interface=config["network-interface"]) + reactor.listenSSL(int(config["port"]), factory, contextFactory, interface=config["network-interface"]) reactor.run() return defer.Deferred() From b6810ce32036064be0db2d644652a3397139f06c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 14:40:36 +1000 Subject: [PATCH 48/57] Minor sweeks --- server.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 9477ce9..9c6e8bc 100644 --- a/server.py +++ b/server.py @@ -27,14 +27,20 @@ class UserDict(TypedDict): - """Typed dictionary representing a user.""" + """Typed dictionary representing a user. + + Keys in this dictionary cannot be renamed, as clients rely on them. + """ id: int connection_type: str | None class Message(TypedDict): - """Type hints for protocol messages.""" + """Type hints for protocol messages. + + Keys in this dictionary cannot be renamed, as clients rely on them. + """ type: str @@ -46,7 +52,7 @@ def __init__(self, key: str, serverState: "ServerState | None" = None) -> None: """Constructor :param key: Unique identifier of this channel. - :param serverSstate: Server state, defaults to None + :param serverState: Server state, defaults to None """ self.clients: OrderedDict[int, User] = OrderedDict() self.key = key @@ -380,8 +386,8 @@ def main() -> Deferred[None]: # pragma: no cover ) # Initialise the server state machine state = ServerState() - if os.path.exists(config["motd"]): - with open(config["motd"], encoding="utf-8") as fp: + if os.path.isfile(config["motd"]): + with open(config["motd"], "r", encoding="utf-8") as fp: state.motd = fp.read().strip() else: state.motd = None From 3ef7d0e2e2db16355c25b96bfcbcaf8013376b1a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:03:59 +1000 Subject: [PATCH 49/57] Try using a composite setup action for coverage --- .github/actions/setup-environment/action.yml | 10 ++++++++++ .github/workflows/coverage.yml | 5 +---- 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .github/actions/setup-environment/action.yml diff --git a/.github/actions/setup-environment/action.yml b/.github/actions/setup-environment/action.yml new file mode 100644 index 0000000..27fecf5 --- /dev/null +++ b/.github/actions/setup-environment/action.yml @@ -0,0 +1,10 @@ +name: 'Setup CI Environment' +description: 'Setup the environment in which CI scripts for the NVDA Remote Access server can be run' + +runs: + using: "composite" + steps: + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6.1.0 + - name: Setup environment + run: uv sync --dev diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 61b7086..a636803 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,10 +10,7 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v4.2.2 - - name: Install the latest version of uv - uses: astral-sh/setup-uv@v6.1.0 - - name: Setup environment - run: uv sync --dev + - uses: ./.github/actions/setup-environment - name: Run unit tests run: uv run coverage run - name: Report coverage From 2f511254757fb6c1d61e373f6d99d1553073d714 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:10:27 +1000 Subject: [PATCH 50/57] Add default shell to actions/setup-environment --- .github/actions/setup-environment/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/setup-environment/action.yml b/.github/actions/setup-environment/action.yml index 27fecf5..b81339b 100644 --- a/.github/actions/setup-environment/action.yml +++ b/.github/actions/setup-environment/action.yml @@ -1,6 +1,10 @@ name: 'Setup CI Environment' description: 'Setup the environment in which CI scripts for the NVDA Remote Access server can be run' +defaults: + run: + shell: bash + runs: using: "composite" steps: From 6a20da4ebdcd66fe9dce617f03bad58edf154116 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:12:45 +1000 Subject: [PATCH 51/57] run -> runs --- .github/actions/setup-environment/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-environment/action.yml b/.github/actions/setup-environment/action.yml index b81339b..7650a76 100644 --- a/.github/actions/setup-environment/action.yml +++ b/.github/actions/setup-environment/action.yml @@ -2,7 +2,7 @@ name: 'Setup CI Environment' description: 'Setup the environment in which CI scripts for the NVDA Remote Access server can be run' defaults: - run: + runs: shell: bash runs: From 7d1ec7aaf12bb339295ebcda24210ee35a549562 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:16:03 +1000 Subject: [PATCH 52/57] Move shell to steps with runs --- .github/actions/setup-environment/action.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/actions/setup-environment/action.yml b/.github/actions/setup-environment/action.yml index 7650a76..d04462e 100644 --- a/.github/actions/setup-environment/action.yml +++ b/.github/actions/setup-environment/action.yml @@ -1,14 +1,11 @@ name: 'Setup CI Environment' description: 'Setup the environment in which CI scripts for the NVDA Remote Access server can be run' -defaults: - runs: - shell: bash - runs: using: "composite" steps: - name: Install the latest version of uv uses: astral-sh/setup-uv@v6.1.0 - name: Setup environment + shell: bash run: uv sync --dev From 2150f69bcbd577ecbae05e2e498cdea21e094076 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:18:40 +1000 Subject: [PATCH 53/57] Use setup-environment with pyright ci --- .github/workflows/pyright.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index a3bb195..090adf8 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -15,9 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.2.2 - - name: Install the latest version of uv - uses: astral-sh/setup-uv@v6.1.0 - - name: Setup environment - run: uv sync --dev + - uses: ./.github/actions/setup-environment - name: Run pyright run: uv run pyright From 1d8fae123e101b73ed2ded80534eb60a5ac67aa5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:33:37 +1000 Subject: [PATCH 54/57] Add pyright output to GHA summary --- .github/workflows/pyright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 090adf8..35c24af 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -17,4 +17,4 @@ jobs: - uses: actions/checkout@v4.2.2 - uses: ./.github/actions/setup-environment - name: Run pyright - run: uv run pyright + run: uv run pyright >> $GITHUB_STEP_SUMMARY From ba789073f1702940d4bdaffe3084089852a3b507 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:36:46 +1000 Subject: [PATCH 55/57] Test restricting perms --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a636803..3eec15e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,7 +7,7 @@ jobs: name: Check coverage with coverage.py runs-on: ubuntu-latest permissions: - pull-requests: write + pull-requests: read steps: - uses: actions/checkout@v4.2.2 - uses: ./.github/actions/setup-environment From 201c5181ee812451193b22cc6362af32a96fab15 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:41:07 +1000 Subject: [PATCH 56/57] Test restricting perms --- .github/workflows/coverage.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3eec15e..0c2f0d7 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,8 +6,7 @@ jobs: coverage: name: Check coverage with coverage.py runs-on: ubuntu-latest - permissions: - pull-requests: read + permissions: {} steps: - uses: actions/checkout@v4.2.2 - uses: ./.github/actions/setup-environment From 6d37a5565b0c0fbc8536c742f3b09ac0a33af705 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:43:11 +1000 Subject: [PATCH 57/57] Restrict perms of pyright action --- .github/workflows/pyright.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 35c24af..2480733 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -13,6 +13,7 @@ jobs: pyright: name: Check types with pyright runs-on: ubuntu-latest + permissions: {} steps: - uses: actions/checkout@v4.2.2 - uses: ./.github/actions/setup-environment