Skip to content

Commit 06a3cd8

Browse files
authored
Basic support for typing.Annotated (#85)
1 parent a380af2 commit 06a3cd8

File tree

5 files changed

+247
-43
lines changed

5 files changed

+247
-43
lines changed

clize/parser.py

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,61 @@
77
"""
88

99
import itertools
10+
import inspect
11+
import typing
1012
from functools import partial, wraps
1113
import pathlib
1214
import warnings
1315

14-
from sigtools import modifiers
16+
from sigtools import modifiers, signature
1517
import attr
1618

1719
from clize import errors, util
1820

1921

22+
class ClizeAnnotations:
23+
def __init__(self, annotations):
24+
self.clize_annotations = util.maybe_iter(annotations)
25+
26+
def __repr__(self):
27+
arg = ', '.join(repr(item) for item in self.clize_annotations)
28+
return f"clize.Clize[{arg}]"
29+
30+
@classmethod
31+
def get_clize_annotations(cls, top_level_annotation):
32+
if top_level_annotation is inspect.Parameter.empty:
33+
return ParsedAnnotation()
34+
35+
if _is_annotated_instance(top_level_annotation):
36+
return ParsedAnnotation(top_level_annotation.__origin__, tuple(_extract_annotated_metadata(top_level_annotation.__metadata__)))
37+
38+
return ParsedAnnotation(clize_annotations=util.maybe_iter(top_level_annotation))
39+
40+
41+
@attr.define
42+
class ParsedAnnotation:
43+
type_annotation: typing.Any = inspect.Parameter.empty
44+
clize_annotations: typing.Tuple[typing.Any] = ()
45+
46+
47+
def _is_annotated_instance(annotation):
48+
try:
49+
annotation.__origin__
50+
annotation.__metadata__
51+
except AttributeError:
52+
return False
53+
else:
54+
return True
55+
56+
57+
def _extract_annotated_metadata(metadata):
58+
for item in metadata:
59+
if _is_annotated_instance(item):
60+
yield from _extract_annotated_metadata(item.__metadata__)
61+
elif isinstance(item, ClizeAnnotations):
62+
yield from item.clize_annotations
63+
64+
2065
class ParameterFlag(object):
2166
def __init__(self, name, prefix='clize.Parameter'):
2267
self.name = name
@@ -790,7 +835,7 @@ class _NamedWithMixin(cls, OptionParameter): pass
790835

791836

792837
def _use_class(pos_cls, varargs_cls, named_cls, varkwargs_cls, kwargs,
793-
param, annotations):
838+
param, annotations, *, type_annotation):
794839
named = param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD)
795840
aliases = [util.name_py2cli(param.name, named)]
796841
default = util.UNSET
@@ -811,7 +856,19 @@ def _use_class(pos_cls, varargs_cls, named_cls, varkwargs_cls, kwargs,
811856
if Parameter.LAST_OPTION in annotations:
812857
kwargs['last_option'] = True
813858

814-
prev_conv = None
859+
exclusive_converter = None
860+
set_converter = False
861+
862+
if type_annotation != param.empty:
863+
try:
864+
# we specifically don't set exclusive_converter
865+
# so that clize annotations can set a different converter
866+
conv = get_value_converter(type_annotation)
867+
except ValueError:
868+
pass
869+
else:
870+
set_converter = True
871+
815872
for thing in annotations:
816873
if isinstance(thing, Parameter):
817874
return thing
@@ -826,11 +883,13 @@ def _use_class(pos_cls, varargs_cls, named_cls, varkwargs_cls, kwargs,
826883
except ValueError:
827884
pass
828885
else:
829-
if prev_conv is not None:
886+
if exclusive_converter is not None:
830887
raise ValueError(
831888
"Value converter specified twice in annotation: "
832-
"{0.__name__} {1.__name__}".format(prev_conv, thing))
833-
prev_conv = thing
889+
f"{exclusive_converter.__name__} {thing.__name__}"
890+
)
891+
exclusive_converter = thing
892+
set_converter = True
834893
continue
835894
if isinstance(thing, str):
836895
if not named:
@@ -853,9 +912,10 @@ def _use_class(pos_cls, varargs_cls, named_cls, varkwargs_cls, kwargs,
853912

854913
kwargs['default'] = default if not kwargs.get('required') else util.UNSET
855914
kwargs['conv'] = conv
856-
if prev_conv is None and default is not util.UNSET and default is not None:
915+
if not set_converter and default is not util.UNSET and default is not None:
857916
try:
858917
kwargs['conv'] = get_value_converter(type(default))
918+
set_converter = True
859919
except ValueError:
860920
raise ValueError(
861921
"Cannot find value converter for default value {!r}. "
@@ -864,6 +924,14 @@ def _use_class(pos_cls, varargs_cls, named_cls, varkwargs_cls, kwargs,
864924
"convert the value, make sure it is decorated "
865925
"with clize.parser.value_converter()"
866926
.format(default))
927+
if not set_converter and type_annotation is not inspect.Parameter.empty:
928+
raise ValueError(
929+
f"Cannot find a value converter for type {type_annotation}. "
930+
"Please specify one as an annotation.\n"
931+
"If the type should be used to "
932+
"convert the value, make sure it is decorated "
933+
"with clize.parser.value_converter()"
934+
)
867935

868936
if named:
869937
kwargs['aliases'] = aliases
@@ -998,10 +1066,9 @@ def from_signature(cls, sig, extra=(), **kwargs):
9981066
def convert_parameter(cls, param):
9991067
"""Convert a Python parameter to a CLI parameter."""
10001068
param_annotation = param.upgraded_annotation.source_value()
1001-
if param.annotation != param.empty:
1002-
annotations = util.maybe_iter(param_annotation)
1003-
else:
1004-
annotations = []
1069+
ca = ClizeAnnotations.get_clize_annotations(param_annotation)
1070+
annotations = ca.clize_annotations
1071+
type_annotation = ca.type_annotation
10051072

10061073
if Parameter.IGNORE in annotations:
10071074
return Parameter.IGNORE
@@ -1014,9 +1081,29 @@ def convert_parameter(cls, param):
10141081
else:
10151082
conv = cls.converter
10161083

1017-
return conv(param, annotations)
1018-
1019-
1084+
try:
1085+
return conv(param, annotations, type_annotation=type_annotation)
1086+
except TypeError as e:
1087+
if "type_annotation" in signature(conv).parameters:
1088+
raise e
1089+
else:
1090+
result = conv(param, annotations)
1091+
name = getattr(conv, "__name__", repr(conv))
1092+
while isinstance(conv, partial):
1093+
conv = conv.func
1094+
impl_name = getattr(conv, "__qualname__", name)
1095+
module = getattr(conv, "__module__")
1096+
if module:
1097+
impl_name = f"{module}.{impl_name}"
1098+
warnings.warn(
1099+
(
1100+
"Clize 6.0 will require parameter converters "
1101+
"to support the 'type_annotation' keyword argument: "
1102+
f"converter '{name}' ({impl_name}) should be updated to accept it"
1103+
),
1104+
DeprecationWarning,
1105+
)
1106+
return result
10201107

10211108
def read_arguments(self, args, name):
10221109
"""Returns a `.CliBoundArguments` instance for this CLI signature

clize/runner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ def __init__(self, fn, owner=None, alt=(), extra=(),
8282
self.helper_class = helper_class
8383
self.hide_help = hide_help
8484

85+
def __class_getitem__(cls, item):
86+
return parser.ClizeAnnotations(item)
87+
8588
def parameters(self):
8689
"""Returns the parameters used to instantiate this class, minus the
8790
wrapped callable."""

0 commit comments

Comments
 (0)