From 1d357e6407e3c4bd6b534b9fbf08b8ab5bb71e50 Mon Sep 17 00:00:00 2001 From: Daniel Roschka Date: Thu, 18 Apr 2024 16:20:17 +0200 Subject: [PATCH] Support using a config file for config options This adds support to specify command line parameters for the bots in a TOML config file instead. This allows running the bots without sensitive parameters being provided as command line options, as that makes them visible to other processes/users as well. --- tests/test_utils.py | 112 ++++++++++++++++++++++++++++++++++++++- xpartamupp/echelon.py | 8 +-- xpartamupp/modbot.py | 5 +- xpartamupp/utils.py | 64 ++++++++++++++++++++++ xpartamupp/xpartamupp.py | 8 +-- 5 files changed, 186 insertions(+), 11 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8126564..c3334e1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -16,12 +16,15 @@ """Tests for utility functions.""" +from contextlib import redirect_stderr +from io import BytesIO, StringIO from unittest import TestCase +from unittest.mock import patch from hypothesis import given from hypothesis import strategies as st -from xpartamupp.utils import LimitedSizeDict +from xpartamupp.utils import ArgumentParserWithConfigFile, LimitedSizeDict class TestLimitedSizeDict(TestCase): @@ -43,3 +46,110 @@ def test_max_items(self, size_limit): self.assertFalse(0 in test_dict.values()) self.assertTrue(1 in test_dict.values()) self.assertTrue(size_limit + 1 in test_dict.values()) + + +class TestArgumentParserWithConfigFile(TestCase): + """Test ArgumentParser with support for config files.""" + + def test_missing_config_file(self): + """Test specified, but not existing config file.""" + config_file_name = "config.toml" + args = ["--config-file", config_file_name] + + with patch("xpartamupp.utils.open") as file_open_mock: + file_open_mock.side_effect = FileNotFoundError() + + parser = ArgumentParserWithConfigFile() + parser.add_argument("-v", action="count", dest="verbosity", default=0) + + stderr = StringIO() + with self.assertRaises(SystemExit), redirect_stderr(stderr): + parser.parse_args(args=args) + self.assertIn(f"The given configuration file \"{config_file_name}\" " + "doesn't exist.", stderr.getvalue()) + + file_open_mock.assert_called_once_with(config_file_name, "rb") + + def test_config_file(self): + """Test successful reading options from a config file.""" + config_file_name = "config.toml" + args = ["--config-file", config_file_name] + + with patch("xpartamupp.utils.open") as file_open_mock: + file_open_mock.return_value = BytesIO(b"verbosity = 2\n") + + parser = ArgumentParserWithConfigFile() + parser.add_argument("-v", action="count", dest="verbosity", default=0) + parsed_args = parser.parse_args(args=args) + + file_open_mock.assert_called_once_with(config_file_name, "rb") + self.assertEqual(2, parsed_args.verbosity) + + def test_config_file_invalid_option(self): + """Test invalid options in the config file.""" + config_file_name = "config.toml" + args = ["--config-file", config_file_name] + + with patch("xpartamupp.utils.open") as file_open_mock: + file_open_mock.return_value = BytesIO(b'foo = "bar"\n') + + parser = ArgumentParserWithConfigFile() + parser.add_argument("-v", action="count", dest="verbosity", default=0) + + stderr = StringIO() + with self.assertRaises(SystemExit), redirect_stderr(stderr): + parser.parse_args(args=args) + self.assertIn("The configuration file contains an unrecognized option: foo", + stderr.getvalue()) + + def test_config_file_with_cmdl_option(self): + """Test overwriting an option in config.""" + config_file_name = "config.toml" + args = ["--config-file", config_file_name, "-v"] + + with patch("xpartamupp.utils.open") as file_open_mock: + file_open_mock.return_value = BytesIO(b"verbosity = 2\n") + + parser = ArgumentParserWithConfigFile() + parser.add_argument("-v", action="count", dest="verbosity", default=0) + parsed_args = parser.parse_args(args=args) + + file_open_mock.assert_called_once_with(config_file_name, "rb") + self.assertEqual(1, parsed_args.verbosity) + + def test_namespace(self): + """Test functionality of the namespace parameter.""" + class Namespace: + verbosity = 3 + + args = ["-v"] + namespace = Namespace() + + with patch("xpartamupp.utils.open") as file_open_mock: + parser = ArgumentParserWithConfigFile() + parser.add_argument("-v", action="count", dest="verbosity", default=0) + parsed_args = parser.parse_args(args=args, namespace=namespace) + + file_open_mock.load.assert_not_called() + self.assertIs(namespace, parsed_args) + self.assertEqual(4, parsed_args.verbosity) + + def test_config_file_and_namespace(self): + """Test combination of config file and namespace parameter.""" + class Namespace: + verbosity = 3 + + config_file_name = "config.toml" + args = ["--config-file", config_file_name] + namespace = Namespace() + + with patch("xpartamupp.utils.open") as file_open_mock: + file_open_mock.return_value = BytesIO(b"verbosity = 2\n") + + parser = ArgumentParserWithConfigFile() + parser.add_argument("-v", action="count", dest="verbosity", default=0) + parsed_args = parser.parse_args(args=args, namespace=namespace) + + file_open_mock.assert_called_once_with(config_file_name, "rb") + self.assertIs(namespace, parsed_args) + self.assertEqual(2, parsed_args.verbosity) diff --git a/xpartamupp/echelon.py b/xpartamupp/echelon.py index dc3f200..3362fe5 100755 --- a/xpartamupp/echelon.py +++ b/xpartamupp/echelon.py @@ -18,12 +18,12 @@ """0ad XMPP-bot responsible for managing game ratings.""" -import argparse import asyncio import difflib import logging import ssl +from argparse import ArgumentDefaultsHelpFormatter from asyncio import Future from collections import deque from datetime import datetime, timedelta, timezone @@ -41,7 +41,7 @@ from xpartamupp.elo import get_rating_adjustment from xpartamupp.lobby_ranking import Game, Player, PlayerInfo from xpartamupp.stanzas import BoardListXmppPlugin, GameReportXmppPlugin, ProfileXmppPlugin -from xpartamupp.utils import LimitedSizeDict +from xpartamupp.utils import ArgumentParserWithConfigFile, LimitedSizeDict # Rating that new players should be inserted into the # database with, before they've played any games. @@ -803,8 +803,8 @@ def parse_args(): Parsed command line arguments """ - parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, - description="EcheLOn - XMPP Rating Bot") + parser = ArgumentParserWithConfigFile(formatter_class=ArgumentDefaultsHelpFormatter, + description="EcheLOn - XMPP Rating Bot") verbosity_parser = parser.add_mutually_exclusive_group() verbosity_parser.add_argument("-v", action="count", dest="verbosity", default=0, diff --git a/xpartamupp/modbot.py b/xpartamupp/modbot.py index 4a54e05..d4d98c7 100755 --- a/xpartamupp/modbot.py +++ b/xpartamupp/modbot.py @@ -42,6 +42,7 @@ from xpartamupp.lobby_moderation_db import (JIDNickWhitelist, KickEvent, Moderator, MuteEvent, UnmuteEvent) +from xpartamupp.utils import ArgumentParserWithConfigFile logger = logging.getLogger(__name__) @@ -658,8 +659,8 @@ def parse_args(): Parsed command line arguments """ - parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter, - description="ModBot - XMPP Moderation Bot") + parser = ArgumentParserWithConfigFile(formatter_class=ArgumentDefaultsHelpFormatter, + description="ModBot - XMPP Moderation Bot") verbosity_parser = parser.add_mutually_exclusive_group() verbosity_parser.add_argument("-v", action="count", dest="verbosity", default=0, diff --git a/xpartamupp/utils.py b/xpartamupp/utils.py index 97a0d5f..619984c 100644 --- a/xpartamupp/utils.py +++ b/xpartamupp/utils.py @@ -16,7 +16,11 @@ """Collection of utility functions used by the XMPP-bots.""" +import tomllib + +from argparse import ArgumentParser, Namespace from collections import OrderedDict +from typing import Sequence class LimitedSizeDict(OrderedDict): @@ -46,3 +50,63 @@ def _check_size_limit(self): if self.size_limit: while len(self) > self.size_limit: self.popitem(last=False) + + +class ArgumentParserWithConfigFile(ArgumentParser): + """ArgumentParser with support for values in TOML files. + + This extends the ArgumentParser class by a pre-defined + `--config-file` parameter, which allows storing config options in + TOML files, instead of providing them as command line options. + The options in the configuration file have to be named like the + destination variables of the parser arguments. + If an option is present in the configuration file and in the + command line options, the value from the command line options takes + precedence. + """ + + def __init__(self, *args, **kwargs): + """Create a parser with an option for a config file.""" + super().__init__(*args, **kwargs) + self.add_argument("--config-file", + help="Path to a TOML configuration file. Options in the configuration " + "will be used as defaults for command line options and will be " + "overwritten if the command line option is provided with a " + "non-default value.") + + def parse_args(self, args: Sequence[str] | None = None, + namespace: Namespace | None = None) -> Namespace: + """Parse arguments and use values from TOML as default.""" + parsed_args = super().parse_args(args, namespace) + + if not parsed_args.config_file: + delattr(parsed_args, "config_file") + return parsed_args + + try: + with open(parsed_args.config_file, "rb") as r: + toml_data = tomllib.load(r) + except FileNotFoundError: + self.error( + f"The given configuration file \"{parsed_args.config_file}\" doesn't exist.") + + delattr(parsed_args, "config_file") + + default_args = vars(super().parse_args([])) + changed_args = [] + + for key, value in vars(parsed_args).items(): + if key in default_args and value == default_args[key]: + continue + + changed_args.append(key) + + for key, value in toml_data.items(): + if key not in default_args: + self.error( + f"The configuration file contains an unrecognized option: {key}") + if key in changed_args: + continue + setattr(parsed_args, key, value) + + return parsed_args diff --git a/xpartamupp/xpartamupp.py b/xpartamupp/xpartamupp.py index 2d64777..f6a5547 100755 --- a/xpartamupp/xpartamupp.py +++ b/xpartamupp/xpartamupp.py @@ -17,12 +17,12 @@ """0ad XMPP-bot responsible for managing game listings.""" -import argparse import asyncio import logging import ssl import time +from argparse import ArgumentDefaultsHelpFormatter from asyncio import Future from datetime import datetime, timedelta, timezone @@ -34,7 +34,7 @@ from slixmpp.xmlstream.stanzabase import register_stanza_plugin from xpartamupp.stanzas import GameListXmppPlugin -from xpartamupp.utils import LimitedSizeDict +from xpartamupp.utils import ArgumentParserWithConfigFile, LimitedSizeDict # Number of seconds to not respond to mentions after having responded # to a mention. @@ -382,8 +382,8 @@ def parse_args(): Parsed command line arguments """ - parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, - description="XpartaMuPP - XMPP Multiplayer Game Manager") + parser = ArgumentParserWithConfigFile(formatter_class=ArgumentDefaultsHelpFormatter, + description="XpartaMuPP - XMPP Multiplayer Game Manager") verbosity_parser = parser.add_mutually_exclusive_group() verbosity_parser.add_argument("-v", action="count", dest="verbosity", default=0,