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" +``` 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/tests/integration/conftest.py b/tests/integration/conftest.py index d9421a8..f09c0fe 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" ``` """ @@ -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/__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/console.py b/toot/console.py index 39842c4..3076517 100644 --- a/toot/console.py +++ b/toot/console.py @@ -7,9 +7,10 @@ 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 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) @@ -919,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/output.py b/toot/output.py index 83cd235..6fd59a2 100644 --- a/toot/output.py +++ b/toot/output.py @@ -3,6 +3,8 @@ import re 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 @@ -99,6 +101,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 +118,24 @@ def use_ansi_color(): if "--no-color" in sys.argv: return False + # Check in settings + color = settings.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] + if not settings.get_quiet(): + 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 new file mode 100644 index 0000000..3b0a07a --- /dev/null +++ b/toot/settings.py @@ -0,0 +1,84 @@ +import os +import sys + +from functools import lru_cache +from os.path import exists, join +from tomlkit import parse +from toot import get_config_dir +from typing import Optional, Type + + +DISABLE_SETTINGS = False + +TOOT_SETTINGS_FILE_NAME = "settings.toml" + + +def get_settings_path(): + return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME) + + +def load_settings() -> dict: + # Used for testing without config file + if DISABLE_SETTINGS: + return {} + + path = get_settings_path() + + if not exists(path): + return {} + + with open(path) 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: + # TODO: warn? cast? both? + return default + + key = keys[0] + if isinstance(dct, dict) and key in dct: + 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) + + +@lru_cache(maxsize=None) +def get_quiet(): + if "--quiet" in sys.argv: + return True + + return get_setting("common.quiet", str, False)