diff --git a/nrw/network/__init__.py b/nrw/network/__init__.py index 44cea46..ce5d688 100644 --- a/nrw/network/__init__.py +++ b/nrw/network/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations -__all__: Final[tuple[str]] = ("Connection",) +__all__: Final[tuple[str, str, str]] = ("Connection", "Client", "Server") from typing import Final +from nrw.network._client import Client from nrw.network._connection import Connection +from nrw.network._server import Server diff --git a/nrw/network/__init__.pyi b/nrw/network/__init__.pyi index 926b095..c5f3104 100644 --- a/nrw/network/__init__.pyi +++ b/nrw/network/__init__.pyi @@ -1,18 +1,18 @@ # pylint: skip-file -__all__: Final[tuple[str]] = ("Connection",) +__all__: Final[tuple[str, str, str]] = ("Connection", "Client", "Server") from abc import ABC, abstractmethod from typing import Final class Connection: __slots__: Final[tuple[str, str, str]] = ("_socket", "_to_server", "_from_server") - def __init__(self, server_ip: str, server_port: int) -> None: ... def receive(self) -> str | None: ... def send(self, message: str) -> None: ... def close(self) -> None: ... class Client(ABC): + __slots__: Final[tuple[str, str]] = ("_socket_wrapper", "_active") def __init__(self, server_ip: str, server_port: int) -> None: ... @property def is_connected(self) -> bool: ... @@ -22,6 +22,11 @@ class Client(ABC): def process_message(self, message: str) -> None: ... class Server(ABC): + __slots__: Final[tuple[str, str, str]] = ( + "_connection_handler", + "_message_handlers", + "_lock", + ) def __init__(self, port: int) -> None: ... @property def is_open(self) -> bool: ... @@ -35,9 +40,13 @@ class Server(ABC): @abstractmethod def process_message( self, - client_ip: str, + client_ip: str | None, client_port: int, message: str, ) -> None: ... @abstractmethod - def process_closing_connection(self, client_ip: str, client_port: int) -> None: ... + def process_closing_connection( + self, + client_ip: str | None, + client_port: int, + ) -> None: ... diff --git a/nrw/network/_client.py b/nrw/network/_client.py new file mode 100644 index 0000000..078c77c --- /dev/null +++ b/nrw/network/_client.py @@ -0,0 +1,126 @@ +"""Klasse `Client`.""" + +from __future__ import annotations + +__all__: Final[tuple[str]] = ("Client",) + +import socket +import threading +from abc import ABC, abstractmethod +from contextlib import suppress +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from io import TextIOWrapper + + +class _SocketWrapper: + __slots__: Final[tuple[str, str, str]] = ("_socket", "_to_server", "_from_server") + + def __init__(self, server_ip: str, server_port: int) -> None: + try: + self._socket: socket.socket | None = socket.socket( + socket.AF_INET, + socket.SOCK_STREAM, + ) + self._socket.connect((server_ip, server_port)) + self._to_server: TextIOWrapper | None = self._socket.makefile( + mode="w", + encoding="utf-8", + ) + self._from_server: TextIOWrapper | None = self._socket.makefile( + mode="r", + encoding="utf-8", + ) + except OSError: + self._socket = None + self._to_server = None + self._from_server = None + + def receive(self) -> str | None: + if self._from_server is not None: + with suppress(OSError, ValueError): + received_line: str = self._from_server.readline().strip() + return received_line if received_line else None + return None + + def send(self, message: str) -> None: + if self._to_server is not None: + with suppress(OSError, ValueError): + self._to_server.write(f"{message}\n") + self._to_server.flush() + + def close(self) -> None: + if self._socket is not None: + with suppress(OSError): + self._to_server.close() + self._from_server.close() + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + + +class Client(ABC): + """Objekte von Unterklassen der abstrakten Klasse `Client` ermöglichen + Netzwerkverbindungen zu einem Server mittels TCP/IP-Protokoll. + Nach Verbindungsaufbau können Zeichenketten (`Strings`) zum Server gesendet und von + diesem empfangen werden, wobei der Nachrichtenempfang nebenläufig geschieht. + Zur Vereinfachung finden Nachrichtenversand und -empfang zeilenweise statt, d. h., + beim Senden einer Zeichenkette wird ein Zeilentrenner ergänzt und + beim Empfang wird dieser entfernt. Jede empfangene Nachricht wird einer + Ereignisbehandlungsmethode übergeben, die in Unterklassen implementiert werden muss. + Es findet nur eine rudimentäre Fehlerbehandlung statt, so dass z. B. + Verbindungsabbrüche nicht zu einem Programmabbruch führen. + Eine einmal unterbrochene oder getrennte Verbindung kann nicht reaktiviert werden. + """ + + __slots__: Final[tuple[str, str]] = ("_socket_wrapper", "_active") + + def __init__(self, server_ip: str, server_port: int) -> None: + self._socket_wrapper: _SocketWrapper = _SocketWrapper(server_ip, server_port) + if self._socket_wrapper._socket is not None: + self._active: bool = True + else: + self._active = False + thread = threading.Thread(target=self._run) + thread.start() + + def _run(self) -> None: + message: str | None = None + while self._active: + message = self._socket_wrapper.receive() + if message is not None: + self.process_message(message) + else: + self.close() + + def send(self, message: str) -> None: + """Die Nachricht `message` wird - um einen Zeilentrenner ergänzt - an den Server + gesendet. Schlägt der Versand fehl, geschieht nichts. + """ + if self._active: + self._socket_wrapper.send(message) + + def close(self) -> None: + """Die Verbindung zum Server wird getrennt und der Client kann nicht mehr + verwendet werden. Ist `Client` bereits vor Aufruf der Methode in diesem Zustand, + geschieht nichts. + """ + if self._active: + self._active = False + self._socket_wrapper.close() + + @property + def is_connected(self) -> bool: + """Die Anfrage liefert den Wert `True`, wenn der Client mit dem Server + aktuell verbunden ist. Ansonsten liefert sie den Wert `False`. + """ + return self._active + + @abstractmethod + def process_message(self, message: str) -> None: + """Diese Methode wird aufgerufen, wenn der Client die Nachricht `message` vom + Server empfangen hat. Der vom Server ergänzte Zeilentrenner wurde zuvor + entfernt. Die Methode ist abstrakt und muss in einer Unterklasse der Klasse + `Client` überschrieben werden, so dass auf den Empfang der Nachricht reagiert + wird. Der Aufruf der Methode erfolgt nicht synchronisiert. + """ diff --git a/nrw/network/_server.py b/nrw/network/_server.py new file mode 100644 index 0000000..b6a558f --- /dev/null +++ b/nrw/network/_server.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +__all__: Final[tuple[str]] = ("Server",) + +import socket +import threading +from abc import ABC, abstractmethod +from contextlib import suppress +from typing import TYPE_CHECKING, Final, override + +from nrw.datastructures import List + +if TYPE_CHECKING: + from io import TextIOWrapper + + +class _NewConnectionHandler(threading.Thread): + __slots__: Final[tuple[str, str, str]] = ("_server", "_active", "_server_socket") + + def __init__( + self, + port: int, + server: Server, + ) -> None: + super().__init__() + self._server: Server = server + self._active = False + self._server_socket: socket.socket | None = None + with suppress(Exception): + self._server_socket = socket.socket( + socket.AF_INET, + socket.SOCK_STREAM, + ) + self._server_socket.bind(("127.0.0.1", port)) + self._server_socket.listen() + self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._active = True + self.start() + + @override + def run(self) -> None: + while self._active: + with suppress(OSError): + client_socket, (client_ip, client_port) = self._server_socket.accept() + self._server._add_new_client_message_handler(client_socket) + self._server.process_new_connection(client_ip, client_port) + + def close(self) -> None: + self._active = False + if self._server_socket is not None: + with suppress(OSError): + self._server_socket.close() + + +class _ClientSocketWrapper: + __slots__: Final[tuple[str, str, str]] = ( + "_client_socket", + "_to_client", + "_from_client", + ) + + def __init__(self, client_socket: socket.socket | None) -> None: + try: + self._client_socket: socket.socket | None = client_socket + self._to_client: TextIOWrapper | None = self._client_socket.makefile( + mode="w", + encoding="utf-8", + ) + self._from_client: TextIOWrapper | None = self._client_socket.makefile( + mode="r", + encoding="utf-8", + ) + except OSError: + self._client_socket = None + self._to_client = None + self._from_client = None + + def receive(self) -> str | None: + if self._from_client is not None: + with suppress(OSError, ValueError): + received_line: str = self._from_client.readline().strip() + return received_line if received_line else None + return None + + def send(self, message: str) -> None: + if self._to_client is not None: + with suppress(OSError, ValueError): + self._to_client.write(f"{message}\n") + self._to_client.flush() + + @property + def client_ip(self) -> str | None: + if self._client_socket is None: + return None + return self._client_socket.getpeername()[0] # type: ignore[no-any-return] + + @property + def client_port(self) -> int: + if self._client_socket is None: + return 0 + return self._client_socket.getpeername()[1] # type: ignore[no-any-return] + + def close(self) -> None: + if self._client_socket is not None: + with suppress(OSError): + self._to_client.close() + self._from_client.close() + self._client_socket.shutdown(socket.SHUT_RDWR) + self._client_socket.close() + + +class _ClientMessageHandler(threading.Thread): + __slots__: Final[tuple[str, str, str]] = ("_server", "_active", "_socket_wrapper") + + def __init__(self, client_socket: socket.socket | None, server: Server) -> None: + super().__init__() + self._server: Server = server + self._active: bool = False + self._socket_wrapper: _ClientSocketWrapper = _ClientSocketWrapper(client_socket) + if client_socket is not None: + self.start() + self._active = True + + @override + def run(self) -> None: + message: str | None = None + while self._active: + message = self._socket_wrapper.receive() + client_ip: str | None = self._socket_wrapper.client_ip + client_port: int = self._socket_wrapper.client_port + if message is not None: + self._server.process_message(client_ip, client_port, message) + else: + message_handler: _ClientMessageHandler | None = ( + self._server._find_client_message_handler( + client_ip, + client_port, + ) + ) + if message_handler is not None: + message_handler.close() + self._server._remove_client_message_handler(message_handler) + self._server.process_closing_connection(client_ip, client_port) + + def send(self, message: str) -> None: + if self._active: + self._socket_wrapper.send(message) + + def close(self) -> None: + if self._active: + self._active = False + self._socket_wrapper.close() + + @property + def client_ip(self) -> str | None: + return self._socket_wrapper.client_ip + + @property + def client_port(self) -> int: + return self._socket_wrapper.client_port + + +class Server(ABC): + """Objekte von Unterklassen der abstrakten Klasse `Server` ermöglichen das Anbieten + von Serverdiensten, so dass Clients Verbindungen zum Server mittels TCP/IP-Protokoll + aufbauen können. Zur Vereinfachung finden Nachrichtenversand und -empfang + zeilenweise statt, d. h., beim Senden einer Zeichenkette wird ein Zeilentrenner + ergänzt und beim Empfang wird dieser entfernt. Verbindungsannahme, + Nachrichtenempfang und Verbindungsende geschehen nebenläufig. Auf diese Ereignisse + muss durch Überschreiben der entsprechenden Ereignisbehandlungsmethoden reagiert + werden. Es findet nur eine rudimentäre Fehlerbehandlung statt, so dass z. B. + Verbindungsabbrüche nicht zu einem Programmabbruch führen. Einmal unterbrochene oder + getrennte Verbindungen können nicht reaktiviert werden. + """ + + __slots__: Final[tuple[str, str, str]] = ( + "_connection_handler", + "_message_handlers", + "_lock", + ) + + def __init__(self, port: int) -> None: + """Ein Objekt vom Typ `Server` wird erstellt, das über die angegebene Portnummer + einen Dienst anbietet an. Clients können sich mit dem Server verbinden, so dass + Daten (Zeichenketten) zu diesen gesendet und von diesen empfangen werden können. + Kann der Server unter der angegebenen Portnummer keinen Dienst anbieten (z. B. + weil die Portnummer bereits belegt ist), ist keine Verbindungsaufnahme zum + Server und kein Datenaustausch möglich. + """ + self._connection_handler: _NewConnectionHandler = _NewConnectionHandler( + port, + self, + ) + self._message_handlers: List[_ClientMessageHandler] = List() + self._lock: threading.Lock = threading.Lock() + + def _add_new_client_message_handler(self, client_socket: socket.socket) -> None: + with self._lock: + self._message_handlers.append(_ClientMessageHandler(client_socket, self)) + + def _remove_client_message_handler( + self, + client_message_handler: _ClientMessageHandler, + ) -> None: + with self._lock: + self._message_handlers.to_first() + while self._message_handlers.has_access: + if client_message_handler is self._message_handlers.content: + self._message_handlers.remove() + return + self._message_handlers.next() + + def _find_client_message_handler( + self, + client_ip: str | None, + client_port: int, + ) -> _ClientMessageHandler | None: + with self._lock: + self._message_handlers.to_first() + while self._message_handlers.has_access: + current_message_handler: _ClientMessageHandler | None = ( + self._message_handlers.content + ) + assert current_message_handler is not None + if ( + current_message_handler.client_ip == client_ip + and current_message_handler.client_port == client_port + ): + return current_message_handler + self._message_handlers.next() + return None + + @property + def is_open(self) -> bool: + """Die Anfrage liefert den Wert `True`, wenn der `Server` auf Port `port` einen + Dienst anbietet. Ansonsten liefert die Methode den Wert `False`. + """ + return self._connection_handler._active + + def is_connected_to(self, client_ip: str, client_port: int) -> bool: + """Die Anfrage liefert den Wert `True`, wenn der Server mit dem durch + `client_ip` und `client_port` spezifizierten Client aktuell verbunden ist. + Ansonsten liefert die Methode den Wert `False`. + """ + return self._find_client_message_handler(client_ip, client_port) is not None + + def send(self, client_ip: str, client_port: int, message: str) -> None: + """Die Nachricht `message` wird - um einen Zeilentrenner erweitert - an den + durch `client_ip` und `client_port` spezifizierten Client gesendet. Schlägt der + Versand fehl, geschieht nichts. + """ + message_handler: _ClientMessageHandler | None = ( + self._find_client_message_handler(client_ip, client_port) + ) + if message_handler is not None: + message_handler.send(message) + + def send_to_all(self, message: str) -> None: + """Die Nachricht `message` wird - um einen Zeilentrenner erweitert - an alle mit + dem Server verbundenen Clients gesendet. Schlägt der Versand an einen Client + fehl, wird dieser Client übersprungen. + """ + with self._lock: + self._message_handlers.to_first() + while self._message_handlers.has_access: + self._message_handlers.content.send(message) + self._message_handlers.next() + + def close_connection(self, client_ip: str, client_port: int) -> None: + """Die Verbindung des Servers zu dem durch `client_ip` und `client_port` + spezifizierten Client wird getrennt. Zuvor wird die Methode + `process_closing_connection` mit IP-Adresse und Port des jeweiligen Clients + aufgerufen. Ist der Server nicht mit dem in der Parameterliste spezifizierten + Client verbunden, geschieht nichts. + """ + message_handler: _ClientMessageHandler | None = ( + self._find_client_message_handler(client_ip, client_port) + ) + if message_handler is not None: + self.process_closing_connection(client_ip, client_port) + message_handler.close() + self._remove_client_message_handler(message_handler) + + def close(self) -> None: + """Alle bestehenden Verbindungen zu Clients werden getrennt und der Server kann + nicht mehr verwendet werden. Ist der Server bereits vor Aufruf der Methode in + diesem Zustand, geschieht nichts. + """ + self._connection_handler.close() + with self._lock: + self._message_handlers.to_first() + while self._message_handlers.has_access: + message_handler: _ClientMessageHandler | None = ( + self._message_handlers.content + ) + assert message_handler is not None + self.process_closing_connection( + message_handler.client_ip, + message_handler.client_port, + ) + self._message_handlers.remove() + self._message_handlers.next() + + @abstractmethod + def process_new_connection( + self, + client_ip: str, + client_port: int, + ) -> None: + """Diese Ereignisbehandlungsmethode wird aufgerufen, wenn sich ein Client mit + IP-Adresse `client_ip` und Portnummer `client_port` mit dem Server verbunden + hat. Die Methode ist abstrakt und muss in einer Unterklasse der Klasse `Server` + überschrieben werden, so dass auf den Neuaufbau der Verbindung reagiert wird. + Der Aufruf der Methode erfolgt nicht synchronisiert. + """ + + @abstractmethod + def process_message( + self, + client_ip: str | None, + client_port: int, + message: str, + ) -> None: + """Diese Ereignisbehandlungsmethode wird aufgerufen, wenn der Server die + Nachricht `message` von dem durch `client_ip` und `client_port` spezifizierten + Client empfangen hat. Der vom Client hinzugefügte Zeilentrenner wurde zuvor + entfernt. Die Methode ist abstrakt und muss in einer Unterklasse der Klasse + `Server` überschrieben werden, so dass auf den Empfang der Nachricht reagiert + wird. Der Aufruf der Methode erfolgt nicht synchronisiert. + """ + + @abstractmethod + def process_closing_connection( + self, + client_ip: str | None, + client_port: int, + ) -> None: + """Sofern der Server die Verbindung zu dem durch `client_ip` und `client_port` + spezifizierten Client trennt, wird diese Ereignisbehandlungsmethode aufgerufen, + unmittelbar bevor die Verbindungstrennung tatsächlich erfolgt. + Wird die Verbindung unvermittelt unterbrochen oder hat der in der Parameterliste + spezifizierte Client die Verbindung zum Server unvermittelt getrennt, erfolgt + der Methodenaufruf nach der Unterbrechung / Trennung der Verbindung. + Die Methode ist abstrakt und muss in einer Unterklasse der Klasse Server + überschrieben werden, so dass auf das Ende der Verbindung zum angegebenen Client + reagiert wird. Der Aufruf der Methode erfolgt nicht synchronisiert. + """