Adds a function to generate a class for typing a namespace

This commit is contained in:
Timmy Welch 2023-06-08 21:37:33 -07:00
parent 8af75d3962
commit b599097cc1
2 changed files with 208 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import logging
import pathlib import pathlib
import re import re
import sys import sys
import typing
from argparse import Namespace from argparse import Namespace
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
@ -13,6 +14,7 @@ from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import Generic from typing import Generic
from typing import Literal
from typing import NoReturn from typing import NoReturn
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
@ -27,6 +29,9 @@ else: # pragma: no cover
if sys.version_info < (3, 9): # pragma: no cover if sys.version_info < (3, 9): # pragma: no cover
from typing import List
from typing import _GenericAlias as types_GenericAlias
def removeprefix(self: str, prefix: str, /) -> str: def removeprefix(self: str, prefix: str, /) -> str:
if self.startswith(prefix): if self.startswith(prefix):
return self[len(prefix):] return self[len(prefix):]
@ -73,6 +78,8 @@ if sys.version_info < (3, 9): # pragma: no cover
if option_string in self.option_strings: if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-')) setattr(namespace, self.dest, not option_string.startswith('--no-'))
else: # pragma: no cover else: # pragma: no cover
List = list
from types import GenericAlias as types_GenericAlias
from argparse import BooleanOptionalAction from argparse import BooleanOptionalAction
removeprefix = str.removeprefix removeprefix = str.removeprefix
@ -84,7 +91,7 @@ class Setting:
*names: str, *names: str,
action: type[argparse.Action] | str | None = None, action: type[argparse.Action] | str | None = None,
nargs: str | int | None = None, nargs: str | int | None = None,
const: str | None = None, const: Any | None = None,
default: Any | None = None, default: Any | None = None,
type: Callable[..., Any] | None = None, # noqa: A002 type: Callable[..., Any] | None = None, # noqa: A002
choices: Sequence[Any] | None = None, choices: Sequence[Any] | None = None,
@ -180,6 +187,45 @@ class Setting:
return NotImplemented return NotImplemented
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
def _guess_type(self) -> type | Literal['Any'] | None:
if self.type is None and self.action is None:
if self.cmdline:
return str
else:
if not self.cmdline and self.default is not None:
return type(self.default)
return 'Any'
if isinstance(self.type, type):
return self.type
if self.type is not None:
type_hints = typing.get_type_hints(self.type)
if 'return' in type_hints and isinstance(type_hints['return'], type):
return type_hints['return']
if self.default is not None:
return type(self.default)
return 'Any'
if self.action in ('store_true', 'store_false', BooleanOptionalAction):
return bool
if self.action in ('store_const',):
return type(self.const)
if self.action in ('count',):
return int
if self.action in ('append', 'extend'):
return List[str]
if self.action in ('append_const',):
return list # list[type(self.const)]
if self.action in ('help', 'version'):
return None
return 'Any'
def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]: def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]:
dest_name = None dest_name = None
flag = False flag = False
@ -230,6 +276,39 @@ if TYPE_CHECKING:
ns = Namespace | Config[T] | None ns = Namespace | Config[T] | None
def generate_ns(definitions: Definitions) -> str:
imports = ['from __future__ import annotations', 'import typing', 'import settngs']
ns = 'class settngs_namespace(settngs.Namespace):\n'
types = []
for group_name, group in definitions.items():
for setting_name, setting in group.v.items():
t = setting._guess_type()
if t is None:
continue
type_name = 'Any'
if isinstance(t, str):
type_name = t
elif type(t) == types_GenericAlias:
type_name = str(t)
elif isinstance(t, type):
type_name = t.__name__
if t.__module__ != 'builtins':
imports.append(f'import {t.__module__}')
type_name = t.__module__ + '.' + type_name
if type_name == 'Any':
type_name = 'typing.Any'
types.append(f' {setting.internal_name}: {type_name}')
if types and types[-1] != '':
types.append('')
if not types or all(x == '' for x in types):
ns += ' ...\n'
types = ['']
return '\n'.join(imports) + '\n\n' + ns + '\n'.join(types)
def sanitize_name(name: str) -> str: def sanitize_name(name: str) -> str:
return re.sub('[' + re.escape(' -_,.!@#$%^&*(){}[]\',."<>;:') + ']+', '_', name).strip('_') return re.sub('[' + re.escape(' -_,.!@#$%^&*(){}[]\',."<>;:') + ']+', '_', name).strip('_')
@ -337,7 +416,9 @@ def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Config
opts = json.load(file) opts = json.load(file)
if isinstance(opts, dict): if isinstance(opts, dict):
options = opts options = opts
except Exception: else: # pragma: no cover
raise Exception('Loaded file is not a JSON Dictionary')
except Exception: # pragma: no cover
logger.exception('Failed to load config file: %s', filename) logger.exception('Failed to load config file: %s', filename)
success = False success = False
else: else:
@ -551,6 +632,9 @@ class Manager:
self.exclusive_group = False self.exclusive_group = False
self.current_group_name = '' self.current_group_name = ''
def generate_ns(self) -> str:
return generate_ns(self.definitions)
def create_argparser(self) -> None: def create_argparser(self) -> None:
self.argparser = create_argparser(self.definitions, self.description, self.epilog) self.argparser = create_argparser(self.definitions, self.description, self.epilog)

