21 Commits

Author SHA1 Message Date
c588fc891e Support type generation for dicts an addition to namespaces 2024-02-22 19:15:47 -08:00
1ce6079285 Fix pre-commit 2024-02-22 19:14:45 -08:00
7c748f6815 Merge branch 'pre-commit-ci-update-config' 2024-02-22 14:44:51 -08:00
8d5b30546e Improve type guessing for generic Sequence types 2024-02-22 14:42:07 -08:00
cebca481fc [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.15.0 → v3.15.1](https://github.com/asottile/pyupgrade/compare/v3.15.0...v3.15.1)
2024-02-19 17:21:12 +00:00
dd8cd1188e [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 6.1.0 → 7.0.0](https://github.com/PyCQA/flake8/compare/6.1.0...7.0.0)
- [github.com/pre-commit/mirrors-mypy: v1.7.0 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.0...v1.8.0)
2024-01-08 17:17:39 +00:00
d30e73a679 Do not add duplicate settings when generating a namespace 2023-12-17 18:26:57 -08:00
fc2a175e5b Fix normalization of settings using a custom dest 2023-12-17 16:09:41 -08:00
4c385667e8 Fix settings being overwritten when using the same dest attribute 2023-12-16 16:51:00 -08:00
9ffefb3e21 Fix readme example 2023-11-19 00:20:43 -08:00
23d0139144 Map argparse --help output to python versions 2023-11-19 00:14:06 -08:00
0c49b9309d Persist unknown groups 2023-11-18 23:58:18 -08:00
ccacca1b32 Improve generated namespace
Improve formatting of namespace
Allow _guess_type to return any string
Add tests
2023-11-18 23:31:24 -08:00
2c79e62765 Fix exported names 2023-11-18 12:43:53 -08:00
27ca830b60 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)
- [github.com/asottile/setup-cfg-fmt: v2.4.0 → v2.5.0](https://github.com/asottile/setup-cfg-fmt/compare/v2.4.0...v2.5.0)
- [github.com/asottile/reorder-python-imports: v3.10.0 → v3.12.0](https://github.com/asottile/reorder-python-imports/compare/v3.10.0...v3.12.0)
- [github.com/asottile/pyupgrade: v3.10.1 → v3.15.0](https://github.com/asottile/pyupgrade/compare/v3.10.1...v3.15.0)
- [github.com/pre-commit/mirrors-mypy: v1.5.1 → v1.7.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.1...v1.7.0)
2023-11-13 17:18:39 +00:00
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
6 changed files with 621 additions and 209 deletions

View File

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v4.5.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@ -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.3.0 rev: v2.5.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.12.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.1 rev: v1.5.2
hooks: hooks:
- id: dead - id: dead
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.4.0 rev: v3.15.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.2 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: 7.0.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-print]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.3.0 rev: v1.8.0
hooks: hooks:
- id: mypy - id: mypy

View File

@ -29,14 +29,14 @@ $ python -m settngs
Hello lordwelch Hello lordwelch
$ python -m settngs -v $ python -m settngs -v
Hello lordwelch Hello lordwelch
merged_namespace.values.example_verbose=True merged_namespace.values.Example_Group__verbose=True
$ python -m settngs -v -s $ python -m settngs -v -s
Hello lordwelch Hello lordwelch
Successfully saved settings to settings.json Successfully saved settings to settings.json
merged_namespace.values.example_verbose=True merged_namespace.values.Example_Group__verbose=True
$ python -m settngs $ python -m settngs
Hello lordwelch Hello lordwelch
merged_namespace.values.example_verbose=True merged_namespace.values.Example_Group__verbose=True
$ cat >settings.json << EOF $ cat >settings.json << EOF
{ {
"example": { "example": {
@ -64,7 +64,7 @@ Hello world
settngs.json at the end: settngs.json at the end:
```json ```json
{ {
"example": { "Example Group": {
"hello": "world", "hello": "world",
"verbose": false "verbose": false
}, },

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import copy
import json import json
import logging import logging
import pathlib import pathlib
@ -12,9 +13,9 @@ 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 cast
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 TYPE_CHECKING from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
@ -84,6 +85,20 @@ else: # pragma: no cover
removeprefix = str.removeprefix removeprefix = str.removeprefix
def _isnamedtupleinstance(x: Any) -> bool:
t = type(x)
b = t.__bases__
if len(b) != 1 or b[0] != tuple:
return False
f = getattr(t, '_fields', None)
if not isinstance(f, tuple):
return False
return all(isinstance(n, str) for n in f)
class Setting: class Setting:
def __init__( def __init__(
self, self,
@ -107,6 +122,9 @@ class Setting:
exclusive: bool = False, exclusive: bool = False,
): ):
""" """
Attributes:
setting_name: This is the name used to retrieve this Setting object from a `Config` Definitions dictionary.
This only differs from dest when a custom dest is given
Args: Args:
*names: Passed directly to argparse *names: Passed directly to argparse
@ -119,7 +137,8 @@ class Setting:
required: Passed directly to argparse required: Passed directly to argparse
help: Passed directly to argparse help: Passed directly to argparse
metavar: Passed directly to argparse, defaults to `dest` upper-cased 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 dest: This is the name used to retrieve the value from a `Config` object as a dictionary.
Default to `setting_name`.
display_name: This is not used by settngs. This is a human-readable name to be used when generating a GUI. display_name: This is not used by settngs. This is a human-readable name to be used when generating a GUI.
Defaults to `dest`. Defaults to `dest`.
cmdline: If this setting can be set via the commandline cmdline: If this setting can be set via the commandline
@ -133,7 +152,7 @@ class Setting:
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
# Argument names will still cause an exception if there is a conflict e.g. if '-f' is defined twice # Argument names will still cause an exception if there is a conflict e.g. if '-f' is defined twice
self.internal_name, dest, flag = self.get_dest(group, names, dest) self.internal_name, setting_name, dest, self.flag = self.get_dest(group, names, dest)
args: Sequence[str] = names args: Sequence[str] = names
# We then also set the metavar so that '--config' in the group runtime shows as 'CONFIG' instead of 'RUNTIME_CONFIG' # We then also set the metavar so that '--config' in the group runtime shows as 'CONFIG' instead of 'RUNTIME_CONFIG'
@ -141,10 +160,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 self.flag:
if not flag: args = tuple((self.internal_name, *names[1:]))
args = tuple((f'{group}_{names[0]}'.lstrip('_'), *names[1:]))
self.action = action self.action = action
self.nargs = nargs self.nargs = nargs
@ -156,6 +174,7 @@ class Setting:
self.help = help self.help = help
self.metavar = metavar self.metavar = metavar
self.dest = dest self.dest = dest
self.setting_name = setting_name
self.cmdline = cmdline self.cmdline = cmdline
self.file = file self.file = file
self.argparse_args = args self.argparse_args = args
@ -173,7 +192,7 @@ class Setting:
'required': required, 'required': required,
'help': help, 'help': help,
'metavar': metavar, 'metavar': metavar,
'dest': self.internal_name if flag else None, 'dest': self.internal_name if self.flag else None,
} }
def __str__(self) -> str: # pragma: no cover def __str__(self) -> str: # pragma: no cover
@ -187,12 +206,19 @@ class Setting:
return NotImplemented return NotImplemented
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
def _guess_type(self) -> type | Literal['Any'] | None: def _guess_type(self) -> type | str | None:
if self.type is None and self.action is None: if self.type is None and self.action is None:
if self.cmdline: if self.cmdline:
if self.nargs in ('+', '*') or isinstance(self.nargs, int) and self.nargs > 1:
return List[str]
return str return str
else: else:
if not self.cmdline and self.default is not None: if not self.cmdline and self.default is not None:
if not isinstance(self.default, str) and not _isnamedtupleinstance(self.default) and isinstance(self.default, Sequence) and self.default and self.default[0]:
try:
return cast(type, type(self.default)[type(self.default[0])])
except Exception:
...
return type(self.default) return type(self.default)
return 'Any' return 'Any'
@ -201,9 +227,15 @@ class Setting:
if self.type is not None: if self.type is not None:
type_hints = typing.get_type_hints(self.type) type_hints = typing.get_type_hints(self.type)
if 'return' in type_hints and isinstance(type_hints['return'], type): if 'return' in type_hints:
return type_hints['return'] t: type | str = type_hints['return']
return t
if self.default is not None: if self.default is not None:
if not isinstance(self.default, str) and not _isnamedtupleinstance(self.default) and isinstance(self.default, Sequence) and self.default and self.default[0]:
try:
return cast(type, type(self.default)[type(self.default[0])])
except Exception:
...
return type(self.default) return type(self.default)
return 'Any' return 'Any'
@ -226,27 +258,30 @@ class Setting:
return None return None
return 'Any' 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, str, bool]:
dest_name = None setting_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
dest_name = sanitize_name(n) setting_name = sanitize_name(n)
break break
if n.startswith('-'): if n.startswith('-'):
flag = True flag = True
if dest_name is None: if setting_name is None:
dest_name = names[0] setting_name = names[0]
if dest: if dest:
dest_name = dest dest_name = dest
else:
dest_name = setting_name
if not dest_name.isidentifier(): if not dest_name.isidentifier():
raise Exception(f'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, setting_name, dest_name, flag
def filter_argparse_kwargs(self) -> dict[str, Any]: def filter_argparse_kwargs(self) -> dict[str, Any]:
return {k: v for k, v in self.argparse_kwargs.items() if v is not None} return {k: v for k, v in self.argparse_kwargs.items() if v is not None}
@ -255,6 +290,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]
@ -263,7 +303,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]):
@ -273,47 +313,133 @@ 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: def generate_ns(definitions: Definitions) -> tuple[str, str]:
imports = ['from __future__ import annotations', 'import typing', 'import settngs'] initial_imports = ['from __future__ import annotations', '', 'import settngs']
ns = 'class settngs_namespace(settngs.Namespace):\n' imports: Sequence[str] | set[str]
types = [] imports = set()
for group_name, group in definitions.items():
for setting_name, setting in group.v.items(): attributes = []
for group in definitions.values():
for setting in group.v.values():
t = setting._guess_type() t = setting._guess_type()
if t is None: if t is None:
continue continue
# Default to any
type_name = 'Any' type_name = 'Any'
# Take a string as is
if isinstance(t, str): if isinstance(t, str):
type_name = t type_name = t
elif type(t) == types_GenericAlias: # Handle generic aliases eg dict[str, str] instead of dict
elif isinstance(t, types_GenericAlias):
type_name = str(t) type_name = str(t)
# Handle standard type objects
elif isinstance(t, type): elif isinstance(t, type):
type_name = t.__name__ type_name = t.__name__
# Builtin types don't need an import
if t.__module__ != 'builtins': if t.__module__ != 'builtins':
imports.append(f'import {t.__module__}') imports.add(f'import {t.__module__}')
# Use the full imported name
type_name = t.__module__ + '.' + type_name type_name = t.__module__ + '.' + type_name
# Expand Any to typing.Any
if type_name == 'Any': if type_name == 'Any':
type_name = 'typing.Any' type_name = 'typing.Any'
types.append(f' {setting.internal_name}: {type_name}') attribute = f' {setting.internal_name}: {type_name}'
if types and types[-1] != '': if attribute not in attributes:
types.append('') attributes.append(attribute)
# Add a blank line between groups
if attributes and attributes[-1] != '':
attributes.append('')
if not types or all(x == '' for x in types): ns = 'class SettngsNS(settngs.TypedNS):\n'
# Add a '...' expression if there are no attributes
if not attributes or all(x == '' for x in attributes):
ns += ' ...\n' ns += ' ...\n'
types = [''] attributes = ['']
return '\n'.join(imports) + '\n\n' + ns + '\n'.join(types) # Add the tying import before extra imports
if 'typing.' in '\n'.join(attributes):
initial_imports.append('import typing')
# Remove the possible duplicate typing import
imports = sorted(list(imports - {'import typing'}))
# Merge the imports the ns class definition and the attributes
return '\n'.join(initial_imports + imports), ns + '\n'.join(attributes)
def generate_dict(definitions: Definitions) -> tuple[str, str]:
initial_imports = ['from __future__ import annotations', '', 'import typing']
imports: Sequence[str] | set[str]
imports = set()
groups_are_identifiers = all(n.isidentifier() for n in definitions.keys())
classes = []
for group_name, group in definitions.items():
attributes = []
for setting in group.v.values():
t = setting._guess_type()
if t is None:
continue
# Default to any
type_name = 'Any'
# Take a string as is
if isinstance(t, str):
type_name = t
# Handle generic aliases eg dict[str, str] instead of dict
elif isinstance(t, types_GenericAlias):
type_name = str(t)
# Handle standard type objects
elif isinstance(t, type):
type_name = t.__name__
# Builtin types don't need an import
if t.__module__ != 'builtins':
imports.add(f'import {t.__module__}')
# Use the full imported name
type_name = t.__module__ + '.' + type_name
# Expand Any to typing.Any
if type_name == 'Any':
type_name = 'typing.Any'
attribute = f' {setting.dest}: {type_name}'
if attribute not in attributes:
attributes.append(attribute)
if not attributes or all(x == '' for x in attributes):
attributes = [' ...']
classes.append(
f'class {sanitize_name(group_name)}(typing.TypedDict):\n'
+ '\n'.join(attributes) + '\n\n',
)
# Remove the possible duplicate typing import
imports = sorted(list(imports - {'import typing'}))
if groups_are_identifiers:
ns = '\nclass SettngsDict(typing.TypedDict):\n'
ns += '\n'.join(f' {n}: {sanitize_name(n)}' for n in definitions.keys())
else:
ns = '\nSettngsDict = typing.TypedDict(\n'
ns += " 'SettngsDict', {\n"
for n in definitions.keys():
ns += f' {n!r}: {sanitize_name(n)},\n'
ns += ' },\n'
ns += ')\n'
# Merge the imports the ns class definition and the attributes
return '\n'.join(initial_imports + imports), '\n'.join(classes) + ns + '\n'
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 current value is the default value Helper function to retrieve the value for a setting and if the current value is the default value
@ -337,7 +463,7 @@ def get_options(config: Config[T], group: str) -> dict[str, Any]:
group: The name of the group to retrieve group: The name of the group to retrieve
""" """
if isinstance(config[0], dict): if isinstance(config[0], dict):
values = config[0].get(group, {}).copy() values: dict[str, Any] = config[0].get(group, {}).copy()
else: else:
internal_names = {x.internal_name: x for x in config[1][group].v.values()} internal_names = {x.internal_name: x for x in config[1][group].v.values()}
values = {} values = {}
@ -347,11 +473,34 @@ def get_options(config: Config[T], group: str) -> dict[str, Any]:
if name in internal_names: if name in internal_names:
values[internal_names[name].dest] = value values[internal_names[name].dest] = value
else: else:
values[removeprefix(name, f'{group}_')] = value values[removeprefix(name, f'{group}').lstrip('_')] = value
return values return values
def get_groups(values: Values | Namespace | TypedNS) -> list[str]:
if isinstance(values, dict):
return [x[0] for x in values.items() if isinstance(x[1], dict)]
if isinstance(values, Namespace):
groups = set()
for name in values.__dict__:
if '__' in name:
group, _, _ = name.partition('__')
groups.add(group.replace('_', ' '))
else:
groups.add('')
return list(groups)
return []
def _get_internal_definitions(config: Config[T], persistent: bool) -> Definitions:
definitions = copy.deepcopy(dict(config.definitions))
if persistent:
for group_name in get_groups(config.values):
if group_name not in definitions:
definitions[group_name] = Group(True, {})
return defaultdict(lambda: Group(False, {}), definitions)
def normalize_config( def normalize_config(
config: Config[T], config: Config[T],
file: bool = False, file: bool = False,
@ -369,33 +518,35 @@ def normalize_config(
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
default: Include default values in the returned Config object default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups and unknown groups
""" """
if not file and not cmdline: if not file and not cmdline:
raise ValueError('Invalid parameters: you must set either file or cmdline to True') raise ValueError('Invalid parameters: you must set either file or cmdline to True')
normalized: Values = {} normalized: Values = {}
options, definitions = config options = config.values
definitions = _get_internal_definitions(config=config, persistent=persistent)
for group_name, group in definitions.items(): for group_name, group in definitions.items():
group_options = {} group_options = {}
if group.persistent and persistent: if group.persistent and persistent:
group_options = get_options(config, group_name) group_options = get_options(Config(options, definitions), group_name)
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, is_default = get_option(options, setting) value, is_default = get_option(options, setting)
if not is_default or default: if not is_default or default:
# User has set a custom value or has requested the default value # User has set a custom value or has requested the default value
group_options[setting_name] = value group_options[setting.dest] = value
elif setting_name in group_options: elif setting.dest in group_options:
# default values have been requested to be removed # default values have been requested to be removed
del group_options[setting_name] del group_options[setting.dest]
elif setting_name in group_options: elif setting.dest in group_options:
# Setting type (file or cmdline) has not been requested and should be removed for persistent groups # Setting type (file or cmdline) has not been requested and should be removed for persistent groups
del group_options[setting_name] del group_options[setting.dest]
normalized[group_name] = group_options normalized[group_name] = group_options
return Config(normalized, definitions)
return Config(normalized, config.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]:
@ -438,7 +589,7 @@ def clean_config(
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
default: Include default values in the returned Config object default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups and unknown groups
""" """
cleaned, _ = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent) cleaned, _ = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent)
@ -464,21 +615,22 @@ def get_namespace(
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
default: Include default values in the returned Config object default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups and unknown groups
""" """
if not file and not cmdline: if not file and not cmdline:
raise ValueError('Invalid parameters: you must set either file or cmdline to True') raise ValueError('Invalid parameters: you must set either file or cmdline to True')
if isinstance(config.values, Namespace): options: Values
cfg = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent) definitions = _get_internal_definitions(config=config, persistent=persistent)
options, definitions = cfg if isinstance(config.values, dict):
options = config.values
else: else:
options, definitions = config cfg = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent)
options = cfg.values
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) group_options = get_options(Config(options, definitions), group_name)
if group.persistent and persistent: if group.persistent and persistent:
for name, value in group_options.items(): for name, value in group_options.items():
if name in group.v: if name in group.v:
@ -487,7 +639,7 @@ def get_namespace(
internal_name = group.v[name].internal_name internal_name = group.v[name].internal_name
else: else:
setting_file = setting_cmdline = True setting_file = setting_cmdline = True
internal_name, is_default = f'{group_name}_' + sanitize_name(name), None internal_name, is_default = f'{group_name}__' + sanitize_name(name), None
if ((setting_cmdline and cmdline) or (setting_file and file)) and (not is_default or default): if ((setting_cmdline and cmdline) or (setting_file and file)) and (not is_default or default):
setattr(namespace, internal_name, value) setattr(namespace, internal_name, value)
@ -499,7 +651,7 @@ def get_namespace(
if not is_default or default: if not is_default or default:
# User has set a custom value or has requested the default value # 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, config.definitions)
def save_file( def save_file(
@ -548,8 +700,11 @@ def create_argparser(definitions: Definitions, description: str, epilog: str) ->
else: else:
groups[setting.group] = argparser.add_argument_group(setting.group) groups[setting.group] = argparser.add_argument_group(setting.group)
# hard coded exception for files # Hard coded exception for positional arguments
if not (setting.group == 'runtime' and setting.nargs == '*'): # Ensures that the option shows at the top of the help output
if 'runtime' in setting.group.casefold() and setting.nargs == '*' and not setting.flag:
current_group = argparser
else:
current_group = groups[setting.group] current_group = groups[setting.group]
current_group.add_argument(*argparse_args, **argparse_kwargs) current_group.add_argument(*argparse_args, **argparse_kwargs)
return argparser return argparser
@ -573,7 +728,7 @@ def parse_cmdline(
args: Passed to argparse.ArgumentParser.parse_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 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
@ -581,6 +736,7 @@ def parse_cmdline(
namespace = get_namespace(config, file=True, cmdline=True, default=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)
@ -624,24 +780,34 @@ class Manager:
self.description = description self.description = description
self.epilog = epilog self.epilog = epilog
self.definitions: Definitions
if isinstance(definitions, Config): if isinstance(definitions, Config):
self.definitions = definitions.definitions self.definitions = defaultdict(lambda: Group(False, {}), dict(definitions.definitions) or {})
else: else:
self.definitions = defaultdict(lambda: Group(False, {}), definitions or {}) self.definitions = defaultdict(lambda: Group(False, {}), dict(definitions or {}))
self.exclusive_group = False self.exclusive_group = False
self.current_group_name = '' self.current_group_name = ''
def generate_ns(self) -> str: def _get_config(self, c: T | Config[T]) -> Config[T]:
if not isinstance(c, Config):
return Config(c, self.definitions)
return c
def generate_ns(self) -> tuple[str, str]:
return generate_ns(self.definitions) return generate_ns(self.definitions)
def generate_dict(self) -> tuple[str, str]:
return generate_dict(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:
"""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.setting_name] = 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:
""" """
@ -652,6 +818,7 @@ class Manager:
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 != '': if self.current_group_name != '':
raise ValueError('Sub groups are not allowed') raise ValueError('Sub groups are not allowed')
self.current_group_name = name self.current_group_name = name
@ -670,6 +837,7 @@ class Manager:
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 != '': if self.current_group_name != '':
raise ValueError('Sub groups are not allowed') raise ValueError('Sub groups are not allowed')
self.current_group_name = name self.current_group_name = name
@ -702,9 +870,7 @@ class Manager:
cmdline: Include cmdline options cmdline: Include cmdline options
""" """
if not isinstance(config, Config): return clean_config(self._get_config(config), file=file, cmdline=cmdline)
config = Config(config, self.definitions)
return clean_config(config, file=file, cmdline=cmdline)
def normalize_config( def normalize_config(
self, self,
@ -724,13 +890,11 @@ class Manager:
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
default: Include default values in the returned Config object default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups and unknown groups
""" """
if not isinstance(config, Config):
config = Config(config, self.definitions)
return normalize_config( return normalize_config(
config=config, config=self._get_config(config),
file=file, file=file,
cmdline=cmdline, cmdline=cmdline,
default=default, default=default,
@ -739,7 +903,7 @@ class Manager:
def get_namespace( def get_namespace(
self, self,
config: Values | Config[Values], config: T | Config[T],
file: bool = False, file: bool = False,
cmdline: bool = False, cmdline: bool = False,
default: bool = True, default: bool = True,
@ -755,14 +919,12 @@ class Manager:
file: Include file options file: Include file options
cmdline: Include cmdline options cmdline: Include cmdline options
default: Include default values in the returned Config object default: Include default values in the returned Config object
persistent: Include unknown keys in persistent groups persistent: Include unknown keys in persistent groups and unknown groups
""" """
if isinstance(config, Config): return get_namespace(
self.definitions = config[1] self._get_config(config), file=file, cmdline=cmdline, default=default, persistent=persistent,
else: )
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]:
""" """
@ -773,6 +935,7 @@ class Manager:
Args: Args:
filename: A pathlib.Path object to read a JSON dictionary from 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, config: T | Config[T], filename: pathlib.Path) -> bool: def save_file(self, config: T | Config[T], filename: pathlib.Path) -> bool:
@ -785,9 +948,8 @@ class Manager:
config: 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
""" """
if not isinstance(config, Config):
config = Config(config, self.definitions) return save_file(self._get_config(config), filename=filename)
return save_file(config, filename=filename)
def parse_cmdline(self, args: list[str] | None = None, config: ns[T] = None) -> Config[Values]: def parse_cmdline(self, args: list[str] | None = None, config: ns[T] = None) -> Config[Values]:
""" """
@ -815,11 +977,12 @@ class Manager:
__all__ = [ __all__ = [
'Setting', 'Setting',
'TypedNS',
'Group', 'Group',
'Values', 'Values',
'Definitions', 'Definitions',
'Config', 'Config',
'generate_settings', 'generate_ns',
'sanitize_name', 'sanitize_name',
'get_option', 'get_option',
'get_options', 'get_options',
@ -866,7 +1029,7 @@ 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_group) manager.add_group('Example Group', example_group)
manager.add_persistent_group('persistent', persistent_group) manager.add_persistent_group('persistent', persistent_group)
file_config, success = manager.parse_file(settings_path) file_config, success = manager.parse_file(settings_path)
@ -875,14 +1038,14 @@ def _main(args: list[str] | None = None) -> None:
merged_config = manager.parse_cmdline(args=args, config=file_namespace) merged_config = manager.parse_cmdline(args=args, config=file_namespace)
merged_namespace = manager.get_namespace(merged_config, file=True, cmdline=True) 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: # pragma: no cover
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__':

View File

@ -90,6 +90,7 @@ fail_under = 95
[mypy] [mypy]
check_untyped_defs = true check_untyped_defs = true
disallow_any_generics = true disallow_any_generics = true
warn_return_any = true
disallow_incomplete_defs = true disallow_incomplete_defs = true
disallow_untyped_defs = true disallow_untyped_defs = true
no_implicit_optional = true no_implicit_optional = true
@ -97,7 +98,9 @@ warn_redundant_casts = true
warn_unused_ignores = true warn_unused_ignores = true
[mypy-testing.*] [mypy-testing.*]
warn_return_any = false
disallow_untyped_defs = false disallow_untyped_defs = false
[mypy-tests.*] [mypy-tests.*]
warn_return_any = false
disallow_untyped_defs = false disallow_untyped_defs = false

View File

@ -15,52 +15,52 @@ 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 = [
@ -77,13 +77,15 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': 'test_setting', # dest is calculated by Setting and is not used by argparse
'dest': 'test_setting', # dest is calculated by Setting and is not used by argparse 'dest': 'test_setting', # dest is calculated by Setting and is not used by argparse
'display_name': 'test_setting', # defaults to dest 'display_name': 'test_setting', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': True,
'group': 'tst', 'group': 'tst',
'help': None, 'help': None,
'internal_name': 'tst_test_setting', # Should almost always be "{group}_{dest}" 'internal_name': 'tst__test_setting', # Should almost always be "{group}__{dest}"
'metavar': 'TEST_SETTING', # Set manually so argparse doesn't use TST_TEST 'metavar': 'TEST_SETTING', # Set manually so argparse doesn't use TST_TEST
'nargs': None, 'nargs': None,
'required': None, 'required': None,
@ -94,7 +96,7 @@ success = [
'choices': None, 'choices': None,
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'tst_test_setting', 'dest': 'tst__test_setting',
'help': None, 'help': None,
'metavar': 'TEST_SETTING', 'metavar': 'TEST_SETTING',
'nargs': None, 'nargs': None,
@ -117,13 +119,15 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': 'test', # setting_name is calculated by Setting and is not used by argparse
'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 'display_name': 'testing', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': True,
'group': 'tst', 'group': 'tst',
'help': None, 'help': None,
'internal_name': 'tst_testing', # Should almost always be "{group}_{dest}" 'internal_name': 'tst__testing', # Should almost always be "{group}__{dest}"
'metavar': 'TESTING', # Set manually so argparse doesn't use TST_TEST 'metavar': 'TESTING', # Set manually so argparse doesn't use TST_TEST
'nargs': None, 'nargs': None,
'required': None, 'required': None,
@ -134,7 +138,7 @@ success = [
'choices': None, 'choices': None,
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'tst_testing', 'dest': 'tst__testing',
'help': None, 'help': None,
'metavar': 'TESTING', 'metavar': 'TESTING',
'nargs': None, 'nargs': None,
@ -156,13 +160,15 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': '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 'dest': 'test', # dest is calculated by Setting and is not used by argparse
'display_name': 'test', # defaults to dest 'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': True,
'group': 'tst', 'group': 'tst',
'help': None, 'help': None,
'internal_name': 'tst_test', # Should almost always be "{group}_{dest}" 'internal_name': 'tst__test', # Should almost always be "{group}__{dest}"
'metavar': 'TEST', # Set manually so argparse doesn't use TST_TEST 'metavar': 'TEST', # Set manually so argparse doesn't use TST_TEST
'nargs': None, 'nargs': None,
'required': None, 'required': None,
@ -173,7 +179,7 @@ success = [
'choices': None, 'choices': None,
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'tst_test', 'dest': 'tst__test',
'help': None, 'help': None,
'metavar': 'TEST', 'metavar': 'TEST',
'nargs': None, 'nargs': None,
@ -196,13 +202,15 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': '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 'dest': 'test', # dest is calculated by Setting and is not used by argparse
'display_name': 'test', # defaults to dest 'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': True,
'group': 'tst', 'group': 'tst',
'help': None, 'help': None,
'internal_name': 'tst_test', # Should almost always be "{group}_{dest}" 'internal_name': 'tst__test', # Should almost always be "{group}__{dest}"
'metavar': None, # store_true does not get a metavar 'metavar': None, # store_true does not get a metavar
'nargs': None, 'nargs': None,
'required': None, 'required': None,
@ -213,7 +221,7 @@ success = [
'choices': None, 'choices': None,
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'tst_test', 'dest': 'tst__test',
'help': None, 'help': None,
'metavar': None, 'metavar': None,
'nargs': None, 'nargs': None,
@ -235,13 +243,15 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': 'test',
'dest': 'test', 'dest': 'test',
'display_name': 'test', # defaults to dest 'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': True,
'group': 'tst', 'group': 'tst',
'help': None, 'help': None,
'internal_name': 'tst_test', 'internal_name': 'tst__test',
'metavar': 'TEST', 'metavar': 'TEST',
'nargs': None, 'nargs': None,
'required': None, 'required': None,
@ -252,7 +262,7 @@ success = [
'choices': None, 'choices': None,
'const': None, 'const': None,
'default': None, 'default': None,
'dest': 'tst_test', 'dest': 'tst__test',
'help': None, 'help': None,
'metavar': 'TEST', 'metavar': 'TEST',
'nargs': None, 'nargs': None,
@ -274,18 +284,20 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': 'test',
'dest': 'test', 'dest': 'test',
'display_name': 'test', # defaults to dest 'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': False,
'group': 'tst', 'group': 'tst',
'help': None, 'help': None,
'internal_name': 'tst_test', 'internal_name': 'tst__test',
'metavar': 'TEST', 'metavar': 'TEST',
'nargs': None, 'nargs': None,
'required': None, 'required': None,
'type': None, 'type': None,
'argparse_args': ('tst_test',), 'argparse_args': ('tst__test',),
'argparse_kwargs': { 'argparse_kwargs': {
'action': None, 'action': None,
'choices': None, 'choices': None,
@ -311,10 +323,12 @@ success = [
'cmdline': True, 'cmdline': True,
'const': None, 'const': None,
'default': None, 'default': None,
'setting_name': 'test',
'dest': 'test', 'dest': 'test',
'display_name': 'test', # defaults to dest 'display_name': 'test', # defaults to dest
'exclusive': False, 'exclusive': False,
'file': True, 'file': True,
'flag': True,
'group': '', 'group': '',
'help': None, 'help': None,
'internal_name': 'test', # No group, leading _ is stripped 'internal_name': 'test', # No group, leading _ is stripped

View File

@ -17,10 +17,40 @@ from testing.settngs import failure
from testing.settngs import success from testing.settngs import success
if sys.version_info < (3, 9): # pragma: no cover if sys.version_info >= (3, 10): # pragma: no cover
from typing import List
else:
List = list List = list
help_output = '''\
usage: __main__.py [-h] [TEST ...]
positional arguments:
TEST
options:
-h, --help show this help message and exit
'''
elif sys.version_info < (3, 9): # pragma: no cover
from typing import List
help_output = '''\
usage: __main__.py [-h] [TEST [TEST ...]]
positional arguments:
TEST
optional arguments:
-h, --help show this help message and exit
'''
else: # pragma: no cover
List = list
help_output = '''\
usage: __main__.py [-h] [TEST ...]
positional arguments:
TEST
optional arguments:
-h, --help show this help message and exit
'''
@pytest.fixture @pytest.fixture
@ -63,6 +93,55 @@ def test_add_setting(settngs_manager):
assert settngs_manager.add_setting('--test') is None assert settngs_manager.add_setting('--test') is None
def test_add_setting_invalid_name(settngs_manager):
with pytest.raises(Exception, match='Cannot use test¥ in a namespace'):
assert settngs_manager.add_setting('--test¥') is None
def test_sub_group(settngs_manager):
with pytest.raises(Exception, match='Sub groups are not allowed'):
settngs_manager.add_group('tst', lambda parser: parser.add_group('tst', lambda parser: parser.add_setting('--test2', default='hello')))
def test_sub_persistent_group(settngs_manager):
with pytest.raises(Exception, match='Sub groups are not allowed'):
settngs_manager.add_persistent_group('tst', lambda parser: parser.add_persistent_group('tst', lambda parser: parser.add_setting('--test2', default='hello')))
def test_redefine_persistent_group(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test2', default='hello'))
with pytest.raises(Exception, match='Group already exists and is not persistent'):
settngs_manager.add_persistent_group('tst', None)
def test_exclusive_group(settngs_manager):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'), exclusive_group=True)
settngs_manager.create_argparser()
args = settngs_manager.argparser.parse_args(['--test', 'never'])
assert args.tst__test == 'never'
with pytest.raises(SystemExit):
settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test2', default='hello'), exclusive_group=True)
settngs_manager.create_argparser()
args = settngs_manager.argparser.parse_args(['--test', 'never', '--test2', 'never'])
def test_files_group(capsys, settngs_manager):
settngs_manager.add_group('runtime', lambda parser: parser.add_setting('test', default='hello', nargs='*'))
settngs_manager.create_argparser()
settngs_manager.argparser.print_help()
captured = capsys.readouterr()
assert captured.out == help_output
def test_setting_without_group(capsys, settngs_manager):
settngs_manager.add_setting('test', default='hello', nargs='*')
settngs_manager.create_argparser()
settngs_manager.argparser.print_help()
captured = capsys.readouterr()
assert captured.out == help_output
class TestValues: class TestValues:
def test_invalid_normalize(self, settngs_manager): def test_invalid_normalize(self, settngs_manager):
@ -81,6 +160,11 @@ class TestValues:
defaults, _ = settngs_manager.defaults() defaults, _ = settngs_manager.defaults()
assert defaults['tst']['test'] == 'hello' 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): 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('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))
@ -125,6 +209,24 @@ class TestValues:
assert non_defaults_normalized.values['tst'] == {'test': 'world'} assert non_defaults_normalized.values['tst'] == {'test': 'world'}
assert non_defaults_normalized.values['tst_persistent'] == {'test': 'world'} assert non_defaults_normalized.values['tst_persistent'] == {'test': 'world'}
def test_normalize_dest(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', dest='test', 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): def test_normalize(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('--test', default='hello'))
settngs_manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world')) settngs_manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
@ -142,6 +244,29 @@ class TestValues:
assert normalized['persistent']['hello'] == 'success' assert normalized['persistent']['hello'] == 'success'
assert normalized['persistent']['world'] == 'world' assert normalized['persistent']['world'] == 'world'
def test_unknown_group(self):
manager = settngs.Manager()
manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
manager_unknown = settngs.Manager()
manager_unknown.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
# This manager doesn't know about this group
# manager_unknown.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
defaults = manager.defaults()
defaults.values['test'] = 'fail' # type: ignore[assignment] # Not defined in manager, should be removed
defaults.values['persistent']['hello'] = 'success' # Group is not defined in manager_unknown, should stay
normalized, _ = manager_unknown.normalize_config(defaults.values, 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'
class TestNamespace: class TestNamespace:
@ -159,7 +284,12 @@ class TestNamespace:
def test_get_defaults_group(self, settngs_manager): def test_get_defaults_group(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('--test', default='hello'))
defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True) defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
assert defaults.tst_test == 'hello' 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): 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('tst', lambda parser: parser.add_setting('--test', default='hello', file=False))
@ -168,11 +298,11 @@ class TestNamespace:
file_normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(settngs_manager.defaults(), file=True), file=True) 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) 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 'tst__test' not in file_normalized.__dict__
assert 'tst2_test2' in file_normalized.__dict__ assert 'tst2__test2' in file_normalized.__dict__
assert 'tst_test' in cmdline_normalized.__dict__ assert 'tst__test' in cmdline_normalized.__dict__
assert 'tst2_test2' not in cmdline_normalized.__dict__ assert 'tst2__test2' not in cmdline_normalized.__dict__
def test_cmdline_only_persistent_group(self, settngs_manager): 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_persistent_group('tst', lambda parser: parser.add_setting('--test', default='hello', file=False))
@ -181,11 +311,11 @@ class TestNamespace:
file_normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(settngs_manager.defaults(), file=True), file=True) 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) 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 'tst__test' not in file_normalized.__dict__
assert 'tst2_test2' in file_normalized.__dict__ assert 'tst2__test2' in file_normalized.__dict__
assert 'tst_test' in cmdline_normalized.__dict__ assert 'tst__test' in cmdline_normalized.__dict__
assert 'tst2_test2' not in cmdline_normalized.__dict__ assert 'tst2__test2' not in cmdline_normalized.__dict__
def test_normalize_defaults(self, settngs_manager): 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('--test', default='hello'))
@ -197,12 +327,29 @@ class TestNamespace:
assert defaults_normalized.values.__dict__ == {} assert defaults_normalized.values.__dict__ == {}
non_defaults = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True) non_defaults = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True)
non_defaults.values.tst_test = 'world' non_defaults.values.tst__test = 'world'
non_defaults.values.tst_persistent_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) 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__test == 'world'
assert non_defaults_normalized.values.tst_persistent_test == 'world' assert non_defaults_normalized.values.tst_persistent__test == 'world'
def test_normalize_dest(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', dest='test', 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): def test_normalize(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('--test', default='hello'))
@ -210,15 +357,37 @@ class TestNamespace:
defaults = settngs_manager.get_namespace(settngs_manager.defaults(), file=True, cmdline=True) 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.test = 'fail' # Not defined in settngs_manager, should be removed
defaults.values.persistent_hello = 'success' # Not defined in settngs_manager, should stay 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) normalized, _ = settngs_manager.get_namespace(settngs_manager.normalize_config(defaults, file=True), file=True)
assert not hasattr(normalized, 'test') assert not hasattr(normalized, 'test')
assert hasattr(normalized, 'tst_test') assert hasattr(normalized, 'tst__test')
assert normalized.tst_test == 'hello' assert normalized.tst__test == 'hello'
assert normalized.persistent_hello == 'success' assert normalized.persistent__hello == 'success'
assert normalized.persistent_world == 'world' assert normalized.persistent__world == 'world'
def test_normalize_unknown_group(self, settngs_manager):
manager = settngs.Manager()
manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
manager.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
manager_unknown = settngs.Manager()
manager_unknown.add_group('tst', lambda parser: parser.add_setting('--test', default='hello'))
# This manager doesn't know about this group
# manager_unknown.add_persistent_group('persistent', lambda parser: parser.add_setting('--world', default='world'))
defaults = manager.get_namespace(manager.defaults(), file=True, cmdline=True)
defaults.values.test = 'fail' # Not defined in manager, should be removed
defaults.values.persistent__hello = 'success' # Not defined in manager, should stay
normalized, _ = manager_unknown.get_namespace(defaults.values, 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):
@ -230,7 +399,7 @@ def test_get_namespace_with_namespace(settngs_manager):
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(), file=True) defaults, _ = settngs_manager.get_namespace(settngs_manager.defaults(), file=True)
assert defaults.tst_test == 'hello' assert defaults.tst__test == 'hello'
def test_clean_config(settngs_manager): def test_clean_config(settngs_manager):
@ -261,8 +430,8 @@ def test_parse_cmdline(settngs_manager):
namespaces = ( namespaces = (
lambda definitions: settngs.Config({'tst': {'test': 'fail', 'test2': 'success'}}, definitions), 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: settngs.Config(argparse.Namespace(tst__test='fail', tst__test2='success'), definitions),
lambda definitions: argparse.Namespace(tst_test='fail', tst_test2='success'), lambda definitions: argparse.Namespace(tst__test='fail', tst__test2='success'),
) )
@ -480,77 +649,140 @@ class _customAction(argparse.Action): # pragma: no cover
types = ( types = (
(settngs.Setting('-t', '--test'), str), (0, settngs.Setting('-t', '--test'), str),
(settngs.Setting('-t', '--test', cmdline=False), 'Any'), (1, settngs.Setting('-t', '--test', cmdline=False), 'Any'),
(settngs.Setting('-t', '--test', default=1, file=True, cmdline=False), int), (2, settngs.Setting('-t', '--test', default=1, file=True, cmdline=False), int),
(settngs.Setting('-t', '--test', action='count'), int), (3, settngs.Setting('-t', '--test', action='count'), int),
(settngs.Setting('-t', '--test', action='append'), List[str]), (4, settngs.Setting('-t', '--test', action='append'), List[str]),
(settngs.Setting('-t', '--test', action='extend'), List[str]), (5, settngs.Setting('-t', '--test', action='extend'), List[str]),
(settngs.Setting('-t', '--test', action='store_const', const=1), int), (6, settngs.Setting('-t', '--test', nargs='+'), List[str]),
(settngs.Setting('-t', '--test', action='append_const', const=1), list), (7, settngs.Setting('-t', '--test', action='store_const', const=1), int),
(settngs.Setting('-t', '--test', action='store_true'), bool), (8, settngs.Setting('-t', '--test', action='append_const', const=1), list),
(settngs.Setting('-t', '--test', action='store_false'), bool), (9, settngs.Setting('-t', '--test', action='store_true'), bool),
(settngs.Setting('-t', '--test', action=settngs.BooleanOptionalAction), bool), (10, settngs.Setting('-t', '--test', action='store_false'), bool),
(settngs.Setting('-t', '--test', action=_customAction), 'Any'), (11, settngs.Setting('-t', '--test', action=settngs.BooleanOptionalAction), bool),
(settngs.Setting('-t', '--test', action='help'), None), (12, settngs.Setting('-t', '--test', action=_customAction), 'Any'),
(settngs.Setting('-t', '--test', action='version'), None), (13, settngs.Setting('-t', '--test', action='help'), None),
(settngs.Setting('-t', '--test', type=int), int), (14, settngs.Setting('-t', '--test', action='version'), None),
(settngs.Setting('-t', '--test', type=_typed_function), test_type), (15, settngs.Setting('-t', '--test', type=int), int),
(settngs.Setting('-t', '--test', type=_untyped_function, default=1), int), (16, settngs.Setting('-t', '--test', type=_typed_function), test_type),
(settngs.Setting('-t', '--test', type=_untyped_function), 'Any'), (17, settngs.Setting('-t', '--test', type=_untyped_function, default=1), int),
(18, settngs.Setting('-t', '--test', type=_untyped_function), 'Any'),
) )
@pytest.mark.parametrize('setting,typ', types) @pytest.mark.parametrize('num,setting,typ', types)
def test_guess_type(setting, typ): def test_guess_type(num, setting, typ):
guessed_type = setting._guess_type() guessed_type = setting._guess_type()
assert guessed_type == typ assert guessed_type == typ
expected_src = '''from __future__ import annotations
import settngs
{extra_imports}
class SettngsNS(settngs.TypedNS):
test__test: {typ}
'''
no_type_expected_src = '''from __future__ import annotations
import settngs
class SettngsNS(settngs.TypedNS):
...
'''
settings = ( settings = (
(lambda parser: parser.add_setting('-t', '--test'), 'str'), (0, lambda parser: parser.add_setting('-t', '--test'), expected_src.format(extra_imports='', typ='str')),
(lambda parser: parser.add_setting('-t', '--test', cmdline=False), 'typing.Any'), (1, lambda parser: parser.add_setting('-t', '--test', cmdline=False), expected_src.format(extra_imports='import typing\n', typ='typing.Any')),
(lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), 'int'), (2, lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), expected_src.format(extra_imports='', typ='int')),
(lambda parser: parser.add_setting('-t', '--test', action='count'), 'int'), (3, lambda parser: parser.add_setting('-t', '--test', action='count'), expected_src.format(extra_imports='', typ='int')),
(lambda parser: parser.add_setting('-t', '--test', action='append'), List[str]), (4, lambda parser: parser.add_setting('-t', '--test', action='append'), expected_src.format(extra_imports='import typing\n' if sys.version_info < (3, 9) else '', typ='typing.List[str]' if sys.version_info < (3, 9) else 'list[str]')),
(lambda parser: parser.add_setting('-t', '--test', action='extend'), List[str]), (5, lambda parser: parser.add_setting('-t', '--test', action='extend'), expected_src.format(extra_imports='import typing\n' if sys.version_info < (3, 9) else '', typ='typing.List[str]' if sys.version_info < (3, 9) else 'list[str]')),
(lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), 'int'), (6, lambda parser: parser.add_setting('-t', '--test', nargs='+'), expected_src.format(extra_imports='import typing\n' if sys.version_info < (3, 9) else '', typ='typing.List[str]' if sys.version_info < (3, 9) else 'list[str]')),
(lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), 'list'), (7, lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), expected_src.format(extra_imports='', typ='int')),
(lambda parser: parser.add_setting('-t', '--test', action='store_true'), 'bool'), (8, lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), expected_src.format(extra_imports='', typ='list')),
(lambda parser: parser.add_setting('-t', '--test', action='store_false'), 'bool'), (9, lambda parser: parser.add_setting('-t', '--test', action='store_true'), expected_src.format(extra_imports='', typ='bool')),
(lambda parser: parser.add_setting('-t', '--test', action=settngs.BooleanOptionalAction), 'bool'), (10, lambda parser: parser.add_setting('-t', '--test', action='store_false'), expected_src.format(extra_imports='', typ='bool')),
(lambda parser: parser.add_setting('-t', '--test', action=_customAction), 'typing.Any'), (11, lambda parser: parser.add_setting('-t', '--test', action=settngs.BooleanOptionalAction), expected_src.format(extra_imports='', typ='bool')),
(lambda parser: parser.add_setting('-t', '--test', action='help'), None), (12, lambda parser: parser.add_setting('-t', '--test', action=_customAction), expected_src.format(extra_imports='import typing\n', typ='typing.Any')),
(lambda parser: parser.add_setting('-t', '--test', action='version'), None), (13, lambda parser: parser.add_setting('-t', '--test', action='help'), no_type_expected_src),
(lambda parser: parser.add_setting('-t', '--test', type=int), 'int'), (14, lambda parser: parser.add_setting('-t', '--test', action='version'), no_type_expected_src),
(lambda parser: parser.add_setting('-t', '--test', type=_typed_function), 'tests.settngs_test.test_type'), (15, lambda parser: parser.add_setting('-t', '--test', type=int), expected_src.format(extra_imports='', typ='int')),
(lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=1), 'int'), (16, lambda parser: parser.add_setting('-t', '--test', type=_typed_function), expected_src.format(extra_imports='import tests.settngs_test\n', typ='tests.settngs_test.test_type')),
(lambda parser: parser.add_setting('-t', '--test', type=_untyped_function), 'typing.Any'), (17, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=1), expected_src.format(extra_imports='', typ='int')),
(18, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function), expected_src.format(extra_imports='import typing\n', typ='typing.Any')),
) )
@pytest.mark.parametrize('set_options,typ', settings) @pytest.mark.parametrize('num,set_options,expected', settings)
def test_generate_ns(settngs_manager, set_options, typ): def test_generate_ns(settngs_manager, num, set_options, expected):
settngs_manager.add_group('test', set_options) settngs_manager.add_group('test', set_options)
src = '''\ imports, types = settngs_manager.generate_ns()
from __future__ import annotations generated_src = '\n\n\n'.join((imports, types))
assert generated_src == expected
ast.parse(generated_src)
expected_src_dict = '''from __future__ import annotations
import typing import typing
import settngs {extra_imports}
'''
if typ == 'tests.settngs_test.test_type':
src += 'import tests.settngs_test\n'
src += '''
class settngs_namespace(settngs.Namespace):
'''
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() class test(typing.TypedDict):
test: {typ}
assert generated_src == src
class SettngsDict(typing.TypedDict):
test: test
'''
no_type_expected_src_dict = '''from __future__ import annotations
import typing
class test(typing.TypedDict):
...
class SettngsDict(typing.TypedDict):
test: test
'''
settings_dict = (
(0, lambda parser: parser.add_setting('-t', '--test'), expected_src_dict.format(extra_imports='', typ='str')),
(1, lambda parser: parser.add_setting('-t', '--test', cmdline=False), expected_src_dict.format(extra_imports='', typ='typing.Any')),
(2, lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), expected_src_dict.format(extra_imports='', typ='int')),
(3, lambda parser: parser.add_setting('-t', '--test', action='count'), expected_src_dict.format(extra_imports='', typ='int')),
(4, lambda parser: parser.add_setting('-t', '--test', action='append'), expected_src_dict.format(extra_imports='' if sys.version_info < (3, 9) else '', typ='typing.List[str]' if sys.version_info < (3, 9) else 'list[str]')),
(5, lambda parser: parser.add_setting('-t', '--test', action='extend'), expected_src_dict.format(extra_imports='' if sys.version_info < (3, 9) else '', typ='typing.List[str]' if sys.version_info < (3, 9) else 'list[str]')),
(6, lambda parser: parser.add_setting('-t', '--test', nargs='+'), expected_src_dict.format(extra_imports='' if sys.version_info < (3, 9) else '', typ='typing.List[str]' if sys.version_info < (3, 9) else 'list[str]')),
(7, lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), expected_src_dict.format(extra_imports='', typ='int')),
(8, lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), expected_src_dict.format(extra_imports='', typ='list')),
(9, lambda parser: parser.add_setting('-t', '--test', action='store_true'), expected_src_dict.format(extra_imports='', typ='bool')),
(10, lambda parser: parser.add_setting('-t', '--test', action='store_false'), expected_src_dict.format(extra_imports='', typ='bool')),
(11, lambda parser: parser.add_setting('-t', '--test', action=settngs.BooleanOptionalAction), expected_src_dict.format(extra_imports='', typ='bool')),
(12, lambda parser: parser.add_setting('-t', '--test', action=_customAction), expected_src_dict.format(extra_imports='', typ='typing.Any')),
(13, lambda parser: parser.add_setting('-t', '--test', action='help'), no_type_expected_src_dict),
(14, lambda parser: parser.add_setting('-t', '--test', action='version'), no_type_expected_src_dict),
(15, lambda parser: parser.add_setting('-t', '--test', type=int), expected_src_dict.format(extra_imports='', typ='int')),
(16, lambda parser: parser.add_setting('-t', '--test', type=_typed_function), expected_src_dict.format(extra_imports='import tests.settngs_test\n', typ='tests.settngs_test.test_type')),
(17, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=1), expected_src_dict.format(extra_imports='', typ='int')),
(18, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function), expected_src_dict.format(extra_imports='', typ='typing.Any')),
)
@pytest.mark.parametrize('num,set_options,expected', settings_dict)
def test_generate_dict(settngs_manager, num, set_options, expected):
settngs_manager.add_group('test', set_options)
imports, types = settngs_manager.generate_dict()
generated_src = '\n\n\n'.join((imports, types))
assert generated_src == expected
ast.parse(generated_src) ast.parse(generated_src)
@ -564,7 +796,7 @@ 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 i += 1
continue continue
else: else: