From ed0baf077053a522fb6f88314c8a53cbc8d18d37 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 24 Jun 2023 17:29:11 +0200 Subject: [PATCH 1/9] Implement reading from a settings file --- setup.py | 1 + toot/settings.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 toot/settings.py diff --git a/setup.py b/setup.py index 8ffd88a..664cd9e 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ setup( "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7", "urwid>=2.0.0,<3.0", + "tomlkit>=0.10.0,<1.0" ], entry_points={ 'console_scripts': [ diff --git a/toot/settings.py b/toot/settings.py new file mode 100644 index 0000000..3bb0ab2 --- /dev/null +++ b/toot/settings.py @@ -0,0 +1,52 @@ +from functools import lru_cache +from os.path import exists, join +from typing import Optional, Type, TypeVar +from tomlkit import parse +from toot.config import get_config_dir + + +TOOT_SETTINGS_FILE_NAME = "settings.toml" + + +def get_settings_path(): + return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME) + + +SETTINGS_FILE = get_settings_path() + + +def load_settings() -> dict: + if not exists(SETTINGS_FILE): + return {} + + with open(SETTINGS_FILE) as f: + return parse(f.read()) + + +@lru_cache(maxsize=None) +def get_settings(): + return load_settings() + + +def get_setting(key: str, type: Type, default=None): + """ + Get a setting value. The key should be a dot-separated string, + e.g. "commands.post.editor" which will correspond to the "editor" setting + inside the `[commands.post]` section. + """ + settings = get_settings() + return _get_setting(settings, key.split("."), type, default) + + +def _get_setting(dct, keys, type: Type, default=None): + if len(keys) == 0: + if isinstance(dct, type): + return dct + else: + return default + + key = keys[0] + if isinstance(dct, dict) and key in dct: + return _get_setting(dct[key], keys[1:], type, default) + + return default From 85260ed99d9b6fe7b603a923ddd89a3596fc381d Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 24 Jun 2023 18:44:22 +0200 Subject: [PATCH 2/9] Apply command defaults from settings --- toot/console.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/toot/console.py b/toot/console.py index 39842c4..a280e52 100644 --- a/toot/console.py +++ b/toot/console.py @@ -10,6 +10,7 @@ from itertools import chain from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out, print_err +from toot.settings import get_setting VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES) @@ -882,12 +883,24 @@ def get_argument_parser(name, command): if command.require_auth: combined_args += common_auth_args + defaults = get_setting(f"commands.{name}", dict, {}) + for args, kwargs in combined_args: + # Set default value from settings if exists + default = get_default_value(defaults, args) + if default is not None: + kwargs["default"] = default parser.add_argument(*args, **kwargs) return parser +def get_default_value(defaults, args): + # Hacky way to determine command name from argparse args + name = args[-1].lstrip("-").replace("-", "_") + return defaults.get(name) + + def run_command(app, user, name, args): command = next((c for c in COMMANDS if c.name == name), None) From 4388175cb43f43acd269accbd98977c029482f62 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 13:56:59 +0200 Subject: [PATCH 3/9] Respect color setting --- toot/output.py | 15 +++++++++++---- toot/settings.py | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/toot/output.py b/toot/output.py index 83cd235..186f4d9 100644 --- a/toot/output.py +++ b/toot/output.py @@ -3,6 +3,7 @@ import re import sys import textwrap +from functools import lru_cache from toot.entities import Instance, Notification, Poll, Status from toot.utils import get_text, parse_html from toot.wcstring import wc_wrap @@ -99,6 +100,7 @@ def strip_tags(message): return re.sub(STYLE_TAG_PATTERN, "", message) +@lru_cache(maxsize=None) def use_ansi_color(): """Returns True if ANSI color codes should be used.""" @@ -115,23 +117,28 @@ def use_ansi_color(): if "--no-color" in sys.argv: return False + # Check in settings + from toot.settings import get_setting + color = get_setting("common.color", bool) + if color is not None: + return color + + # Use color by default return True -USE_ANSI_COLOR = use_ansi_color() - QUIET = "--quiet" in sys.argv def print_out(*args, **kwargs): if not QUIET: - args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args] + args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] print(*args, **kwargs) def print_err(*args, **kwargs): args = [f"{a}" for a in args] - args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args] + args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] print(*args, file=sys.stderr, **kwargs) diff --git a/toot/settings.py b/toot/settings.py index 3bb0ab2..1f08afe 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -1,8 +1,8 @@ from functools import lru_cache from os.path import exists, join -from typing import Optional, Type, TypeVar from tomlkit import parse from toot.config import get_config_dir +from typing import Type TOOT_SETTINGS_FILE_NAME = "settings.toml" @@ -43,6 +43,7 @@ def _get_setting(dct, keys, type: Type, default=None): if isinstance(dct, type): return dct else: + # TODO: warn? cast? both? return default key = keys[0] From ee20b7ac0e94042629431d1a9f19897bf19b9adc Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 13:57:29 +0200 Subject: [PATCH 4/9] Add settings documentation --- docs/SUMMARY.md | 1 + docs/settings.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 docs/settings.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index da4170b..5c67529 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -5,6 +5,7 @@ - [Installation](installation.md) - [Usage](usage.md) - [Advanced](advanced.md) + - [Settings](settings.md) - [TUI](tui.md) - [Contributing](contributing.md) - [Documentation](documentation.md) diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..f128b7b --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,52 @@ +# Settings + +Toot can be configured via a [TOML](https://toml.io/en/) settings file. + +> Introduced in toot 0.37.0 + +> **Warning:** Settings are experimental and things may change without warning. + +Toot will look for the settings file at: + +* `~/.config/toot/settings.toml` (Linux & co.) +* `%APPDATA%\toot\settings.toml` (Windows) + +Toot will respect the `XDG_CONFIG_HOME` environement variable if it's set and +look for the settings file in `$XDG_CONFIG_HOME/toot` instead of +`~/.config/toot`. + +## Common options + +The `[common]` section includes common options which are applied to all commands. + +```toml +[common] +# Whether to use ANSI color in output +color = true + +# Enable debug logging, shows HTTP requests +debug = true + +# Redirect debug log to the given file +debug_file = "/tmp/toot.log" + +# Log request and response bodies in the debug log +verbose = false + +# Do not write to output +quiet = false +``` + +## Overriding command defaults + +Defaults for command arguments can be override by specifying a `[command.]` section. + +For example, to override `toot post`. + +```toml +[command.post] +editor = "vim" +sensitive = true +visibility = "unlisted" +scheduled_in = "30 minutes" +``` From cee2c938154704cc96ccff97bffe5fdf194595d2 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 14:09:55 +0200 Subject: [PATCH 5/9] Respect debug and debug_file settings --- toot/console.py | 7 +++---- toot/settings.py | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/toot/console.py b/toot/console.py index a280e52..3076517 100644 --- a/toot/console.py +++ b/toot/console.py @@ -7,7 +7,7 @@ import sys from argparse import ArgumentParser, FileType, ArgumentTypeError, Action from collections import namedtuple from itertools import chain -from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__ +from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings from toot.exceptions import ApiError, ConsoleError from toot.output import print_out, print_err from toot.settings import get_setting @@ -932,9 +932,8 @@ def run_command(app, user, name, args): def main(): - # Enable debug logging if --debug is in args - if "--debug" in sys.argv: - filename = os.getenv("TOOT_LOG_FILE") + if settings.get_debug(): + filename = settings.get_debug_file() logging.basicConfig(level=logging.DEBUG, filename=filename) logging.getLogger("urllib3").setLevel(logging.INFO) diff --git a/toot/settings.py b/toot/settings.py index 1f08afe..aa02c1b 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -1,8 +1,11 @@ +import os +import sys + from functools import lru_cache from os.path import exists, join from tomlkit import parse from toot.config import get_config_dir -from typing import Type +from typing import Optional, Type TOOT_SETTINGS_FILE_NAME = "settings.toml" @@ -51,3 +54,18 @@ def _get_setting(dct, keys, type: Type, default=None): return _get_setting(dct[key], keys[1:], type, default) return default + + +def get_debug() -> bool: + if "--debug" in sys.argv: + return True + + return get_setting("common.debug", bool, False) + + +def get_debug_file() -> Optional[str]: + from_env = os.getenv("TOOT_LOG_FILE") + if from_env: + return from_env + + return get_setting("common.debug_file", str) From 953cad50231cc4045173c7aaa180a6a54ce3f33f Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 14:17:35 +0200 Subject: [PATCH 6/9] Respect quiet setting --- toot/output.py | 6 ++---- toot/settings.py | 8 ++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/toot/output.py b/toot/output.py index 186f4d9..c1d89c6 100644 --- a/toot/output.py +++ b/toot/output.py @@ -127,11 +127,9 @@ def use_ansi_color(): return True -QUIET = "--quiet" in sys.argv - - def print_out(*args, **kwargs): - if not QUIET: + from toot import settings + if not settings.get_quiet(): args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] print(*args, **kwargs) diff --git a/toot/settings.py b/toot/settings.py index aa02c1b..990fcc1 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -69,3 +69,11 @@ def get_debug_file() -> Optional[str]: return from_env return get_setting("common.debug_file", str) + + +@lru_cache(maxsize=None) +def get_quiet(): + if "--quiet" in sys.argv: + return True + + return get_setting("common.quiet", str, False) From d4f8acb3ce36083e72df8243afe6cdd077ed2107 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 14:30:22 +0200 Subject: [PATCH 7/9] Resolve circular import by moving get_config_dir --- toot/__init__.py | 23 +++++++++++++++++++++++ toot/config.py | 37 +++++++++---------------------------- toot/output.py | 5 ++--- toot/settings.py | 11 +++++------ 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/toot/__init__.py b/toot/__init__.py index 739ff78..e293c15 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -1,3 +1,7 @@ +import os +import sys + +from os.path import join, expanduser from collections import namedtuple __version__ = '0.37.0' @@ -9,3 +13,22 @@ DEFAULT_INSTANCE = 'https://mastodon.social' CLIENT_NAME = 'toot - a Mastodon CLI client' CLIENT_WEBSITE = 'https://github.com/ihabunek/toot' + +TOOT_CONFIG_DIR_NAME = "toot" + + +def get_config_dir(): + """Returns the path to toot config directory""" + + # On Windows, store the config in roaming appdata + if sys.platform == "win32" and "APPDATA" in os.environ: + return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME) + + # Respect XDG_CONFIG_HOME env variable if set + # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + if "XDG_CONFIG_HOME" in os.environ: + config_home = expanduser(os.environ["XDG_CONFIG_HOME"]) + return join(config_home, TOOT_CONFIG_DIR_NAME) + + # Default to ~/.config/toot/ + return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME) diff --git a/toot/config.py b/toot/config.py index cff9856..077e098 100644 --- a/toot/config.py +++ b/toot/config.py @@ -1,44 +1,22 @@ import json import os -import sys from functools import wraps -from os.path import dirname, join, expanduser +from os.path import dirname, join -from toot import User, App +from toot import User, App, get_config_dir from toot.exceptions import ConsoleError from toot.output import print_out -TOOT_CONFIG_DIR_NAME = "toot" TOOT_CONFIG_FILE_NAME = "config.json" -def get_config_dir(): - """Returns the path to toot config directory""" - - # On Windows, store the config in roaming appdata - if sys.platform == "win32" and "APPDATA" in os.environ: - return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME) - - # Respect XDG_CONFIG_HOME env variable if set - # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - if "XDG_CONFIG_HOME" in os.environ: - config_home = expanduser(os.environ["XDG_CONFIG_HOME"]) - return join(config_home, TOOT_CONFIG_DIR_NAME) - - # Default to ~/.config/toot/ - return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME) - - def get_config_file_path(): """Returns the path to toot config file.""" return join(get_config_dir(), TOOT_CONFIG_FILE_NAME) -CONFIG_FILE = get_config_file_path() - - def user_id(user): return "{}@{}".format(user.username, user.instance) @@ -63,15 +41,18 @@ def make_config(path): def load_config(): - if not os.path.exists(CONFIG_FILE): - make_config(CONFIG_FILE) + path = get_config_file_path() - with open(CONFIG_FILE) as f: + if not os.path.exists(path): + make_config(path) + + with open(path) as f: return json.load(f) def save_config(config): - with open(CONFIG_FILE, 'w') as f: + path = get_config_file_path() + with open(path, "w") as f: return json.dump(config, f, indent=True, sort_keys=True) diff --git a/toot/output.py b/toot/output.py index c1d89c6..6fd59a2 100644 --- a/toot/output.py +++ b/toot/output.py @@ -4,6 +4,7 @@ import sys import textwrap from functools import lru_cache +from toot import settings from toot.entities import Instance, Notification, Poll, Status from toot.utils import get_text, parse_html from toot.wcstring import wc_wrap @@ -118,8 +119,7 @@ def use_ansi_color(): return False # Check in settings - from toot.settings import get_setting - color = get_setting("common.color", bool) + color = settings.get_setting("common.color", bool) if color is not None: return color @@ -128,7 +128,6 @@ def use_ansi_color(): def print_out(*args, **kwargs): - from toot import settings if not settings.get_quiet(): args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] print(*args, **kwargs) diff --git a/toot/settings.py b/toot/settings.py index 990fcc1..408b1ae 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -4,7 +4,7 @@ import sys from functools import lru_cache from os.path import exists, join from tomlkit import parse -from toot.config import get_config_dir +from toot import get_config_dir from typing import Optional, Type @@ -15,14 +15,13 @@ def get_settings_path(): return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME) -SETTINGS_FILE = get_settings_path() - - def load_settings() -> dict: - if not exists(SETTINGS_FILE): + path = get_settings_path() + + if not exists(path): return {} - with open(SETTINGS_FILE) as f: + with open(path) as f: return parse(f.read()) From 38487a07744762e609a354970efc0dac282798d2 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 14:42:44 +0200 Subject: [PATCH 8/9] Fix error in docs --- tests/integration/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d9421a8..ca5c4b7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -8,7 +8,7 @@ To enable integration tests, export the following environment variables to match your test server and database: ``` -export TOOT_TEST_HOSTNAME="localhost:3000" +export TOOT_TEST_BASE_URL="localhost:3000" export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development" ``` """ From 7da372e4a8eb4998bf45a94629cdae7d290aca53 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Jun 2023 14:55:28 +0200 Subject: [PATCH 9/9] Disable settings for testing --- tests/integration/conftest.py | 5 +++++ toot/settings.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ca5c4b7..f09c0fe 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -26,6 +26,11 @@ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out +def pytest_configure(config): + import toot.settings + toot.settings.DISABLE_SETTINGS = True + + # Mastodon database name, used to confirm user registration without having to click the link DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN") diff --git a/toot/settings.py b/toot/settings.py index 408b1ae..3b0a07a 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -8,6 +8,8 @@ from toot import get_config_dir from typing import Optional, Type +DISABLE_SETTINGS = False + TOOT_SETTINGS_FILE_NAME = "settings.toml" @@ -16,6 +18,10 @@ def get_settings_path(): def load_settings() -> dict: + # Used for testing without config file + if DISABLE_SETTINGS: + return {} + path = get_settings_path() if not exists(path):