View File

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ast
import json import json
import pathlib import pathlib
import sys
from collections import defaultdict from collections import defaultdict
from typing import Generator from typing import Generator
@ -15,6 +17,12 @@ from testing.settngs import failure
from testing.settngs import success from testing.settngs import success
if sys.version_info < (3, 9): # pragma: no cover
from typing import List
else:
List = list
@pytest.fixture @pytest.fixture
def settngs_manager() -> Generator[settngs.Manager, None, None]: def settngs_manager() -> Generator[settngs.Manager, None, None]:
manager = settngs.Manager() manager = settngs.Manager()
@ -433,6 +441,120 @@ 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) assert default_to_regular(settngs_manager.definitions) == default_to_regular(settngs_manager2.definitions)
class test_type(int):
...
def _typed_function(something: str) -> test_type: # pragma: no cover
return test_type()
def _untyped_function(something):
...
class _customAction(argparse.Action): # pragma: no cover
def __init__(
self,
option_strings,
dest,
const=None,
default=None,
required=False,
help=None, # noqa: A002
metavar=None,
):
super().__init__(
option_strings=option_strings,
dest=dest,
nargs=0,
const=const,
default=default,
required=required,
help=help,
)
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, 'Something')
types = (
(settngs.Setting('-t', '--test'), str),
(settngs.Setting('-t', '--test', cmdline=False), 'Any'),
(settngs.Setting('-t', '--test', default=1, file=True, cmdline=False), int),
(settngs.Setting('-t', '--test', action='count'), int),
(settngs.Setting('-t', '--test', action='append'), List[str]),
(settngs.Setting('-t', '--test', action='extend'), List[str]),
(settngs.Setting('-t', '--test', action='store_const', const=1), int),
(settngs.Setting('-t', '--test', action='append_const', const=1), list),
(settngs.Setting('-t', '--test', action='store_true'), bool),
(settngs.Setting('-t', '--test', action='store_false'), bool),
(settngs.Setting('-t', '--test', action=settngs.BooleanOptionalAction), bool),
(settngs.Setting('-t', '--test', action=_customAction), 'Any'),
(settngs.Setting('-t', '--test', action='help'), None),
(settngs.Setting('-t', '--test', action='version'), None),
(settngs.Setting('-t', '--test', type=int), int),
(settngs.Setting('-t', '--test', type=_typed_function), test_type),
(settngs.Setting('-t', '--test', type=_untyped_function, default=1), int),
(settngs.Setting('-t', '--test', type=_untyped_function), 'Any'),
)
@pytest.mark.parametrize('setting,typ', types)
def test_guess_type(setting, typ):
guessed_type = setting._guess_type()
assert guessed_type == typ
settings = (
(lambda parser: parser.add_setting('-t', '--test'), 'str'),
(lambda parser: parser.add_setting('-t', '--test', cmdline=False), 'typing.Any'),
(lambda parser: parser.add_setting('-t', '--test', default=1, file=True, cmdline=False), 'int'),
(lambda parser: parser.add_setting('-t', '--test', action='count'), 'int'),
(lambda parser: parser.add_setting('-t', '--test', action='append'), List[str]),
(lambda parser: parser.add_setting('-t', '--test', action='extend'), List[str]),
(lambda parser: parser.add_setting('-t', '--test', action='store_const', const=1), 'int'),
(lambda parser: parser.add_setting('-t', '--test', action='append_const', const=1), 'list'),
(lambda parser: parser.add_setting('-t', '--test', action='store_true'), 'bool'),
(lambda parser: parser.add_setting('-t', '--test', action='store_false'), 'bool'),
(lambda parser: parser.add_setting('-t', '--test', action=settngs.BooleanOptionalAction), 'bool'),
(lambda parser: parser.add_setting('-t', '--test', action=_customAction), 'typing.Any'),
(lambda parser: parser.add_setting('-t', '--test', action='help'), None),
(lambda parser: parser.add_setting('-t', '--test', action='version'), None),
(lambda parser: parser.add_setting('-t', '--test', type=int), 'int'),
(lambda parser: parser.add_setting('-t', '--test', type=_typed_function), 'tests.settngs_test.test_type'),
(lambda parser: parser.add_setting('-t', '--test', type=_untyped_function, default=1), 'int'),
(lambda parser: parser.add_setting('-t', '--test', type=_untyped_function), 'typing.Any'),
)
@pytest.mark.parametrize('set_options,typ', settings)
def test_generate_ns(settngs_manager, set_options, typ):
settngs_manager.add_group('test', set_options)
src = '''\
from __future__ import annotations
import typing
import settngs
'''
if typ == 'tests.settngs_test.test_type':
src += 'import tests.settngs_test\n'
src += '''
class settngs_namespace(settngs.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()
assert generated_src == src
ast.parse(generated_src)
def test_example(capsys, tmp_path, monkeypatch): def test_example(capsys, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
settings_file = tmp_path / 'settings.json' settings_file = tmp_path / 'settings.json'