13 Commits
0.2.0 ... 0.6.2

Author SHA1 Message Date
ea5be60c63 Add un-committed fix and version bump 2023-02-20 02:07:35 -08:00
391f65c71f Version Bump 2023-02-20 02:01:02 -08:00
ba645eb7c6 Implement qoc fixes
Fix fstring
Add comments explaining execution of normalize_config with defaults arg
Fix not removing file or cmdline settings in persistent groups
Fix a bug in get_namespace when config is a namespace
Add additional tests
2023-02-20 01:59:40 -08:00
69800f01b6 Version Bump 2023-02-19 22:22:45 -08:00
b2eaa12a0e Upgrade pre-commit 2023-02-19 22:21:05 -08:00
fe7c821605 Add a display_name attribute to Setting 2023-02-19 18:39:35 -08:00
d07cf9949b Allow adding settings to existing groups
Calling add_group or add_persistent_group twice will add any new
settings defined.

Raise a ValueError if add_group or add_persistent_group is called during
a call to add_group or add_persistent_group.
2023-02-19 18:07:14 -08:00
41cf2dc7cd Version Bump 2023-01-31 19:35:35 -08:00
577b43c4e8 Fix regression with settings with a '-' 2023-01-31 19:35:10 -08:00
983fe782a3 Version Bump 2023-01-31 19:19:04 -08:00
d2326eadb9 Add support for persistent setting groups
Persistent setting groups allow settings that are not declared to
 survive normalization and other operations that would normally remove
 any unknown keys in a group.

Calling normalize or get_namespace with persistent=False will remove
 unknown keys even from persistent groups.

Currently the only way to retrieve the defaults for a config and
 preserve unknown keys is to manually get the defaults and update each
 existing group with the default values.
2023-01-31 19:18:09 -08:00
c3976c2fb5 Version Bump 2022-12-15 19:00:40 -08:00
38e42bf3f9 Make most functions work off of Config objects and Values/Namespace objects
Used Generic for better typing
Backported BooleanOptionalAction for tests on Python 3.8
Remove merge support in normalize_config
Add test to validate that the example works
2022-12-15 18:53:29 -08:00
6 changed files with 609 additions and 157 deletions

View File

@ -33,7 +33,7 @@ repos:
- id: pyupgrade - id: pyupgrade
args: [--py38-plus] args: [--py38-plus]
- repo: https://github.com/pre-commit/mirrors-autopep8 - repo: https://github.com/pre-commit/mirrors-autopep8
rev: v2.0.0 rev: v2.0.1
hooks: hooks:
- id: autopep8 - id: autopep8
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
@ -42,6 +42,6 @@ repos:
- id: flake8 - id: flake8
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print] additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991 rev: v1.0.1
hooks: hooks:
- id: mypy - id: mypy

View File

@ -16,7 +16,7 @@ pip install settngs
``` ```
A trivial example is included at the bottom of settngs.py with the output below. For a more complete example see [ComicTagger]. A trivial example is included at the bottom of settngs.py with the output below (using bash). For a more complete example see [ComicTagger].
```console ```console
$ python -m settngs $ python -m settngs
Hello world Hello world
@ -24,27 +24,39 @@ $ python -m settngs --hello lordwelch
Hello lordwelch Hello lordwelch
$ python -m settngs --hello lordwelch -s $ python -m settngs --hello lordwelch -s
Hello lordwelch Hello lordwelch
Successfully saved settngs to settngs.json Successfully saved settings to settings.json
$ python -m settngs $ python -m settngs
Hello lordwelch Hello lordwelch
$ python -m settngs -v $ python -m settngs -v
Hello lordwelch Hello lordwelch
merged_namespace.example_verbose=True merged_namespace.values.example_verbose=True
$ python -m settngs -v -s $ python -m settngs -v -s
Hello lordwelch Hello lordwelch
Successfully saved settngs to settngs.json Successfully saved settings to settings.json
merged_namespace.example_verbose=True merged_namespace.values.example_verbose=True
$ python -m settngs $ python -m settngs
Hello lordwelch Hello lordwelch
merged_namespace.example_verbose=True merged_namespace.values.example_verbose=True
$ cat >settings.json << EOF
{
"example": {
"hello": "lordwelch",
"verbose": true
},
"persistent": {
"test": false,
"hello": "world"
}
}
EOF
$ python -m settngs --no-verbose $ python -m settngs --no-verbose
Hello lordwelch Hello lordwelch
$ python -m settngs --no-verbose -s $ python -m settngs --no-verbose -s
Hello lordwelch Hello lordwelch
Successfully saved settngs to settngs.json Successfully saved settings to settings.json
$ python -m settngs --hello world --no-verbose -s $ python -m settngs --hello world --no-verbose -s
Hello world Hello world
Successfully saved settngs to settngs.json Successfully saved settings to settings.json
$ python -m settngs $ python -m settngs
Hello world Hello world
``` ```
@ -55,7 +67,9 @@ settngs.json at the end:
"example": { "example": {
"hello": "world", "hello": "world",
"verbose": false "verbose": false
} },
"persistent": false,
"hello": "world"
} }
``` ```

View File

@ -4,18 +4,78 @@ import argparse
import json import json
import logging import logging
import pathlib import pathlib
import re
import sys
from argparse import Namespace
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import NamedTuple from typing import Generic
from typing import NoReturn from typing import NoReturn
from typing import overload
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union from typing import Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if sys.version_info < (3, 11): # pragma: no cover
from typing_extensions import NamedTuple
else: # pragma: no cover
from typing import NamedTuple
if sys.version_info < (3, 9): # pragma: no cover
def removeprefix(self: str, prefix: str, /) -> str:
if self.startswith(prefix):
return self[len(prefix):]
else:
return self[:]
class BooleanOptionalAction(argparse.Action):
def __init__(
self,
option_strings,
dest,
default=None,
type=None, # noqa: A002
choices=None,
required=False,
help=None, # noqa: A002
metavar=None,
):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
if help is not None and default is not None and default is not argparse.SUPPRESS:
help += ' (default: %(default)s)'
super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar,
)
def __call__(self, parser, namespace, values, option_string=None): # dead: disable
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
else: # pragma: no cover
from argparse import BooleanOptionalAction
removeprefix = str.removeprefix
class Setting: class Setting:
def __init__( def __init__(
@ -33,11 +93,35 @@ class Setting:
metavar: str | None = None, metavar: str | None = None,
dest: str | None = None, dest: str | None = None,
# ComicTagger # ComicTagger
display_name: str = '',
cmdline: bool = True, cmdline: bool = True,
file: bool = True, file: bool = True,
group: str = '', group: str = '',
exclusive: bool = False, exclusive: bool = False,
): ):
"""
Args:
*names: Passed directly to argparse
action: Passed directly to argparse
nargs: Passed directly to argparse
const: Passed directly to argparse
default: Passed directly to argparse
type: Passed directly to argparse
choices: Passed directly to argparse
required: Passed directly to argparse
help: Passed directly to argparse
metavar: Passed directly to argparse, defaults to `dest` uppercased
dest: This is the name used to retrieve the value from a `Config` object as a dictionary
display_name: This is not used by settngs. This is a human-readable name to be used when generating a GUI.
Defaults to `dest`.
cmdline: If this setting can be set via the commandline
file: If this setting can be set via a file
group: The group this option is in.
This is an internal argument and should only be set by settngs
exclusive: If this setting is exclusive to other settings in this group.
This is an internal argument and should only be set by settngs
"""
if not names: if not names:
raise ValueError('names must be specified') raise ValueError('names must be specified')
# We prefix the destination name used by argparse so that there are no conflicts # We prefix the destination name used by argparse so that there are no conflicts
@ -70,6 +154,7 @@ class Setting:
self.argparse_args = args self.argparse_args = args
self.group = group self.group = group
self.exclusive = exclusive self.exclusive = exclusive
self.display_name = display_name or dest
self.argparse_kwargs = { self.argparse_kwargs = {
'action': action, 'action': action,
@ -84,12 +169,17 @@ class Setting:
'dest': self.internal_name if flag else None, 'dest': self.internal_name if flag else None,
} }
def __str__(self) -> str: def __str__(self) -> str: # pragma: no cover
return f'Setting({self.argparse_args}, type={self.type}, file={self.file}, cmdline={self.cmdline}, kwargs={self.argparse_kwargs})' return f'Setting({self.argparse_args}, type={self.type}, file={self.file}, cmdline={self.cmdline}, kwargs={self.argparse_kwargs})'
def __repr__(self) -> str: def __repr__(self) -> str: # pragma: no cover
return self.__str__() return self.__str__()
def __eq__(self, other: object) -> bool:
if not isinstance(other, Setting):
return NotImplemented
return self.__dict__ == other.__dict__
def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]: def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]:
dest_name = None dest_name = None
flag = False flag = False
@ -97,7 +187,7 @@ class Setting:
for n in names: for n in names:
if n.startswith('--'): if n.startswith('--'):
flag = True flag = True
dest_name = n.lstrip('-').replace('-', '_') dest_name = sanitize_name(n)
break break
if n.startswith('-'): if n.startswith('-'):
flag = True flag = True
@ -106,6 +196,8 @@ class Setting:
dest_name = names[0] dest_name = names[0]
if dest: if dest:
dest_name = dest dest_name = dest
if not dest_name.isidentifier():
raise Exception(f'Cannot use {dest_name} in a namespace')
internal_name = f'{prefix}_{dest_name}'.lstrip('_') internal_name = f'{prefix}_{dest_name}'.lstrip('_')
return internal_name, dest_name, flag return internal_name, dest_name, flag
@ -117,20 +209,32 @@ class Setting:
return self.argparse_args, self.filter_argparse_kwargs() return self.argparse_args, self.filter_argparse_kwargs()
class Group(NamedTuple):
persistent: bool
v: dict[str, Setting]
Values = Dict[str, Dict[str, Any]] Values = Dict[str, Dict[str, Any]]
Definitions = Dict[str, Dict[str, Setting]] Definitions = Dict[str, Group]
T = TypeVar('T', Values, Namespace)
class Config(NamedTuple): class Config(NamedTuple, Generic[T]):
values: Values values: T
definitions: Definitions definitions: Definitions
if TYPE_CHECKING: if TYPE_CHECKING:
ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser]
ns = Namespace | Config[T] | None
def get_option(options: Values | argparse.Namespace, setting: Setting) -> tuple[Any, bool]: def sanitize_name(name: str) -> str:
return re.sub('[' + re.escape(' -_,.!@#$%^&*(){}[]\',."<>;:') + ']+', '_', name).strip('_')
def get_option(options: Values | Namespace, setting: Setting) -> tuple[Any, bool]:
""" """
Helper function to retrieve the value for a setting and if the value is the default value Helper function to retrieve the value for a setting and if the value is the default value
@ -145,14 +249,37 @@ def get_option(options: Values | argparse.Namespace, setting: Setting) -> tuple[
return value, value == setting.default return value, value == setting.default
def get_options(options: Config[T], group: str) -> dict[str, Any]:
"""
Helper function to retrieve all of the values for a group. Only to be used on persistent groups.
Args:
options: Dictionary or namespace of options
group: The name of the group to retrieve
"""
if isinstance(options[0], dict):
values = options[0].get(group, {}).copy()
else:
internal_names = {x.internal_name: x for x in options[1][group].v.values()}
values = {}
v = vars(options[0])
for name, value in v.items():
if name.startswith(f'{group}_'):
if name in internal_names:
values[internal_names[name].dest] = value
else:
values[removeprefix(name, f'{group}_')] = value
return values
def normalize_config( def normalize_config(
raw_options: Values | argparse.Namespace, config: Config[T],
definitions: Definitions,
file: bool = False, file: bool = False,
cmdline: bool = False, cmdline: bool = False,
defaults: bool = True, defaults: bool = True,
raw_options_2: Values | argparse.Namespace | None = None, persistent: bool = True,
) -> Values: ) -> Config[Values]:
""" """
Creates an `OptionValues` dictionary with setting definitions taken from `self.definitions` Creates an `OptionValues` dictionary with setting definitions taken from `self.definitions`
and values taken from `raw_options` and `raw_options_2' if defined. and values taken from `raw_options` and `raw_options_2' if defined.
@ -164,29 +291,33 @@ def normalize_config(
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
defaults: Include default values in the returned dict defaults: Include default values in the returned dict
raw_options_2: If set, merges non-default values into the returned dict persistent: Include unknown keys in persistent groups
""" """
options: Values = {}
normalized: Values = {}
options, definitions = config
for group_name, group in definitions.items(): for group_name, group in definitions.items():
group_options = {} group_options = {}
for setting_name, setting in group.items(): if group.persistent and persistent:
group_options = get_options(config, group_name)
for setting_name, setting in group.v.items():
if (setting.cmdline and cmdline) or (setting.file and file): if (setting.cmdline and cmdline) or (setting.file and file):
# Ensures the option exists with the default if not already set # Ensures the option exists with the default if not already set
value, default = get_option(raw_options, setting) value, default = get_option(options, setting)
if not default or default and defaults: if not default or (default and defaults):
# User has set a custom value or has requested the default value
group_options[setting_name] = value group_options[setting_name] = value
elif setting_name in group_options:
# will override with option from raw_options_2 if it is not the default # defaults have been requested to be removed
if raw_options_2 is not None: del group_options[setting_name]
value, default = get_option(raw_options_2, setting) elif setting_name in group_options:
if not default: # Setting type (file or cmdline) has not been requested and should be removed for persistent groups
group_options[setting_name] = value del group_options[setting_name]
options[group_name] = group_options normalized[group_name] = group_options
# options["definitions"] = definitions return Config(normalized, definitions)
return options
def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Values, bool]: def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Config[Values], bool]:
""" """
Helper function to read options from a json dictionary from a file Helper function to read options from a json dictionary from a file
Args: Args:
@ -205,16 +336,16 @@ def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Values
success = False success = False
else: else:
logger.info('No config file found') logger.info('No config file found')
success = False success = True
return (normalize_config(options, definitions, file=True), success) return (normalize_config(Config(options, definitions), file=True), success)
def clean_config( def clean_config(
options: Values | argparse.Namespace, definitions: Definitions, file: bool = False, cmdline: bool = False, config: Config[T], file: bool = False, cmdline: bool = False,
) -> Values: ) -> Values:
""" """
Normalizes options and then cleans up empty groups and removes 'definitions' Normalizes options and then cleans up empty groups
Args: Args:
options: options:
file: file:
@ -224,42 +355,62 @@ def clean_config(
""" """
clean_options = normalize_config(options, definitions, file=file, cmdline=cmdline) clean_options, definitions = normalize_config(config, file=file, cmdline=cmdline)
for group in list(clean_options.keys()): for group in list(clean_options.keys()):
if not clean_options[group]: if not clean_options[group]:
del clean_options[group] del clean_options[group]
return clean_options return clean_options
def defaults(definitions: Definitions) -> Values: def defaults(definitions: Definitions) -> Config[Values]:
return normalize_config({}, definitions, file=True, cmdline=True) return normalize_config(Config(Namespace(), definitions), file=True, cmdline=True)
def get_namespace(options: Values, definitions: Definitions, defaults: bool = True) -> argparse.Namespace: def get_namespace(config: Config[T], defaults: bool = True, persistent: bool = True) -> Config[Namespace]:
""" """
Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" Returns an Namespace object with options in the form "{group_name}_{setting_name}"
`options` should already be normalized. `options` should already be normalized.
Throws an exception if the internal_name is duplicated Throws an exception if the internal_name is duplicated
Args: Args:
options: Normalized options to turn into a Namespace options: Normalized options to turn into a Namespace
defaults: Include default values in the returned dict defaults: Include default values in the returned dict
persistent: Include unknown keys in persistent groups
""" """
options = normalize_config(options, definitions, file=True, cmdline=True)
namespace = argparse.Namespace()
for group_name, group in definitions.items():
for setting_name, setting in group.items():
if hasattr(namespace, setting.internal_name):
raise Exception(f'Duplicate internal name: {setting.internal_name}')
value, default = get_option(options, setting)
if not default or default and defaults: if isinstance(config.values, Namespace):
setattr(namespace, setting.internal_name, value) options, definitions = normalize_config(config, True, True, defaults=defaults, persistent=persistent)
return namespace else:
options, definitions = config
namespace = Namespace()
for group_name, group in definitions.items():
if group.persistent and persistent:
group_options = get_options(config, group_name)
for name, value in group_options.items():
if name in group.v:
internal_name, default = group.v[name].internal_name, group.v[name].default == value
else:
internal_name, default = f'{group_name}_' + sanitize_name(name), None
if hasattr(namespace, internal_name):
raise Exception(f'Duplicate internal name: {internal_name}')
if not default or default and defaults:
setattr(namespace, internal_name, value)
else:
for setting_name, setting in group.v.items():
if hasattr(namespace, setting.internal_name):
raise Exception(f'Duplicate internal name: {setting.internal_name}')
value, default = get_option(options, setting)
if not default or default and defaults:
setattr(namespace, setting.internal_name, value)
return Config(namespace, definitions)
def save_file( def save_file(
options: Values | argparse.Namespace, definitions: Definitions, filename: pathlib.Path, config: Config[T], filename: pathlib.Path,
) -> bool: ) -> bool:
""" """
Helper function to save options from a json dictionary to a file Helper function to save options from a json dictionary to a file
@ -267,14 +418,14 @@ def save_file(
options: The options to save to a json dictionary options: The options to save to a json dictionary
filename: A pathlib.Path object to save the json dictionary to filename: A pathlib.Path object to save the json dictionary to
""" """
file_options = clean_config(options, definitions, file=True) file_options = clean_config(config, file=True)
if not filename.exists(): if not filename.exists():
filename.parent.mkdir(exist_ok=True, parents=True) filename.parent.mkdir(exist_ok=True, parents=True)
filename.touch() filename.touch()
try: try:
json_str = json.dumps(file_options, indent=2) json_str = json.dumps(file_options, indent=2)
filename.write_text(json_str, encoding='utf-8') filename.write_text(json_str + '\n', encoding='utf-8')
except Exception: except Exception:
logger.exception('Failed to save config file: %s', filename) logger.exception('Failed to save config file: %s', filename)
return False return False
@ -288,7 +439,7 @@ def create_argparser(definitions: Definitions, description: str, epilog: str) ->
description=description, epilog=epilog, formatter_class=argparse.RawTextHelpFormatter, description=description, epilog=epilog, formatter_class=argparse.RawTextHelpFormatter,
) )
for group_name, group in definitions.items(): for group_name, group in definitions.items():
for setting_name, setting in group.items(): for setting_name, setting in group.v.items():
if setting.cmdline: if setting.cmdline:
argparse_args, argparse_kwargs = setting.to_argparse() argparse_args, argparse_kwargs = setting.to_argparse()
current_group: ArgParser = argparser current_group: ArgParser = argparser
@ -313,8 +464,8 @@ def parse_cmdline(
description: str, description: str,
epilog: str, epilog: str,
args: list[str] | None = None, args: list[str] | None = None,
namespace: argparse.Namespace | None = None, config: ns[T] = None,
) -> Values: ) -> Config[Values]:
""" """
Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`. Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`.
`args` and `namespace` are passed to `argparse.ArgumentParser.parse_args` `args` and `namespace` are passed to `argparse.ArgumentParser.parse_args`
@ -323,10 +474,18 @@ def parse_cmdline(
args: Passed to argparse.ArgumentParser.parse args: Passed to argparse.ArgumentParser.parse
namespace: Passed to argparse.ArgumentParser.parse namespace: Passed to argparse.ArgumentParser.parse
""" """
namespace = None
if isinstance(config, Config):
if isinstance(config.values, Namespace):
namespace = config.values
else:
namespace = get_namespace(config, defaults=False)[0]
else:
namespace = config
argparser = create_argparser(definitions, description, epilog) argparser = create_argparser(definitions, description, epilog)
ns = argparser.parse_args(args, namespace=namespace) ns = argparser.parse_args(args, namespace=namespace)
return normalize_config(ns, definitions=definitions, cmdline=True, file=True) return normalize_config(Config(ns, definitions), cmdline=True, file=True)
def parse_config( def parse_config(
@ -335,26 +494,29 @@ def parse_config(
epilog: str, epilog: str,
config_path: pathlib.Path, config_path: pathlib.Path,
args: list[str] | None = None, args: list[str] | None = None,
) -> tuple[Values, bool]: ) -> tuple[Config[Values], bool]:
file_options, success = parse_file(definitions, config_path) file_options, success = parse_file(definitions, config_path)
cmdline_options = parse_cmdline( cmdline_options = parse_cmdline(
definitions, description, epilog, args, get_namespace(file_options, definitions, defaults=False), definitions, description, epilog, args, get_namespace(file_options, defaults=False),
) )
final_options = normalize_config(cmdline_options, definitions=definitions, file=True, cmdline=True) final_options = normalize_config(cmdline_options, file=True, cmdline=True)
return (final_options, success) return (final_options, success)
class Manager: class Manager:
"""docstring for Manager""" """docstring for Manager"""
def __init__(self, description: str = '', epilog: str = '', definitions: Definitions | None = None): def __init__(self, description: str = '', epilog: str = '', definitions: Definitions | Config[T] | None = None):
# This one is never used, it just makes MyPy happy # This one is never used, it just makes MyPy happy
self.argparser = argparse.ArgumentParser(description=description, epilog=epilog) self.argparser = argparse.ArgumentParser(description=description, epilog=epilog)
self.description = description self.description = description
self.epilog = epilog self.epilog = epilog
self.definitions: Definitions = defaultdict(lambda: dict(), definitions or {}) if isinstance(definitions, Config):
self.definitions = definitions.definitions
else:
self.definitions = defaultdict(lambda: Group(False, {}), definitions or {})
self.exclusive_group = False self.exclusive_group = False
self.current_group_name = '' self.current_group_name = ''
@ -364,21 +526,46 @@ class Manager:
def add_setting(self, *args: Any, **kwargs: Any) -> None: def add_setting(self, *args: Any, **kwargs: Any) -> None:
"""Takes passes all arguments through to `Setting`, `group` and `exclusive` are already set""" """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) setting = Setting(*args, **kwargs, group=self.current_group_name, exclusive=self.exclusive_group)
self.definitions[self.current_group_name][setting.dest] = setting self.definitions[self.current_group_name].v[setting.dest] = setting
def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None: def add_group(self, name: str, group: Callable[[Manager], None], exclusive_group: bool = False) -> None:
""" """
The primary way to add define options on this class The primary way to add define options on this class.
Args: Args:
name: The name of the group to define name: The name of the group to define
add_settings: A function that registers individual options using :meth:`add_setting` group: A function that registers individual options using :meth:`add_setting`
exclusive_group: If this group is an argparse exclusive group exclusive_group: If this group is an argparse exclusive group
""" """
if self.current_group_name != '':
raise ValueError('Sub groups are not allowed')
self.current_group_name = name self.current_group_name = name
self.exclusive_group = exclusive_group self.exclusive_group = exclusive_group
add_settings(self) group(self)
self.current_group_name = ''
self.exclusive_group = False
def add_persistent_group(self, name: str, group: Callable[[Manager], None], exclusive_group: bool = False) -> None:
"""
The primary way to add define options on this class.
This group allows existing values to persist even if there is no corresponding setting defined for it.
Args:
name: The name of the group to define
group: A function that registers individual options using :meth:`add_setting`
exclusive_group: If this group is an argparse exclusive group
"""
if self.current_group_name != '':
raise ValueError('Sub groups are not allowed')
self.current_group_name = name
self.exclusive_group = exclusive_group
if self.current_group_name in self.definitions:
if not self.definitions[self.current_group_name].persistent:
raise ValueError('Group already existis and is not persistent')
else:
self.definitions[self.current_group_name] = Group(True, {})
group(self)
self.current_group_name = '' self.current_group_name = ''
self.exclusive_group = False self.exclusive_group = False
@ -387,49 +574,64 @@ class Manager:
self.argparser.exit(*args, **kwargs) self.argparser.exit(*args, **kwargs)
raise SystemExit(99) raise SystemExit(99)
def defaults(self) -> Values: def defaults(self) -> Config[Values]:
return defaults(self.definitions) return defaults(self.definitions)
def clean_config( def clean_config(
self, options: Values | argparse.Namespace, file: bool = False, cmdline: bool = False, self, options: T | Config[T], file: bool = False, cmdline: bool = False,
) -> Values: ) -> Values:
return clean_config(options=options, definitions=self.definitions, file=file, cmdline=cmdline) if isinstance(options, Config):
config = options
else:
config = Config(options, self.definitions)
return clean_config(config, file=file, cmdline=cmdline)
def normalize_config( def normalize_config(
self, self,
raw_options: Values | argparse.Namespace, options: T | Config[T],
file: bool = False, file: bool = False,
cmdline: bool = False, cmdline: bool = False,
defaults: bool = True, defaults: bool = True,
raw_options_2: Values | argparse.Namespace | None = None, ) -> Config[Values]:
) -> Config: if isinstance(options, Config):
return Config( config = options
normalize_config( else:
raw_options=raw_options, config = Config(options, self.definitions)
definitions=self.definitions, return normalize_config(
file=file, config=config,
cmdline=cmdline, file=file,
defaults=defaults, cmdline=cmdline,
raw_options_2=raw_options_2, defaults=defaults,
),
self.definitions,
) )
def get_namespace(self, options: Values, defaults: bool = True) -> argparse.Namespace: @overload
return get_namespace(options=options, definitions=self.definitions, defaults=defaults) def get_namespace(self, options: Values, defaults: bool = True) -> Namespace:
...
def parse_file(self, filename: pathlib.Path) -> tuple[Values, bool]: @overload
def get_namespace(self, options: Config[Values], defaults: bool = True) -> Config[Namespace]:
...
def get_namespace(self, options: Values | Config[Values], defaults: bool = True) -> Config[Namespace] | Namespace:
if isinstance(options, Config):
self.definitions = options[1]
return get_namespace(options, defaults=defaults)
else:
return get_namespace(Config(options, self.definitions), defaults=defaults)
def parse_file(self, filename: pathlib.Path) -> tuple[Config[Values], bool]:
return parse_file(filename=filename, definitions=self.definitions) return parse_file(filename=filename, definitions=self.definitions)
def save_file(self, options: Values | argparse.Namespace, filename: pathlib.Path) -> bool: def save_file(self, options: T | Config[T], filename: pathlib.Path) -> bool:
return save_file(options=options, definitions=self.definitions, filename=filename) if isinstance(options, Config):
return save_file(options, filename=filename)
return save_file(Config(options, self.definitions), filename=filename)
def parse_cmdline(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> Values: def parse_cmdline(self, args: list[str] | None = None, namespace: ns[T] = None) -> Config[Values]:
return parse_cmdline(self.definitions, self.description, self.epilog, args, namespace) return parse_cmdline(self.definitions, self.description, self.epilog, args, namespace)
def parse_config(self, config_path: pathlib.Path, args: list[str] | None = None) -> tuple[Config, bool]: def parse_config(self, config_path: pathlib.Path, args: list[str] | None = None) -> tuple[Config[Values], bool]:
values, success = parse_config(self.definitions, self.description, self.epilog, config_path, args) return parse_config(self.definitions, self.description, self.epilog, config_path, args)
return (Config(values, self.definitions), success)
def example(manager: Manager) -> None: def example(manager: Manager) -> None:
@ -446,27 +648,40 @@ def example(manager: Manager) -> None:
manager.add_setting( manager.add_setting(
'--verbose', '-v', '--verbose', '-v',
default=False, default=False,
action=argparse.BooleanOptionalAction, action=BooleanOptionalAction, # Added in Python 3.9
) )
if __name__ == '__main__': def persistent(manager: Manager) -> None:
manager.add_setting(
'--test', '-t',
default=False,
action=BooleanOptionalAction, # Added in Python 3.9
)
def _main(args: list[str] | None = None) -> None:
settings_path = pathlib.Path('./settings.json') settings_path = pathlib.Path('./settings.json')
manager = Manager(description='This is an example', epilog='goodbye!') manager = Manager(description='This is an example', epilog='goodbye!')
manager.add_group('example', example) manager.add_group('example', example)
manager.add_persistent_group('persistent', persistent)
file_config, success = manager.parse_file(settings_path) file_config, success = manager.parse_file(settings_path)
file_namespace = manager.get_namespace(file_config) file_namespace = manager.get_namespace(file_config)
merged_config = manager.parse_cmdline(namespace=file_namespace) merged_config = manager.parse_cmdline(args=args, namespace=file_namespace)
merged_namespace = manager.get_namespace(merged_config) merged_namespace = manager.get_namespace(merged_config)
print(f'Hello {merged_config["example"]["hello"]}') # noqa: T201 print(f'Hello {merged_config.values["example"]["hello"]}') # noqa: T201
if merged_namespace.example_save: if merged_namespace.values.example_save:
if manager.save_file(merged_config, settings_path): if manager.save_file(merged_config, settings_path):
print(f'Successfully saved settings to {settings_path}') # noqa: T201 print(f'Successfully saved settings to {settings_path}') # noqa: T201
else: else:
print(f'Failed saving settings to a {settings_path}') # noqa: T201 print(f'Failed saving settings to a {settings_path}') # noqa: T201
if merged_namespace.example_verbose: if merged_namespace.values.example_verbose:
print(f'{merged_namespace.example_verbose=}') # noqa: T201 print(f'{merged_namespace.values.example_verbose=}') # noqa: T201
if __name__ == '__main__':
_main()

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
name = settngs name = settngs
version = 0.2.0 version = 0.6.2
description = A library for managing settings description = A library for managing settings
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown
@ -18,6 +18,8 @@ classifiers =
[options] [options]
py_modules = settngs py_modules = settngs
install_requires =
typing-extensions;python_version < '3.11'
python_requires = >=3.8 python_requires = >=3.8
[options.packages.find] [options.packages.find]

View File

@ -1,7 +1,108 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
example: list[tuple[list[str], str, str]] = [
(
[],
'Hello world\n',
'',
),
(
['--hello', 'lordwelch'],
'Hello lordwelch\n',
'',
),
(
['--hello', 'lordwelch', '-s'],
'Hello lordwelch\nSuccessfully saved settings to settings.json\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n',
),
(
[],
'Hello lordwelch\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n',
),
(
['-v'],
'Hello lordwelch\nmerged_namespace.values.example_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n',
),
(
['-v', '-s'],
'Hello lordwelch\nSuccessfully saved settings to settings.json\nmerged_namespace.values.example_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false\n }\n}\n',
),
(
[],
'Hello lordwelch\nmerged_namespace.values.example_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false\n }\n}\n',
),
(
['manual settings.json'],
'Hello lordwelch\nmerged_namespace.values.example_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
),
(
['--no-verbose', '-t'],
'Hello lordwelch\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
),
(
['--no-verbose', '-s', '-t'],
'Hello lordwelch\nSuccessfully saved settings to settings.json\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": true,\n "hello": "world"\n }\n}\n',
),
(
['--hello', 'world', '--no-verbose', '--no-test', '-s'],
'Hello world\nSuccessfully saved settings to settings.json\n',
'{\n "example": {\n "hello": "world",\n "verbose": false\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
),
(
[],
'Hello world\n',
'{\n "example": {\n "hello": "world",\n "verbose": false\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
),
]
success = [ success = [
(
(
('--test-setting',),
dict(
group='tst',
),
), # Equivalent to Setting("--test-setting", group="tst")
{
'action': None,
'choices': None,
'cmdline': True,
'const': None,
'default': None,
'dest': 'test_setting', # dest is calculated by Setting and is not used by argparse
'display_name': 'test_setting', # defaults to dest
'exclusive': False,
'file': True,
'group': 'tst',
'help': None,
'internal_name': 'tst_test_setting', # Should almost always be "{group}_{dest}"
'metavar': 'TEST_SETTING', # Set manually so argparse doesn't use TST_TEST
'nargs': None,
'required': None,
'type': None,
'argparse_args': ('--test-setting',), # *args actually sent to argparse
'argparse_kwargs': {
'action': None,
'choices': None,
'const': None,
'default': None,
'dest': 'tst_test_setting',
'help': None,
'metavar': 'TEST_SETTING',
'nargs': None,
'required': None,
'type': None,
}, # Non-None **kwargs sent to argparse
},
),
( (
( (
('--test',), ('--test',),
@ -9,7 +110,7 @@ success = [
group='tst', group='tst',
dest='testing', dest='testing',
), ),
), # Equivalent to Setting("--test", group="tst") ), # Equivalent to Setting("--test", group="tst", dest="testing")
{ {
'action': None, 'action': None,
'choices': None, 'choices': None,
@ -17,6 +118,7 @@ success = [
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'testing', # dest is calculated by Setting and is not used by argparse 'dest': 'testing', # dest is calculated by Setting and is not used by argparse
'display_name': 'testing', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'group': 'tst', 'group': 'tst',
@ -55,6 +157,7 @@ success = [
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'test', # dest is calculated by Setting and is not used by argparse 'dest': 'test', # dest is calculated by Setting and is not used by argparse
'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'group': 'tst', 'group': 'tst',
@ -94,6 +197,7 @@ success = [
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'test', # dest is calculated by Setting and is not used by argparse 'dest': 'test', # dest is calculated by Setting and is not used by argparse
'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'group': 'tst', 'group': 'tst',
@ -132,6 +236,7 @@ success = [
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'test', 'dest': 'test',
'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'group': 'tst', 'group': 'tst',
@ -170,6 +275,7 @@ success = [
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'test', 'dest': 'test',
'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'group': 'tst', 'group': 'tst',
@ -206,6 +312,7 @@ success = [
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'test', 'dest': 'test',
'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'group': '', 'group': '',

View File

@ -2,10 +2,13 @@ from __future__ import annotations
import argparse import argparse
import json import json
from collections import defaultdict
import pytest import pytest
import settngs import settngs
from settngs import Group
from testing.settngs import example
from testing.settngs import failure from testing.settngs import failure
from testing.settngs import success from testing.settngs import success
@ -22,6 +25,19 @@ def test_settngs_manager():
assert manager is not None and defaults is not None assert manager is not None and defaults is not None
def test_settngs_manager_config():
manager = settngs.Manager(
definitions=settngs.Config[settngs.Namespace](
settngs.Namespace(),
{'tst': Group(False, {'test': settngs.Setting('--test', default='hello', group='tst', exclusive=False)})},
),
)
defaults = manager.defaults()
assert manager is not None and defaults is not None
assert defaults.values['tst']['test'] == 'hello'
@pytest.mark.parametrize('arguments, expected', success) @pytest.mark.parametrize('arguments, expected', success)
def test_setting_success(arguments, expected): def test_setting_success(arguments, expected):
assert vars(settngs.Setting(*arguments[0], **arguments[1])) == expected assert vars(settngs.Setting(*arguments[0], **arguments[1])) == expected
@ -39,25 +55,31 @@ def test_add_setting(settngs_manager):
def test_get_defaults(settngs_manager): def test_get_defaults(settngs_manager):
settngs_manager.add_setting('--test', default='hello') settngs_manager.add_setting('--test', default='hello')
defaults = settngs_manager.defaults() defaults, _ = settngs_manager.defaults()
assert defaults['']['test'] == 'hello' assert defaults['']['test'] == 'hello'
def test_get_namespace(settngs_manager): def test_get_defaults_namespace(settngs_manager):
settngs_manager.add_setting('--test', default='hello') settngs_manager.add_setting('--test', default='hello')
defaults = settngs_manager.get_namespace(settngs_manager.defaults()) defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults())
assert defaults.test == 'hello' assert defaults.test == 'hello'
def test_get_namespace_with_namespace(settngs_manager):
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.get_namespace(argparse.Namespace(test='success'))
assert defaults.test == 'success'
def test_get_defaults_group(settngs_manager): def test_get_defaults_group(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults = settngs_manager.defaults() defaults, _ = settngs_manager.defaults()
assert defaults['tst']['test'] == 'hello' assert defaults['tst']['test'] == 'hello'
def test_get_namespace_group(settngs_manager): def test_get_namespace_group(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults = settngs_manager.get_namespace(settngs_manager.defaults()) defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults())
assert defaults.tst_test == 'hello' assert defaults.tst_test == 'hello'
@ -65,8 +87,22 @@ def test_cmdline_only(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', file=False)) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', file=False))
settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', cmdline=False)) settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', cmdline=False))
file_normalized, _ = settngs_manager.normalize_config({}, file=True) file_normalized, _ = settngs_manager.normalize_config(settngs_manager.defaults(), file=True)
cmdline_normalized, _ = settngs_manager.normalize_config({}, cmdline=True) cmdline_normalized, _ = settngs_manager.normalize_config(settngs_manager.defaults(), cmdline=True)
assert 'test' in cmdline_normalized['tst']
assert 'test2' not in cmdline_normalized['tst2']
assert 'test' not in file_normalized['tst']
assert 'test2' in file_normalized['tst2']
def test_cmdline_only_persistent_group(settngs_manager):
settngs_manager.add_persistent_group('tst', lambda parser: parser.add_setting('--test', default='hello', file=False))
settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', cmdline=False))
file_normalized, _ = settngs_manager.normalize_config(settngs_manager.defaults(), file=True)
cmdline_normalized, _ = settngs_manager.normalize_config(settngs_manager.defaults(), cmdline=True)
assert 'test' in cmdline_normalized['tst'] assert 'test' in cmdline_normalized['tst']
assert 'test2' not in cmdline_normalized['tst2'] assert 'test2' not in cmdline_normalized['tst2']
@ -77,31 +113,41 @@ def test_cmdline_only(settngs_manager):
def test_normalize(settngs_manager): def test_normalize(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
settngs_manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
defaults = settngs_manager.defaults() defaults = settngs_manager.defaults()
defaults['test'] = 'fail' # Not defined in settngs_manager defaults.values['test'] = 'fail' # Not defined in settngs_manager, should be removed
defaults.values['persistent']['hello'] = 'success' # Not defined in settngs_manager, should stay
defaults_namespace = settngs_manager.get_namespace(defaults) defaults_namespace = settngs_manager.get_namespace(settngs_manager.defaults())
defaults_namespace.test = 'fail' defaults_namespace.values.test = 'fail' # Not defined in settngs_manager, should be removed
defaults_namespace.values.persistent_hello = 'success' # Not defined in settngs_manager, should stay
normalized, _ = settngs_manager.normalize_config(defaults, file=True) normalized, _ = settngs_manager.normalize_config(defaults, file=True)
normalized_namespace = settngs_manager.get_namespace(settngs_manager.normalize_config(defaults, file=True)[0]) normalized_from_namespace = settngs_manager.normalize_config(defaults_namespace, file=True)
normalized_namespace, _ = settngs_manager.get_namespace(normalized_from_namespace)
assert 'test' not in normalized assert 'test' not in normalized
assert 'tst' in normalized assert 'tst' in normalized
assert 'test' in normalized['tst'] assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'hello' assert normalized['tst']['test'] == 'hello'
assert normalized['persistent']['hello'] == 'success'
assert normalized['persistent']['world'] == 'world'
assert not hasattr(normalized_namespace, 'test') assert not hasattr(normalized_namespace, 'test')
assert hasattr(normalized_namespace, 'tst_test') assert hasattr(normalized_namespace, 'tst_test')
assert normalized_namespace.tst_test == 'hello' assert normalized_namespace.tst_test == 'hello'
assert normalized_namespace.persistent_hello == 'success'
assert normalized_namespace.persistent_world == 'world'
def test_clean_config(settngs_manager): def test_clean_config(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False))
settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', file=False)) settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', file=False))
normalized = settngs_manager.defaults() settngs_manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
normalized, _ = settngs_manager.defaults()
normalized['tst']['test'] = 'success' normalized['tst']['test'] = 'success'
normalized['persistent']['hello'] = 'success'
normalized['fail'] = 'fail' normalized['fail'] = 'fail'
cleaned = settngs_manager.clean_config(normalized, file=True) cleaned = settngs_manager.clean_config(normalized, file=True)
@ -109,27 +155,51 @@ def test_clean_config(settngs_manager):
assert 'fail' not in cleaned assert 'fail' not in cleaned
assert 'tst2' not in cleaned assert 'tst2' not in cleaned
assert cleaned['tst']['test'] == 'success' assert cleaned['tst']['test'] == 'success'
assert cleaned['persistent']['hello'] == 'success'
def test_parse_cmdline(settngs_manager, tmp_path): def test_parse_cmdline(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=True)) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=True))
normalized = settngs_manager.parse_cmdline(['--test', 'success']) normalized, _ = settngs_manager.parse_cmdline(['--test', 'success'])
assert 'test' in normalized['tst'] assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'success' assert normalized['tst']['test'] == 'success'
namespaces = (
lambda definitions: settngs.Config({'tst': {'test': 'fail', 'test2': 'success'}}, definitions),
lambda definitions: settngs.Config(argparse.Namespace(tst_test='fail', tst_test2='success'), definitions),
lambda definitions: argparse.Namespace(tst_test='fail', tst_test2='success'),
)
@pytest.mark.parametrize('ns', namespaces)
def test_parse_cmdline_with_namespace(settngs_manager, ns):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=True))
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test2', default='fail', cmdline=True))
normalized, _ = settngs_manager.parse_cmdline(
['--test', 'success'], namespace=ns(settngs_manager.definitions),
)
assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'success'
assert normalized['tst']['test2'] == 'success'
def test_parse_file(settngs_manager, tmp_path): def test_parse_file(settngs_manager, tmp_path):
settngs_file = tmp_path / 'settngs.json' settngs_file = tmp_path / 'settngs.json'
settngs_file.write_text(json.dumps({'tst': {'test': 'success'}})) settngs_file.write_text(json.dumps({'tst': {'test': 'success'}, 'persistent': {'hello': 'success'}}))
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False))
settngs_manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
normalized, success = settngs_manager.parse_file(settngs_file) normalized, success = settngs_manager.parse_file(settngs_file)
assert success assert success
assert 'test' in normalized['tst'] assert 'test' in normalized[0]['tst']
assert normalized['tst']['test'] == 'success' assert normalized[0]['tst']['test'] == 'success'
assert normalized[0]['persistent']['hello'] == 'success'
def test_parse_non_existent_file(settngs_manager, tmp_path): def test_parse_non_existent_file(settngs_manager, tmp_path):
@ -138,9 +208,9 @@ def test_parse_non_existent_file(settngs_manager, tmp_path):
normalized, success = settngs_manager.parse_file(settngs_file) normalized, success = settngs_manager.parse_file(settngs_file)
assert not success assert success
assert 'test' in normalized['tst'] assert 'test' in normalized[0]['tst']
assert normalized['tst']['test'] == 'hello' assert normalized[0]['tst']['test'] == 'hello'
def test_parse_corrupt_file(settngs_manager, tmp_path): def test_parse_corrupt_file(settngs_manager, tmp_path):
@ -151,54 +221,44 @@ def test_parse_corrupt_file(settngs_manager, tmp_path):
normalized, success = settngs_manager.parse_file(settngs_file) normalized, success = settngs_manager.parse_file(settngs_file)
assert not success assert not success
assert 'test' in normalized['tst'] assert 'test' in normalized[0]['tst']
assert normalized['tst']['test'] == 'hello' assert normalized[0]['tst']['test'] == 'hello'
def test_save_file(settngs_manager, tmp_path): def test_save_file(settngs_manager, tmp_path):
settngs_file = tmp_path / 'settngs.json' settngs_file = tmp_path / 'settngs.json'
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False))
normalized = settngs_manager.defaults() settngs_manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
normalized, _ = settngs_manager.defaults()
normalized['tst']['test'] = 'success' normalized['tst']['test'] = 'success'
normalized['persistent']['hello'] = 'success'
success = settngs_manager.save_file(normalized, settngs_file) success = settngs_manager.save_file(normalized, settngs_file)
normalized, success_r = settngs_manager.parse_file(settngs_file) normalized_r, success_r = settngs_manager.parse_file(settngs_file)
assert success and success_r assert success and success_r
assert 'test' in normalized['tst'] assert 'test' in normalized_r[0]['tst']
assert normalized['tst']['test'] == 'success' assert normalized_r[0]['tst']['test'] == 'success'
assert normalized_r[0]['persistent']['hello'] == 'success'
def test_save_file_not_seriazable(settngs_manager, tmp_path): def test_save_file_not_seriazable(settngs_manager, tmp_path):
settngs_file = tmp_path / 'settngs.json' settngs_file = tmp_path / 'settngs.json'
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False))
normalized = settngs_manager.defaults() normalized, _ = settngs_manager.defaults()
normalized['tst']['test'] = {'fail'} # Sets are not serializabl normalized['tst']['test'] = {'fail'} # Sets are not serializabl
success = settngs_manager.save_file(normalized, settngs_file) success = settngs_manager.save_file(normalized, settngs_file)
normalized, success_r = settngs_manager.parse_file(settngs_file) normalized_r, success_r = settngs_manager.parse_file(settngs_file)
# normalized_r will be the default settings
assert not (success and success_r) assert not success
assert not success_r
assert 'test' in normalized['tst'] assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'hello' assert normalized['tst']['test'] == {'fail'}
assert 'test' in normalized_r[0]['tst']
@pytest.mark.parametrize( assert normalized_r[0]['tst']['test'] == 'hello'
'raw, raw2, expected',
[
({'tst': {'test': 'fail'}}, argparse.Namespace(tst_test='success'), 'success'),
# hello is default so is not used in raw_options_2
({'tst': {'test': 'success'}}, argparse.Namespace(tst_test='hello'), 'success'),
(argparse.Namespace(tst_test='fail'), {'tst': {'test': 'success'}}, 'success'),
(argparse.Namespace(tst_test='success'), {'tst': {'test': 'hello'}}, 'success'),
],
)
def test_normalize_merge(raw, raw2, expected, settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
normalized, _ = settngs_manager.normalize_config(raw, file=True, raw_options_2=raw2)
assert normalized['tst']['test'] == expected
def test_cli_set(settngs_manager, tmp_path): def test_cli_set(settngs_manager, tmp_path):
@ -251,3 +311,57 @@ def test_cli_explicit_default(settngs_manager, tmp_path):
assert success assert success
assert 'test' in normalized['tst'] assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'success' assert normalized['tst']['test'] == 'success'
def test_adding_to_existing_group(settngs_manager, tmp_path):
def default_to_regular(d):
if isinstance(d, defaultdict):
d = {k: default_to_regular(v) for k, v in d.items()}
return d
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='success'))
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test2', default='success'))
def tst(parser):
parser.add_setting('--test', default='success')
parser.add_setting('--test2', default='success')
settngs_manager2 = settngs.Manager()
settngs_manager2.add_group('tst', tst)
assert default_to_regular(settngs_manager.definitions) == default_to_regular(settngs_manager2.definitions)
def test_adding_to_existing_persistent_group(settngs_manager, tmp_path):
def default_to_regular(d):
if isinstance(d, defaultdict):
d = {k: default_to_regular(v) for k, v in d.items()}
return d
settngs_manager.add_persistent_group('tst', lambda parser: parser.add_setting('--test', default='success'))
settngs_manager.add_persistent_group('tst', lambda parser: parser.add_setting('--test2', default='success'))
def tst(parser):
parser.add_setting('--test', default='success')
parser.add_setting('--test2', default='success')
settngs_manager2 = settngs.Manager()
settngs_manager2.add_persistent_group('tst', tst)
assert default_to_regular(settngs_manager.definitions) == default_to_regular(settngs_manager2.definitions)
def test_example(capsys, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
settings_file = tmp_path / 'settings.json'
settings_file.touch()
i = 0
for args, expected_out, expected_file in example:
if args == ['manual settings.json']:
settings_file.unlink()
settings_file.write_text('{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n')
else:
settngs._main(args)
captured = capsys.readouterr()
assert captured.out == expected_out, f'{i}, {args}'
assert settings_file.read_text() == expected_file, f'{i}, {args}'
i += 1