Generalize settings

Add comments and docstrings
Create parent directories when saving
Add merging to normalize_options
Change get_option to return if the value is the default value
This commit is contained in:
Timmy Welch 2022-12-05 18:58:28 -08:00
parent 0302511f5f
commit 9aff3ae38e
No known key found for this signature in database
6 changed files with 107 additions and 61 deletions

View File

@ -31,7 +31,7 @@ repos:
rev: v1.7.7
hooks:
- id: autoflake
args: [-i]
args: [-i, --remove-all-unused-imports, --ignore-init-module-imports]
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:

View File

@ -81,12 +81,15 @@ class App:
return opts
def register_options(self) -> None:
self.manager = settings.Manager()
self.manager = settings.Manager(
"""A utility for reading and writing metadata to comic archives.\n\n\nIf no options are given, %(prog)s will run in windowed mode.""",
"For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
)
settings.register_commandline(self.manager)
settings.register_settings(self.manager)
def parse_options(self, config_paths: settings.ComicTaggerPaths) -> None:
options = self.manager.parse_options(config_paths)
options = self.manager.parse_options(config_paths.user_config_dir / "settings.json")
self.options = settings.validate_commandline_options(options, self.manager)
self.options = settings.validate_settings(options, self.manager)

View File

@ -2,8 +2,8 @@ from __future__ import annotations
from comictaggerlib.settings.cmdline import initial_cmd_line_parser, register_commandline, validate_commandline_options
from comictaggerlib.settings.file import register_settings, validate_settings
from comictaggerlib.settings.manager import Manager
from comictaggerlib.settings.types import ComicTaggerPaths, OptionValues
from comictaggerlib.settings.manager import Manager, OptionDefinitions, OptionValues
from comictaggerlib.settings.types import ComicTaggerPaths
__all__ = [
"initial_cmd_line_parser",
@ -14,4 +14,5 @@ __all__ = [
"Manager",
"ComicTaggerPaths",
"OptionValues",
"OptionDefinitions",
]

View File

@ -8,8 +8,6 @@ from collections import defaultdict
from collections.abc import Sequence
from typing import Any, Callable, NoReturn, Union
from comictaggerlib.settings.types import ComicTaggerPaths, OptionValues
logger = logging.getLogger(__name__)
@ -36,13 +34,20 @@ class Setting:
):
if not names:
raise ValueError("names must be specified")
# We prefix the destination name used by argparse so that there are no conflicts
# Argument names will still cause an exception if there is a conflict e.g. if '-f' is defined twice
self.internal_name, dest, flag = self.get_dest(group, names, dest)
args: Sequence[str] = names
# We then also set the metavar so that '--config' in the group runtime shows as 'CONFIG' instead of 'RUNTIME_CONFIG'
if not metavar and action not in ("store_true", "store_false", "count"):
metavar = dest.upper()
# If we are not a flag, no '--' or '-' in front
# we prefix the first name with the group as argparse sets dest to args[0]
# I believe internal name may be able to be used here
if not flag:
args = [f"{group}_{names[0]}".lstrip("_"), *names[1:]]
args = tuple((f"{group}_{names[0]}".lstrip("_"), *names[1:]))
self.action = action
self.nargs = nargs
@ -108,53 +113,68 @@ class Setting:
return self.argparse_args, self.filter_argparse_kwargs()
OptionValues = dict[str, dict[str, Any]]
OptionDefinitions = dict[str, dict[str, Setting]]
ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser]
class Manager:
"""docstring for SettingManager"""
def __init__(self, options: dict[str, dict[str, Setting]] | None = None):
self.argparser = argparse.ArgumentParser()
def __init__(
self, description: str | None = None, epilog: str | None = None, definitions: OptionDefinitions | None = None
):
# This one is never used, it just makes MyPy happy
self.argparser = argparse.ArgumentParser(description=description, epilog=epilog)
self.description = description
self.epilog = epilog
self.options: dict[str, dict[str, Setting]] = defaultdict(lambda: dict())
if options:
self.options = options
self.option_definitions: OptionDefinitions = defaultdict(lambda: dict())
if definitions:
self.option_definitions = definitions
self.current_group: ArgParser | None = None
self.exclusive_group = False
self.current_group_name = ""
def defaults(self) -> OptionValues:
return self.normalize_options({}, file=True, cmdline=True)
def get_namespace_for_args(self, options: OptionValues) -> argparse.Namespace:
def get_namespace(self, options: OptionValues) -> argparse.Namespace:
"""
Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}"
`options` should already be normalized.
Throws an exception if the internal_name is duplicated
"""
namespace = argparse.Namespace()
for group_name, group in self.options.items():
for group_name, group in self.option_definitions.items():
for setting_name, setting in group.items():
if not setting.cmdline:
if hasattr(namespace, setting.internal_name):
raise Exception(f"Duplicate internal name: {setting.internal_name}")
setattr(
namespace, setting.internal_name, options.get(group_name, {}).get(setting.dest, setting.default)
)
if hasattr(namespace, setting.internal_name):
raise Exception(f"Duplicate internal name: {setting.internal_name}")
setattr(
namespace,
setting.internal_name,
options.get(group_name, {}).get(
setting.dest,
setting.default,
),
)
setattr(namespace, "option_definitions", options.get("option_definitions"))
return namespace
def add_setting(self, *args: Any, **kwargs: Any) -> None:
exclusive = isinstance(self.current_group, argparse._MutuallyExclusiveGroup)
setting = Setting(*args, group=self.current_group_name, exclusive=exclusive, **kwargs)
self.options[self.current_group_name][setting.dest] = setting
"""Takes passes all arguments through to `Setting`, `group` and `exclusive` are already set"""
setting = Setting(*args, group=self.current_group_name, exclusive=self.exclusive_group, **kwargs)
self.option_definitions[self.current_group_name][setting.dest] = setting
def create_argparser(self) -> None:
"""Creates an argparser object from all cmdline settings"""
groups: dict[str, ArgParser] = {}
self.argparser = argparse.ArgumentParser(
description="""A utility for reading and writing metadata to comic archives.
If no options are given, %(prog)s will run in windowed mode.""",
epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
description=self.description,
epilog=self.epilog,
formatter_class=argparse.RawTextHelpFormatter,
)
for group_name, group in self.options.items():
for group_name, group in self.option_definitions.items():
for setting_name, setting in group.items():
if setting.cmdline:
argparse_args, argparse_kwargs = setting.to_argparse()
@ -163,42 +183,46 @@ If no options are given, %(prog)s will run in windowed mode.""",
if setting.group not in groups:
if setting.exclusive:
groups[setting.group] = self.argparser.add_argument_group(
setting.group
setting.group,
).add_mutually_exclusive_group()
else:
groups[setting.group] = self.argparser.add_argument_group(setting.group)
# hardcoded exception for files
# hard coded exception for files
if not (setting.group == "runtime" and setting.nargs == "*"):
current_group = groups[setting.group]
current_group.add_argument(*argparse_args, **argparse_kwargs)
def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None:
self.current_group_name = name
if exclusive_group:
self.current_group = self.argparser.add_mutually_exclusive_group()
self.exclusive_group = exclusive_group
add_settings(self)
self.current_group_name = ""
self.current_group = None
self.exclusive_group = False
def exit(self, *args: Any, **kwargs: Any) -> NoReturn:
"""Same as `argparser.ArgParser.exit`"""
self.argparser.exit(*args, **kwargs)
raise SystemExit(99)
def save_file(self, options: OptionValues, filename: pathlib.Path) -> bool:
self.options = options["option_definitions"]
def save_file(self, options: OptionValues | argparse.Namespace, filename: pathlib.Path) -> bool:
if isinstance(options, dict):
self.option_definitions = options["option_definitions"]
elif isinstance(options, argparse.Namespace):
self.option_definitions = options.option_definitions
file_options = self.normalize_options(options, file=True)
del file_options["option_definitions"]
for group in list(file_options.keys()):
if not file_options[group]:
del file_options[group]
if not filename.exists():
filename.parent.mkdir(exist_ok=True, parents=True)
filename.touch()
try:
json_str = json.dumps(file_options, indent=2)
with filename.open(mode="w") as file:
file.write(json_str)
filename.write_text(json_str, encoding="utf-8")
except Exception:
logger.exception("Failed to save config file: %s", filename)
return False
@ -220,37 +244,57 @@ If no options are given, %(prog)s will run in windowed mode.""",
return self.normalize_options(options, file=True)
def normalize_options(
self, raw_options: OptionValues | argparse.Namespace, file: bool = False, cmdline: bool = False
self,
raw_options: OptionValues | argparse.Namespace,
file: bool = False,
cmdline: bool = False,
raw_options_2: OptionValues | argparse.Namespace | None = None,
) -> OptionValues:
"""
Creates an `OptionValues` dictionary with setting definitions taken from `self.option_definitions`
and values taken from `raw_options` and `raw_options_2' if defined.
Values are assigned so if the value is a dictionary mutating it will mutate the original.
"""
options: OptionValues = {}
for group_name, group in self.options.items():
for group_name, group in self.option_definitions.items():
group_options = {}
for setting_name, setting in group.items():
if (setting.cmdline and cmdline) or (setting.file and file):
group_options[setting_name] = self.get_option(raw_options, setting, group_name)
# Ensures the option exists with the default if not already set
group_options[setting_name], _ = self.get_option(raw_options, setting, group_name)
# will override with option from raw_options_2 if it exists
if raw_options_2 is not None:
value, default = self.get_option(raw_options_2, setting, group_name)
if not default:
group_options[setting_name] = value
options[group_name] = group_options
options["option_definitions"] = self.options
options["option_definitions"] = self.option_definitions
return options
def get_option(self, options: OptionValues | argparse.Namespace, setting: Setting, group_name: str) -> Any:
def get_option(
self, options: OptionValues | argparse.Namespace, setting: Setting, group_name: str
) -> tuple[Any, bool]:
"""Helper function to retrieve the the value for a setting and if the value is the default value"""
if isinstance(options, dict):
return options.get(group_name, {}).get(setting.dest, setting.default)
return getattr(options, setting.internal_name)
value = options.get(group_name, {}).get(setting.dest, setting.default)
else:
value = getattr(options, setting.internal_name, setting.default)
return value, value == setting.default
def parse_args(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> OptionValues:
"""Must handle a namespace with both argparse values and file values"""
"""
Creates an `argparse.ArgumentParser` from cmdline settings in `self.option_definitions`.
`args` and `namespace` are passed to `argparse.ArgumentParser.parse_args`
"""
self.create_argparser()
ns = self.argparser.parse_args(args, namespace=namespace)
return self.normalize_options(ns, cmdline=True, file=True)
return self.normalize_options(ns, cmdline=True, file=False)
def parse_options(self, config_paths: ComicTaggerPaths, args: list[str] | None = None) -> OptionValues:
file_options = self.parse_file(config_paths.user_config_dir / "settings.json")
cli_options = self.parse_args(args, namespace=self.get_namespace_for_args(file_options))
def parse_options(self, config_path: pathlib.Path, args: list[str] | None = None) -> OptionValues:
file_options = self.parse_file(config_path)
cli_options = self.parse_args(args)
if "runtime" in cli_options:
# Just in case something weird happens with the commandline options
cli_options["runtime"]["config"] = config_paths
# Normalize a final time for fun
return self.normalize_options(cli_options, file=True, cmdline=True)
final_options = self.normalize_options(file_options, file=True, cmdline=True, raw_options_2=cli_options)
return final_options

View File

@ -10,8 +10,6 @@ from appdirs import AppDirs
from comicapi.comicarchive import MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
OptionValues = dict[str, dict[str, Any]]
class ComicTaggerPaths(AppDirs):
def __init__(self, config_path: pathlib.Path | str | None = None) -> None:

View File

@ -457,7 +457,7 @@ class SettingsWindow(QtWidgets.QDialog):
QtWidgets.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.")
def reset_settings(self) -> None:
self.options = settings.Manager(self.options["option_definitions"]).defaults()
self.options = settings.Manager(definitions=self.options["option_definitions"]).defaults()
self.settings_to_form()
QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.")