diff --git a/.gitignore b/.gitignore index e92d1c2..9b9f0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,10 @@ dmypy.json .pyre/ # VSC -/.vscode \ No newline at end of file +/.vscode + +# Ignore ini files +*.ini + +# Ignore logs directory +logs/ diff --git a/sampy/__main__.py b/sampy/__main__.py index 7eefca9..9b49b44 100644 --- a/sampy/__main__.py +++ b/sampy/__main__.py @@ -1,13 +1,18 @@ import argparse import asyncio +import logging import textwrap def main(args: argparse.Namespace) -> int: + from sampy.config import Config from sampy.network.protocol import Protocol from sampy.server import InteractiveServer - server = InteractiveServer(Protocol) + server = InteractiveServer( + Protocol, + config=Config(*args.config, logging_level=args.log), + ) server.start() asyncio.get_event_loop().run_forever() @@ -23,25 +28,83 @@ if __name__ == "__main__": parser = argparse.ArgumentParser( prog="sampy", - description="A SAMP server made in python", - epilog=textwrap.dedent( + description=textwrap.dedent( """ + A SAMP server made in python + SAMP (or SA-MP) is a free multiplayer mod for the PC port of GTA: San Andreas. GTA: San Andreas was developed by Rockstar North and released in 2005. SA-MP is an unofficial multiplayer mod made by the `SA-MP.com` team released @[sa-mp.com](https://www.sa-mp.com/) """ ), + epilog=textwrap.dedent( + """ + example: + %(prog)s --config default.ini secret.ini race.ini + + This will first load default.ini configuration, + secret.ini might override the rcon- and server-password, + race.ini might change the hostname and rules. + + example default.ini: + [sampy] + hostname = My SAMP Server + password = + rcon_password = VerySecretPassword + + [sampy.rules] + lagcomp = off + + [logging.loggers] + keys = root + + [logging.handlers] + keys = console, file + + [logging.formatters] + keys = simple, color + + [logging.logger_root] + level = DEBUG + handlers = console, file + + [logging.handler_console] + class = StreamHandler + formatter = color + args = (sys.stdout,) + + [logging.handler_file] + class = logging.handlers.TimedRotatingFileHandler + formatter = simple + args = ("logs/sampy.log", "d", 1, 7,) # Note that the logs folder has to exist + + [logging.formatter_simple] + format = %%(asctime)s.%%(msecs)03d | %%(levelname)-8s | %%(message)s + datefmt = %%Y-%%m-%%d %%H:%%M:%%S + + [logging.formatter_color] + class = sampy.config.ColorFormatter + format = §6%%(asctime)s.%%(msecs)03d §1| %%(levelname)-8s §1|§r %%(message)s + datefmt = %%Y-%%m-%%d %%H:%%M:%%S + """ + ), formatter_class=Formatter, ) - parser.add_argument("--host", type=str, default="0.0.0.0", help="Server host ip") - parser.add_argument("-p", "--port", type=int, default=7777, help="Server port") + parser.add_argument( - "-v", - "--version", + "--config", type=str, - default="latest", - help="Game version", - choices=["latest"], + nargs="+", + default=[], + help="Config filenames", ) + parser.add_argument( + "--log", + type=str, + nargs="?", + help="Global logging level (Overrides any config)", + choices=logging._nameToLevel.keys(), + ) + args = parser.parse_args() raise SystemExit(main(args)) diff --git a/sampy/config.py b/sampy/config.py index 892e92a..8e15549 100644 --- a/sampy/config.py +++ b/sampy/config.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +import logging.config from configparser import ConfigParser -from typing import Dict, Mapping, Union +from typing import Any, Dict, Mapping, Optional, Union class Config(ConfigParser): @@ -20,27 +21,67 @@ class Config(ConfigParser): "sampy.rules": { "weburl": "https://git.osufx.com/Sunpy/sampy", }, - "logging": { - "filename": "", + "logging.loggers": { + "keys": "root", + }, + "logging.handlers": { + "keys": "console", + }, + "logging.formatters": { + "keys": "simple", + }, + "logging.logger_root": { "level": "INFO", - "datefmt": r"%%d-%%b-%%y %%H:%%M:%%S", + "handlers": "console", + }, + "logging.handler_console": { + "class": "StreamHandler", + "formatter": "simple", + "args": "(sys.stdout,)", + }, + "logging.formatter_simple": { + "format": "%(levelname)s - %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", }, } - def __init__(self, *filenames): - super().__init__() + def __init__( + self, + *filenames, + dictionary: Mapping[str, Mapping[str, Union[str, int]]] = {}, + logging_level: Optional[int] = None, + ): + super().__init__(interpolation=None) + if logging_level is not None: + logging.root.setLevel(logging_level) self.read_dict(self.DEFAULTS) + self.read_dict(dictionary) - found = self.read(filenames) + found = self.read(filenames, encoding="utf-8-sig") missing = set(filenames) - set(found) if len(missing): logging.warn("Config files not found: %s" % missing) - def save(self, path: str): - with open(path, "w") as f: - self.write(f) + logging_config = self.get_logging_config() + if logging_config: + logging.config.fileConfig(logging_config) + + if logging_level is not None: + logging.root.setLevel(logging_level) + + logging.debug("Logging module has been configured") + else: + logging.warn("Logging module was not configured") + + def get_logging_config(self) -> ConfigParser: + config = ConfigParser(interpolation=None) + for section in self.sections(): + if not section.startswith("logging."): + continue + config[section.replace("logging.", "")] = self[section] + return config @property def host(self) -> str: @@ -77,3 +118,65 @@ class Config(ConfigParser): @property def rules(self) -> Dict[str, str]: return self["sampy.rules"] + + +class LogRecordProxy: + def __init__(self, record: logging.LogRecord): + self._record = record + + def __getattribute__(self, name: str) -> Any: + attr = { + k: v + for k, v in object.__getattribute__(self, "__dict__").items() + if k != "_record" + } + if name in attr: + return attr[name] + elif name == "__dict__": # Combine dicts + return {**object.__getattribute__(self, "_record").__dict__, **attr} + return object.__getattribute__(self, "_record").__getattribute__(name) + + +class ColorFormatter(logging.Formatter): + COLORS: Dict[str, str] = { + "0": "30", # Black + "1": "34", # Blue + "2": "32", # Green + "3": "36", # Cyan + "4": "31", # Red, + "5": "35", # Purple/Magenta + "6": "33", # Yellow/Gold + "7": "37", # White/Light Gray + "8": "30;1", # Dark Gray + "9": "34;1", # Light Blue + "a": "32;1", # Light Green + "b": "36;1", # Light Cyan + "c": "31;1", # Light Red + "d": "35;1", # Light Purple/Magenta + "e": "33;1", # Yellow + "f": "37;1", # White + "r": "0", # Reset + "l": "1", # Bold + "n": "4", # Underline + } + + LEVEL_COLOR = { + logging.CRITICAL: "31", + logging.ERROR: "31", + logging.WARNING: "33", + logging.INFO: "32", + logging.DEBUG: "35", + logging.NOTSET: "37", + } + + def format(self, record: logging.LogRecord) -> str: + record = LogRecordProxy(record) + level_color = ColorFormatter.LEVEL_COLOR.get(record.levelno, None) + if level_color is not None: + record.levelname = "\x1b[%sm%s\x1b[0m" % (level_color, record.levelname) + + message = super().format(record) + for k, v in ColorFormatter.COLORS.items(): + message = message.replace("§%s" % k, "\x1b[%sm" % v) + + return message diff --git a/sampy/server.py b/sampy/server.py index 4e0f44c..ddaf130 100644 --- a/sampy/server.py +++ b/sampy/server.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Tuple, Type +import logging +from typing import TYPE_CHECKING, Optional, Tuple, Type from sampy.client.player import Player from sampy.config import Config @@ -19,6 +20,7 @@ class UDPProtocol: def start(self): loop = asyncio.get_event_loop() + logging.debug("Creating datagram endpoint") connect = loop.create_datagram_endpoint( lambda: self, local_addr=self.local_addr, @@ -29,13 +31,15 @@ class UDPProtocol: if self.transport is None: raise Exception("Cannot stop a server that hasn't been started") + logging.debug("Shutting down") self.transport.close() def connection_made(self, transport: asyncio.transports.DatagramTransport): + logging.debug("UDP Protocol: connection_made") self.transport = transport def connection_lost(self, exc: Exception | None): - pass + logging.debug("UDP Protocol: connection_lost") def datagram_received(self, data: bytes, addr: Tuple[str, int]): raise NotImplementedError @@ -47,7 +51,10 @@ class UDPProtocol: class Server(UDPProtocol): config: Config - def __init__(self, protocol: Type[Protocol], config: Config = Config()): + def __init__(self, protocol: Type[Protocol], config: Optional[Config] = None): + if config is None: + config = Config() + logging.warn("Server was initialized with default config") super().__init__( protocol=protocol(), local_addr=( @@ -55,6 +62,7 @@ class Server(UDPProtocol): config.getint("sampy", "port"), ), ) + self.config = config def datagram_received(self, data: bytes, addr: Tuple[str, int]): @@ -67,7 +75,7 @@ class Server(UDPProtocol): class InteractiveServer(Server): - def __init__(self, protocol: Type[Protocol], config: Config = Config()): + def __init__(self, protocol: Type[Protocol], config: Optional[Config] = None): super().__init__(protocol=protocol, config=config) loop = asyncio.get_event_loop() loop.create_task(self.run_input_loop()) diff --git a/setup.py b/setup.py index a4ead8f..7f1a176 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ from setuptools import setup if __name__ == "__main__": - setup() + setup()