diff --git a/.travis.yml b/.travis.yml index 01c1225..a473cad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ +dist: xenial language: python python: - "3.6" + - "3.7" install: - pip install codecov diff --git a/nubia/internal/helpers.py b/nubia/internal/helpers.py index 66e5290..52a53a4 100644 --- a/nubia/internal/helpers.py +++ b/nubia/internal/helpers.py @@ -14,7 +14,6 @@ import subprocess from collections import namedtuple -from typing import _Union, Any, Iterable # noqa T484 def add_command_arguments(parser, options): @@ -165,41 +164,6 @@ def issubclass_(obj, class_): return False -def is_union(t: Any) -> bool: - """Check whether type is a Union. - - @param t: type to check - @type: Any - @returns: `True` if type is a Union, `False` otherwise - @rtype: bool - - @note: This is a hack. See https://bugs.python.org/issue29262 and - https://github.com/ilevkivskyi/typing_inspect for the rationale behind the - implementation. - """ - return type(t) is _Union - - -def is_optional(t: Any) -> bool: - """Check whether a type is an Optional. - - @note: This is a hack. - """ - if not is_union(t): - return False - elif len(t.__args__) > 2: # `Optional` is a `Union` of 2 values - return False - else: - _, right = t.__args__ - return right is type(None) # noqa E721 - - -def get_first_type_variable(t: Any) -> Any: - """Given an List[T], return T.""" - assert hasattr(t, "__args__") and len(t.__args__) > 0 - return t.__args__[0] - - def catchall(func, *args): """ Run the given function with the given arguments, diff --git a/nubia/internal/typing/__init__.py b/nubia/internal/typing/__init__.py index 5293e3e..a11f986 100644 --- a/nubia/internal/typing/__init__.py +++ b/nubia/internal/typing/__init__.py @@ -59,10 +59,10 @@ def foo(arg1: str, arg2: typing.List[int]): ... """ -import inspect from collections import namedtuple, Container, OrderedDict from functools import partial +from inspect import ismethod, isclass from nubia.internal.helpers import ( get_arg_spec, @@ -168,7 +168,7 @@ def decorator(function): raise ValueError(msg) # reject positional=True if we are applied over a class - if inspect.isclass(function) and positional: + if isclass(function) and positional: raise ValueError( "Cannot set positional arguments for super " "commands" ) @@ -202,7 +202,7 @@ def command( """ def decorator(function, name=None): - is_class = inspect.isclass(name_or_function) + is_supercommand = isclass(name_or_function) exclusive_arguments_ = _normalize_exclusive_arguments( exclusive_arguments ) @@ -214,7 +214,7 @@ def decorator(function, name=None): else: function.__command["name"] = ( transform_name(function.__name__) - if not is_class + if not is_supercommand else transform_class_name(function.__name__) ) function.__command["help"] = help @@ -249,7 +249,7 @@ def inspect_object(obj, accept_bound_methods=False): args = argspec.args # remove the first argument in case this is a method (normally the first # arg is 'self') - if inspect.ismethod(obj): + if ismethod(obj): args = args[1:] result = {"arguments": OrderedDict(), "command": None, "subcommands": {}} @@ -263,10 +263,10 @@ def inspect_object(obj, accept_bound_methods=False): ) # Is this a super command? - is_class = inspect.isclass(obj) + is_supercommand = isclass(obj) for i, arg in enumerate(args): - if (is_class or accept_bound_methods) and arg == "self": + if (is_supercommand or accept_bound_methods) and arg == "self": continue arg_idx_with_default = len(args) - len(argspec.defaults) default_value_set = bool(argspec.defaults and i >= arg_idx_with_default) @@ -277,7 +277,7 @@ def inspect_object(obj, accept_bound_methods=False): ) # We will reject classes (super-commands) that has required arguments to # reduce complexity - if is_class and not default_value_set: + if is_supercommand and not default_value_set: raise ValueError( "Cannot accept super commands that has required " "arguments with no default value " @@ -320,7 +320,7 @@ def inspect_object(obj, accept_bound_methods=False): ) # Super Command Support - if is_class: + if is_supercommand: result["subcommands"] = [] for attr in dir(obj): if attr.startswith("_"): # ignore "private" methods diff --git a/nubia/internal/typing/argparse.py b/nubia/internal/typing/argparse.py index e7c1c78..ea52539 100644 --- a/nubia/internal/typing/argparse.py +++ b/nubia/internal/typing/argparse.py @@ -8,31 +8,29 @@ # import argparse +import copy import os import shutil import subprocess -import copy import sys - -from collections import Iterable, Mapping, defaultdict +from collections import defaultdict from functools import partial +from typing import Any, Dict, List, Tuple # noqa F401 from nubia.internal.typing.builder import ( build_value, get_dict_kv_arg_type_as_str, get_list_arg_type_as_str, ) - -from nubia.internal.helpers import ( - is_optional, - issubclass_, - get_first_type_variable, +from nubia.internal.typing.inspect import ( + get_first_type_argument, + is_iterable_type, + is_mapping_type, + is_optional_type, ) from . import command, inspect_object, transform_name -from typing import Any, Tuple, List, Dict # noqa F401 - def create_subparser_class(opts_parser): # This is a hack to add the main parser arguments to each subcommand in @@ -198,8 +196,8 @@ def _argument_to_argparse_input(arg): argument_type = ( arg.type - if not is_optional(arg.type) - else get_first_type_variable(arg.type) + if not is_optional_type(arg.type) + else get_first_type_argument(arg.type) ) if argument_type in [int, float, str]: add_argument_kwargs["type"] = argument_type @@ -208,13 +206,13 @@ def _argument_to_argparse_input(arg): add_argument_kwargs["action"] = "store_true" elif arg.default_value is True: add_argument_kwargs["action"] = "store_false" - elif issubclass_(argument_type, Mapping): + elif is_mapping_type(argument_type): add_argument_kwargs["type"] = _parse_dict(argument_type) add_argument_kwargs["metavar"] = "DICT[{}: {}]".format( *get_dict_kv_arg_type_as_str(argument_type) ) - elif issubclass_(argument_type, Iterable): - add_argument_kwargs["type"] = get_first_type_variable(argument_type) + elif is_iterable_type(argument_type): + add_argument_kwargs["type"] = get_first_type_argument(argument_type) add_argument_kwargs["nargs"] = "+" add_argument_kwargs["metavar"] = "{}".format( get_list_arg_type_as_str(argument_type) diff --git a/nubia/internal/typing/builder.py b/nubia/internal/typing/builder.py index 5f6e90b..e01e35c 100644 --- a/nubia/internal/typing/builder.py +++ b/nubia/internal/typing/builder.py @@ -8,57 +8,64 @@ # import ast -import collections import re import sys import typing from functools import wraps -from nubia.internal.helpers import issubclass_, is_union +from nubia.internal.helpers import issubclass_ +from nubia.internal.typing.inspect import ( + PEP_560, + is_iterable_type, + is_mapping_type, + is_optional_type, + is_tuple_type, + is_typevar, +) -def build_value(string, type=None, python_syntax=False): +def build_value(string, tp=None, python_syntax=False): value = ( _safe_eval(string) if python_syntax - else _build_simple_value(string, type) + else _build_simple_value(string, tp) ) - if type: - value = apply_typing(value, type) + if tp: + value = apply_typing(value, tp) return value -def apply_typing(value, type): - return get_typing_function(type)(value) +def apply_typing(value, tp): + return get_typing_function(tp)(value) -def get_list_arg_type_as_str(type): +def get_list_arg_type_as_str(tp): """ This takes a type (typing.List[int]) and returns a string representation of the type argument, or "any" if it's not defined """ - assert issubclass_(type, collections.Iterable) - args = getattr(type, "__args__", None) + assert is_iterable_type(tp) + args = getattr(tp, "__args__", None) return args[0].__name__ if args else "any" -def is_dict_value_iterable(type): - assert issubclass_(type, collections.Mapping) - args = getattr(type, "__args__", None) +def is_dict_value_iterable(tp): + assert is_mapping_type(tp), f"{tp} is not a mapping type" + args = getattr(tp, "__args__", None) if args and len(args) == 2: - return issubclass_(args[1], typing.List) + return is_iterable_type(args[1]) return False -def get_dict_kv_arg_type_as_str(type): +def get_dict_kv_arg_type_as_str(tp): """ This takes a type (typing.Mapping[str, int]) and returns a tuple (key_type, value_type) that contains string representations of the type arguments, or "any" if it's not defined """ - assert issubclass_(type, collections.Mapping) - args = getattr(type, "__args__", None) + assert is_mapping_type(tp), f"{tp} is not a mapping type" + args = getattr(tp, "__args__", None) key_type = "any" value_type = "any" if args and len(args) >= 2: @@ -67,42 +74,48 @@ def get_dict_kv_arg_type_as_str(type): return key_type, value_type -def get_typing_function(type): +def get_typing_function(tp): func = None # TypeVars are a problem as they can defined multiple possible types. # While a single type TypeVar is somewhat useless, no reason to deny it # though - if type == typing.TypeVar: - subtypes = type.__constraints__ - if len(subtypes) != 1: + if is_typevar(tp): + if len(tp.__constraints__) == 0: + # Unconstrained TypeVars may come from generics + func = _identity_function + elif len(tp.__constraints__) == 1: + assert not PEP_560, "Python 3.7+ forbids single constraint for `TypeVar'" + func = get_typing_function(tp.__constraints__[0]) + else: raise ValueError( - "Cannot resolve typing function for TypeVar({}) " - "as it declares none or multiple types".format( - ", ".format(str(x) for x in subtypes) + "Cannot resolve typing function for TypeVar({constraints}) " + "as it declares multiple types".format( + constraints=', '.join( + getattr(c, "_name", c.__name__) for c in tp.__constraints__ + ) ) ) - func = get_typing_function(subtypes[0]) - elif type == typing.Any: + elif tp == typing.Any: func = _identity_function - elif issubclass_(type, str): + elif issubclass_(tp, str): func = str - elif issubclass_(type, collections.Mapping): + elif is_mapping_type(tp): func = _apply_dict_type - elif issubclass_(type, tuple): + elif is_tuple_type(tp): func = _apply_tuple_type - elif issubclass_(type, collections.Iterable): + elif is_iterable_type(tp): func = _apply_list_type - elif is_union(type): + elif is_optional_type(tp): func = _apply_optional_type - elif callable(type): - func = type + elif callable(tp): + func = tp else: raise ValueError( - 'Cannot find a function to apply type "{}"'.format(type) + 'Cannot find a function to apply type "{}"'.format(tp) ) - args = getattr(type, "__args__", None) + args = getattr(tp, "__args__", None) if args: # this can be a Generic type from the typing module, like @@ -126,20 +139,20 @@ def _safe_eval(string): raise -def _build_simple_value(string, type): - if not type or issubclass_(type, str): +def _build_simple_value(string, tp): + if not tp or issubclass_(tp, str): return string - elif issubclass_(type, collections.Mapping): + elif is_mapping_type(tp): entries = ( re.split(r"\s*[:=]\s*", entry, maxsplit=1) for entry in string.split(";") ) - if is_dict_value_iterable(type): + if is_dict_value_iterable(tp): entries = ((k, re.split(r"\s*,\s*", v)) for k, v in entries) return {k.strip(): v for k, v in entries} - elif issubclass_(type, tuple): + elif is_tuple_type(tp): return tuple(item for item in string.split(",")) - elif issubclass_(type, collections.Iterable): + elif is_iterable_type(tp): return [item for item in string.split(",")] else: return string diff --git a/nubia/internal/typing/inspect.py b/nubia/internal/typing/inspect.py new file mode 100644 index 0000000..243c851 --- /dev/null +++ b/nubia/internal/typing/inspect.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +# Copyright (c) Facebook, Inc. and its affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# + +import collections.abc +import sys +from typing import Iterable, Mapping, TypeVar + +from nubia.internal.helpers import issubclass_ + +PEP_560: bool = sys.version_info[:3] >= (3, 7, 0) + +if PEP_560: + from typing import Tuple, Union, _GenericAlias +else: + from typing import TupleMeta, _Union + + +def is_none_type(tp) -> bool: + """Checks whether a type is a `None' type.""" + return tp is type(None) # noqa E721 + + +def is_union_type(tp) -> bool: + """Checks whether a type is a union type.""" + if PEP_560: + return ( + tp is Union + or isinstance(tp, _GenericAlias) + and tp.__origin__ is Union + ) + return type(tp) is _Union + + +def is_optional_type(tp) -> bool: + """Checks whether a type is an optional type.""" + return ( + is_union_type(tp) + and len(tp.__args__) == 2 + and any(map(is_none_type, tp.__args__)) + ) + + +def is_mapping_type(tp) -> bool: + """Checks whether a type is a mapping type.""" + if PEP_560: + return ( + tp is Mapping + or isinstance(tp, _GenericAlias) + and issubclass_(tp.__origin__, collections.abc.Mapping) + ) + return issubclass_(tp, collections.abc.Mapping) + + +def is_tuple_type(tp) -> bool: + """Checks whether a type is a tuple type.""" + if PEP_560: + return ( + tp is Tuple + or isinstance(tp, _GenericAlias) + and tp.__origin__ is tuple + ) + return type(tp) is TupleMeta + + +def is_iterable_type(tp) -> bool: + """Checks whether a type is an iterable type.""" + if PEP_560: + return ( + tp is Iterable + or isinstance(tp, _GenericAlias) + and issubclass_(tp.__origin__, collections.abc.Iterable) + ) + return issubclass_(tp, list) + + +def is_typevar(tp) -> bool: + """Checks whether a type is a `TypeVar'.""" + return type(tp) is TypeVar + + +def get_first_type_argument(tp): + """Returns first type argument, e.g. `int' for `List[int]'.""" + assert hasattr(tp, "__args__") and len(tp.__args__) > 0 + return tp.__args__[0] diff --git a/requirements.txt b/requirements.txt index 415a786..78a9025 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -dataclasses +dataclasses;python_version<'3.7' prettytable prompt-toolkit>=2 Pygments diff --git a/setup.py b/setup.py index d901ab6..6dbf707 100755 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def get_version() -> str: packages=setuptools.find_packages(exclude=["sample", "docs", "tests"]), python_requires=">=3.6", setup_requires=["nose>=1.0", "coverage"], - tests_require=["nose>=1.0"], + tests_require=["nose>=1.0", "dataclasses;python_version<'3.7'"], install_requires=reqs, classifiers=( "Development Status :: 4 - Beta", diff --git a/tests/helpers_test.py b/tests/helpers_test.py index 4722dc5..3be2fe1 100644 --- a/tests/helpers_test.py +++ b/tests/helpers_test.py @@ -10,12 +10,8 @@ import unittest from typing import Dict, List, Optional, Union -from nubia.internal.helpers import ( - catchall, - function_to_str, - is_optional, - is_union, -) +from nubia.internal.helpers import catchall, function_to_str +from nubia.internal.typing.inspect import is_optional_type class HelpersTest(unittest.TestCase): @@ -48,14 +44,9 @@ def raise_sysexit(): self.assertRaises(KeyboardInterrupt, catchall, raise_keyboard_interrupt) self.assertRaises(SystemExit, catchall, raise_sysexit) - def test_is_union(self): - self.assertTrue(is_union(Union[str, int])) - self.assertFalse(is_union(List[str])) - self.assertFalse(is_union(Dict[str, int])) - def test_is_optional(self): - self.assertFalse(is_optional(List[str])) - self.assertFalse(is_optional(Dict[str, int])) - self.assertFalse(is_optional(Union[str, int])) - self.assertTrue(is_optional(Optional[str])) - self.assertTrue(is_optional(Union[str, None])) + self.assertFalse(is_optional_type(List[str])) + self.assertFalse(is_optional_type(Dict[str, int])) + self.assertFalse(is_optional_type(Union[str, int])) + self.assertTrue(is_optional_type(Optional[str])) + self.assertTrue(is_optional_type(Union[str, None]))