14 Commits

Author SHA1 Message Date
d284039f12 Update pre-commit 2025-08-30 20:26:32 -07:00
5fc7a5173d Update tests
Remove python3.8 leftovers
Add prog to create_argparser
Remove metavar usage from BooleanOptionalAction
2025-08-30 20:26:18 -07:00
ecda6fe610 Extract arguments for generic types 2025-08-30 20:07:43 -07:00
cfc55b7366 Drop python 3.8 2025-05-01 17:13:07 -07:00
6b158ca495 Handle lists of Enum 2025-05-01 17:01:34 -07:00
3b0ae0f24a [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0)
- [github.com/hhatto/autopep8: v2.2.0 → v2.3.1](https://github.com/hhatto/autopep8/compare/v2.2.0...v2.3.1)
- [github.com/PyCQA/flake8: 7.0.0 → 7.1.0](https://github.com/PyCQA/flake8/compare/7.0.0...7.1.0)
2024-06-24 17:18:37 +00:00
f1735c879d Don't inherit from argparse Namespace 2024-06-06 19:33:07 -07:00
afdc11eb6f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder-python-imports: v3.12.0 → v3.13.0](https://github.com/asottile/reorder-python-imports/compare/v3.12.0...v3.13.0)
- [github.com/hhatto/autopep8: v2.1.1 → v2.2.0](https://github.com/hhatto/autopep8/compare/v2.1.1...v2.2.0)
2024-06-03 17:17:48 +00:00
9b71af1c75 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/hhatto/autopep8: v2.1.0 → v2.1.1](https://github.com/hhatto/autopep8/compare/v2.1.0...v2.1.1)
2024-05-27 17:15:51 +00:00
8d772c6513 Improve types 2024-05-20 15:09:33 -07:00
73552e0dd2 Improve Generic list and set typing 2024-05-19 14:11:18 -07:00
58daa4b274 Fix deduplication 2024-05-18 16:46:44 -07:00
43f6bf1eac Improve type detection 2024-05-18 15:49:32 -07:00
eca7be0c51 Set optional None types 2024-05-18 13:03:50 -07:00
5 changed files with 334 additions and 275 deletions

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python_version: ['3.8', '3.9','3.10','3.11']
python_version: ['3.9','3.10','3.11','3.13']
os: [ubuntu-latest]
steps:

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@ -10,38 +10,39 @@ repos:
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.5.0
rev: v2.8.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/asottile/reorder-python-imports
rev: v3.12.0
rev: v3.15.0
hooks:
- id: reorder-python-imports
args: [--py38-plus, --add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/add-trailing-comma
rev: v3.1.0
rev: v3.2.0
hooks:
- id: add-trailing-comma
args: [--py36-plus]
- repo: https://github.com/asottile/dead
rev: v1.5.2
rev: v2.1.0
hooks:
- id: dead
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.2
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py38-plus]
exclude: tests
- repo: https://github.com/hhatto/autopep8
rev: v2.1.0
rev: v2.3.2
hooks:
- id: autopep8
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
rev: 7.3.0
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-print]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
rev: v1.17.1
hooks:
- id: mypy

View File

@ -10,84 +10,38 @@ import re
import sys
import typing
import warnings
from argparse import BooleanOptionalAction
from argparse import Namespace
from collections import defaultdict
from collections.abc import Sequence
from collections.abc import Set
from enum import Enum
from types import GenericAlias as types_GenericAlias
from typing import Any
from typing import Callable
from typing import cast
from typing import Collection
from typing import Dict
from typing import Generic
from typing import get_args
from typing import Mapping
from typing import NoReturn
from typing import Tuple
from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union
logger = logging.getLogger(__name__)
NoneType = type(None)
if sys.version_info < (3, 11): # pragma: no cover
from typing_extensions import NamedTuple
else: # pragma: no cover
from typing import NamedTuple
if sys.version_info < (3, 9): # pragma: no cover
from typing import List
from typing import _GenericAlias as types_GenericAlias
def removeprefix(self: str, prefix: str, /) -> str:
if self.startswith(prefix):
return self[len(prefix):]
else:
return self[:]
class BooleanOptionalAction(argparse.Action):
def __init__(
self,
option_strings,
dest,
default=None,
type=None, # noqa: A002
choices=None,
required=False,
help=None, # noqa: A002
metavar=None,
):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
if help is not None and default is not None and default is not argparse.SUPPRESS:
help += ' (default: %(default)s)'
super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar,
)
def __call__(self, parser, namespace, values, option_string=None): # dead: disable
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
else: # pragma: no cover
List = list
from types import GenericAlias as types_GenericAlias
from argparse import BooleanOptionalAction
removeprefix = str.removeprefix
def _isnamedtupleinstance(x: Any) -> bool:
def _isnamedtupleinstance(x: Any) -> bool: # pragma: no cover
t = type(x)
b = t.__bases__
@ -208,57 +162,114 @@ class Setting:
return NotImplemented
return self.__dict__ == other.__dict__
def _guess_type(self) -> type | str | 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:
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 'Any'
__no_type = object()
def _guess_collection(self) -> tuple[type | str | None, bool]:
def get_item_type(x: Any) -> type | None:
if x is None or not isinstance(x, (Set, Sequence)) or len(x) == 0:
t = self._process_type() # Specifically this is needed when using the extend action
if typing.get_args(t): # We need the item type not the type of the collection
return typing.get_args(t)[0] # type: ignore[no-any-return]
# Return None so that we get the default
return None if t is None else self.__no_type # type: ignore[return-value]
if isinstance(x, Set):
return type(next(iter(x)))
return type(x[0])
try:
list_type = self._process_type()
# if the type is a generic alias than return it immediately
if isinstance(list_type, types_GenericAlias) and issubclass(list_type.__origin__, Collection):
return list_type, self.default is None
if list_type is NoneType:
list_type = None
if list_type is None and self.default is not None:
list_type = type(self.default)
# Default to a list if we don't know what type of collection this is
if list_type is None or not issubclass(list_type, Collection) or issubclass(list_type, Enum):
list_type = list
# Get the item type (int) in list[int]
it = get_item_type(self.default)
if isinstance(self.type, type):
it = self.type
if it is self.__no_type:
return self._process_type() or list[str], self.default is None
# Try to get the generic alias for this type
if it is not None:
try:
ret = cast(type, list_type[it]), self.default is None # type: ignore[index]
return ret
except Exception:
...
# Fall back to list[str] if anything fails
return cast(type, list_type[str]), self.default is None # type: ignore[index]
except Exception:
return None, self.default is None
def _process_type(self) -> type | None:
if self.type is None:
return None
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:
t: type | str = type_hints['return']
return t
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 'Any'
return cast(dict[str, type], typing.get_type_hints(self.type)).get('return', None)
if self.action in ('store_true', 'store_false', BooleanOptionalAction):
return bool
def _guess_type_internal(self) -> tuple[type | str | None, bool]:
default_is_none = self.default is None
__action_to_type = {
'store_true': (bool, False),
'store_false': (bool, False),
BooleanOptionalAction: (bool, default_is_none),
'store_const': (type(self.const), default_is_none),
'count': (int, default_is_none),
'extend': self._guess_collection(),
'append_const': (cast(type, list[type(self.const)]), default_is_none), # type: ignore[misc]
'help': (None, default_is_none),
'version': (None, default_is_none),
}
if self.action in ('store_const',):
return type(self.const)
# Process standard actions
if self.action in __action_to_type:
return __action_to_type[self.action]
if self.action in ('count',):
return int
# nargs > 1 is always a list
if self.nargs in ('+', '*') or isinstance(self.nargs, int) and self.nargs > 1:
return self._guess_collection()
if self.action in ('append', 'extend'):
return List[str]
# Process the type argument
type_type = self._process_type()
if type_type is not None:
return type_type, default_is_none
if self.action in ('append_const',):
return list # list[type(self.const)]
# Check if a default value was given.
if self.default is not None:
if not isinstance(self.default, str) and not _isnamedtupleinstance(self.default) and isinstance(self.default, (Set, Sequence)):
return self._guess_collection()
# The type argument will convert this if it is a string. We only get here if type is a function without type hints
if not (isinstance(self.default, str) and self.type is not None):
return type(self.default), default_is_none
if self.action in ('help', 'version'):
return None
return 'Any'
# There is no way to detemine the type from an action
if callable(self.action):
return 'Any', default_is_none
# Finally if this is a commandline argument it will default to a string
if self.cmdline and self.type is None:
return str, default_is_none
# For file only settings it will default to Any
return 'Any', default_is_none
def _guess_type(self) -> tuple[type | str | None, bool]:
if self.action == 'append':
return cast(type, list[self._guess_type_internal()[0]]), self.default is None # type: ignore[misc]
return self._guess_type_internal()
def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, str, bool]:
setting_name = None
@ -292,7 +303,7 @@ class Setting:
return self.argparse_args, self.filter_argparse_kwargs()
class TypedNS:
class TypedNS():
def __init__(self) -> None:
raise TypeError('TypedNS cannot be instantiated')
@ -302,7 +313,8 @@ class Group(NamedTuple):
v: dict[str, Setting]
Values = Dict[str, Dict[str, Any]]
Values = Mapping[str, Any]
_values = Dict[str, Dict[str, Any]]
Definitions = Dict[str, Group]
T = TypeVar('T', bound=Union[Values, Namespace, TypedNS])
@ -315,7 +327,45 @@ class Config(NamedTuple, Generic[T]):
if TYPE_CHECKING:
ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser]
ns = Namespace | TypedNS | Config[T] | None
ns = Union[TypedNS, Config[T], None]
def _get_import(t: type) -> tuple[str, str]:
type_name = t.__name__
import_needed = ''
# Builtin types don't need an import
if t.__module__ != 'builtins':
import_needed = f'import {t.__module__}'
# Use the full imported name
type_name = t.__module__ + '.' + type_name
return type_name, import_needed
def _type_to_string(t: type | str) -> tuple[str, str]:
type_name = 'Any'
import_needed = ''
# 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):
args = get_args(t)
if args:
import_needed = ''
for arg in args:
_, arg_import = _get_import(arg)
import_needed += '\n' + arg_import
else:
t = t.__origin__.__name__
type_name = str(t)
# Handle standard type objects
elif isinstance(t, type):
type_name, import_needed = _get_import(t)
# Expand Any to typing.Any
if type_name == 'Any':
type_name = 'typing.Any'
return type_name.strip(), import_needed.strip()
def generate_ns(definitions: Definitions) -> tuple[str, str]:
@ -324,35 +374,21 @@ def generate_ns(definitions: Definitions) -> tuple[str, str]:
imports = set()
attributes = []
used_attributes: set[str] = set()
for group in definitions.values():
for setting in group.v.values():
t = setting._guess_type()
t, noneable = setting._guess_type()
if t is None:
continue
# Default to any
type_name = 'Any'
type_name, import_needed = _type_to_string(t)
imports.add(import_needed)
# 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.internal_name}: {type_name}'
if attribute not in attributes:
if noneable and type_name not in ('typing.Any', 'None'):
attribute = f' {setting.internal_name}: {type_name} | None'
else:
attribute = f' {setting.internal_name}: {type_name}'
if setting.internal_name not in used_attributes:
used_attributes.add(setting.internal_name)
attributes.append(attribute)
# Add a blank line between groups
if attributes and attributes[-1] != '':
@ -369,7 +405,7 @@ def generate_ns(definitions: Definitions) -> tuple[str, str]:
initial_imports.append('import typing')
# Remove the possible duplicate typing import
imports = sorted(list(imports - {'import typing'}))
imports = sorted(imports - {'import typing', ''})
# Merge the imports the ns class definition and the attributes
return '\n'.join(initial_imports + imports), ns + '\n'.join(attributes)
@ -384,34 +420,20 @@ def generate_dict(definitions: Definitions) -> tuple[str, str]:
classes = []
for group_name, group in definitions.items():
attributes = []
used_attributes: set[str] = set()
for setting in group.v.values():
t = setting._guess_type()
t, no_default = setting._guess_type()
if t is None:
continue
# Default to any
type_name = 'Any'
type_name, import_needed = _type_to_string(t)
imports.add(import_needed)
# 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:
if no_default and type_name not in ('typing.Any', 'None'):
attribute = f' {setting.dest}: {type_name} | None'
else:
attribute = f' {setting.dest}: {type_name}'
if setting.dest not in used_attributes:
used_attributes.add(setting.dest)
attributes.append(attribute)
if not attributes or all(x == '' for x in attributes):
attributes = [' ...']
@ -421,7 +443,7 @@ def generate_dict(definitions: Definitions) -> tuple[str, str]:
)
# Remove the possible duplicate typing import
imports = sorted(list(imports - {'import typing'}))
imports = sorted(list(imports - {'import typing', ''}))
if groups_are_identifiers:
ns = '\nclass SettngsDict(typing.TypedDict):\n'
@ -475,7 +497,7 @@ def get_options(config: Config[T], group: str) -> dict[str, Any]:
if name in internal_names:
values[internal_names[name].dest] = value
else:
values[removeprefix(name, f'{group}').lstrip('_')] = value
values[name.removeprefix(f'{group}').lstrip('_')] = value
return values
@ -526,7 +548,7 @@ def normalize_config(
if not file and not cmdline:
raise ValueError('Invalid parameters: you must set either file or cmdline to True')
normalized: Values = {}
normalized: dict[str, dict[str, Any]] = {}
options = config.values
definitions = _get_internal_definitions(config=config, persistent=persistent)
for group_name, group in definitions.items():
@ -594,7 +616,7 @@ def clean_config(
persistent: Include unknown keys in persistent groups and unknown groups
"""
cleaned, _ = normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent)
cleaned, _ = cast(Config[_values], normalize_config(config, file=file, cmdline=cmdline, default=default, persistent=persistent))
for group in list(cleaned.keys()):
if not cleaned[group]:
del cleaned[group]
@ -682,11 +704,12 @@ def save_file(
return True
def create_argparser(definitions: Definitions, description: str, epilog: str) -> argparse.ArgumentParser:
def create_argparser(definitions: Definitions, description: str, epilog: str, *, prog: str | None = None) -> argparse.ArgumentParser:
"""Creates an :class:`argparse.ArgumentParser` from all cmdline settings"""
groups: dict[str, ArgParser] = {}
argparser = argparse.ArgumentParser(
description=description, epilog=epilog, formatter_class=argparse.RawTextHelpFormatter,
prog=prog,
)
def get_current_group(setting: Setting) -> ArgParser:
@ -727,6 +750,8 @@ def parse_cmdline(
epilog: str,
args: list[str] | None = None,
config: ns[T] = None,
*,
prog: str | None = None,
) -> Config[Values]:
"""
Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`.
@ -748,7 +773,7 @@ def parse_cmdline(
else:
namespace = config
argparser = create_argparser(definitions, description, epilog)
argparser = create_argparser(definitions, description, epilog, prog=prog)
ns = argparser.parse_args(args, namespace=namespace)
return normalize_config(Config(ns, definitions), cmdline=True, file=True)
@ -785,11 +810,12 @@ def parse_config(
class Manager:
"""docstring for Manager"""
def __init__(self, description: str = '', epilog: str = '', definitions: Definitions | Config[T] | None = None):
def __init__(self, description: str = '', epilog: str = '', definitions: Definitions | Config[T] | None = None, *, prog: str | None = None):
# This one is never used, it just makes MyPy happy
self.argparser = argparse.ArgumentParser(description=description, epilog=epilog)
self.description = description
self.epilog = epilog
self.prog = prog
self.definitions: Definitions
if isinstance(definitions, Config):
@ -812,7 +838,7 @@ class Manager:
return generate_dict(self.definitions)
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, prog=self.prog)
def add_setting(self, *args: Any, **kwargs: Any) -> None:
"""Passes all arguments through to `Setting`, `group` and `exclusive` are already set"""
@ -1024,7 +1050,6 @@ def example_group(manager: Manager) -> None:
manager.add_setting(
'--verbose', '-v',
default=False,
metavar='nothing',
action=BooleanOptionalAction, # Added in Python 3.9
)
@ -1037,6 +1062,32 @@ def persistent_group(manager: Manager) -> None:
)
class SettngsNS(TypedNS):
Example_Group__hello: str
Example_Group__save: bool
Example_Group__verbose: bool
persistent__test: bool
class Example_Group(typing.TypedDict):
hello: str
save: bool
verbose: bool
class persistent(typing.TypedDict):
test: bool
SettngsDict = typing.TypedDict(
'SettngsDict', {
'Example Group': Example_Group,
'persistent': persistent,
},
)
def _main(args: list[str] | None = None) -> None:
settings_path = pathlib.Path('./settings.json')
manager = Manager(description='This is an example', epilog='goodbye!')
@ -1044,11 +1095,11 @@ def _main(args: list[str] | None = None) -> None:
manager.add_group('Example Group', example_group)
manager.add_persistent_group('persistent', persistent_group)
file_config, success = manager.parse_file(settings_path)
file_config, success = cast(Tuple[Config[SettngsDict], bool], manager.parse_file(settings_path))
file_namespace = manager.get_namespace(file_config, file=True, cmdline=True)
merged_config = manager.parse_cmdline(args=args, config=file_namespace)
merged_namespace = manager.get_namespace(merged_config, file=True, cmdline=True)
merged_config = cast(Config[SettngsDict], manager.parse_cmdline(args=args, config=file_namespace))
merged_namespace = cast(Config[SettngsNS], manager.get_namespace(merged_config, file=True, cmdline=True))
print(f'Hello {merged_config.values["Example Group"]["hello"]}') # noqa: T201
if merged_namespace.values.Example_Group__save:

View File

@ -9,7 +9,6 @@ author_email = timmy@narnian.us
license = MIT
license_files = LICENSE
classifiers =
License :: OSI Approved :: MIT License
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: Implementation :: CPython
@ -19,7 +18,7 @@ classifiers =
packages = find:
install_requires =
typing-extensions>=4.3.0;python_version < '3.11'
python_requires = >=3.8
python_requires = >=3.9
include_package_data = True
[options.packages.find]
@ -31,7 +30,7 @@ exclude =
settngs = py.typed
[tox:tox]
envlist = py3.8,py3.9,py3.10,py3.11,py3.12,pypy3
envlist = py3.9,py3.10,py3.11,py3.12,py3.13,pypy3
[testenv]
deps = -rrequirements-dev.txt

View File

@ -2,11 +2,13 @@ from __future__ import annotations
import argparse
import ast
from enum import Enum, auto
import json
import pathlib
import sys
from collections import defaultdict
from typing import Generator
from typing import NamedTuple
import pytest
@ -19,6 +21,7 @@ from testing.settngs import success
if sys.version_info >= (3, 10): # pragma: no cover
List = list
Set = set
help_output = '''\
usage: __main__.py [-h] [TEST ...]
@ -28,20 +31,9 @@ positional arguments:
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
Set = set
help_output = '''\
usage: __main__.py [-h] [TEST ...]
@ -123,12 +115,14 @@ def test_exclusive_group(settngs_manager):
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'])
args = settngs_manager.argparser.parse_args(['__main__.py', '--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.prog = '__main__.py'
settngs_manager.create_argparser()
settngs_manager.prog = '__main__.py'
settngs_manager.argparser.print_help()
captured = capsys.readouterr()
assert captured.out == help_output
@ -136,7 +130,9 @@ def test_files_group(capsys, settngs_manager):
def test_setting_without_group(capsys, settngs_manager):
settngs_manager.add_setting('test', default='hello', nargs='*')
settngs_manager.prog = '__main__.py'
settngs_manager.create_argparser()
settngs_manager.prog = '__main__.py'
settngs_manager.argparser.print_help()
captured = capsys.readouterr()
assert captured.out == help_output
@ -255,7 +251,7 @@ class TestValues:
# 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['test'] = 'fail' # type: ignore[index] # 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)
@ -610,6 +606,10 @@ def test_adding_to_existing_persistent_group(settngs_manager: settngs.Manager, t
assert default_to_regular(settngs_manager.definitions) == default_to_regular(settngs_manager2.definitions)
class test_enum(Enum):
test = auto()
class test_type(int):
...
@ -618,6 +618,18 @@ def _typed_function(something: str) -> test_type: # pragma: no cover
return test_type()
def _typed_list_generic_function(something: test_type) -> List[test_type]: # pragma: no cover
return [test_type()]
def _typed_list_function() -> List: # type: ignore[type-arg] # pragma: no cover
return []
def _typed_set_function() -> Set: # type: ignore[type-arg] # pragma: no cover
return set()
def _untyped_function(something):
...
@ -644,37 +656,59 @@ class _customAction(argparse.Action): # pragma: no cover
help=help,
)
def __call__(self, parser, namespace, values, option_string=None):
def __call__(self, parser, namespace, values, option_string=None): # pragma: no cover
setattr(namespace, self.dest, 'Something')
types = (
(0, settngs.Setting('-t', '--test'), str),
(1, settngs.Setting('-t', '--test', cmdline=False), 'Any'),
(2, settngs.Setting('-t', '--test', default=1, file=True, cmdline=False), int),
(3, settngs.Setting('-t', '--test', action='count'), int),
(4, settngs.Setting('-t', '--test', action='append'), List[str]),
(5, settngs.Setting('-t', '--test', action='extend'), List[str]),
(6, settngs.Setting('-t', '--test', nargs='+'), List[str]),
(7, settngs.Setting('-t', '--test', action='store_const', const=1), int),
(8, settngs.Setting('-t', '--test', action='append_const', const=1), list),
(9, settngs.Setting('-t', '--test', action='store_true'), bool),
(10, settngs.Setting('-t', '--test', action='store_false'), bool),
(11, settngs.Setting('-t', '--test', action=settngs.BooleanOptionalAction), bool),
(12, settngs.Setting('-t', '--test', action=_customAction), 'Any'),
(13, settngs.Setting('-t', '--test', action='help'), None),
(14, settngs.Setting('-t', '--test', action='version'), None),
(15, settngs.Setting('-t', '--test', type=int), int),
(16, settngs.Setting('-t', '--test', type=_typed_function), test_type),
(17, settngs.Setting('-t', '--test', type=_untyped_function, default=1), int),
(18, settngs.Setting('-t', '--test', type=_untyped_function), 'Any'),
(0, settngs.Setting('-t', '--test'), str, True),
(1, settngs.Setting('-t', '--test', cmdline=False), 'Any', True),
(2, settngs.Setting('-t', '--test', default=1, file=True, cmdline=False), int, False),
(3, settngs.Setting('-t', '--test', default='test'), str, False),
(4, settngs.Setting('-t', '--test', default='test', file=True, cmdline=False), str, False),
(5, settngs.Setting('-t', '--test', action='count'), int, True),
(6, settngs.Setting('-t', '--test', action='append'), List[str], True),
(7, settngs.Setting('-t', '--test', action='extend'), List[str], True),
(8, settngs.Setting('-t', '--test', nargs='+'), List[str], True),
(9, settngs.Setting('-t', '--test', nargs='+', type=pathlib.Path), List[pathlib.Path], True),
(10, settngs.Setting('-t', '--test', nargs='+', type=test_enum), List[test_enum], True),
(11, settngs.Setting('-t', '--test', action='store_const', const=1), int, True),
(12, settngs.Setting('-t', '--test', action='append_const', const=1), List[int], True),
(13, settngs.Setting('-t', '--test', action='store_true'), bool, False),
(14, settngs.Setting('-t', '--test', action='store_false'), bool, False),
(15, settngs.Setting('-t', '--test', action=argparse.BooleanOptionalAction), bool, True),
(16, settngs.Setting('-t', '--test', action=_customAction), 'Any', True),
(17, settngs.Setting('-t', '--test', type=test_enum), test_enum, True),
(18, settngs.Setting('-t', '--test', type=int), int, True),
(19, settngs.Setting('-t', '--test', type=int, nargs='+'), List[int], True),
(20, settngs.Setting('-t', '--test', type=_typed_function), test_type, True),
(21, settngs.Setting('-t', '--test', type=_untyped_function, default=1), int, False),
(22, settngs.Setting('-t', '--test', type=_untyped_function, default=[1]), List[int], False),
(23, settngs.Setting('-t', '--test', type=_untyped_function), 'Any', True),
(24, settngs.Setting('-t', '--test', type=_untyped_function, default={1}), Set[int], False),
(25, settngs.Setting('-t', '--test', action='append', type=int), List[int], True),
(26, settngs.Setting('-t', '--test', action='extend', type=int, nargs=2), List[int], True),
(27, settngs.Setting('-t', '--test', action='append', type=int, nargs=2), List[List[int]], True),
(28, settngs.Setting('-t', '--test', action='extend', nargs='+'), List[str], True),
(29, settngs.Setting('-t', '--test', action='extend', type=_typed_list_generic_function), List[test_type], True),
(30, settngs.Setting('-t', '--test', action='extend', type=_typed_list_function), List, True),
(31, settngs.Setting('-t', '--test', action='extend', type=_typed_set_function), Set, True),
(32, settngs.Setting('-t', '--test', action='help'), None, True),
(33, settngs.Setting('-t', '--test', action='version'), None, True),
)
@pytest.mark.parametrize('num,setting,typ', types)
def test_guess_type(num, setting, typ):
guessed_type = setting._guess_type()
@pytest.mark.parametrize('num,setting,typ,noneable_expected', types)
def test_guess_type(num, setting, typ, noneable_expected):
x = setting._guess_type()
guessed_type, noneable = x
assert guessed_type == typ
assert noneable == noneable_expected
class TypeResult(NamedTuple):
extra_imports: str
typ: str
expected_src = '''from __future__ import annotations
@ -685,34 +719,40 @@ import settngs
class SettngsNS(settngs.TypedNS):
test__test: {typ}
'''
no_type_expected_src = '''from __future__ import annotations
import settngs
class SettngsNS(settngs.TypedNS):
...
'''
settings = (
(0, lambda parser: parser.add_setting('-t', '--test'), expected_src.format(extra_imports='', typ='str')),
(1, lambda parser: parser.add_setting('-t', '--test', cmdline=False), expected_src.format(extra_imports='import typing\n', typ='typing.Any')),
(2, lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), expected_src.format(extra_imports='', typ='int')),
(3, lambda parser: parser.add_setting('-t', '--test', action='count'), expected_src.format(extra_imports='', typ='int')),
(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]')),
(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]')),
(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]')),
(7, lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), expected_src.format(extra_imports='', typ='int')),
(8, lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), expected_src.format(extra_imports='', typ='list')),
(9, lambda parser: parser.add_setting('-t', '--test', action='store_true'), expected_src.format(extra_imports='', typ='bool')),
(10, lambda parser: parser.add_setting('-t', '--test', action='store_false'), expected_src.format(extra_imports='', typ='bool')),
(11, lambda parser: parser.add_setting('-t', '--test', action=settngs.BooleanOptionalAction), expected_src.format(extra_imports='', typ='bool')),
(12, lambda parser: parser.add_setting('-t', '--test', action=_customAction), expected_src.format(extra_imports='import typing\n', typ='typing.Any')),
(13, lambda parser: parser.add_setting('-t', '--test', action='help'), no_type_expected_src),
(14, lambda parser: parser.add_setting('-t', '--test', action='version'), no_type_expected_src),
(15, lambda parser: parser.add_setting('-t', '--test', type=int), expected_src.format(extra_imports='', typ='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')),
(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')),
(0, lambda parser: parser.add_setting('-t', '--test'), TypeResult(extra_imports='', typ='str | None')),
(1, lambda parser: parser.add_setting('-t', '--test', cmdline=False), TypeResult(extra_imports='import typing\n', typ='typing.Any')),
(2, lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), TypeResult(extra_imports='', typ='int')),
(3, lambda parser: parser.add_setting('-t', '--test', default='test'), TypeResult(extra_imports='', typ='str')),
(4, lambda parser: parser.add_setting('-t', '--test', default='test', file=True, cmdline=False), TypeResult(extra_imports='', typ='str')),
(5, lambda parser: parser.add_setting('-t', '--test', action='count'), TypeResult(extra_imports='', typ='int | None')),
(6, lambda parser: parser.add_setting('-t', '--test', action='append'), TypeResult(extra_imports='', typ=f'{List[str]} | None')),
(7, lambda parser: parser.add_setting('-t', '--test', action='extend'), TypeResult(extra_imports='', typ=f'{List[str]} | None')),
(8, lambda parser: parser.add_setting('-t', '--test', nargs='+'), TypeResult(extra_imports='', typ=f'{List[str]} | None')),
(9, lambda parser: parser.add_setting('-t', '--test', nargs='+', type=pathlib.Path), TypeResult(extra_imports='import pathlib._local\n' if sys.version_info[:2] == (3, 13) else 'import pathlib\n', typ=f'{List[pathlib.Path]} | None')),
(10, lambda parser: parser.add_setting('-t', '--test', nargs='+', type=test_enum), TypeResult(extra_imports='import tests.settngs_test\n', typ=f'{List[test_enum]} | None')),
(11, lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), TypeResult(extra_imports='', typ='int | None')),
(12, lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), TypeResult(extra_imports='', typ=f'{List[int]} | None')),
(13, lambda parser: parser.add_setting('-t', '--test', action='store_true'), TypeResult(extra_imports='', typ='bool')),
(14, lambda parser: parser.add_setting('-t', '--test', action='store_false'), TypeResult(extra_imports='', typ='bool')),
(15, lambda parser: parser.add_setting('-t', '--test', action=argparse.BooleanOptionalAction), TypeResult(extra_imports='', typ='bool | None')),
(16, lambda parser: parser.add_setting('-t', '--test', action=_customAction), TypeResult(extra_imports='import typing\n', typ='typing.Any')),
(17, lambda parser: parser.add_setting('-t', '--test', type=test_enum), TypeResult(extra_imports='import tests.settngs_test\n', typ='tests.settngs_test.test_enum | None')),
(18, lambda parser: parser.add_setting('-t', '--test', type=int), TypeResult(extra_imports='', typ='int | None')),
(19, lambda parser: parser.add_setting('-t', '--test', type=int, nargs='+'), TypeResult(extra_imports='', typ=f'{List[int]} | None')),
(20, lambda parser: parser.add_setting('-t', '--test', type=_typed_function), TypeResult(extra_imports='import tests.settngs_test\n', typ='tests.settngs_test.test_type | None')),
(21, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=1), TypeResult(extra_imports='', typ='int')),
(22, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=[1]), TypeResult(extra_imports='', typ=f'{List[int]}')),
(23, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function), TypeResult(extra_imports='import typing\n', typ='typing.Any')),
(24, lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default={1}), TypeResult(extra_imports='', typ=f'{Set[int]}')),
(25, lambda parser: parser.add_setting('-t', '--test', action='append', type=int), TypeResult(extra_imports='', typ=f'{List[int]} | None')),
(26, lambda parser: parser.add_setting('-t', '--test', action='extend', type=int, nargs=2), TypeResult(extra_imports='', typ=f'{List[int]} | None')),
(27, lambda parser: parser.add_setting('-t', '--test', action='append', type=int, nargs=2), TypeResult(extra_imports='', typ=f'{List[List[int]]} | None')),
(28, lambda parser: parser.add_setting('-t', '--test', action='extend', nargs='+'), TypeResult(extra_imports='', typ=f'{List[str]} | None')),
(29, lambda parser: parser.add_setting('-t', '--test', action='extend', type=_typed_list_generic_function), TypeResult(extra_imports='import tests.settngs_test\n', typ=f'{List[test_type]} | None')),
(30, lambda parser: parser.add_setting('-t', '--test', action='extend', type=_typed_list_function), TypeResult(extra_imports='', typ=f'{settngs._type_to_string(List)[0]} | None')),
(31, lambda parser: parser.add_setting('-t', '--test', action='extend', type=_typed_set_function), TypeResult(extra_imports='', typ=f'{settngs._type_to_string(Set)[0]} | None')),
)
@ -723,14 +763,13 @@ def test_generate_ns(settngs_manager, num, set_options, expected):
imports, types = settngs_manager.generate_ns()
generated_src = '\n\n\n'.join((imports, types))
assert generated_src == expected
assert generated_src == expected_src.format(**expected._asdict())
ast.parse(generated_src)
expected_src_dict = '''from __future__ import annotations
import typing
{extra_imports}
class test(typing.TypedDict):
@ -740,49 +779,18 @@ class test(typing.TypedDict):
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)
@pytest.mark.parametrize('num,set_options,expected', settings)
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
if 'import typing' not in expected.extra_imports:
expected = TypeResult('import typing\n' + expected.extra_imports, expected.typ)
assert generated_src == expected_src_dict.format(**expected._asdict())
ast.parse(generated_src)