diff --git a/aurweb/config.py b/aurweb/config.py index aa111f15..0d0cf676 100644 --- a/aurweb/config.py +++ b/aurweb/config.py @@ -1,6 +1,8 @@ import configparser import os +from typing import Any + # Publicly visible version of aurweb. This is used to display # aurweb versioning in the footer and must be maintained. # Todo: Make this dynamic/automated. @@ -52,3 +54,13 @@ def getint(section, option, fallback=None): def get_section(section): if section in _get_parser().sections(): return _get_parser()[section] + + +def replace_key(section: str, option: str, value: Any) -> Any: + _get_parser().set(section, option, value) + + +def save() -> None: + aur_config = os.environ.get("AUR_CONFIG", "/etc/aurweb/config") + with open(aur_config, "w") as fp: + _get_parser().write(fp) diff --git a/aurweb/scripts/config.py b/aurweb/scripts/config.py new file mode 100644 index 00000000..dd7bcf5f --- /dev/null +++ b/aurweb/scripts/config.py @@ -0,0 +1,61 @@ +""" +Perform an action on the aurweb config. + +When AUR_CONFIG_IMMUTABLE is set, the `set` action is noop. +""" +import argparse +import configparser +import os +import sys + +import aurweb.config + + +def action_set(args): + # If AUR_CONFIG_IMMUTABLE is defined, skip out on config setting. + if os.environ.get("AUR_CONFIG_IMMUTABLE", 0): + return + + if not args.value: + print("error: no value provided", file=sys.stderr) + return + + try: + aurweb.config.replace_key(args.section, args.option, args.value) + aurweb.config.save() + except configparser.NoSectionError: + print("error: no section found", file=sys.stderr) + + +def action_get(args): + try: + value = aurweb.config.get(args.section, args.option) + print(value) + except (configparser.NoSectionError): + print("error: no section found", file=sys.stderr) + except (configparser.NoOptionError): + print("error: no option found", file=sys.stderr) + + +def parse_args(): + fmt_cls = argparse.RawDescriptionHelpFormatter + actions = ["get", "set"] + parser = argparse.ArgumentParser( + description="aurweb configuration tool", + formatter_class=lambda prog: fmt_cls(prog=prog, max_help_position=80)) + parser.add_argument("action", choices=actions, help="script action") + parser.add_argument("section", help="config section") + parser.add_argument("option", help="config option") + parser.add_argument("value", nargs="?", default=0, + help="config option value") + return parser.parse_args() + + +def main(): + args = parse_args() + action = getattr(sys.modules[__name__], f"action_{args.action}") + return action(args) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 8d14735a..82c439bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,3 +108,4 @@ aurweb-popupdate = "aurweb.scripts.popupdate:main" aurweb-rendercomment = "aurweb.scripts.rendercomment:main" aurweb-tuvotereminder = "aurweb.scripts.tuvotereminder:main" aurweb-usermaint = "aurweb.scripts.usermaint:main" +aurweb-config = "aurweb.scripts.config:main" diff --git a/test/test_config.py b/test/test_config.py index 4f10b60d..7e9d24b5 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,4 +1,16 @@ +import configparser +import io +import os +import re + +from unittest import mock + from aurweb import config +from aurweb.scripts.config import main + + +def noop(*args, **kwargs) -> None: + return def test_get(): @@ -11,3 +23,116 @@ def test_getboolean(): def test_getint(): assert config.getint("options", "disable_http_login") == 0 + + +def mock_config_get(): + config_get = config.get + + def _mock_config_get(section: str, option: str): + if section == "options": + if option == "salt_rounds": + return "666" + return config_get(section, option) + return _mock_config_get + + +@mock.patch("aurweb.config.get", side_effect=mock_config_get()) +def test_config_main_get(get: str): + stdout = io.StringIO() + args = ["aurweb-config", "get", "options", "salt_rounds"] + with mock.patch("sys.argv", args): + with mock.patch("sys.stdout", stdout): + main() + + expected = "666" + assert stdout.getvalue().strip() == expected + + +@mock.patch("aurweb.config.get", side_effect=mock_config_get()) +def test_config_main_get_unknown_section(get: str): + stderr = io.StringIO() + args = ["aurweb-config", "get", "fakeblahblah", "salt_rounds"] + with mock.patch("sys.argv", args): + with mock.patch("sys.stderr", stderr): + main() + + # With an invalid section, we should get a usage error. + expected = r'^error: no section found$' + assert re.match(expected, stderr.getvalue().strip()) + + +@mock.patch("aurweb.config.get", side_effect=mock_config_get()) +def test_config_main_get_unknown_option(get: str): + stderr = io.StringIO() + args = ["aurweb-config", "get", "options", "fakeblahblah"] + with mock.patch("sys.argv", args): + with mock.patch("sys.stderr", stderr): + main() + + expected = "error: no option found" + assert stderr.getvalue().strip() == expected + + +@mock.patch("aurweb.config.save", side_effect=noop) +def test_config_main_set(save: None): + data = None + + def mock_replace_key(section: str, option: str, value: str) -> None: + nonlocal data + data = value + + args = ["aurweb-config", "set", "options", "salt_rounds", "666"] + with mock.patch("sys.argv", args): + with mock.patch("aurweb.config.replace_key", + side_effect=mock_replace_key): + main() + + expected = "666" + assert data == expected + + +def test_config_main_set_immutable(): + data = None + + def mock_replace_key(section: str, option: str, value: str) -> None: + nonlocal data + data = value + + args = ["aurweb-config", "set", "options", "salt_rounds", "666"] + with mock.patch.dict(os.environ, {"AUR_CONFIG_IMMUTABLE": "1"}): + with mock.patch("sys.argv", args): + with mock.patch("aurweb.config.replace_key", + side_effect=mock_replace_key): + main() + + expected = None + assert data == expected + + +def test_config_main_set_invalid_value(): + stderr = io.StringIO() + + args = ["aurweb-config", "set", "options", "salt_rounds"] + with mock.patch("sys.argv", args): + with mock.patch("sys.stderr", stderr): + main() + + expected = "error: no value provided" + assert stderr.getvalue().strip() == expected + + +@mock.patch("aurweb.config.save", side_effect=noop) +def test_config_main_set_unknown_section(save: None): + stderr = io.StringIO() + + def mock_replace_key(section: str, option: str, value: str) -> None: + raise configparser.NoSectionError(section=section) + + args = ["aurweb-config", "set", "options", "salt_rounds", "666"] + with mock.patch("sys.argv", args): + with mock.patch("sys.stderr", stderr): + with mock.patch("aurweb.config.replace_key", + side_effect=mock_replace_key): + main() + + assert stderr.getvalue().strip() == "error: no section found"