26 Commits
0.4.0 ... 0.7.2

Author SHA1 Message Date
101eef56ca Sanitize group names 2023-09-04 18:32:14 -05:00
2f000e12f3 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/add-trailing-comma: v3.0.1 → v3.1.0](https://github.com/asottile/add-trailing-comma/compare/v3.0.1...v3.1.0)
- https://github.com/pre-commit/mirrors-autopep8https://github.com/hhatto/autopep8
- [github.com/hhatto/autopep8: v2.0.2 → v2.0.4](https://github.com/hhatto/autopep8/compare/v2.0.2...v2.0.4)
- [github.com/pre-commit/mirrors-mypy: v1.5.0 → v1.5.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.0...v1.5.1)
2023-09-04 19:22:46 +00:00
0301e1698c Merge pull request #5 from lordwelch/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-08-15 07:43:05 -07:00
6af1e3c562 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/setup-cfg-fmt: v2.3.0 → v2.4.0](https://github.com/asottile/setup-cfg-fmt/compare/v2.3.0...v2.4.0)
- [github.com/asottile/add-trailing-comma: v2.5.1 → v3.0.1](https://github.com/asottile/add-trailing-comma/compare/v2.5.1...v3.0.1)
- [github.com/asottile/pyupgrade: v3.7.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.7.0...v3.10.1)
- [github.com/PyCQA/flake8: 6.0.0 → 6.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...6.1.0)
- [github.com/pre-commit/mirrors-mypy: v1.3.0 → v1.5.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.3.0...v1.5.0)
2023-08-14 19:13:59 +00:00
fe3bce42fd [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder-python-imports: v3.9.0 → v3.10.0](https://github.com/asottile/reorder-python-imports/compare/v3.9.0...v3.10.0)
- [github.com/asottile/add-trailing-comma: v2.4.0 → v2.5.1](https://github.com/asottile/add-trailing-comma/compare/v2.4.0...v2.5.1)
- [github.com/asottile/dead: v1.5.1 → v1.5.2](https://github.com/asottile/dead/compare/v1.5.1...v1.5.2)
- [github.com/asottile/pyupgrade: v3.4.0 → v3.7.0](https://github.com/asottile/pyupgrade/compare/v3.4.0...v3.7.0)
2023-06-19 19:05:50 +00:00
4c41e6f588 Use a custom class for typing
Add support for nargs to default to list[str]
2023-06-09 15:41:18 -07:00
b599097cc1 Adds a function to generate a class for typing a namespace 2023-06-08 22:54:36 -07:00
8af75d3962 Update docstrings and ensure parameters are consistent 2023-06-08 21:44:07 -07:00
e57ee25a60 Make get_namespace consistent with normalize_config 2023-06-08 21:44:07 -07:00
14357c14de [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/setup-cfg-fmt: v2.2.0 → v2.3.0](https://github.com/asottile/setup-cfg-fmt/compare/v2.2.0...v2.3.0)
- https://github.com/asottile/reorder_python_importshttps://github.com/asottile/reorder-python-imports
- [github.com/asottile/dead: v1.5.0 → v1.5.1](https://github.com/asottile/dead/compare/v1.5.0...v1.5.1)
- [github.com/asottile/pyupgrade: v3.3.2 → v3.4.0](https://github.com/asottile/pyupgrade/compare/v3.3.2...v3.4.0)
- [github.com/pre-commit/mirrors-mypy: v1.2.0 → v1.3.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.2.0...v1.3.0)
2023-06-05 18:41:50 +00:00
3ed9571156 Merge pull request #2 from lordwelch/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-05-06 09:32:31 -07:00
c76ded2c4f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.3.1 → v3.3.2](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.3.2)
2023-05-01 18:48:23 +00:00
78ac274e31 Include pyproject.toml 2023-04-26 15:32:06 -07:00
05c2196fcf Update minimum requirements for typing-extensions 2023-04-26 15:04:57 -07:00
e2ff779c30 Add py.typed 2023-04-25 00:13:51 -07:00
e9d0e874f3 Merge pull request #1 from lordwelch/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-04-24 23:20:57 -07:00
c9f5d57ed1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-autopep8: v2.0.1 → v2.0.2](https://github.com/pre-commit/mirrors-autopep8/compare/v2.0.1...v2.0.2)
- [github.com/pre-commit/mirrors-mypy: v1.0.1 → v1.2.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.0.1...v1.2.0)
2023-04-24 18:48:25 +00:00
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
11 changed files with 947 additions and 237 deletions

View File

@ -33,7 +33,7 @@ jobs:
- name: Build and install wheel - name: Build and install wheel
run: | run: |
python -m build tox run -m build
python -m pip install dist/*.whl python -m pip install dist/*.whl
- name: tox - name: tox

90
.gitignore vendored
View File

@ -1,7 +1,85 @@
*.egg-info # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion
*.py[co]
/.coverage *.iml
/.tox
/dist ## Directory-based project format:
.vscode/ .idea/
### Other editors
.*.swp
nbproject/
.vscode
*.exe
*.zip
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/ build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# for testing
temp/
tmp/

View File

@ -10,38 +10,38 @@ repos:
- id: name-tests-test - id: name-tests-test
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt - repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.2.0 rev: v2.4.0
hooks: hooks:
- id: setup-cfg-fmt - id: setup-cfg-fmt
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder-python-imports
rev: v3.9.0 rev: v3.10.0
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
args: [--py38-plus, --add-import, 'from __future__ import annotations'] args: [--py38-plus, --add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/add-trailing-comma - repo: https://github.com/asottile/add-trailing-comma
rev: v2.4.0 rev: v3.1.0
hooks: hooks:
- id: add-trailing-comma - id: add-trailing-comma
args: [--py36-plus] args: [--py36-plus]
- repo: https://github.com/asottile/dead - repo: https://github.com/asottile/dead
rev: v1.5.0 rev: v1.5.2
hooks: hooks:
- id: dead - id: dead
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.3.1 rev: v3.10.1
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py38-plus] args: [--py38-plus]
- repo: https://github.com/pre-commit/mirrors-autopep8 - repo: https://github.com/hhatto/autopep8
rev: v2.0.0 rev: v2.0.4
hooks: hooks:
- id: autopep8 - id: autopep8
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 6.0.0 rev: 6.1.0
hooks: hooks:
- 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.5.1
hooks: hooks:
- id: mypy - id: mypy

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[build-system]
requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
local_scheme = "no-local-version"

View File

@ -6,6 +6,7 @@ import logging
import pathlib import pathlib
import re import re
import sys import sys
import typing
from argparse import Namespace from argparse import Namespace
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
@ -13,11 +14,12 @@ from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import Generic from typing import Generic
from typing import Literal
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 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 if sys.version_info < (3, 11): # pragma: no cover
@ -27,6 +29,9 @@ else: # pragma: no cover
if sys.version_info < (3, 9): # pragma: no cover if sys.version_info < (3, 9): # pragma: no cover
from typing import List
from typing import _GenericAlias as types_GenericAlias
def removeprefix(self: str, prefix: str, /) -> str: def removeprefix(self: str, prefix: str, /) -> str:
if self.startswith(prefix): if self.startswith(prefix):
return self[len(prefix):] return self[len(prefix):]
@ -73,6 +78,8 @@ if sys.version_info < (3, 9): # pragma: no cover
if option_string in self.option_strings: if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-')) setattr(namespace, self.dest, not option_string.startswith('--no-'))
else: # pragma: no cover else: # pragma: no cover
List = list
from types import GenericAlias as types_GenericAlias
from argparse import BooleanOptionalAction from argparse import BooleanOptionalAction
removeprefix = str.removeprefix removeprefix = str.removeprefix
@ -82,10 +89,10 @@ class Setting:
self, self,
# From argparse # From argparse
*names: str, *names: str,
action: type[argparse.Action] | None = None, action: type[argparse.Action] | str | None = None,
nargs: str | int | None = None, nargs: str | int | None = None,
const: str | None = None, const: Any | None = None,
default: str | None = None, default: Any | None = None,
type: Callable[..., Any] | None = None, # noqa: A002 type: Callable[..., Any] | None = None, # noqa: A002
choices: Sequence[Any] | None = None, choices: Sequence[Any] | None = None,
required: bool | None = None, required: bool | None = None,
@ -93,11 +100,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` upper-cased
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
@ -110,10 +141,9 @@ class Setting:
metavar = dest.upper() metavar = dest.upper()
# If we are not a flag, no '--' or '-' in front # 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] # we use internal_name as argparse sets dest to args[0]
# I believe internal name may be able to be used here
if not flag: if not flag:
args = tuple((f'{group}_{names[0]}'.lstrip('_'), *names[1:])) args = tuple((self.internal_name, *names[1:]))
self.action = action self.action = action
self.nargs = nargs self.nargs = nargs
@ -130,6 +160,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,
@ -150,10 +181,57 @@ class Setting:
def __repr__(self) -> str: # pragma: no cover 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 _guess_type(self) -> type | Literal['Any'] | None:
if self.type is None and self.action is None:
if self.cmdline:
if self.nargs in ('+', '*') or isinstance(self.nargs, int) and self.nargs > 1:
return List[str]
return str
else:
if not self.cmdline and self.default is not None:
return type(self.default)
return 'Any'
if isinstance(self.type, type):
return self.type
if self.type is not None:
type_hints = typing.get_type_hints(self.type)
if 'return' in type_hints and isinstance(type_hints['return'], type):
return type_hints['return']
if self.default is not None:
return type(self.default)
return 'Any'
if self.action in ('store_true', 'store_false', BooleanOptionalAction):
return bool
if self.action in ('store_const',):
return type(self.const)
if self.action in ('count',):
return int
if self.action in ('append', 'extend'):
return List[str]
if self.action in ('append_const',):
return list # list[type(self.const)]
if self.action in ('help', 'version'):
return None
return 'Any'
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
prefix = sanitize_name(prefix)
for n in names: for n in names:
if n.startswith('--'): if n.startswith('--'):
flag = True flag = True
@ -167,7 +245,7 @@ class Setting:
if dest: if dest:
dest_name = dest dest_name = dest
if not dest_name.isidentifier(): if not dest_name.isidentifier():
raise Exception('Cannot use {dest_name} in a namespace') 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
@ -179,6 +257,11 @@ class Setting:
return self.argparse_args, self.filter_argparse_kwargs() return self.argparse_args, self.filter_argparse_kwargs()
class TypedNS:
def __init__(self) -> None:
raise TypeError('TypedNS cannot be instantiated')
class Group(NamedTuple): class Group(NamedTuple):
persistent: bool persistent: bool
v: dict[str, Setting] v: dict[str, Setting]
@ -187,7 +270,7 @@ class Group(NamedTuple):
Values = Dict[str, Dict[str, Any]] Values = Dict[str, Dict[str, Any]]
Definitions = Dict[str, Group] Definitions = Dict[str, Group]
T = TypeVar('T', Values, Namespace) T = TypeVar('T', bound=Union[Values, Namespace, TypedNS])
class Config(NamedTuple, Generic[T]): class Config(NamedTuple, Generic[T]):
@ -197,16 +280,49 @@ class Config(NamedTuple, Generic[T]):
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 ns = Namespace | TypedNS | Config[T] | None
def generate_ns(definitions: Definitions) -> str:
imports = ['from __future__ import annotations', 'import typing', 'import settngs']
ns = 'class settngs_namespace(settngs.TypedNS):\n'
types = []
for group_name, group in definitions.items():
for setting_name, setting in group.v.items():
t = setting._guess_type()
if t is None:
continue
type_name = 'Any'
if isinstance(t, str):
type_name = t
elif isinstance(t, types_GenericAlias):
type_name = str(t)
elif isinstance(t, type):
type_name = t.__name__
if t.__module__ != 'builtins':
imports.append(f'import {t.__module__}')
type_name = t.__module__ + '.' + type_name
if type_name == 'Any':
type_name = 'typing.Any'
types.append(f' {setting.internal_name}: {type_name}')
if types and types[-1] != '':
types.append('')
if not types or all(x == '' for x in types):
ns += ' ...\n'
types = ['']
return '\n'.join(imports) + '\n\n' + ns + '\n'.join(types)
def sanitize_name(name: str) -> str: def sanitize_name(name: str) -> str:
return re.sub('[' + re.escape(' -_,.!@#$%^&*(){}[]\',."<>;:') + ']+', '-', name).strip('-') return re.sub('[' + re.escape(' -_,.!@#$%^&*(){}[]\',."<>;:') + ']+', '_', name).strip('_')
def get_option(options: Values | Namespace, setting: Setting) -> tuple[Any, bool]: def get_option(options: Values | Namespace | TypedNS, 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 current value is the default value
Args: Args:
options: Dictionary or namespace of options options: Dictionary or namespace of options
@ -219,20 +335,20 @@ def get_option(options: Values | Namespace, setting: Setting) -> tuple[Any, bool
return value, value == setting.default return value, value == setting.default
def get_options(options: Config[T], group: str) -> dict[str, Any]: def get_options(config: 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. Helper function to retrieve all of the values for a group. Only to be used on persistent groups.
Args: Args:
options: Dictionary or namespace of options config: Dictionary or namespace of options
group: The name of the group to retrieve group: The name of the group to retrieve
""" """
if isinstance(options[0], dict): if isinstance(config[0], dict):
values = options[0].get(group, {}).copy() values: dict[str, Any] = config[0].get(group, {}).copy()
else: else:
internal_names = {x.internal_name: x for x in options[1][group].v.values()} internal_names = {x.internal_name: x for x in config[1][group].v.values()}
values = {} values = {}
v = vars(options[0]) v = vars(config[0])
for name, value in v.items(): for name, value in v.items():
if name.startswith(f'{group}_'): if name.startswith(f'{group}_'):
if name in internal_names: if name in internal_names:
@ -247,7 +363,7 @@ def normalize_config(
config: Config[T], config: Config[T],
file: bool = False, file: bool = False,
cmdline: bool = False, cmdline: bool = False,
defaults: bool = True, default: bool = True,
persistent: bool = True, persistent: bool = True,
) -> Config[Values]: ) -> Config[Values]:
""" """
@ -256,14 +372,16 @@ def normalize_config(
Values are assigned so if the value is a dictionary mutating it will mutate the original. Values are assigned so if the value is a dictionary mutating it will mutate the original.
Args: Args:
raw_options: The dict or Namespace to normalize options from config: The Config object to normalize options from
definitions: The definition of the options
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
defaults: Include default values in the returned dict default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups
""" """
if not file and not cmdline:
raise ValueError('Invalid parameters: you must set either file or cmdline to True')
normalized: Values = {} normalized: Values = {}
options, definitions = config options, definitions = config
for group_name, group in definitions.items(): for group_name, group in definitions.items():
@ -273,19 +391,28 @@ def normalize_config(
for setting_name, setting in group.v.items(): 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(options, setting) value, is_default = get_option(options, setting)
if not default or default and defaults: if not is_default or default:
# 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: elif setting_name in group_options:
# default values have been requested to be removed
del group_options[setting_name] del group_options[setting_name]
elif setting_name in group_options:
# Setting type (file or cmdline) has not been requested and should be removed for persistent groups
del group_options[setting_name]
normalized[group_name] = group_options normalized[group_name] = group_options
return Config(normalized, definitions) return Config(normalized, definitions)
def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Config[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.
This is purely a convenience function.
If _anything_ more advanced is desired this should be handled by the application.
Args: Args:
definitions: A set of setting definitions. See `Config.definitions` and `Manager.definitions`
filename: A pathlib.Path object to read a json dictionary from filename: A pathlib.Path object to read a json dictionary from
""" """
options: Values = {} options: Values = {}
@ -296,80 +423,91 @@ def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Config
opts = json.load(file) opts = json.load(file)
if isinstance(opts, dict): if isinstance(opts, dict):
options = opts options = opts
except Exception: else: # pragma: no cover
raise Exception('Loaded file is not a JSON Dictionary')
except Exception: # pragma: no cover
logger.exception('Failed to load config file: %s', filename) logger.exception('Failed to load config file: %s', filename)
success = False success = False
else: else:
logger.info('No config file found') logger.info('No config file found')
success = True success = True
return (normalize_config(Config(options, definitions), file=True), success) return normalize_config(Config(options, definitions), file=True), success
def clean_config( def clean_config(
config: Config[T], file: bool = False, cmdline: bool = False, config: Config[T], file: bool = False, cmdline: bool = False, default: bool = True, persistent: bool = True,
) -> Values: ) -> Values:
""" """
Normalizes options and then cleans up empty groups Normalizes options and then cleans up empty groups. The returned value is probably JSON serializable.
Args: Args:
options: config: The Config object to normalize options from
file: file: Include file options
cmdline: cmdline: Include cmdline options
default: Include default values in the returned Config object
Returns: persistent: Include unknown keys in persistent groups
""" """
clean_options, definitions = normalize_config(config, file=file, cmdline=cmdline) cleaned, _ = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent)
for group in list(clean_options.keys()): for group in list(cleaned.keys()):
if not clean_options[group]: if not cleaned[group]:
del clean_options[group] del cleaned[group]
return clean_options return cleaned
def defaults(definitions: Definitions) -> Config[Values]: def defaults(definitions: Definitions) -> Config[Values]:
return normalize_config(Config(Namespace(), definitions), file=True, cmdline=True) return normalize_config(Config(Namespace(), definitions), file=True, cmdline=True)
def get_namespace(config: Config[T], defaults: bool = True, persistent: bool = True) -> Config[Namespace]: def get_namespace(
config: Config[T], file: bool = False, cmdline: bool = False, default: bool = True, persistent: bool = True,
) -> Config[Namespace]:
""" """
Returns an Namespace object with options in the form "{group_name}_{setting_name}" Returns a Namespace object with options in the form "{group_name}_{setting_name}"
`options` should already be normalized. `config` should already be normalized or be a `Config[Namespace]`.
Throws an exception if the internal_name is duplicated
Args: Args:
options: Normalized options to turn into a Namespace config: The Config object to turn into a namespace
defaults: Include default values in the returned dict file: Include file options
cmdline: Include cmdline options
default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups
""" """
if isinstance(config.values, Namespace): if not file and not cmdline:
options, definitions = normalize_config(config) raise ValueError('Invalid parameters: you must set either file or cmdline to True')
options: Values
definitions: Definitions
if isinstance(config.values, dict):
options = config.values
definitions = config.definitions
else: else:
options, definitions = config cfg = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent)
options, definitions = cfg
namespace = Namespace() namespace = Namespace()
for group_name, group in definitions.items(): for group_name, group in definitions.items():
group_options = get_options(config, group_name)
if group.persistent and persistent: if group.persistent and persistent:
group_options = get_options(config, group_name)
for name, value in group_options.items(): for name, value in group_options.items():
if name in group.v: if name in group.v:
internal_name, default = group.v[name].internal_name, group.v[name].default == value setting_file, setting_cmdline = group.v[name].file, group.v[name].cmdline
value, is_default = get_option(options, group.v[name])
internal_name = group.v[name].internal_name
else: else:
internal_name, default = f'{group_name}_' + sanitize_name(name), None setting_file = setting_cmdline = True
internal_name, is_default = f'{group_name}_' + sanitize_name(name), None
if hasattr(namespace, internal_name): if ((setting_cmdline and cmdline) or (setting_file and file)) and (not is_default or default):
raise Exception(f'Duplicate internal name: {internal_name}')
if not default or default and defaults:
setattr(namespace, internal_name, value) setattr(namespace, internal_name, value)
else: for setting in group.v.values():
for setting_name, setting in group.v.items(): if (setting.cmdline and cmdline) or (setting.file and file):
if hasattr(namespace, setting.internal_name): value, is_default = get_option(options, setting)
raise Exception(f'Duplicate internal name: {setting.internal_name}')
value, default = get_option(options, setting)
if not default or default and defaults: if not is_default or default:
# User has set a custom value or has requested the default value
setattr(namespace, setting.internal_name, value) setattr(namespace, setting.internal_name, value)
return Config(namespace, definitions) return Config(namespace, definitions)
@ -379,16 +517,19 @@ def save_file(
) -> 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
This is purely a convenience function.
If _anything_ more advanced is desired this should be handled by the application.
Args: Args:
options: The options to save to a json dictionary config: 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(config, file=True) file_options = clean_config(config, file=True)
if not filename.exists():
filename.parent.mkdir(exist_ok=True, parents=True)
filename.touch()
try: try:
if not filename.exists():
filename.parent.mkdir(exist_ok=True, parents=True)
filename.touch()
json_str = json.dumps(file_options, indent=2) json_str = json.dumps(file_options, indent=2)
filename.write_text(json_str + '\n', encoding='utf-8') filename.write_text(json_str + '\n', encoding='utf-8')
except Exception: except Exception:
@ -403,8 +544,8 @@ def create_argparser(definitions: Definitions, description: str, epilog: str) ->
argparser = argparse.ArgumentParser( argparser = argparse.ArgumentParser(
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 in definitions.values():
for setting_name, setting in group.v.items(): for setting in group.v.values():
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
@ -429,24 +570,28 @@ def parse_cmdline(
description: str, description: str,
epilog: str, epilog: str,
args: list[str] | None = None, args: list[str] | None = None,
config: Namespace | Config[T] | None = None, config: ns[T] = None,
) -> Config[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`
Args: Args:
args: Passed to argparse.ArgumentParser.parse definitions: A set of setting definitions. See `Config.definitions` and `Manager.definitions`
namespace: Passed to argparse.ArgumentParser.parse description: Passed to argparse.ArgumentParser
epilog: Passed to argparse.ArgumentParser
args: Passed to argparse.ArgumentParser.parse_args
config: The Config or Namespace object to use as a Namespace passed to argparse.ArgumentParser.parse_args
""" """
namespace = None namespace: Namespace | TypedNS | None = None
if isinstance(config, Config): if isinstance(config, Config):
if isinstance(config.values, Namespace): if isinstance(config.values, Namespace):
namespace = config.values namespace = config.values
else: else:
namespace = get_namespace(config, defaults=False)[0] namespace = get_namespace(config, file=True, cmdline=True, default=False)[0]
else: else:
namespace = config 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)
@ -460,13 +605,25 @@ def parse_config(
config_path: pathlib.Path, config_path: pathlib.Path,
args: list[str] | None = None, args: list[str] | None = None,
) -> tuple[Config[Values], bool]: ) -> tuple[Config[Values], bool]:
"""
Convenience function to parse options from a json file and passes the resulting Config object to parse_cmdline.
This is purely a convenience function.
If _anything_ more advanced is desired this should be handled by the application.
Args:
definitions: A set of setting definitions. See `Config.definitions` and `Manager.definitions`
description: Passed to argparse.ArgumentParser
epilog: Passed to argparse.ArgumentParser
config_path: A `pathlib.Path` object
args: Passed to argparse.ArgumentParser.parse_args
"""
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, defaults=False), definitions, description, epilog, args, file_options,
) )
final_options = normalize_config(cmdline_options, 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:
@ -486,23 +643,28 @@ class Manager:
self.exclusive_group = False self.exclusive_group = False
self.current_group_name = '' self.current_group_name = ''
def generate_ns(self) -> str:
return generate_ns(self.definitions)
def create_argparser(self) -> None: def create_argparser(self) -> None:
self.argparser = create_argparser(self.definitions, self.description, self.epilog) self.argparser = create_argparser(self.definitions, self.description, self.epilog)
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""" """Passes all arguments through to `Setting`, `group` and `exclusive` are already set"""
setting = Setting(*args, **kwargs, group=self.current_group_name, exclusive=self.exclusive_group) setting = Setting(*args, **kwargs, group=self.current_group_name, exclusive=self.exclusive_group)
self.definitions[self.current_group_name].v[setting.dest] = setting self.definitions[self.current_group_name].v[setting.dest] = setting
def add_group(self, name: str, group: 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
group: 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
group(self) group(self)
@ -511,16 +673,23 @@ class Manager:
def add_persistent_group(self, name: str, group: Callable[[Manager], None], exclusive_group: bool = False) -> None: 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 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: Args:
name: The name of the group to define name: The name of the group to define
group: 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
self.definitions[self.current_group_name] = Group(True, {}) if self.current_group_name in self.definitions:
if not self.definitions[self.current_group_name].persistent:
raise ValueError('Group already exists and is not persistent')
else:
self.definitions[self.current_group_name] = Group(True, {})
group(self) group(self)
self.current_group_name = '' self.current_group_name = ''
self.exclusive_group = False self.exclusive_group = False
@ -534,63 +703,151 @@ class Manager:
return defaults(self.definitions) return defaults(self.definitions)
def clean_config( def clean_config(
self, options: T | Config[T], file: bool = False, cmdline: bool = False, self, config: T | Config[T], file: bool = False, cmdline: bool = False,
) -> Values: ) -> Values:
if isinstance(options, Config): """
config = options Normalizes options and then cleans up empty groups. The returned value is probably JSON serializable.
else: Args:
config = Config(options, self.definitions) config: The Config object to normalize options from
file: Include file options
cmdline: Include cmdline options
"""
if not isinstance(config, Config):
config = Config(config, self.definitions)
return clean_config(config, file=file, cmdline=cmdline) return clean_config(config, file=file, cmdline=cmdline)
def normalize_config( def normalize_config(
self, self,
options: T | Config[T], config: T | Config[T],
file: bool = False, file: bool = False,
cmdline: bool = False, cmdline: bool = False,
defaults: bool = True, default: bool = True,
persistent: bool = True,
) -> Config[Values]: ) -> Config[Values]:
if isinstance(options, Config): """
config = options Creates an `OptionValues` dictionary with setting definitions taken from `self.definitions`
else: and values taken from `raw_options` and `raw_options_2' if defined.
config = Config(options, self.definitions) Values are assigned so if the value is a dictionary mutating it will mutate the original.
Args:
config: The Config object to normalize options from
file: Include file options
cmdline: Include cmdline options
default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups
"""
if not isinstance(config, Config):
config = Config(config, self.definitions)
return normalize_config( return normalize_config(
config=config, config=config,
file=file, file=file,
cmdline=cmdline, cmdline=cmdline,
defaults=defaults, default=default,
persistent=persistent,
) )
@overload def get_namespace(
def get_namespace(self, options: Values, defaults: bool = True) -> Namespace: self,
... config: Values | Config[Values],
file: bool = False,
cmdline: bool = False,
default: bool = True,
persistent: bool = True,
) -> Config[Namespace]:
"""
Returns a Namespace object with options in the form "{group_name}_{setting_name}"
`options` should already be normalized or be a `Config[Namespace]`.
Throws an exception if the internal_name is duplicated
@overload Args:
def get_namespace(self, options: Config[Values], defaults: bool = True) -> Config[Namespace]: config: The Config object to turn into a namespace
... file: Include file options
cmdline: Include cmdline options
default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups
"""
def get_namespace(self, options: Values | Config[Values], defaults: bool = True) -> Config[Namespace] | Namespace: if isinstance(config, Config):
if isinstance(options, Config): self.definitions = config[1]
self.definitions = options[1]
return get_namespace(options, defaults=defaults)
else: else:
return get_namespace(Config(options, self.definitions), defaults=defaults) config = Config(config, self.definitions)
return get_namespace(config, file=file, cmdline=cmdline, default=default, persistent=persistent)
def parse_file(self, filename: pathlib.Path) -> tuple[Config[Values], bool]: def parse_file(self, filename: pathlib.Path) -> tuple[Config[Values], bool]:
"""
Helper function to read options from a json dictionary from a file.
This is purely a convenience function.
If _anything_ more advanced is desired this should be handled by the application.
Args:
filename: A pathlib.Path object to read a JSON dictionary from
"""
return parse_file(filename=filename, definitions=self.definitions) return parse_file(filename=filename, definitions=self.definitions)
def save_file(self, options: T | Config[T], filename: pathlib.Path) -> bool: def save_file(self, config: T | Config[T], filename: pathlib.Path) -> bool:
if isinstance(options, Config): """
return save_file(options, filename=filename) Helper function to save options from a json dictionary to a file.
return save_file(Config(options, self.definitions), filename=filename) This is purely a convenience function.
If _anything_ more advanced is desired this should be handled by the application.
def parse_cmdline(self, args: list[str] | None = None, namespace: ns[T] = None) -> Config[Values]: Args:
return parse_cmdline(self.definitions, self.description, self.epilog, args, namespace) config: The options to save to a json dictionary
filename: A pathlib.Path object to save the json dictionary to
"""
if not isinstance(config, Config):
config = Config(config, self.definitions)
return save_file(config, filename=filename)
def parse_cmdline(self, args: list[str] | None = None, config: ns[T] = None) -> Config[Values]:
"""
Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`.
`args` and `config` are passed to `argparse.ArgumentParser.parse_args`
Args:
args: Passed to argparse.ArgumentParser.parse_args
config: The Config or Namespace object to use as a Namespace passed to argparse.ArgumentParser.parse_args
"""
return parse_cmdline(self.definitions, self.description, self.epilog, args, config)
def parse_config(self, config_path: pathlib.Path, args: list[str] | None = None) -> tuple[Config[Values], bool]: def parse_config(self, config_path: pathlib.Path, args: list[str] | None = None) -> tuple[Config[Values], bool]:
"""
Convenience function to parse options from a json file and passes the resulting Config object to parse_cmdline.
This is purely a convenience function.
If _anything_ more advanced is desired this should be handled by the application.
Args:
config_path: A `pathlib.Path` object
args: Passed to argparse.ArgumentParser.parse_args
"""
return parse_config(self.definitions, self.description, self.epilog, config_path, args) return parse_config(self.definitions, self.description, self.epilog, config_path, args)
def example(manager: Manager) -> None: __all__ = [
'Setting',
'Group',
'Values',
'Definitions',
'Config',
'generate_settings',
'sanitize_name',
'get_option',
'get_options',
'normalize_config',
'parse_file',
'clean_config',
'defaults',
'get_namespace',
'save_file',
'create_argparser',
'parse_cmdline',
'parse_config',
'Manager',
]
def example_group(manager: Manager) -> None:
manager.add_setting( manager.add_setting(
'--hello', '--hello',
default='world', default='world',
@ -608,7 +865,7 @@ def example(manager: Manager) -> None:
) )
def persistent(manager: Manager) -> None: def persistent_group(manager: Manager) -> None:
manager.add_setting( manager.add_setting(
'--test', '-t', '--test', '-t',
default=False, default=False,
@ -620,23 +877,23 @@ 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 Group', example_group)
manager.add_persistent_group('persistent', persistent) manager.add_persistent_group('persistent', persistent_group)
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, file=True, cmdline=True)
merged_config = manager.parse_cmdline(args=args, namespace=file_namespace) merged_config = manager.parse_cmdline(args=args, config=file_namespace)
merged_namespace = manager.get_namespace(merged_config) merged_namespace = manager.get_namespace(merged_config, file=True, cmdline=True)
print(f'Hello {merged_config.values["example"]["hello"]}') # noqa: T201 print(f'Hello {merged_config.values["Example Group"]["hello"]}') # noqa: T201
if merged_namespace.values.example_save: if merged_namespace.values.Example_Group_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.values.example_verbose: if merged_namespace.values.Example_Group_verbose:
print(f'{merged_namespace.values.example_verbose=}') # noqa: T201 print(f'{merged_namespace.values.Example_Group_verbose=}') # noqa: T201
if __name__ == '__main__': if __name__ == '__main__':

6
settngs/__main__.py Normal file
View File

@ -0,0 +1,6 @@
from __future__ import annotations
from settngs import _main
if __name__ == '__main__':
_main()

0
settngs/py.typed Normal file
View File

View File

@ -1,6 +1,5 @@
[metadata] [metadata]
name = settngs name = settngs
version = 0.4.0
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
@ -17,16 +16,71 @@ classifiers =
Programming Language :: Python :: Implementation :: PyPy Programming Language :: Python :: Implementation :: PyPy
[options] [options]
py_modules = settngs packages = find:
install_requires = install_requires =
typing-extensions;python_version < '3.11' typing-extensions>=4.3.0;python_version < '3.11'
python_requires = >=3.8 python_requires = >=3.8
include_package_data = True
[options.packages.find] [options.packages.find]
exclude = exclude =
tests* tests*
testing* testing*
[options.package_data]
settngs = py.typed
[tox:tox]
envlist = py3.8,py3.9,py3.10,py3.11,pypy3
[testenv]
deps = -rrequirements-dev.txt
commands =
coverage erase
coverage run -m pytest {posargs:tests}
coverage report
[testenv:wheel]
description = Generate wheel and tar.gz
labels =
release
build
skip_install = true
deps =
build
commands_pre =
-python -c 'import shutil,pathlib; \
shutil.rmtree("./build/", ignore_errors=True); \
shutil.rmtree("./dist/", ignore_errors=True)'
commands =
python -m build
[testenv:pypi-upload]
description = Upload wheel to PyPi
platform = Linux
labels =
release
skip_install = true
depends = wheel
deps =
twine
passenv =
TWINE_*
setenv =
TWINE_NON_INTERACTIVE=true
commands =
python -m twine upload dist/*.whl dist/*.tar.gz
[pep8]
ignore = E265,E501
max_line_length = 120
[flake8]
extend-ignore = E501, A003
max_line_length = 120
per-file-ignores =
*_test.py: LN001
[coverage:run] [coverage:run]
plugins = covdefaults plugins = covdefaults

View File

@ -15,55 +15,94 @@ example: list[tuple[list[str], str, str]] = [
( (
['--hello', 'lordwelch', '-s'], ['--hello', 'lordwelch', '-s'],
'Hello lordwelch\nSuccessfully saved settings to settings.json\n', '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', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n',
), ),
( (
[], [],
'Hello lordwelch\n', 'Hello lordwelch\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n',
), ),
( (
['-v'], ['-v'],
'Hello lordwelch\nmerged_namespace.values.example_verbose=True\n', 'Hello lordwelch\nmerged_namespace.values.Example_Group_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": false\n },\n "persistent": {\n "test": false\n }\n}\n',
), ),
( (
['-v', '-s'], ['-v', '-s'],
'Hello lordwelch\nSuccessfully saved settings to settings.json\nmerged_namespace.values.example_verbose=True\n', 'Hello lordwelch\nSuccessfully saved settings to settings.json\nmerged_namespace.values.Example_Group_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false\n }\n}\n', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false\n }\n}\n',
), ),
( (
[], [],
'Hello lordwelch\nmerged_namespace.values.example_verbose=True\n', 'Hello lordwelch\nmerged_namespace.values.Example_Group_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false\n }\n}\n', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false\n }\n}\n',
), ),
( (
['manual settings.json'], ['manual settings.json'],
'Hello lordwelch\nmerged_namespace.values.example_verbose=True\n', 'Hello lordwelch\nmerged_namespace.values.Example_Group_verbose=True\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
), ),
( (
['--no-verbose', '-t'], ['--no-verbose', '-t'],
'Hello lordwelch\n', 'Hello lordwelch\n',
'{\n "example": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n', '{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
), ),
( (
['--no-verbose', '-s', '-t'], ['--no-verbose', '-s', '-t'],
'Hello lordwelch\nSuccessfully saved settings to settings.json\n', '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', '{\n "Example Group": {\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', '--no-verbose', '--no-test', '-s'],
'Hello world\nSuccessfully saved settings to settings.json\n', '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', '{\n "Example Group": {\n "hello": "world",\n "verbose": false\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n',
), ),
( (
[], [],
'Hello world\n', 'Hello world\n',
'{\n "example": {\n "hello": "world",\n "verbose": false\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n', '{\n "Example Group": {\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',),
@ -71,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,
@ -79,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',
@ -117,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',
@ -156,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',
@ -194,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',
@ -232,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',
@ -268,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

@ -1,7 +1,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ast
import json import json
import pathlib
import sys
from collections import defaultdict
from typing import Generator
import pytest import pytest
@ -12,8 +17,14 @@ from testing.settngs import failure
from testing.settngs import success from testing.settngs import success
if sys.version_info < (3, 9): # pragma: no cover
from typing import List
else: # pragma: no cover
List = list
@pytest.fixture @pytest.fixture
def settngs_manager(): def settngs_manager() -> Generator[settngs.Manager, None, None]:
manager = settngs.Manager() manager = settngs.Manager()
yield manager yield manager
@ -52,78 +63,186 @@ def test_add_setting(settngs_manager):
assert settngs_manager.add_setting('--test') is None assert settngs_manager.add_setting('--test') is None
def test_get_defaults(settngs_manager): class TestValues:
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.defaults() def test_invalid_normalize(self, settngs_manager):
assert defaults['']['test'] == 'hello' with pytest.raises(ValueError) as excinfo:
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.normalize_config({}, file=False, cmdline=False)
assert str(excinfo.value) == 'Invalid parameters: you must set either file or cmdline to True'
def test_get_defaults(self, settngs_manager):
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.defaults()
assert defaults['']['test'] == 'hello'
def test_get_defaults_group(self, settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults, _ = settngs_manager.defaults()
assert defaults['tst']['test'] == 'hello'
def test_get_defaults_group_space(self, settngs_manager):
settngs_manager.add_group('Testing tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults, _ = settngs_manager.defaults()
assert defaults['Testing tst']['test'] == 'hello'
def test_cmdline_only(self, settngs_manager):
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))
file_normalized, _ = settngs_manager.normalize_config(settngs_manager.defaults(), file=True)
cmdline_normalized, _ = settngs_manager.normalize_config(settngs_manager.defaults(), cmdline=True)
assert 'test' not in file_normalized['tst'] # cmdline option not in normalized config
assert 'test2' in file_normalized['tst2'] # file option in normalized config
assert 'test' in cmdline_normalized['tst'] # cmdline option in normalized config
assert 'test2' not in cmdline_normalized['tst2'] # file option not in normalized config
def test_cmdline_only_persistent_group(self, 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' not in file_normalized['tst']
assert 'test2' in file_normalized['tst2']
assert 'test' in cmdline_normalized['tst']
assert 'test2' not in cmdline_normalized['tst2']
def test_normalize_defaults(self, 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('--test2', default='hello'))
settngs_manager.add_persistent_group('tst_persistent', lambda parser: parser.add_setting('--test', default='hello'))
defaults = settngs_manager.defaults()
defaults_normalized = settngs_manager.normalize_config(defaults, file=True, default=False)
assert defaults_normalized.values['tst'] == {}
assert defaults_normalized.values['tst_persistent'] == {}
non_defaults = settngs_manager.defaults()
non_defaults.values['tst']['test'] = 'world'
non_defaults.values['tst_persistent']['test'] = 'world'
non_defaults_normalized = settngs_manager.normalize_config(non_defaults, file=True, default=False)
assert non_defaults_normalized.values['tst'] == {'test': 'world'}
assert non_defaults_normalized.values['tst_persistent'] == {'test': 'world'}
def test_normalize(self, settngs_manager):
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.values['test'] = 'fail' # Not defined in settngs_manager, should be removed
defaults.values['persistent']['hello'] = 'success' # Not defined in settngs_manager, should stay
normalized, _ = settngs_manager.normalize_config(defaults, file=True)
assert 'test' not in normalized
assert 'tst' in normalized
assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'hello'
assert normalized['persistent']['hello'] == 'success'
assert normalized['persistent']['world'] == 'world'
def test_get_namespace(settngs_manager): class TestNamespace:
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults()) def test_invalid_normalize(self, settngs_manager):
assert defaults.test == 'hello' with pytest.raises(ValueError) as excinfo:
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=False, cmdline=False)
assert str(excinfo.value) == 'Invalid parameters: you must set either file or cmdline to True'
def test_get_defaults(self, settngs_manager):
settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
assert defaults.test == 'hello'
def test_get_defaults_group(self, settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
assert defaults.tst_test == 'hello'
def test_get_defaults_group_space(self, settngs_manager):
settngs_manager.add_group('Testing tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
assert defaults.Testing_tst_test == 'hello'
def test_cmdline_only(self, settngs_manager):
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))
file_normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(settngs_manager.defaults(), file=True), file=True)
cmdline_normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(settngs_manager.defaults(), cmdline=True), cmdline=True)
assert 'tst_test' not in file_normalized.__dict__
assert 'tst2_test2' in file_normalized.__dict__
assert 'tst_test' in cmdline_normalized.__dict__
assert 'tst2_test2' not in cmdline_normalized.__dict__
def test_cmdline_only_persistent_group(self, 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.get_namespace(settngs_manager.normalize_config(settngs_manager.defaults(), file=True), file=True)
cmdline_normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(settngs_manager.defaults(), cmdline=True), cmdline=True)
assert 'tst_test' not in file_normalized.__dict__
assert 'tst2_test2' in file_normalized.__dict__
assert 'tst_test' in cmdline_normalized.__dict__
assert 'tst2_test2' not in cmdline_normalized.__dict__
def test_normalize_defaults(self, 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('--test2', default='hello'))
settngs_manager.add_persistent_group('tst_persistent', lambda parser: parser.add_setting('--test', default='hello'))
defaults = settngs_manager.defaults()
defaults_normalized = settngs_manager.get_namespace(settngs_manager.normalize_config(defaults, file=True, default=False), file=True, default=False)
assert defaults_normalized.values.__dict__ == {}
non_defaults = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
non_defaults.values.tst_test = 'world'
non_defaults.values.tst_persistent_test = 'world'
non_defaults_normalized = settngs_manager.get_namespace(settngs_manager.normalize_config(non_defaults, file=True, default=False), file=True, default=False)
assert non_defaults_normalized.values.tst_test == 'world'
assert non_defaults_normalized.values.tst_persistent_test == 'world'
def test_normalize(self, settngs_manager):
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.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
defaults.values.test = 'fail' # Not defined in settngs_manager, should be removed
defaults.values.persistent_hello = 'success' # Not defined in settngs_manager, should stay
normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(defaults, file=True), file=True)
assert not hasattr(normalized, 'test')
assert hasattr(normalized, 'tst_test')
assert normalized.tst_test == 'hello'
assert normalized.persistent_hello == 'success'
assert normalized.persistent_world == 'world'
def test_get_namespace_with_namespace(settngs_manager): def test_get_namespace_with_namespace(settngs_manager):
settngs_manager.add_setting('--test', default='hello') settngs_manager.add_setting('--test', default='hello')
defaults, _ = settngs_manager.get_namespace(argparse.Namespace(test='hello')) defaults, _ = settngs_manager.get_namespace(argparse.Namespace(test='success'), file=True)
assert defaults.test == 'hello' assert defaults.test == 'success'
def test_get_defaults_group(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
defaults, _ = settngs_manager.defaults()
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(), file=True)
assert defaults.tst_test == 'hello' assert defaults.tst_test == 'hello'
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('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 'test2' not in cmdline_normalized['tst2']
assert 'test' not in file_normalized['tst']
assert 'test2' in file_normalized['tst2']
def test_normalize(settngs_manager):
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.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(settngs_manager.defaults())
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_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 'tst' in normalized
assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'hello'
assert normalized['persistent']['hello'] == 'success'
assert not hasattr(normalized_namespace, 'test')
assert hasattr(normalized_namespace, 'tst_test')
assert normalized_namespace.tst_test == 'hello'
assert normalized_namespace.persistent_hello == 'success'
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))
@ -141,7 +260,7 @@ def test_clean_config(settngs_manager):
assert cleaned['persistent']['hello'] == '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'])
@ -150,15 +269,25 @@ def test_parse_cmdline(settngs_manager, tmp_path):
assert normalized['tst']['test'] == 'success' assert normalized['tst']['test'] == 'success'
def test_parse_cmdline_with_namespace(settngs_manager, tmp_path): 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('--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( normalized, _ = settngs_manager.parse_cmdline(
['--test', 'success'], namespace=settngs.Config({'tst': {'test': 'fail'}}, settngs_manager.definitions), ['--test', 'success'], config=ns(settngs_manager.definitions),
) )
assert 'test' in normalized['tst'] assert 'test' in normalized['tst']
assert normalized['tst']['test'] == 'success' 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):
@ -286,6 +415,156 @@ def test_cli_explicit_default(settngs_manager, tmp_path):
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: settngs.Manager, tmp_path: pathlib.Path) -> None:
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)
class test_type(int):
...
def _typed_function(something: str) -> test_type: # pragma: no cover
return test_type()
def _untyped_function(something):
...
class _customAction(argparse.Action): # pragma: no cover
def __init__(
self,
option_strings,
dest,
const=None,
default=None,
required=False,
help=None, # noqa: A002
metavar=None,
):
super().__init__(
option_strings=option_strings,
dest=dest,
nargs=0,
const=const,
default=default,
required=required,
help=help,
)
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, 'Something')
types = (
(settngs.Setting('-t', '--test'), str),
(settngs.Setting('-t', '--test', cmdline=False), 'Any'),
(settngs.Setting('-t', '--test', default=1, file=True, cmdline=False), int),
(settngs.Setting('-t', '--test', action='count'), int),
(settngs.Setting('-t', '--test', action='append'), List[str]),
(settngs.Setting('-t', '--test', action='extend'), List[str]),
(settngs.Setting('-t', '--test', action='store_const', const=1), int),
(settngs.Setting('-t', '--test', action='append_const', const=1), list),
(settngs.Setting('-t', '--test', action='store_true'), bool),
(settngs.Setting('-t', '--test', action='store_false'), bool),
(settngs.Setting('-t', '--test', action=settngs.BooleanOptionalAction), bool),
(settngs.Setting('-t', '--test', action=_customAction), 'Any'),
(settngs.Setting('-t', '--test', action='help'), None),
(settngs.Setting('-t', '--test', action='version'), None),
(settngs.Setting('-t', '--test', type=int), int),
(settngs.Setting('-t', '--test', type=_typed_function), test_type),
(settngs.Setting('-t', '--test', type=_untyped_function, default=1), int),
(settngs.Setting('-t', '--test', type=_untyped_function), 'Any'),
)
@pytest.mark.parametrize('setting,typ', types)
def test_guess_type(setting, typ):
guessed_type = setting._guess_type()
assert guessed_type == typ
settings = (
(lambda parser: parser.add_setting('-t', '--test'), 'str'),
(lambda parser: parser.add_setting('-t', '--test', cmdline=False), 'typing.Any'),
(lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), 'int'),
(lambda parser: parser.add_setting('-t', '--test', action='count'), 'int'),
(lambda parser: parser.add_setting('-t', '--test', action='append'), List[str]),
(lambda parser: parser.add_setting('-t', '--test', action='extend'), List[str]),
(lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), 'int'),
(lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), 'list'),
(lambda parser: parser.add_setting('-t', '--test', action='store_true'), 'bool'),
(lambda parser: parser.add_setting('-t', '--test', action='store_false'), 'bool'),
(lambda parser: parser.add_setting('-t', '--test', action=settngs.BooleanOptionalAction), 'bool'),
(lambda parser: parser.add_setting('-t', '--test', action=_customAction), 'typing.Any'),
(lambda parser: parser.add_setting('-t', '--test', action='help'), None),
(lambda parser: parser.add_setting('-t', '--test', action='version'), None),
(lambda parser: parser.add_setting('-t', '--test', type=int), 'int'),
(lambda parser: parser.add_setting('-t', '--test', type=_typed_function), 'tests.settngs_test.test_type'),
(lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=1), 'int'),
(lambda parser: parser.add_setting('-t', '--test', type=_untyped_function), 'typing.Any'),
)
@pytest.mark.parametrize('set_options,typ', settings)
def test_generate_ns(settngs_manager, set_options, typ):
settngs_manager.add_group('test', set_options)
src = '''\
from __future__ import annotations
import typing
import settngs
'''
if typ == 'tests.settngs_test.test_type':
src += 'import tests.settngs_test\n'
src += '''
class settngs_namespace(settngs.TypedNS):
'''
if typ is None:
src += ' ...\n'
else:
src += f' {settngs_manager.definitions["test"].v["test"].internal_name}: {typ}\n'
generated_src = settngs_manager.generate_ns()
assert generated_src == src
ast.parse(generated_src)
def test_example(capsys, tmp_path, monkeypatch): def test_example(capsys, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
settings_file = tmp_path / 'settings.json' settings_file = tmp_path / 'settings.json'
@ -295,7 +574,9 @@ def test_example(capsys, tmp_path, monkeypatch):
for args, expected_out, expected_file in example: for args, expected_out, expected_file in example:
if args == ['manual settings.json']: if args == ['manual settings.json']:
settings_file.unlink() 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') settings_file.write_text('{\n "Example Group": {\n "hello": "lordwelch",\n "verbose": true\n },\n "persistent": {\n "test": false,\n "hello": "world"\n }\n}\n')
i += 1
continue
else: else:
settngs._main(args) settngs._main(args)
captured = capsys.readouterr() captured = capsys.readouterr()

17
tox.ini
View File

@ -1,17 +0,0 @@
[tox]
envlist = py3.8,py3.9,py3.10,py3.11,pypy3
[testenv]
deps = -rrequirements-dev.txt
commands =
coverage erase
coverage run -m pytest {posargs:tests}
coverage report
[pep8]
ignore = E265,E501
max_line_length = 120
[flake8]
extend-ignore = E501, A003
max_line_length = 120