multiplayer 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ """
2
+ multiplayer, a Python library for managing multiplayer games
3
+ Copyright (C) 2025 [devfred78](https://github.com/devfred78)
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ This package provides tools to manage a local IPC (Inter Process Communication) logging system
19
+ """
20
+
21
+ from pathlib import Path
22
+ import sys
23
+
24
+ # local path
25
+ if getattr(sys, "frozen", False):
26
+ local_path = Path(sys.executable).parent
27
+ else:
28
+ local_path = Path(__file__).resolve().parent
29
+
30
+ UNIX_SOCKET_PATH = local_path / Path('logging_socket')
31
+
32
+ # echoing_path = local_path / Path('echoing.py')
33
+
34
+ # with subprocess.Popen(['wt', 'new-tab', 'cmd', '/k', 'uv', 'run', 'python', str(echoing_path)], stdin = subprocess.PIPE, shell = True) as terminal:
35
+ # # with subprocess.Popen(['uv', 'run', 'python', str(echoing_path)], stdin = subprocess.PIPE, shell = True) as terminal:
36
+ # terminal.stdin.write(b'Hello/n')
37
+ # terminal.stdin.flush()
38
+ # terminal.stdin.write(b'Word !/n')
39
+ # terminal.stdin.flush()
40
+ # terminal.stdin.write(b'EXIT')
41
+ # terminal.stdin.flush()
42
+
@@ -0,0 +1,34 @@
1
+ """
2
+ multiplayer, a Python library for managing multiplayer games
3
+ Copyright (C) 2025 [devfred78](https://github.com/devfred78)
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ Simple Python script echoing in the standard output what is given in the standard input.
19
+
20
+ Exits if the stdin is b"EXIT" (in capitals)
21
+ """
22
+
23
+ from sys import exit
24
+
25
+ while(True):
26
+ print("Waiting for stdin...")
27
+ raw_data = input()
28
+ # raw_data = stdin.read()
29
+ print(f"Input receipt !: {raw_data}")
30
+ if 'EXIT' in raw_data:
31
+ exit(0)
32
+ print(raw_data, flush = True)
33
+ # stdout.write(raw_data)
34
+ # stdout.flush()
@@ -0,0 +1,338 @@
1
+ """
2
+ multiplayer, a Python library for managing multiplayer games
3
+ Copyright (C) 2025 [devfred78](https://github.com/devfred78)
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ This module provides tools to manage a log server
19
+ """
20
+
21
+ import logging
22
+ from pathlib import Path
23
+ import pickle
24
+ import platform
25
+ import selectors
26
+ import socket
27
+ import sys
28
+ import threading
29
+ from time import sleep
30
+
31
+ from colorlog import ColoredFormatter
32
+ from colorlog.escape_codes import escape_codes
33
+
34
+ class OriginColoredFormatter(ColoredFormatter):
35
+ def __init__(self, *args, **kwargs):
36
+ super().__init__(*args, **kwargs)
37
+ self.origin_colors = {
38
+ "GameServer": "purple",
39
+ "GameClient": "green",
40
+ "ServerAdmin": "red",
41
+ "RemoteGame": "blue",
42
+ "Observer": "cyan",
43
+ }
44
+ self.available_colors = ["blue", "cyan", "purple", "red", "white", "yellow"]
45
+ self._next_color_idx = 0
46
+ self._assigned_colors = {}
47
+
48
+ def format(self, record):
49
+ # Use the name as origin
50
+ origin = record.name
51
+
52
+ # Check if we have an assigned color for this specific origin
53
+ if origin in self._assigned_colors:
54
+ color = self._assigned_colors[origin]
55
+ # Check if it's a known fixed origin
56
+ elif origin in self.origin_colors:
57
+ color = self.origin_colors[origin]
58
+ else:
59
+ # Try matching by base (e.g., "RemoteGame.Alice" -> "RemoteGame")
60
+ base_origin = origin.split('.')[0]
61
+ if base_origin in self.origin_colors:
62
+ color = self.origin_colors[base_origin]
63
+ else:
64
+ # Assign a new dynamic color for this specific origin
65
+ color = self.available_colors[self._next_color_idx % len(self.available_colors)]
66
+ self._assigned_colors[origin] = color
67
+ self._next_color_idx += 1
68
+
69
+ # We manually apply the color to avoid ColoredFormatter overriding it
70
+ # based on the log level.
71
+ message = super().format(record)
72
+
73
+ # super().format(record) will have used ColoredFormatter's logic
74
+ # which might have injected a color based on level if %(log_color)s was present.
75
+ # But since we want to OVERRIDE it with the origin color:
76
+
77
+ color_code = escape_codes.get(color, "")
78
+ reset_code = escape_codes.get("reset", "")
79
+
80
+ # If the message already has color codes at the beginning, we replace them.
81
+ # ColoredFormatter usually starts with the color code.
82
+ if message.startswith('\x1b['):
83
+ # Find the end of the first escape sequence
84
+ end_idx = message.find('m')
85
+ if end_idx != -1:
86
+ return f"{color_code}{message[end_idx+1:]}"
87
+
88
+ return f"{color_code}{message}{reset_code}"
89
+
90
+ if __name__ != '__main__':
91
+ from . import UNIX_SOCKET_PATH
92
+ else:
93
+ # local path
94
+ if getattr(sys, "frozen", False):
95
+ local_path = Path(sys.executable).parent
96
+ else:
97
+ local_path = Path(__file__).resolve().parent
98
+
99
+ UNIX_SOCKET_PATH = local_path / Path('logging_socket')
100
+
101
+ class LoggingServer(threading.Thread):
102
+ """
103
+ This class implements a server that receives and processes logs transmitted by SocketHandler handlers. Like the client side, the server manages stream sockets (SOCK_STREAM sockets) from both the AF_UNIX and AF_INET families.
104
+
105
+ The family is defined when the server is instantiated: if the port argument is None (its default value), then the family is AF_UNIX, and the host argument is used to define the file to be used (in this case, host can be either a pathlib.Path object or a string). Otherwise, the family is AF_INET.
106
+
107
+ Warning: in the case of an AF_UNIX family, if *host* is filled in with an existing file, then it is deleted (provided you have the right to do so). It is therefore particularly advisable to provide a non-existent file, or to leave the default value.
108
+
109
+ This class inherits from threading.Thread, meaning that it starts with the `start()` method, and it is NON blocking.
110
+ """
111
+
112
+ def __init__(self, host: Path = UNIX_SOCKET_PATH, port: int|None = None, timeout: int = 5, color_mode: str = "level"):
113
+
114
+ super().__init__()
115
+
116
+ # logging initialization
117
+ self.logger = logging.getLogger("ServerLog")
118
+ self.logger.setLevel(logging.NOTSET)
119
+ handler = logging.StreamHandler()
120
+
121
+ if color_mode == "origin":
122
+ formatter = OriginColoredFormatter(
123
+ "%(log_color)s[%(asctime)s][%(levelname)s][%(name)s]:%(message)s",
124
+ datefmt="%Y-%m-%d %H:%M:%S",
125
+ reset=True,
126
+ style="%",
127
+ )
128
+ else:
129
+ formatter = ColoredFormatter(
130
+ "%(log_color)s[%(asctime)s][%(levelname)s][%(module)s]:%(message)s",
131
+ datefmt="%Y-%m-%d %H:%M:%S",
132
+ reset=True,
133
+ log_colors={
134
+ "DEBUG": "cyan",
135
+ "INFO": "green",
136
+ "WARNING": "yellow",
137
+ "ERROR": "red",
138
+ "CRITICAL": "red,bg_white",
139
+ },
140
+ secondary_log_colors={},
141
+ style="%",
142
+ )
143
+ handler.setFormatter(formatter)
144
+ self.logger.addHandler(handler)
145
+
146
+
147
+ if port is None and platform.system().lower() != "windows" : # AF_UNIX family
148
+ self.address = str(host)
149
+ host.unlink(missing_ok = True) # Delete the socket file if existing
150
+ sock_family = socket.AF_UNIX
151
+ else: # AF_INET family
152
+ self.address = (str(host), port)
153
+ sock_family = socket.AF_INET
154
+
155
+ # maximum wait time (in seconds) before detecting a closed connection
156
+ self._timeout = timeout
157
+
158
+ # Server socket creation
159
+ self._socket = socket.socket(sock_family, socket.SOCK_STREAM)
160
+ self._socket.settimeout(self._timeout)
161
+
162
+ def run(self):
163
+ """
164
+ Waiting for connections until an error occurs or the `stop()` method is called.
165
+
166
+ Automatically called by the `start()` method in a separated thread.
167
+ """
168
+ try:
169
+ # Link the socket to the address
170
+ self._socket.bind(self.address)
171
+ except OSError:
172
+ print(f"\x1b[31m!!! Another server is already running on the port {self.address[1]} !!!")
173
+ else:
174
+
175
+ # Enable the server to accept connections
176
+ self._socket.listen()
177
+
178
+ # Flags
179
+ self._running = True # If True, the server is running
180
+
181
+ # Internal variables
182
+ self._sel = selectors.DefaultSelector() # Selector to deal with connections to clients
183
+ self._registered_connections = list() # list of registered connections
184
+
185
+ # Launch the logging loop (in a separated thread)
186
+ threading.Thread(target = self._logging).start()
187
+
188
+ while self._running:
189
+ try:
190
+ conn, addr = self._socket.accept()
191
+ # print("CONNECTION ESTABLISHED !!")
192
+ conn.setblocking(False) # the connection is now non-blocking
193
+ # self._connections.append(conn)
194
+ self._sel.register(conn, selectors.EVENT_READ)
195
+ self._registered_connections.append(conn)
196
+ # print(f"Number of available connection(s): {len(self._registered_connections)}")
197
+ except socket.timeout:
198
+ pass
199
+ except socket.error as err:
200
+ print("\x1b[31m!!! SOCKET ERROR !!!")
201
+ print(err)
202
+ self.stop()
203
+ # break
204
+
205
+ def stop(self):
206
+ """
207
+ Stop the server.
208
+ """
209
+ self._running = False
210
+ try:
211
+ self._socket.shutdown(socket.SHUT_RD)
212
+ except OSError:
213
+ # The connection has already been shutting down !
214
+ pass
215
+ else:
216
+ self._socket.close()
217
+
218
+ def _receive(self, sock: socket.socket, nb_bytes: int = 1) -> bytearray:
219
+ """
220
+ Receive `nb_bytes` bytes from the socket `sock`.
221
+
222
+ Returns a bytearray of length `nb_bytes` containing the reveived bytes
223
+ """
224
+
225
+ received_bytes = list()
226
+ for _ in range(nb_bytes):
227
+ try:
228
+ rbyte = sock.recv(1)
229
+ except (ConnectionResetError, BrokenPipeError, OSError):
230
+ rbyte = b''
231
+
232
+ if rbyte == b'': # socket connection broken
233
+ self._sel.unregister(sock)
234
+ try:
235
+ sock.shutdown(socket.SHUT_RD)
236
+ except OSError:
237
+ pass
238
+ sock.close()
239
+ if sock in self._registered_connections:
240
+ self._registered_connections.remove(sock)
241
+ # print("CONNECTION CLOSED")
242
+ # print(f"Number of remaining available connection(s): {len(self._registered_connections)}")
243
+ if len(self._registered_connections) == 0:
244
+ print("No connection left: stopping the log server...")
245
+ self._running = False
246
+ self.stop()
247
+ break
248
+ else:
249
+ # print(f"Received byte: {rbyte}")
250
+ received_bytes.append(rbyte)
251
+ return bytearray(b''.join(received_bytes))
252
+
253
+ def _logging(self):
254
+ """
255
+ Print all strings given by connected clients.
256
+ """
257
+ sleep(2)
258
+ print("Log server is now ready")
259
+ while self._running:
260
+ if len(self._registered_connections) > 0:
261
+ events = self._sel.select(self._timeout)
262
+ avail_conns = [key.fileobj for (key, mask) in events]
263
+
264
+ # Check for connections to close
265
+ for conn in list(self._registered_connections):
266
+ if conn not in avail_conns:
267
+ try:
268
+ self._sel.unregister(conn)
269
+ except KeyError:
270
+ pass
271
+ try:
272
+ conn.shutdown(socket.SHUT_RD)
273
+ except OSError:
274
+ pass
275
+ conn.close()
276
+ self._registered_connections.remove(conn)
277
+
278
+ # Read receipt strings from available connections and print them on the standard output
279
+ for conn in avail_conns:
280
+ # 4 first bytes indicate the message length in big-endian order
281
+ # See source code of makePickle() method from SocketHandler class in module [logging.handlers](https://github.com/python/cpython/blob/3.14/Lib/logging/handlers.py)
282
+ msg_length = int.from_bytes(self._receive(conn, 4), byteorder = 'big')
283
+ msg = self._receive(conn, msg_length)
284
+ try:
285
+ msg_dict = pickle.loads(msg)
286
+ record = logging.makeLogRecord(msg_dict)
287
+ self.logger.handle(record)
288
+ except UnicodeError:
289
+ print("\x1b[31m!!! LOG MESSAGE NOT ENCODED PROPERLY !!!")
290
+ except pickle.UnpicklingError:
291
+ print("\x1b[31m!!! UNABLE TO DECODE THE RECEIVED LOG MESSAGE !!!")
292
+ except EOFError:
293
+ # print("CONNECTION CLOSED")
294
+ # print(f"Number of remaining available connection(s): {len(self._registered_connections)}")
295
+ if len(self._registered_connections) == 0:
296
+ # print("No connection left: stopping the log server...")
297
+ self._running = False
298
+ self.stop()
299
+
300
+
301
+ def server(port:int = 5000, color_mode: str = "level"):
302
+ print("****************************")
303
+ print("* Logging server *")
304
+ print(f"* port = {port} *")
305
+ print(f"* color mode = {color_mode} *")
306
+ print("****************************")
307
+ print()
308
+ lserv = LoggingServer(host = "localhost", port=port, color_mode=color_mode)
309
+ lserv.start()
310
+ try:
311
+ lserv.join()
312
+ except KeyboardInterrupt:
313
+ lserv.stop()
314
+
315
+ if __name__ == '__main__':
316
+ ### Launch the logging server in a separated thread ###
317
+
318
+ if len(sys.argv) >= 2:
319
+ port = 5000
320
+ color_mode = "level"
321
+
322
+ if "--port" in sys.argv:
323
+ idx = sys.argv.index("--port")
324
+ try:
325
+ port = int(sys.argv[idx + 1])
326
+ except (ValueError, IndexError):
327
+ pass
328
+
329
+ if "--color-mode" in sys.argv:
330
+ idx = sys.argv.index("--color-mode")
331
+ try:
332
+ color_mode = sys.argv[idx + 1]
333
+ except IndexError:
334
+ pass
335
+
336
+ server(port, color_mode)
337
+ else:
338
+ server()
@@ -0,0 +1,71 @@
1
+ """
2
+ multiplayer, a Python library for managing multiplayer games
3
+ Copyright (C) 2025 [devfred78](https://github.com/devfred78)
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ Script that sends logs to the logging server
19
+ """
20
+
21
+ import logging
22
+ from logging.handlers import SocketHandler
23
+ from pathlib import Path
24
+ import subprocess
25
+ import sys
26
+ from time import sleep
27
+
28
+ # local path
29
+ if getattr(sys, "frozen", False):
30
+ local_path = Path(sys.executable).parent
31
+ else:
32
+ local_path = Path(__file__).resolve().parent
33
+
34
+ UNIX_SOCKET_PATH = local_path / Path('logging_socket')
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ def main(port:int = 5000):
39
+ logger.setLevel(logging.INFO)
40
+ handler = SocketHandler("localhost", port)
41
+ logger.addHandler(handler)
42
+
43
+ server_path = local_path / Path('server.py')
44
+ subprocess.run(['wt', 'new-tab', 'cmd', '/k', 'uv', 'run', 'python', str(server_path), '--port', str(port)], stdin = subprocess.PIPE, shell = True)
45
+
46
+ print("Waiting for 2 secs before sending logs...")
47
+ sleep(2)
48
+ print("Debug logging")
49
+ logger.debug("This is a DEBUG message")
50
+ print("Info logging")
51
+ logger.info("This is an INFO message")
52
+ print("Warning logging")
53
+ logger.warning("This is a WARNING message")
54
+ print("Error logging")
55
+ logger.error("This is an ERROR message")
56
+ print("Critical logging")
57
+ logger.critical("This is a CRITICAL message")
58
+ print("Error logging")
59
+ logger.error("This is an ERROR message")
60
+ print("Warning logging")
61
+ logger.warning("This is a WARNING message")
62
+ print("Info logging")
63
+ logger.info("This is an INFO message")
64
+ print("Debug logging")
65
+ logger.debug("This is a DEBUG message")
66
+
67
+ input("Please push ENTER to finish...")
68
+
69
+ if __name__ == '__main__':
70
+ port = 5005
71
+ main(port)
@@ -0,0 +1,52 @@
1
+ """
2
+ This package provides classes for managing a multiplayer game, both locally and over a network.
3
+ """
4
+ from .game import Game, Player, Observer, GameState, GameGroup
5
+ from .server import GameServer
6
+ from .client import GameClient, RemoteGame, ServerAdmin, GroupAdmin
7
+
8
+ from .utils import (
9
+ suggest_game_name,
10
+ suggest_player_name,
11
+ get_available_categories,
12
+ register_name_category,
13
+ unregister_name_category,
14
+ )
15
+ from .exceptions import (
16
+ MultiplayerError,
17
+ GameLogicError,
18
+ PlayerLimitReachedError,
19
+ ObserverLimitReachedError,
20
+ GameNotFoundError,
21
+ NetworkError,
22
+ ConnectionError,
23
+ ServerError,
24
+ AuthenticationError,
25
+ )
26
+
27
+ __all__ = [
28
+ 'Game',
29
+ 'Player',
30
+ 'Observer',
31
+ 'GameState',
32
+ 'GameGroup',
33
+ 'GameServer',
34
+ 'GameClient',
35
+ 'RemoteGame',
36
+ 'ServerAdmin',
37
+ 'GroupAdmin',
38
+ 'suggest_game_name',
39
+ 'suggest_player_name',
40
+ 'get_available_categories',
41
+ 'register_name_category',
42
+ 'unregister_name_category',
43
+ 'MultiplayerError',
44
+ 'GameLogicError',
45
+ 'PlayerLimitReachedError',
46
+ 'ObserverLimitReachedError',
47
+ 'GameNotFoundError',
48
+ 'NetworkError',
49
+ 'ConnectionError',
50
+ 'ServerError',
51
+ 'AuthenticationError',
52
+ ]