From b599097cc1f2080c227449a6056e01c143ecf1c7 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Thu, 8 Jun 2023 21:37:33 -0700 Subject: [PATCH] Adds a function to generate a class for typing a namespace --- settngs/__init__.py | 88 +++++++++++++++++++++++++++++- tests/settngs_test.py | 122 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) diff --git a/settngs/__init__.py b/settngs/__init__.py index 1e29fa9..398e44e 100644 --- a/settngs/__init__.py +++ b/settngs/__init__.py @@ -6,6 +6,7 @@ import logging import pathlib import re import sys +import typing from argparse import Namespace from collections import defaultdict from collections.abc import Sequence @@ -13,6 +14,7 @@ from typing import Any from typing import Callable from typing import Dict from typing import Generic +from typing import Literal from typing import NoReturn from typing import TYPE_CHECKING from typing import TypeVar @@ -27,6 +29,9 @@ else: # 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: if self.startswith(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: 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 @@ -84,7 +91,7 @@ class Setting: *names: str, action: type[argparse.Action] | str | None = None, nargs: str | int | None = None, - const: str | None = None, + const: Any | None = None, default: Any | None = None, type: Callable[..., Any] | None = None, # noqa: A002 choices: Sequence[Any] | None = None, @@ -180,6 +187,45 @@ class Setting: return NotImplemented return self.__dict__ == other.__dict__ + def _guess_type(self) -> type | Literal['Any'] | None: + if self.type is None and self.action is None: + if self.cmdline: + 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]: dest_name = None flag = False @@ -230,6 +276,39 @@ if TYPE_CHECKING: 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: 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) if isinstance(opts, dict): 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) success = False else: @@ -551,6 +632,9 @@ class Manager: self.exclusive_group = False self.current_group_name = '' + def generate_ns(self) -> str: + return generate_ns(self.definitions) + def create_argparser(self) -> None: self.argparser = create_argparser(self.definitions, self.description, self.epilog) diff --git a/tests/settngs_test.py b/tests/settngs_test.py index 325e735..01171ef 100644 --- a/tests/settngs_test.py +++ b/tests/settngs_test.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +import ast import json import pathlib +import sys from collections import defaultdict from typing import Generator @@ -15,6 +17,12 @@ from testing.settngs import failure from testing.settngs import success +if sys.version_info < (3, 9): # pragma: no cover + from typing import List +else: + List = list + + @pytest.fixture def settngs_manager() -> Generator[settngs.Manager, None, None]: 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) +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): monkeypatch.chdir(tmp_path) settings_file = tmp_path / 'settings.json'