From 9325fbb1331eda70f39db6455b3e46759ee3911b Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 5 Nov 2014 17:37:02 +0000 Subject: [PATCH 01/23] Add a new decorator for functions accepting quantities. --- astropy/units/core.py | 55 +++++++++++++++++++++++++++++++++++++++++- astropy/units/utils.py | 1 - 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 264c5617e64..6df05518baa 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -32,7 +32,7 @@ 'PrefixUnit', 'UnrecognizedUnit', 'get_current_unit_registry', 'set_enabled_units', 'add_enabled_units', 'set_enabled_equivalencies', 'add_enabled_equivalencies', - 'dimensionless_unscaled', 'one'] + 'dimensionless_unscaled', 'one', 'quantity_input'] def _flatten_units_collection(items): @@ -2289,3 +2289,56 @@ def _condition_arg(value): dimensionless_unscaled = CompositeUnit(1, [], [], _error_check=False) # Abbreviation of the above, see #1980 one = dimensionless_unscaled + + +def quantity_input(*f_args, **f_kwargs): + """ + A decorator for a function that accepts some inputs a Quantity objects. + + This decorator attempts to convert the given Quantites to the units specified + to the decortator, and fails nicely if the a non-Quantity or a incompatible + unit was passed. + + Examples + -------- + + @quantity_input(u.deg, u.deg, None) + def myfunc(ra, dec, someflag): + ra.value # this is now in deg + dec.value # now in deg + """ + + def check_quantities(f): + # Number of args in decorator must equal number of args in function + if len(f_args) != f.func_code.co_argcount - len(f.func_defaults): + raise ValueError("Number of decorator arguments does not equal number of function arguments") + + def new_f(*args, **kwds): + # Check args, number of args in decorator must equal number of args in function + args = list(args) + for i, (arg, f_arg) in enumerate(zip(args, f_args)): + if f_arg is not None: + try: + args[i] = args[i].to(f_arg) + except UnitsError: + raise TypeError("Argument '{}' to function '{}' must be in units convertable to '{}'.".format(f.func_code.co_varnames[i], f.func_code.co_name, f_arg.to_string())) + except AttributeError: + raise TypeError("Argument '{}' to function '{}' must be an astropy Quantity object".format(f.func_code.co_varnames[i], f.func_code.co_name)) + + # Check kwargs, only kwargs specified in the decorator are modified + for kwarg, value in f_kwargs.items(): + if kwarg in kwds: + try: + kwds[kwarg] = kwds[kwarg].to(value) + except UnitsError: + raise TypeError("Keyword argument '{}' to function '{}' must be an astropy Quantity object in units convertable to '{}'.".format(kwarg, f.func_code.co_name, value.to_string())) + except AttributeError: + raise TypeError("Argument '{}' to function '{}' must be an astropy Quantity object".format(f.func_code.co_varnames[i], f.func_code.co_name)) + + return f(*args, **kwds) + + + new_f.func_name = f.func_name + return new_f + + return check_quantities diff --git a/astropy/units/utils.py b/astropy/units/utils.py index a25f6d32c7a..d7c7f4c38ec 100644 --- a/astropy/units/utils.py +++ b/astropy/units/utils.py @@ -22,7 +22,6 @@ from ..utils.compat.fractions import Fraction from ..utils.exceptions import AstropyDeprecationWarning - _float_finfo = finfo(float) # take float here to ensure comparison with another float is fast # give a little margin since often multiple calculations happened From 07c45497848e93f9f8478e0130ef00c26a718b8a Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Thu, 6 Nov 2014 11:10:46 +0000 Subject: [PATCH 02/23] Improve quantity decorator and add tests. --- astropy/units/core.py | 42 ++++-- .../units/tests/test_quantity_decorator.py | 142 ++++++++++++++++++ 2 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 astropy/units/tests/test_quantity_decorator.py diff --git a/astropy/units/core.py b/astropy/units/core.py index 6df05518baa..4b7e0058493 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2293,24 +2293,28 @@ def _condition_arg(value): def quantity_input(*f_args, **f_kwargs): """ - A decorator for a function that accepts some inputs a Quantity objects. + A decorator for a function that accepts some inputs as Quantity objects. This decorator attempts to convert the given Quantites to the units specified - to the decortator, and fails nicely if the a non-Quantity or a incompatible + to the decorator, and fails nicely if a non-Quantity or a incompatible unit was passed. Examples -------- - @quantity_input(u.deg, u.deg, None) - def myfunc(ra, dec, someflag): - ra.value # this is now in deg - dec.value # now in deg + @quantity_input(u.arcsec, u.arcsec, None) + def myfunc(solarx, solary, someflag): + pass """ - + equivalencies = f_kwargs.pop('equivalencies', []) def check_quantities(f): # Number of args in decorator must equal number of args in function - if len(f_args) != f.func_code.co_argcount - len(f.func_defaults): + if f.func_defaults: + num_kwargs = len(f.func_defaults) + else: + num_kwargs = 0 + num_args = f.func_code.co_argcount - num_kwargs + if len(f_args) != num_args: raise ValueError("Number of decorator arguments does not equal number of function arguments") def new_f(*args, **kwds): @@ -2319,21 +2323,29 @@ def new_f(*args, **kwds): for i, (arg, f_arg) in enumerate(zip(args, f_args)): if f_arg is not None: try: - args[i] = args[i].to(f_arg) + arg.to(f_arg, equivalencies=equivalencies) except UnitsError: - raise TypeError("Argument '{}' to function '{}' must be in units convertable to '{}'.".format(f.func_code.co_varnames[i], f.func_code.co_name, f_arg.to_string())) + raise TypeError( +"Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( + f.func_code.co_varnames[i], f.func_code.co_name, f_arg.to_string())) except AttributeError: - raise TypeError("Argument '{}' to function '{}' must be an astropy Quantity object".format(f.func_code.co_varnames[i], f.func_code.co_name)) + raise TypeError( +"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( + f.func_code.co_varnames[i], f.func_code.co_name)) # Check kwargs, only kwargs specified in the decorator are modified - for kwarg, value in f_kwargs.items(): + for j, (kwarg, value) in enumerate(f_kwargs.items()): if kwarg in kwds: try: - kwds[kwarg] = kwds[kwarg].to(value) + kwds[kwarg].to(value, equivalencies=equivalencies) except UnitsError: - raise TypeError("Keyword argument '{}' to function '{}' must be an astropy Quantity object in units convertable to '{}'.".format(kwarg, f.func_code.co_name, value.to_string())) + raise TypeError( +"Keyword argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( + kwarg, f.func_code.co_name, value.to_string())) except AttributeError: - raise TypeError("Argument '{}' to function '{}' must be an astropy Quantity object".format(f.func_code.co_varnames[i], f.func_code.co_name)) + raise TypeError( +"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( + f.func_code.co_varnames[j+num_args], f.func_code.co_name)) return f(*args, **kwds) diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py new file mode 100644 index 00000000000..56d149192d3 --- /dev/null +++ b/astropy/units/tests/test_quantity_decorator.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- + +import pytest + +import astropy.units as u +from sunpy.util.quantity_decorator import quantity_input + +def test_wrong_num_args(): + with pytest.raises(ValueError) as e: + @quantity_input(u.arcsec) + def myfunc_args(solarx, solary): + return solarx, solary + assert "Number of decorator arguments does not equal number of function arguments" == str(e.value) + +def test_args(): + @quantity_input(u.arcsec, u.arcsec) + def myfunc_args(solarx, solary): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 1*u.arcsec) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.arcsec + assert solary.unit == u.arcsec + +def test_args_noconvert(): + @quantity_input(u.arcsec, u.arcsec) + def myfunc_args(solarx, solary): + return solarx, solary + + solarx, solary = myfunc_args(1*u.deg, 1*u.arcmin) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.deg + assert solary.unit == u.arcmin + + +def test_args_nonquantity(): + @quantity_input(u.arcsec, None) + def myfunc_args(solarx, solary): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 100) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, int) + + assert solarx.unit == u.arcsec + +def test_arg_equivalencies(): + @quantity_input(u.arcsec, u.eV, equivalencies=u.mass_energy()) + def myfunc_args(solarx, solary): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 100*u.gram) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.arcsec + assert solary.unit == u.gram + +def test_wrong_unit(): + @quantity_input(u.arcsec, u.deg) + def myfunc_args(solarx, solary): + return solarx, solary + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(1*u.arcsec, 100*u.km) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + +def test_not_quantity(): + @quantity_input(u.arcsec, u.deg) + def myfunc_args(solarx, solary): + return solarx, solary + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(1*u.arcsec, 100) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + +def test_kwargs(): + @quantity_input(u.arcsec, None, myk=u.deg) + def myfunc_args(solarx, solary, myk=1*u.arcsec): + return solarx, solary, myk + + solarx, solary, myk = myfunc_args(1*u.arcsec, 100, myk=100*u.deg) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, int) + assert isinstance(myk, u.Quantity) + + assert myk.unit == u.deg + +def test_unused_kwargs(): + @quantity_input(u.arcsec, None, myk=u.deg) + def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): + return solarx, solary, myk, myk2 + + solarx, solary, myk, myk2 = myfunc_args(1*u.arcsec, 100, myk=100*u.deg, myk2=10) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, int) + assert isinstance(myk, u.Quantity) + assert isinstance(myk2, int) + + assert myk.unit == u.deg + assert myk2 == 10 + +def test_kwarg_equivalencies(): + @quantity_input(u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) + def myfunc_args(solarx, energy=10*u.eV): + return solarx, energy + + solarx, energy = myfunc_args(1*u.arcsec, 100*u.gram) + + assert isinstance(solarx, u.Quantity) + assert isinstance(energy, u.Quantity) + + assert solarx.unit == u.arcsec + assert energy.unit == u.gram + +def test_kwarg_wrong_unit(): + @quantity_input(u.arcsec,solary=u.deg) + def myfunc_args(solarx, solary=10*u.deg): + return solarx, solary + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(1*u.arcsec, solary=100*u.km) + assert str(e.value) == "Keyword argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + +def test_kwarg_not_quantity(): + @quantity_input(u.arcsec, solary=u.deg) + def myfunc_args(solarx, solary=10*u.deg): + return solarx, solary + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(1*u.arcsec, solary=100) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" From 27e1f7e6b3bfe8829773e8b80ea068a3d9821c46 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Fri, 21 Nov 2014 17:30:21 +0000 Subject: [PATCH 03/23] Change the decorator to use Python 3 annotations. --- astropy/units/core.py | 133 +++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 55 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 4b7e0058493..7c6f1c2e641 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -13,6 +13,7 @@ import cmath import inspect +import collections import textwrap import warnings import numpy as np @@ -2291,66 +2292,88 @@ def _condition_arg(value): one = dimensionless_unscaled -def quantity_input(*f_args, **f_kwargs): +def _parse_argspec(wrapped_function): """ - A decorator for a function that accepts some inputs as Quantity objects. - - This decorator attempts to convert the given Quantites to the units specified - to the decorator, and fails nicely if a non-Quantity or a incompatible - unit was passed. - - Examples - -------- - - @quantity_input(u.arcsec, u.arcsec, None) - def myfunc(solarx, solary, someflag): - pass + Parse the argspec of the function in a 2 / 3 independant way """ - equivalencies = f_kwargs.pop('equivalencies', []) - def check_quantities(f): - # Number of args in decorator must equal number of args in function - if f.func_defaults: - num_kwargs = len(f.func_defaults) + + outargspec = collections.namedtuple('FullArgSpec', + ['args', 'varargs', 'defaults', + 'annotations']) + if hasattr(inspect, 'getfullargspec'): + # Update the annotations to include any kwargs passed to the decorator + argspec = inspect.getfullargspec(wrapped_function) + outargspec.args=argspec.args + outargspec.varargs=argspec.varargs + outargspec.varkw=argspec.varkw + outargspec.defaults=argspec.defaults + outargspec.annotations=argspec.annotations + + else: + argspec = inspect.getargspec(wrapped_function) + if hasattr(wrapped_function, '__annotations__'): + annotations = wrapped_function.__annotations__ else: - num_kwargs = 0 - num_args = f.func_code.co_argcount - num_kwargs - if len(f_args) != num_args: - raise ValueError("Number of decorator arguments does not equal number of function arguments") - - def new_f(*args, **kwds): - # Check args, number of args in decorator must equal number of args in function - args = list(args) - for i, (arg, f_arg) in enumerate(zip(args, f_args)): - if f_arg is not None: - try: - arg.to(f_arg, equivalencies=equivalencies) - except UnitsError: - raise TypeError( -"Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( - f.func_code.co_varnames[i], f.func_code.co_name, f_arg.to_string())) - except AttributeError: - raise TypeError( -"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( - f.func_code.co_varnames[i], f.func_code.co_name)) + annotations = {} - # Check kwargs, only kwargs specified in the decorator are modified - for j, (kwarg, value) in enumerate(f_kwargs.items()): - if kwarg in kwds: - try: - kwds[kwarg].to(value, equivalencies=equivalencies) - except UnitsError: - raise TypeError( -"Keyword argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( - kwarg, f.func_code.co_name, value.to_string())) - except AttributeError: - raise TypeError( -"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( - f.func_code.co_varnames[j+num_args], f.func_code.co_name)) + outargspec.args=argspec[0] + outargspec.varargs=argspec[1] + outargspec.varkw=argspec[2] + outargspec.defaults=argspec[3] + outargspec.annotations=annotations + + return outargspec + +class QuantityInput(object): + # __init__ is called when the function is parsed, it creates the decorator + def __init__(self, **kwargs): + self.equivalencies = kwargs.pop('equivalencies', []) + self.f_kwargs = kwargs + + # __call__ is called when the wrapped function is called. + def __call__(self, wrapped_function): + + # Update the annotations to include any kwargs passed to the decorator + argspec = _parse_argspec(wrapped_function) + argspec.annotations.update(self.f_kwargs) + + #Define a new function to return in place of the wrapped one + def wrapper(*func_args, **func_kwargs): + + # Update func_kwargs with the default values + func_kwargs.update(dict(zip(argspec.args[len(func_args):], + argspec.defaults))) + + for var, target_unit in argspec.annotations.items(): + loc = argspec.args.index(var) + + if loc < len(func_args): + arg = func_args[loc] + + elif var in func_kwargs: + arg = func_kwargs[var] + + else: + raise ValueError("I have no idea what you are doing") - return f(*args, **kwds) + # Now we have the arg or the kwarg we check to see if it is + # convertable to the unit specified in the decorator. + try: + equivalent = arg.unit.is_equivalent(target_unit, + equivalencies=self.equivalencies) + if not equivalent: + raise UnitsError( +"Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( + var, wrapped_function.__name__, target_unit.to_string())) - new_f.func_name = f.func_name - return new_f + # AttributeError is raised if there is no `to` method. + # i.e. not something that quacks like a Quantity. + except AttributeError: + raise TypeError( +"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( + var, wrapped_function.__name__)) + + return wrapped_function(*func_args, **func_kwargs) - return check_quantities + return wrapper From c71a17c2b2f0503360a5899d44e847b3bf63c672 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 12:46:22 +0000 Subject: [PATCH 04/23] Tidy up and write tests in both py2 and py3 syntax --- astropy/units/core.py | 38 +++-- .../tests/py3_test_quantity_annotations.py | 133 ++++++++++++++++++ .../units/tests/test_quantity_decorator.py | 39 +++-- 3 files changed, 169 insertions(+), 41 deletions(-) create mode 100644 astropy/units/tests/py3_test_quantity_annotations.py diff --git a/astropy/units/core.py b/astropy/units/core.py index 7c6f1c2e641..c24af39bc87 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -33,7 +33,7 @@ 'PrefixUnit', 'UnrecognizedUnit', 'get_current_unit_registry', 'set_enabled_units', 'add_enabled_units', 'set_enabled_equivalencies', 'add_enabled_equivalencies', - 'dimensionless_unscaled', 'one', 'quantity_input'] + 'dimensionless_unscaled', 'one', 'QuantityInput'] def _flatten_units_collection(items): @@ -2297,30 +2297,25 @@ def _parse_argspec(wrapped_function): Parse the argspec of the function in a 2 / 3 independant way """ - outargspec = collections.namedtuple('FullArgSpec', - ['args', 'varargs', 'defaults', - 'annotations']) if hasattr(inspect, 'getfullargspec'): # Update the annotations to include any kwargs passed to the decorator - argspec = inspect.getfullargspec(wrapped_function) - outargspec.args=argspec.args - outargspec.varargs=argspec.varargs - outargspec.varkw=argspec.varkw - outargspec.defaults=argspec.defaults - outargspec.annotations=argspec.annotations + outargspec = inspect.getfullargspec(wrapped_function) else: argspec = inspect.getargspec(wrapped_function) if hasattr(wrapped_function, '__annotations__'): - annotations = wrapped_function.__annotations__ + annotations = wrapped_function.__annotations__ # pragma: no cover else: annotations = {} - outargspec.args=argspec[0] - outargspec.varargs=argspec[1] - outargspec.varkw=argspec[2] - outargspec.defaults=argspec[3] - outargspec.annotations=annotations + outargspec = collections.namedtuple('FullArgSpec', + ['args', 'varargs', 'defaults', + 'annotations']) + outargspec.args = argspec[0] + outargspec.varargs = argspec[1] + outargspec.varkw = argspec[2] + outargspec.defaults = argspec[3] + outargspec.annotations = annotations return outargspec @@ -2340,9 +2335,12 @@ def __call__(self, wrapped_function): #Define a new function to return in place of the wrapped one def wrapper(*func_args, **func_kwargs): - # Update func_kwargs with the default values - func_kwargs.update(dict(zip(argspec.args[len(func_args):], - argspec.defaults))) + if argspec.defaults: + # Update func_kwargs with the default values + defaults = dict(zip(argspec.args[len(func_args):], + argspec.defaults)) + defaults.update(func_kwargs) + func_kwargs = defaults for var, target_unit in argspec.annotations.items(): loc = argspec.args.index(var) @@ -2354,7 +2352,7 @@ def wrapper(*func_args, **func_kwargs): arg = func_kwargs[var] else: - raise ValueError("I have no idea what you are doing") + raise ValueError("Inconsistent function specification!") # pragma: no cover # Now we have the arg or the kwarg we check to see if it is # convertable to the unit specified in the decorator. diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py new file mode 100644 index 00000000000..14138723059 --- /dev/null +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- + +import pytest +import astropy.units as u + +def test_args3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.arcsec): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 1*u.arcsec) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.arcsec + assert solary.unit == u.arcsec + +def test_args_noconvert3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.arcsec): + return solarx, solary + + solarx, solary = myfunc_args(1*u.deg, 1*u.arcmin) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.deg + assert solary.unit == u.arcmin + +def test_args_nonquantity3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 100) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, int) + + assert solarx.unit == u.arcsec + +def test_arg_equivalencies3(): + @u.QuantityInput(equivalencies=u.mass_energy()) + def myfunc_args(solarx: u.arcsec, solary: u.eV): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 100*u.gram) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.arcsec + assert solary.unit == u.gram + +def test_wrong_unit3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.deg): + return solarx, solary + + with pytest.raises(u.UnitsError) as e: + solarx, solary = myfunc_args(1*u.arcsec, 100*u.km) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + +def test_not_quantity3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.deg): + return solarx, solary + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(1*u.arcsec, 100) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + + +def test_kwargs3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): + return solarx, solary, myk + + solarx, solary, myk = myfunc_args(1*u.arcsec, 100, myk=100*u.deg) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, int) + assert isinstance(myk, u.Quantity) + + assert myk.unit == u.deg + +def test_unused_kwargs3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec, myk2=1000): + return solarx, solary, myk, myk2 + + solarx, solary, myk, myk2 = myfunc_args(1*u.arcsec, 100, myk=100*u.deg, myk2=10) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, int) + assert isinstance(myk, u.Quantity) + assert isinstance(myk2, int) + + assert myk.unit == u.deg + assert myk2 == 10 + +def test_kwarg_equivalencies3(): + @u.QuantityInput(equivalencies=u.mass_energy()) + def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): + return solarx, energy + + solarx, energy = myfunc_args(1*u.arcsec, 100*u.gram) + + assert isinstance(solarx, u.Quantity) + assert isinstance(energy, u.Quantity) + + assert solarx.unit == u.arcsec + assert energy.unit == u.gram + +def test_kwarg_wrong_unit3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): + return solarx, solary + + with pytest.raises(u.UnitsError) as e: + solarx, solary = myfunc_args(1*u.arcsec, solary=100*u.km) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + +def test_kwarg_not_quantity3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): + return solarx, solary + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(1*u.arcsec, solary=100) + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index 56d149192d3..5f0f51d1ded 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -2,18 +2,15 @@ import pytest +from astropy.extern import six + import astropy.units as u -from sunpy.util.quantity_decorator import quantity_input -def test_wrong_num_args(): - with pytest.raises(ValueError) as e: - @quantity_input(u.arcsec) - def myfunc_args(solarx, solary): - return solarx, solary - assert "Number of decorator arguments does not equal number of function arguments" == str(e.value) +if not six.PY2: + from .py3_test_quantity_annotations import * def test_args(): - @quantity_input(u.arcsec, u.arcsec) + @u.QuantityInput(solarx=u.arcsec, solary=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary @@ -26,7 +23,7 @@ def myfunc_args(solarx, solary): assert solary.unit == u.arcsec def test_args_noconvert(): - @quantity_input(u.arcsec, u.arcsec) + @u.QuantityInput(solarx=u.arcsec, solary=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary @@ -40,7 +37,7 @@ def myfunc_args(solarx, solary): def test_args_nonquantity(): - @quantity_input(u.arcsec, None) + @u.QuantityInput(solarx=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary @@ -52,7 +49,7 @@ def myfunc_args(solarx, solary): assert solarx.unit == u.arcsec def test_arg_equivalencies(): - @quantity_input(u.arcsec, u.eV, equivalencies=u.mass_energy()) + @u.QuantityInput(solarx=u.arcsec, solary=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, solary): return solarx, solary @@ -65,16 +62,16 @@ def myfunc_args(solarx, solary): assert solary.unit == u.gram def test_wrong_unit(): - @quantity_input(u.arcsec, u.deg) + @u.QuantityInput(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary): return solarx, solary - with pytest.raises(TypeError) as e: + with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." def test_not_quantity(): - @quantity_input(u.arcsec, u.deg) + @u.QuantityInput(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary): return solarx, solary @@ -83,7 +80,7 @@ def myfunc_args(solarx, solary): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" def test_kwargs(): - @quantity_input(u.arcsec, None, myk=u.deg) + @u.QuantityInput(solarx=u.arcsec, myk=u.deg) def myfunc_args(solarx, solary, myk=1*u.arcsec): return solarx, solary, myk @@ -96,7 +93,7 @@ def myfunc_args(solarx, solary, myk=1*u.arcsec): assert myk.unit == u.deg def test_unused_kwargs(): - @quantity_input(u.arcsec, None, myk=u.deg) + @u.QuantityInput(solarx=u.arcsec, myk=u.deg) def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): return solarx, solary, myk, myk2 @@ -111,7 +108,7 @@ def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): assert myk2 == 10 def test_kwarg_equivalencies(): - @quantity_input(u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) + @u.QuantityInput(solarx=u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, energy=10*u.eV): return solarx, energy @@ -124,16 +121,16 @@ def myfunc_args(solarx, energy=10*u.eV): assert energy.unit == u.gram def test_kwarg_wrong_unit(): - @quantity_input(u.arcsec,solary=u.deg) + @u.QuantityInput(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary - with pytest.raises(TypeError) as e: + with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100*u.km) - assert str(e.value) == "Keyword argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." def test_kwarg_not_quantity(): - @quantity_input(u.arcsec, solary=u.deg) + @u.QuantityInput(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary From e77b06688fd5e36de0a69460bc7d364fc1972982 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 13:18:34 +0000 Subject: [PATCH 05/23] Use inspect.signature instead, currently no py2 support. --- astropy/units/core.py | 95 +++++++------------ .../tests/py3_test_quantity_annotations.py | 21 +++- .../units/tests/test_quantity_decorator.py | 7 ++ 3 files changed, 63 insertions(+), 60 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index c24af39bc87..be62bf207fe 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2291,34 +2291,6 @@ def _condition_arg(value): # Abbreviation of the above, see #1980 one = dimensionless_unscaled - -def _parse_argspec(wrapped_function): - """ - Parse the argspec of the function in a 2 / 3 independant way - """ - - if hasattr(inspect, 'getfullargspec'): - # Update the annotations to include any kwargs passed to the decorator - outargspec = inspect.getfullargspec(wrapped_function) - - else: - argspec = inspect.getargspec(wrapped_function) - if hasattr(wrapped_function, '__annotations__'): - annotations = wrapped_function.__annotations__ # pragma: no cover - else: - annotations = {} - - outargspec = collections.namedtuple('FullArgSpec', - ['args', 'varargs', 'defaults', - 'annotations']) - outargspec.args = argspec[0] - outargspec.varargs = argspec[1] - outargspec.varkw = argspec[2] - outargspec.defaults = argspec[3] - outargspec.annotations = annotations - - return outargspec - class QuantityInput(object): # __init__ is called when the function is parsed, it creates the decorator def __init__(self, **kwargs): @@ -2329,49 +2301,54 @@ def __init__(self, **kwargs): def __call__(self, wrapped_function): # Update the annotations to include any kwargs passed to the decorator - argspec = _parse_argspec(wrapped_function) - argspec.annotations.update(self.f_kwargs) - + wrapped_signature = inspect.signature(wrapped_function) + #Define a new function to return in place of the wrapped one def wrapper(*func_args, **func_kwargs): - - if argspec.defaults: - # Update func_kwargs with the default values - defaults = dict(zip(argspec.args[len(func_args):], - argspec.defaults)) - defaults.update(func_kwargs) - func_kwargs = defaults - - for var, target_unit in argspec.annotations.items(): - loc = argspec.args.index(var) + # Iterate through the parameters of the function and extract the + # decorator kwarg or the annotation. + for var, parameter in wrapped_signature.parameters.items(): + if var in self.f_kwargs: + target_unit = self.f_kwargs[var] + else: + target_unit = parameter.annotation + + # Find the location of the var in the arguments to the function. + loc = tuple(wrapped_signature.parameters.values()).index(parameter) if loc < len(func_args): arg = func_args[loc] elif var in func_kwargs: arg = func_kwargs[var] + + # If we are a kwarg without the default being overriden + elif not parameter.default is inspect.Parameter.empty: + arg = parameter.default else: raise ValueError("Inconsistent function specification!") # pragma: no cover - - # Now we have the arg or the kwarg we check to see if it is - # convertable to the unit specified in the decorator. - try: - equivalent = arg.unit.is_equivalent(target_unit, - equivalencies=self.equivalencies) - - if not equivalent: - raise UnitsError( -"Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( - var, wrapped_function.__name__, target_unit.to_string())) - - # AttributeError is raised if there is no `to` method. - # i.e. not something that quacks like a Quantity. - except AttributeError: - raise TypeError( -"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( - var, wrapped_function.__name__)) + + # If the target unit is empty, then no unit was specified so we + # move past it + if not target_unit is inspect.Parameter.empty: + try: + equivalent = arg.unit.is_equivalent(target_unit, + equivalencies=self.equivalencies) + + if not equivalent: + raise UnitsError( + "Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( + var, wrapped_function.__name__, target_unit.to_string())) + # AttributeError is raised if there is no `to` method. + # i.e. not something that quacks like a Quantity. + except AttributeError: + raise TypeError( + "Argument '{0}' to function '{1}' must be an astropy Quantity object".format( + var, wrapped_function.__name__)) + return wrapped_function(*func_args, **func_kwargs) + return wrapper diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index 14138723059..3eb960e41c1 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -72,7 +72,19 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg): solarx, solary = myfunc_args(1*u.arcsec, 100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" - +def test_decorator_override(): + @u.QuantityInput(solarx=u.arcsec) + def myfunc_args(solarx: u.km, solary: u.arcsec): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec, 1*u.arcsec) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.arcsec + assert solary.unit == u.arcsec + def test_kwargs3(): @u.QuantityInput() def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): @@ -131,3 +143,10 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + +def test_kwarg_default3(): + @u.QuantityInput() + def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec) \ No newline at end of file diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index 5f0f51d1ded..b343b69266f 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -137,3 +137,10 @@ def myfunc_args(solarx, solary=10*u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + +def test_kwarg_default(): + @u.QuantityInput(solarx=u.arcsec, solary=u.deg) + def myfunc_args(solarx, solary=10*u.deg): + return solarx, solary + + solarx, solary = myfunc_args(1*u.arcsec) From cbfb0c8986469b5b743f72e36ec42b7a686d69f8 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 16:24:37 +0000 Subject: [PATCH 06/23] move to extern funcsigs --- astropy/units/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index be62bf207fe..4691d9feb2f 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -11,6 +11,7 @@ from ..extern.six.moves import zip if six.PY2: import cmath +from ..extern import funcsigs import inspect import collections @@ -2301,7 +2302,7 @@ def __init__(self, **kwargs): def __call__(self, wrapped_function): # Update the annotations to include any kwargs passed to the decorator - wrapped_signature = inspect.signature(wrapped_function) + wrapped_signature = funcsigs.signature(wrapped_function) #Define a new function to return in place of the wrapped one def wrapper(*func_args, **func_kwargs): @@ -2323,7 +2324,7 @@ def wrapper(*func_args, **func_kwargs): arg = func_kwargs[var] # If we are a kwarg without the default being overriden - elif not parameter.default is inspect.Parameter.empty: + elif not parameter.default is funcsigs.Parameter.empty: arg = parameter.default else: @@ -2331,7 +2332,7 @@ def wrapper(*func_args, **func_kwargs): # If the target unit is empty, then no unit was specified so we # move past it - if not target_unit is inspect.Parameter.empty: + if not target_unit is funcsigs.Parameter.empty: try: equivalent = arg.unit.is_equivalent(target_unit, equivalencies=self.equivalencies) From 58b81702c178b962e96443aa757b16f0e4fcc0b7 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 17:22:07 +0000 Subject: [PATCH 07/23] move to utils.compat funcsigs --- astropy/units/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 4691d9feb2f..f9875b5bbb9 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -11,7 +11,7 @@ from ..extern.six.moves import zip if six.PY2: import cmath -from ..extern import funcsigs +from ..utils.compat import funcsigs import inspect import collections From 457ef6a9f4ad866c548df6e9abcfca4e1723f18e Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 17:28:22 +0000 Subject: [PATCH 08/23] Some small tidying --- astropy/units/core.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index f9875b5bbb9..99a64a41552 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2317,9 +2317,12 @@ def wrapper(*func_args, **func_kwargs): # Find the location of the var in the arguments to the function. loc = tuple(wrapped_signature.parameters.values()).index(parameter) + # loc is an integer which includes the kwargs, so we check if + # we are talking about an arg or a kwarg. if loc < len(func_args): arg = func_args[loc] + # If kwarg then we get it by name. elif var in func_kwargs: arg = func_kwargs[var] @@ -2339,14 +2342,14 @@ def wrapper(*func_args, **func_kwargs): if not equivalent: raise UnitsError( - "Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( +"Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( var, wrapped_function.__name__, target_unit.to_string())) # AttributeError is raised if there is no `to` method. # i.e. not something that quacks like a Quantity. except AttributeError: raise TypeError( - "Argument '{0}' to function '{1}' must be an astropy Quantity object".format( +"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( var, wrapped_function.__name__)) return wrapped_function(*func_args, **func_kwargs) From e83cf960485ef95f329bee579f8eeb87bed3eb36 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 18:09:04 +0000 Subject: [PATCH 09/23] some changes, but not all. [ci skip] --- astropy/units/core.py | 12 ++++----- .../tests/py3_test_quantity_annotations.py | 26 +++++++++---------- .../units/tests/test_quantity_decorator.py | 24 ++++++++--------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 99a64a41552..c79df04f128 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -19,7 +19,7 @@ import warnings import numpy as np -from ..utils.decorators import lazyproperty +from ..utils.decorators import lazyproperty, wraps from ..utils.exceptions import AstropyWarning from ..utils.misc import isiterable, InheritDocstrings from .utils import (is_effectively_unity, sanitize_scale, validate_power, @@ -34,7 +34,7 @@ 'PrefixUnit', 'UnrecognizedUnit', 'get_current_unit_registry', 'set_enabled_units', 'add_enabled_units', 'set_enabled_equivalencies', 'add_enabled_equivalencies', - 'dimensionless_unscaled', 'one', 'QuantityInput'] + 'dimensionless_unscaled', 'one', 'quantity_input'] def _flatten_units_collection(items): @@ -2292,19 +2292,19 @@ def _condition_arg(value): # Abbreviation of the above, see #1980 one = dimensionless_unscaled -class QuantityInput(object): - # __init__ is called when the function is parsed, it creates the decorator - def __init__(self, **kwargs): +class quantity_input(object): + + def __init__(self, func=None, **kwargs): self.equivalencies = kwargs.pop('equivalencies', []) self.f_kwargs = kwargs - # __call__ is called when the wrapped function is called. def __call__(self, wrapped_function): # Update the annotations to include any kwargs passed to the decorator wrapped_signature = funcsigs.signature(wrapped_function) #Define a new function to return in place of the wrapped one + @wraps(wrapped_function) def wrapper(*func_args, **func_kwargs): # Iterate through the parameters of the function and extract the # decorator kwarg or the annotation. diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index 3eb960e41c1..5a65fecfbef 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -4,7 +4,7 @@ import astropy.units as u def test_args3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.arcsec): return solarx, solary @@ -17,7 +17,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.arcsec): assert solary.unit == u.arcsec def test_args_noconvert3(): - @u.QuantityInput() + @u.quantity_input() def myfunc_args(solarx: u.arcsec, solary: u.arcsec): return solarx, solary @@ -30,7 +30,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.arcsec): assert solary.unit == u.arcmin def test_args_nonquantity3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary): return solarx, solary @@ -42,7 +42,7 @@ def myfunc_args(solarx: u.arcsec, solary): assert solarx.unit == u.arcsec def test_arg_equivalencies3(): - @u.QuantityInput(equivalencies=u.mass_energy()) + @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, solary: u.eV): return solarx, solary @@ -55,7 +55,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.eV): assert solary.unit == u.gram def test_wrong_unit3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg): return solarx, solary @@ -64,7 +64,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." def test_not_quantity3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg): return solarx, solary @@ -73,7 +73,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" def test_decorator_override(): - @u.QuantityInput(solarx=u.arcsec) + @u.quantity_input(solarx=u.arcsec) def myfunc_args(solarx: u.km, solary: u.arcsec): return solarx, solary @@ -86,7 +86,7 @@ def myfunc_args(solarx: u.km, solary: u.arcsec): assert solary.unit == u.arcsec def test_kwargs3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): return solarx, solary, myk @@ -99,7 +99,7 @@ def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): assert myk.unit == u.deg def test_unused_kwargs3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec, myk2=1000): return solarx, solary, myk, myk2 @@ -114,7 +114,7 @@ def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec, myk2=1000): assert myk2 == 10 def test_kwarg_equivalencies3(): - @u.QuantityInput(equivalencies=u.mass_energy()) + @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): return solarx, energy @@ -127,7 +127,7 @@ def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): assert energy.unit == u.gram def test_kwarg_wrong_unit3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary @@ -136,7 +136,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." def test_kwarg_not_quantity3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary @@ -145,7 +145,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" def test_kwarg_default3(): - @u.QuantityInput() + @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index b343b69266f..b8d3fe39ba7 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -10,7 +10,7 @@ from .py3_test_quantity_annotations import * def test_args(): - @u.QuantityInput(solarx=u.arcsec, solary=u.arcsec) + @u.quantity_input(solarx=u.arcsec, solary=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary @@ -23,7 +23,7 @@ def myfunc_args(solarx, solary): assert solary.unit == u.arcsec def test_args_noconvert(): - @u.QuantityInput(solarx=u.arcsec, solary=u.arcsec) + @u.quantity_input(solarx=u.arcsec, solary=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary @@ -37,7 +37,7 @@ def myfunc_args(solarx, solary): def test_args_nonquantity(): - @u.QuantityInput(solarx=u.arcsec) + @u.quantity_input(solarx=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary @@ -49,7 +49,7 @@ def myfunc_args(solarx, solary): assert solarx.unit == u.arcsec def test_arg_equivalencies(): - @u.QuantityInput(solarx=u.arcsec, solary=u.eV, equivalencies=u.mass_energy()) + @u.quantity_input(solarx=u.arcsec, solary=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, solary): return solarx, solary @@ -62,7 +62,7 @@ def myfunc_args(solarx, solary): assert solary.unit == u.gram def test_wrong_unit(): - @u.QuantityInput(solarx=u.arcsec, solary=u.deg) + @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary): return solarx, solary @@ -71,7 +71,7 @@ def myfunc_args(solarx, solary): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." def test_not_quantity(): - @u.QuantityInput(solarx=u.arcsec, solary=u.deg) + @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary): return solarx, solary @@ -80,7 +80,7 @@ def myfunc_args(solarx, solary): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" def test_kwargs(): - @u.QuantityInput(solarx=u.arcsec, myk=u.deg) + @u.quantity_input(solarx=u.arcsec, myk=u.deg) def myfunc_args(solarx, solary, myk=1*u.arcsec): return solarx, solary, myk @@ -93,7 +93,7 @@ def myfunc_args(solarx, solary, myk=1*u.arcsec): assert myk.unit == u.deg def test_unused_kwargs(): - @u.QuantityInput(solarx=u.arcsec, myk=u.deg) + @u.quantity_input(solarx=u.arcsec, myk=u.deg) def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): return solarx, solary, myk, myk2 @@ -108,7 +108,7 @@ def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): assert myk2 == 10 def test_kwarg_equivalencies(): - @u.QuantityInput(solarx=u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) + @u.quantity_input(solarx=u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, energy=10*u.eV): return solarx, energy @@ -121,7 +121,7 @@ def myfunc_args(solarx, energy=10*u.eV): assert energy.unit == u.gram def test_kwarg_wrong_unit(): - @u.QuantityInput(solarx=u.arcsec, solary=u.deg) + @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary @@ -130,7 +130,7 @@ def myfunc_args(solarx, solary=10*u.deg): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." def test_kwarg_not_quantity(): - @u.QuantityInput(solarx=u.arcsec, solary=u.deg) + @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary @@ -139,7 +139,7 @@ def myfunc_args(solarx, solary=10*u.deg): assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" def test_kwarg_default(): - @u.QuantityInput(solarx=u.arcsec, solary=u.deg) + @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary From d959ae47a2dee11cac3d17f12cc85bde2b426c51 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 26 Nov 2014 22:15:05 +0000 Subject: [PATCH 10/23] make it work with or without calling it. --- astropy/units/core.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index c79df04f128..9b62fb4f501 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2292,12 +2292,20 @@ def _condition_arg(value): # Abbreviation of the above, see #1980 one = dimensionless_unscaled -class quantity_input(object): +class QuantityInput(object): def __init__(self, func=None, **kwargs): self.equivalencies = kwargs.pop('equivalencies', []) self.f_kwargs = kwargs + @classmethod + def as_decorator(cls, func=None, **kwargs): + self = cls(**kwargs) + if func is not None and not kwargs: + return self(func) + else: + return self + def __call__(self, wrapped_function): # Update the annotations to include any kwargs passed to the decorator @@ -2356,3 +2364,5 @@ def wrapper(*func_args, **func_kwargs): return wrapper + +quantity_input = QuantityInput.as_decorator \ No newline at end of file From af99f4614acda0e003880bbf3a3ec34ea53d4468 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Thu, 27 Nov 2014 14:33:33 +0000 Subject: [PATCH 11/23] Docs and changelog --- CHANGES.rst | 3 +++ astropy/units/core.py | 41 ++++++++++++++++++++++++++++++++++++----- docs/units/quantity.rst | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 20b9944829b..0cf7affef9d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -191,6 +191,9 @@ New Features - Add ability to use ``WCS`` object to define projections in Matplotlib, using the ``WCSAxes`` package. [#3183] + - Added units.quantity_input decorator to validate quantity inputs to a + function for unit compatibility. [#3072] + API Changes ^^^^^^^^^^^ diff --git a/astropy/units/core.py b/astropy/units/core.py index 9b62fb4f501..e9eeb2ba0d8 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2294,18 +2294,49 @@ def _condition_arg(value): class QuantityInput(object): - def __init__(self, func=None, **kwargs): - self.equivalencies = kwargs.pop('equivalencies', []) - self.f_kwargs = kwargs - @classmethod def as_decorator(cls, func=None, **kwargs): + """ + A decorator for validating the units of arguments to functions. + + Unit specifications can be provided as keyword arguments to the decorator, + or by usng Python 3's function annotation syntax. Arguments to the decorator + take precidence over any function annotations present. + + A `~astropy.units.UnitsError` will be raised if the unit attribute of + the argument is not equivalent to the unit specified to the decorator + or in the annotation. + If the argument has no unit attribute, i.e. it is not a Quantity object, a + `~exceptions.ValueError` will be raised. + + Examples + -------- + + Python 2:: + + import astropy.units as u + @u.quantity_input(myangle=u.arcsec) + def myfunction(myangle): + return myangle**2 + + Python 3:: + + import astropy.units as u + @u.quantity_input + def myfunction(myangle: u.arcsec): + return myangle**2 + + """ self = cls(**kwargs) if func is not None and not kwargs: return self(func) else: return self + def __init__(self, func=None, **kwargs): + self.equivalencies = kwargs.pop('equivalencies', []) + self.f_kwargs = kwargs + def __call__(self, wrapped_function): # Update the annotations to include any kwargs passed to the decorator @@ -2365,4 +2396,4 @@ def wrapper(*func_args, **func_kwargs): return wrapper -quantity_input = QuantityInput.as_decorator \ No newline at end of file +quantity_input = QuantityInput.as_decorator diff --git a/docs/units/quantity.rst b/docs/units/quantity.rst index 8ed3f6bbca8..1a22141c602 100644 --- a/docs/units/quantity.rst +++ b/docs/units/quantity.rst @@ -249,6 +249,38 @@ Instead, only dimensionless values can be converted to plain Python scalars: >>> int(6. * u.km / (2. * u.m)) 3000 +Functions Accepting Quantities +------------------------------ + +Validation of inputs to a function where quantities can lead to many repitions +of the same checking code. A decorator is provided which verifies that certain +arguments to a function are `~astropy.units.Quantity` objects and that the units +are compatitble with a desired unit. + +The decorator does not convert the unit to the desired unit, say arcsecsonds +to degrees, it merely checks that such a conversion is possible, thus verifying +that the `~astropy.units.Quantity` object can be used in calculations. + +The decorator `~astropy.units.quantity_input` accepts keyword arguments to +spcifiy which arguments should be validated and what unit they are expected to +be compatible with: + + >>> @u.quantity_input(myarg=u.deg) + ... def myfunction(myarg): + ... return myarg.unit + + >>> myfunction(100*u.arcsec) + Unit("arcsec") + +Under Python 3 you can use the annotations syntax to provide the units: + + >>> @u.quantity_input + ... def myfunction(myarg: u.arcsec): + ... return myarg.unit + + >>> myfunction(100*u.arcsec) + Unit("arcsec") + Known issues with conversion to numpy arrays -------------------------------------------- From d61fb42c70128a9b5d60b54ea347a8a98c481539 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Thu, 27 Nov 2014 15:19:30 +0000 Subject: [PATCH 12/23] fix some typos. [ci skip] --- docs/units/quantity.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/units/quantity.rst b/docs/units/quantity.rst index 1a22141c602..2add8d024d8 100644 --- a/docs/units/quantity.rst +++ b/docs/units/quantity.rst @@ -252,14 +252,14 @@ Instead, only dimensionless values can be converted to plain Python scalars: Functions Accepting Quantities ------------------------------ -Validation of inputs to a function where quantities can lead to many repitions +Validation of quanitty arguments to functions can lead to many repetitons of the same checking code. A decorator is provided which verifies that certain arguments to a function are `~astropy.units.Quantity` objects and that the units are compatitble with a desired unit. -The decorator does not convert the unit to the desired unit, say arcsecsonds +The decorator does not convert the unit to the desired unit, say arcseconds to degrees, it merely checks that such a conversion is possible, thus verifying -that the `~astropy.units.Quantity` object can be used in calculations. +that the `~astropy.units.Quantity` argument can be used in calculations. The decorator `~astropy.units.quantity_input` accepts keyword arguments to spcifiy which arguments should be validated and what unit they are expected to From df4d4a176b956cbc9773d5158b7cdf8bbdfbffdb Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Thu, 27 Nov 2014 15:33:08 +0000 Subject: [PATCH 13/23] whitespace removal. --- astropy/units/core.py | 36 ++++++------- .../tests/py3_test_quantity_annotations.py | 54 +++++++++---------- .../units/tests/test_quantity_decorator.py | 44 +++++++-------- 3 files changed, 67 insertions(+), 67 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index e9eeb2ba0d8..318d9895932 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2298,22 +2298,22 @@ class QuantityInput(object): def as_decorator(cls, func=None, **kwargs): """ A decorator for validating the units of arguments to functions. - + Unit specifications can be provided as keyword arguments to the decorator, or by usng Python 3's function annotation syntax. Arguments to the decorator take precidence over any function annotations present. - - A `~astropy.units.UnitsError` will be raised if the unit attribute of - the argument is not equivalent to the unit specified to the decorator + + A `~astropy.units.UnitsError` will be raised if the unit attribute of + the argument is not equivalent to the unit specified to the decorator or in the annotation. - If the argument has no unit attribute, i.e. it is not a Quantity object, a - `~exceptions.ValueError` will be raised. - + If the argument has no unit attribute, i.e. it is not a Quantity object, a + `~exceptions.ValueError` will be raised. + Examples -------- - + Python 2:: - + import astropy.units as u @u.quantity_input(myangle=u.arcsec) def myfunction(myangle): @@ -2338,21 +2338,21 @@ def __init__(self, func=None, **kwargs): self.f_kwargs = kwargs def __call__(self, wrapped_function): - + # Update the annotations to include any kwargs passed to the decorator wrapped_signature = funcsigs.signature(wrapped_function) - + #Define a new function to return in place of the wrapped one @wraps(wrapped_function) def wrapper(*func_args, **func_kwargs): - # Iterate through the parameters of the function and extract the + # Iterate through the parameters of the function and extract the # decorator kwarg or the annotation. for var, parameter in wrapped_signature.parameters.items(): if var in self.f_kwargs: target_unit = self.f_kwargs[var] else: target_unit = parameter.annotation - + # Find the location of the var in the arguments to the function. loc = tuple(wrapped_signature.parameters.values()).index(parameter) @@ -2364,26 +2364,26 @@ def wrapper(*func_args, **func_kwargs): # If kwarg then we get it by name. elif var in func_kwargs: arg = func_kwargs[var] - + # If we are a kwarg without the default being overriden elif not parameter.default is funcsigs.Parameter.empty: arg = parameter.default - + else: raise ValueError("Inconsistent function specification!") # pragma: no cover - + # If the target unit is empty, then no unit was specified so we # move past it if not target_unit is funcsigs.Parameter.empty: try: equivalent = arg.unit.is_equivalent(target_unit, equivalencies=self.equivalencies) - + if not equivalent: raise UnitsError( "Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( var, wrapped_function.__name__, target_unit.to_string())) - + # AttributeError is raised if there is no `to` method. # i.e. not something that quacks like a Quantity. except AttributeError: diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index 5a65fecfbef..bd05dca4d1a 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -7,12 +7,12 @@ def test_args3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.arcsec): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 1*u.arcsec) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) - + assert solarx.unit == u.arcsec assert solary.unit == u.arcsec @@ -22,7 +22,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.arcsec): return solarx, solary solarx, solary = myfunc_args(1*u.deg, 1*u.arcmin) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) @@ -33,24 +33,24 @@ def test_args_nonquantity3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 100) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, int) - + assert solarx.unit == u.arcsec def test_arg_equivalencies3(): @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, solary: u.eV): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 100*u.gram) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) - + assert solarx.unit == u.arcsec assert solary.unit == u.gram @@ -58,7 +58,7 @@ def test_wrong_unit3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg): return solarx, solary - + with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." @@ -67,7 +67,7 @@ def test_not_quantity3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg): return solarx, solary - + with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" @@ -76,22 +76,22 @@ def test_decorator_override(): @u.quantity_input(solarx=u.arcsec) def myfunc_args(solarx: u.km, solary: u.arcsec): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 1*u.arcsec) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) - + assert solarx.unit == u.arcsec assert solary.unit == u.arcsec - + def test_kwargs3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): return solarx, solary, myk - + solarx, solary, myk = myfunc_args(1*u.arcsec, 100, myk=100*u.deg) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, int) assert isinstance(myk, u.Quantity) @@ -102,9 +102,9 @@ def test_unused_kwargs3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec, myk2=1000): return solarx, solary, myk, myk2 - + solarx, solary, myk, myk2 = myfunc_args(1*u.arcsec, 100, myk=100*u.deg, myk2=10) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, int) assert isinstance(myk, u.Quantity) @@ -117,12 +117,12 @@ def test_kwarg_equivalencies3(): @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): return solarx, energy - + solarx, energy = myfunc_args(1*u.arcsec, 100*u.gram) - + assert isinstance(solarx, u.Quantity) assert isinstance(energy, u.Quantity) - + assert solarx.unit == u.arcsec assert energy.unit == u.gram @@ -130,7 +130,7 @@ def test_kwarg_wrong_unit3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary - + with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." @@ -139,7 +139,7 @@ def test_kwarg_not_quantity3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary - + with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" @@ -148,5 +148,5 @@ def test_kwarg_default3(): @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary - - solarx, solary = myfunc_args(1*u.arcsec) \ No newline at end of file + + solarx, solary = myfunc_args(1*u.arcsec) diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index b8d3fe39ba7..327c00f33dd 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -13,12 +13,12 @@ def test_args(): @u.quantity_input(solarx=u.arcsec, solary=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 1*u.arcsec) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) - + assert solarx.unit == u.arcsec assert solary.unit == u.arcsec @@ -28,7 +28,7 @@ def myfunc_args(solarx, solary): return solarx, solary solarx, solary = myfunc_args(1*u.deg, 1*u.arcmin) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) @@ -40,24 +40,24 @@ def test_args_nonquantity(): @u.quantity_input(solarx=u.arcsec) def myfunc_args(solarx, solary): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 100) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, int) - + assert solarx.unit == u.arcsec def test_arg_equivalencies(): @u.quantity_input(solarx=u.arcsec, solary=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, solary): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec, 100*u.gram) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, u.Quantity) - + assert solarx.unit == u.arcsec assert solary.unit == u.gram @@ -65,7 +65,7 @@ def test_wrong_unit(): @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary): return solarx, solary - + with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." @@ -74,7 +74,7 @@ def test_not_quantity(): @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary): return solarx, solary - + with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" @@ -83,9 +83,9 @@ def test_kwargs(): @u.quantity_input(solarx=u.arcsec, myk=u.deg) def myfunc_args(solarx, solary, myk=1*u.arcsec): return solarx, solary, myk - + solarx, solary, myk = myfunc_args(1*u.arcsec, 100, myk=100*u.deg) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, int) assert isinstance(myk, u.Quantity) @@ -96,9 +96,9 @@ def test_unused_kwargs(): @u.quantity_input(solarx=u.arcsec, myk=u.deg) def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): return solarx, solary, myk, myk2 - + solarx, solary, myk, myk2 = myfunc_args(1*u.arcsec, 100, myk=100*u.deg, myk2=10) - + assert isinstance(solarx, u.Quantity) assert isinstance(solary, int) assert isinstance(myk, u.Quantity) @@ -111,12 +111,12 @@ def test_kwarg_equivalencies(): @u.quantity_input(solarx=u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, energy=10*u.eV): return solarx, energy - + solarx, energy = myfunc_args(1*u.arcsec, 100*u.gram) - + assert isinstance(solarx, u.Quantity) assert isinstance(energy, u.Quantity) - + assert solarx.unit == u.arcsec assert energy.unit == u.gram @@ -124,7 +124,7 @@ def test_kwarg_wrong_unit(): @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary - + with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." @@ -133,7 +133,7 @@ def test_kwarg_not_quantity(): @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary - + with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" @@ -142,5 +142,5 @@ def test_kwarg_default(): @u.quantity_input(solarx=u.arcsec, solary=u.deg) def myfunc_args(solarx, solary=10*u.deg): return solarx, solary - + solarx, solary = myfunc_args(1*u.arcsec) From a3a995e11016b2c114e40f0049481bf542644da8 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Thu, 27 Nov 2014 15:34:28 +0000 Subject: [PATCH 14/23] Skip doctests on Python 3 examples --- docs/units/quantity.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/units/quantity.rst b/docs/units/quantity.rst index 2add8d024d8..5543715b170 100644 --- a/docs/units/quantity.rst +++ b/docs/units/quantity.rst @@ -274,11 +274,11 @@ be compatible with: Under Python 3 you can use the annotations syntax to provide the units: - >>> @u.quantity_input + >>> @u.quantity_input # doctest: +SKIP ... def myfunction(myarg: u.arcsec): ... return myarg.unit - >>> myfunction(100*u.arcsec) + >>> myfunction(100*u.arcsec) # doctest: +SKIP Unit("arcsec") Known issues with conversion to numpy arrays From dc263d2e2713e5b27a160ef6e797099ad66dfc1c Mon Sep 17 00:00:00 2001 From: "Erik M. Bray" Date: Mon, 1 Dec 2014 14:36:24 -0500 Subject: [PATCH 15/23] This adds a test decorator called @py3only--tests using this decorator should write the entire test in the test's docstring, so that it may contain Python 3 only syntax. On Python 2 the test will be marked skip. On Python 3 the test body will by compiled from the docstring and executed. Perhaps some version of this could be moved to general test utilities, but for now this is the only test module we have, I think, that could make use of this. --- .../tests/py3_test_quantity_annotations.py | 75 ++++++++++++++++++- .../units/tests/test_quantity_decorator.py | 11 +-- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index bd05dca4d1a..098ffc356e8 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -1,9 +1,31 @@ # -*- coding: utf-8 -*- -import pytest -import astropy.units as u +from functools import wraps +from textwrap import dedent +from ... import units as u +from ...extern import six +from ...tests.helper import pytest + + +def py3only(func): + if not six.PY3: + return pytest.mark.skipif('not six.PY3')(func) + else: + @wraps(func) + def wrapper(*args, **kwargs): + code = compile(dedent(func.__doc__), __file__, 'exec') + # This uses an unqualified exec statement illegally in Python 2, + # but perfectly allowed in Python 3 so in fact we eval the exec + # call :) + eval('exec(code)') + + return wrapper + + +@py3only def test_args3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.arcsec): return solarx, solary @@ -15,8 +37,12 @@ def myfunc_args(solarx: u.arcsec, solary: u.arcsec): assert solarx.unit == u.arcsec assert solary.unit == u.arcsec + """ + +@py3only def test_args_noconvert3(): + """ @u.quantity_input() def myfunc_args(solarx: u.arcsec, solary: u.arcsec): return solarx, solary @@ -28,8 +54,12 @@ def myfunc_args(solarx: u.arcsec, solary: u.arcsec): assert solarx.unit == u.deg assert solary.unit == u.arcmin + """ + +@py3only def test_args_nonquantity3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary): return solarx, solary @@ -40,8 +70,12 @@ def myfunc_args(solarx: u.arcsec, solary): assert isinstance(solary, int) assert solarx.unit == u.arcsec + """ + +@py3only def test_arg_equivalencies3(): + """ @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, solary: u.eV): return solarx, solary @@ -53,8 +87,12 @@ def myfunc_args(solarx: u.arcsec, solary: u.eV): assert solarx.unit == u.arcsec assert solary.unit == u.gram + """ + +@py3only def test_wrong_unit3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg): return solarx, solary @@ -62,8 +100,12 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg): with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + """ + +@py3only def test_not_quantity3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg): return solarx, solary @@ -71,8 +113,12 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + """ + +@py3only def test_decorator_override(): + """ @u.quantity_input(solarx=u.arcsec) def myfunc_args(solarx: u.km, solary: u.arcsec): return solarx, solary @@ -84,8 +130,12 @@ def myfunc_args(solarx: u.km, solary: u.arcsec): assert solarx.unit == u.arcsec assert solary.unit == u.arcsec + """ + +@py3only def test_kwargs3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): return solarx, solary, myk @@ -97,8 +147,12 @@ def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec): assert isinstance(myk, u.Quantity) assert myk.unit == u.deg + """ + +@py3only def test_unused_kwargs3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec, myk2=1000): return solarx, solary, myk, myk2 @@ -112,8 +166,12 @@ def myfunc_args(solarx: u.arcsec, solary, myk: u.arcsec=1*u.arcsec, myk2=1000): assert myk.unit == u.deg assert myk2 == 10 + """ + +@py3only def test_kwarg_equivalencies3(): + """ @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): return solarx, energy @@ -125,8 +183,12 @@ def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): assert solarx.unit == u.arcsec assert energy.unit == u.gram + """ + +@py3only def test_kwarg_wrong_unit3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary @@ -134,8 +196,12 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): with pytest.raises(u.UnitsError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100*u.km) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be in units convertable to 'deg'." + """ + +@py3only def test_kwarg_not_quantity3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary @@ -143,10 +209,15 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + """ + +@py3only def test_kwarg_default3(): + """ @u.quantity_input def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): return solarx, solary solarx, solary = myfunc_args(1*u.arcsec) + """ diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index 327c00f33dd..c23f168ba0a 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- -import pytest - from astropy.extern import six -import astropy.units as u +from ... import units as u +from ...extern import six +from ...tests.helper import pytest + + +from .py3_test_quantity_annotations import * -if not six.PY2: - from .py3_test_quantity_annotations import * def test_args(): @u.quantity_input(solarx=u.arcsec, solary=u.arcsec) From a7996d62d0fc3d30c0355be7f635f8407f0d2c5e Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Tue, 2 Dec 2014 13:02:11 +0000 Subject: [PATCH 16/23] Fixes and typos --- astropy/units/core.py | 25 ++++++++++++------- .../tests/py3_test_quantity_annotations.py | 1 + .../units/tests/test_quantity_decorator.py | 1 + docs/units/quantity.rst | 4 +-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 318d9895932..6f259c1fc53 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2300,7 +2300,7 @@ def as_decorator(cls, func=None, **kwargs): A decorator for validating the units of arguments to functions. Unit specifications can be provided as keyword arguments to the decorator, - or by usng Python 3's function annotation syntax. Arguments to the decorator + or by using Python 3's function annotation syntax. Arguments to the decorator take precidence over any function annotations present. A `~astropy.units.UnitsError` will be raised if the unit attribute of @@ -2312,19 +2312,26 @@ def as_decorator(cls, func=None, **kwargs): Examples -------- - Python 2:: + Python 2 and 3:: import astropy.units as u @u.quantity_input(myangle=u.arcsec) def myfunction(myangle): return myangle**2 - Python 3:: + Python 3 only:: import astropy.units as u @u.quantity_input def myfunction(myangle: u.arcsec): return myangle**2 + + Using equivalencies: + + import astropy.units as u + @u.quantity_input(myangle=u.eV, equivalencies=u.spectral()) + def myfunction(myenergy): + return myenergy**2 """ self = cls(**kwargs) @@ -2342,7 +2349,7 @@ def __call__(self, wrapped_function): # Update the annotations to include any kwargs passed to the decorator wrapped_signature = funcsigs.signature(wrapped_function) - #Define a new function to return in place of the wrapped one + # Define a new function to return in place of the wrapped one @wraps(wrapped_function) def wrapper(*func_args, **func_kwargs): # Iterate through the parameters of the function and extract the @@ -2380,20 +2387,20 @@ def wrapper(*func_args, **func_kwargs): equivalencies=self.equivalencies) if not equivalent: - raise UnitsError( -"Argument '{0}' to function '{1}' must be in units convertable to '{2}'.".format( + raise UnitsError("Argument '{0}' to function '{1}'" + " must be in units convertable to" + " '{2}'.".format( var, wrapped_function.__name__, target_unit.to_string())) # AttributeError is raised if there is no `to` method. # i.e. not something that quacks like a Quantity. except AttributeError: - raise TypeError( -"Argument '{0}' to function '{1}' must be an astropy Quantity object".format( + raise TypeError("Argument '{0}' to function '{1}' must" + " be an astropy Quantity object".format( var, wrapped_function.__name__)) return wrapped_function(*func_args, **func_kwargs) - return wrapper quantity_input = QuantityInput.as_decorator diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index 098ffc356e8..537449c21e9 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst from functools import wraps from textwrap import dedent diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index c23f168ba0a..4d2248cbce6 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst from astropy.extern import six diff --git a/docs/units/quantity.rst b/docs/units/quantity.rst index 5543715b170..75443b5d06e 100644 --- a/docs/units/quantity.rst +++ b/docs/units/quantity.rst @@ -252,10 +252,10 @@ Instead, only dimensionless values can be converted to plain Python scalars: Functions Accepting Quantities ------------------------------ -Validation of quanitty arguments to functions can lead to many repetitons +Validation of quantity arguments to functions can lead to many repetitons of the same checking code. A decorator is provided which verifies that certain arguments to a function are `~astropy.units.Quantity` objects and that the units -are compatitble with a desired unit. +are compatible with a desired unit. The decorator does not convert the unit to the desired unit, say arcseconds to degrees, it merely checks that such a conversion is possible, thus verifying From 9182a44fcc578b0d138e25a319b9f58fb3ca8c4a Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Tue, 2 Dec 2014 15:34:07 +0000 Subject: [PATCH 17/23] more fixes and code readability --- astropy/units/core.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 6f259c1fc53..a4b4ac6f726 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2329,7 +2329,7 @@ def myfunction(myangle: u.arcsec): Using equivalencies: import astropy.units as u - @u.quantity_input(myangle=u.eV, equivalencies=u.spectral()) + @u.quantity_input(myenergy=u.eV, equivalencies=u.spectral()) def myfunction(myenergy): return myenergy**2 @@ -2373,7 +2373,7 @@ def wrapper(*func_args, **func_kwargs): arg = func_kwargs[var] # If we are a kwarg without the default being overriden - elif not parameter.default is funcsigs.Parameter.empty: + elif parameter.default is not funcsigs.Parameter.empty: arg = parameter.default else: @@ -2381,7 +2381,7 @@ def wrapper(*func_args, **func_kwargs): # If the target unit is empty, then no unit was specified so we # move past it - if not target_unit is funcsigs.Parameter.empty: + if target_unit is not funcsigs.Parameter.empty: try: equivalent = arg.unit.is_equivalent(target_unit, equivalencies=self.equivalencies) @@ -2389,8 +2389,9 @@ def wrapper(*func_args, **func_kwargs): if not equivalent: raise UnitsError("Argument '{0}' to function '{1}'" " must be in units convertable to" - " '{2}'.".format( - var, wrapped_function.__name__, target_unit.to_string())) + " '{2}'.".format(var, + wrapped_function.__name__, + target_unit.to_string())) # AttributeError is raised if there is no `to` method. # i.e. not something that quacks like a Quantity. From b21a9b61e6ab0137d8534eac8156cb76e0e8367a Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Mon, 8 Dec 2014 23:32:29 +0000 Subject: [PATCH 18/23] some fixes --- astropy/units/core.py | 29 +++++++++++-------- .../tests/py3_test_quantity_annotations.py | 4 +-- .../units/tests/test_quantity_decorator.py | 4 +-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index a4b4ac6f726..a19b9a44437 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2325,9 +2325,9 @@ def myfunction(myangle): @u.quantity_input def myfunction(myangle: u.arcsec): return myangle**2 - + Using equivalencies: - + import astropy.units as u @u.quantity_input(myenergy=u.eV, equivalencies=u.spectral()) def myfunction(myenergy): @@ -2354,14 +2354,16 @@ def __call__(self, wrapped_function): def wrapper(*func_args, **func_kwargs): # Iterate through the parameters of the function and extract the # decorator kwarg or the annotation. - for var, parameter in wrapped_signature.parameters.items(): +# for var, parameter in wrapped_signature.parameters.items(): + parameters = wrapped_signature.parameters # Added this for brevity's sake + for loc, (var, parameter) in enumerate(parameters.items()): if var in self.f_kwargs: target_unit = self.f_kwargs[var] else: target_unit = parameter.annotation # Find the location of the var in the arguments to the function. - loc = tuple(wrapped_signature.parameters.values()).index(parameter) +# loc = tuple(wrapped_signature.parameters.values()).index(parameter) # loc is an integer which includes the kwargs, so we check if # we are talking about an arg or a kwarg. @@ -2389,18 +2391,21 @@ def wrapper(*func_args, **func_kwargs): if not equivalent: raise UnitsError("Argument '{0}' to function '{1}'" " must be in units convertable to" - " '{2}'.".format(var, + " '{2}'.".format(var, wrapped_function.__name__, target_unit.to_string())) - # AttributeError is raised if there is no `to` method. - # i.e. not something that quacks like a Quantity. except AttributeError: - raise TypeError("Argument '{0}' to function '{1}' must" - " be an astropy Quantity object".format( - var, wrapped_function.__name__)) - - return wrapped_function(*func_args, **func_kwargs) + if hasattr(arg, "unit"): + error_msg = "a 'unit' attribute without an 'is_equivalent' method" + else: + error_msg = "no 'unit' attribute" + raise TypeError("Argument '{0}' to function has '{1}' {2}. " + "You may want to pass in an astropy Quantity instead." + .format(var, wrapped_function.__name__, error_msg)) + + with add_enabled_equivalencies(self.equivalencies): + return wrapped_function(*func_args, **func_kwargs) return wrapper diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index 537449c21e9..3af7aaf1bb4 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -113,7 +113,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100) - assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + assert str(e.value) == "Argument 'solary' to function has 'myfunc_args' no 'unit' attribute. You may want to pass in an astropy Quantity instead." """ @@ -209,7 +209,7 @@ def myfunc_args(solarx: u.arcsec, solary: u.deg=10*u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) - assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + assert str(e.value) == "Argument 'solary' to function has 'myfunc_args' no 'unit' attribute. You may want to pass in an astropy Quantity instead." """ diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index 4d2248cbce6..95020a0e86f 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -79,7 +79,7 @@ def myfunc_args(solarx, solary): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, 100) - assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + assert str(e.value) == "Argument 'solary' to function has 'myfunc_args' no 'unit' attribute. You may want to pass in an astropy Quantity instead." def test_kwargs(): @u.quantity_input(solarx=u.arcsec, myk=u.deg) @@ -138,7 +138,7 @@ def myfunc_args(solarx, solary=10*u.deg): with pytest.raises(TypeError) as e: solarx, solary = myfunc_args(1*u.arcsec, solary=100) - assert str(e.value) == "Argument 'solary' to function 'myfunc_args' must be an astropy Quantity object" + assert str(e.value) == "Argument 'solary' to function has 'myfunc_args' no 'unit' attribute. You may want to pass in an astropy Quantity instead." def test_kwarg_default(): @u.quantity_input(solarx=u.arcsec, solary=u.deg) From 0368c732695682e872feca6a4831eae6589e64bd Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Tue, 9 Dec 2014 16:42:27 +0000 Subject: [PATCH 19/23] Use signature.bind to remove a lot of arg parsing nonsense --- astropy/units/core.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index a19b9a44437..8c50ba5d84e 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2326,7 +2326,7 @@ def myfunction(myangle): def myfunction(myangle: u.arcsec): return myangle**2 - Using equivalencies: + Using equivalencies:: import astropy.units as u @u.quantity_input(myenergy=u.eV, equivalencies=u.spectral()) @@ -2354,32 +2354,18 @@ def __call__(self, wrapped_function): def wrapper(*func_args, **func_kwargs): # Iterate through the parameters of the function and extract the # decorator kwarg or the annotation. -# for var, parameter in wrapped_signature.parameters.items(): - parameters = wrapped_signature.parameters # Added this for brevity's sake - for loc, (var, parameter) in enumerate(parameters.items()): - if var in self.f_kwargs: - target_unit = self.f_kwargs[var] - else: - target_unit = parameter.annotation - - # Find the location of the var in the arguments to the function. -# loc = tuple(wrapped_signature.parameters.values()).index(parameter) - - # loc is an integer which includes the kwargs, so we check if - # we are talking about an arg or a kwarg. - if loc < len(func_args): - arg = func_args[loc] + bound_args = wrapped_signature.bind(*func_args, **func_kwargs) - # If kwarg then we get it by name. - elif var in func_kwargs: - arg = func_kwargs[var] + for param in wrapped_signature.parameters.values(): + if (param.name not in bound_args.arguments and param.default is not param.empty): + bound_args.arguments[param.name] = param.default - # If we are a kwarg without the default being overriden - elif parameter.default is not funcsigs.Parameter.empty: - arg = parameter.default + arg = bound_args.arguments[param.name] + if param.name in self.f_kwargs: + target_unit = self.f_kwargs[param.name] else: - raise ValueError("Inconsistent function specification!") # pragma: no cover + target_unit = param.annotation # If the target unit is empty, then no unit was specified so we # move past it @@ -2391,7 +2377,7 @@ def wrapper(*func_args, **func_kwargs): if not equivalent: raise UnitsError("Argument '{0}' to function '{1}'" " must be in units convertable to" - " '{2}'.".format(var, + " '{2}'.".format(param.name, wrapped_function.__name__, target_unit.to_string())) @@ -2402,7 +2388,7 @@ def wrapper(*func_args, **func_kwargs): error_msg = "no 'unit' attribute" raise TypeError("Argument '{0}' to function has '{1}' {2}. " "You may want to pass in an astropy Quantity instead." - .format(var, wrapped_function.__name__, error_msg)) + .format(param.name, wrapped_function.__name__, error_msg)) with add_enabled_equivalencies(self.equivalencies): return wrapped_function(*func_args, **func_kwargs) From 2a0ebafc5d0a5ebccb04e30e9940d145cb150520 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Tue, 9 Dec 2014 16:45:54 +0000 Subject: [PATCH 20/23] Add test to make sure equivalencies propagate to the function. --- astropy/units/tests/py3_test_quantity_annotations.py | 4 ++-- astropy/units/tests/test_quantity_decorator.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/astropy/units/tests/py3_test_quantity_annotations.py b/astropy/units/tests/py3_test_quantity_annotations.py index 3af7aaf1bb4..0d873ad48b9 100644 --- a/astropy/units/tests/py3_test_quantity_annotations.py +++ b/astropy/units/tests/py3_test_quantity_annotations.py @@ -79,7 +79,7 @@ def test_arg_equivalencies3(): """ @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, solary: u.eV): - return solarx, solary + return solarx, solary+(10*u.J) # Add an energy to check equiv is working solarx, solary = myfunc_args(1*u.arcsec, 100*u.gram) @@ -175,7 +175,7 @@ def test_kwarg_equivalencies3(): """ @u.quantity_input(equivalencies=u.mass_energy()) def myfunc_args(solarx: u.arcsec, energy: u.eV=10*u.eV): - return solarx, energy + return solarx, energy+(10*u.J) # Add an energy to check equiv is working solarx, energy = myfunc_args(1*u.arcsec, 100*u.gram) diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index 95020a0e86f..78d0ecc62f0 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -53,7 +53,7 @@ def myfunc_args(solarx, solary): def test_arg_equivalencies(): @u.quantity_input(solarx=u.arcsec, solary=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, solary): - return solarx, solary + return solarx, solary+(10*u.J) # Add an energy to check equiv is working solarx, solary = myfunc_args(1*u.arcsec, 100*u.gram) @@ -112,7 +112,7 @@ def myfunc_args(solarx, solary, myk=1*u.arcsec, myk2=1000): def test_kwarg_equivalencies(): @u.quantity_input(solarx=u.arcsec, energy=u.eV, equivalencies=u.mass_energy()) def myfunc_args(solarx, energy=10*u.eV): - return solarx, energy + return solarx, energy+(10*u.J) # Add an energy to check equiv is working solarx, energy = myfunc_args(1*u.arcsec, 100*u.gram) From 9ca243e942f02bd2fc9cf7ce013bf363cb4aa5d5 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Tue, 9 Dec 2014 17:07:24 +0000 Subject: [PATCH 21/23] Comments and doc tweaks --- astropy/units/core.py | 24 ++++++++++++------- .../units/tests/test_quantity_decorator.py | 21 ++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/astropy/units/core.py b/astropy/units/core.py index 8c50ba5d84e..fc71b06b8fd 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -2309,6 +2309,9 @@ def as_decorator(cls, func=None, **kwargs): If the argument has no unit attribute, i.e. it is not a Quantity object, a `~exceptions.ValueError` will be raised. + Where an equivalency is specified in the decorator, the function will be + executed with that equivalency in force. + Examples -------- @@ -2329,7 +2332,7 @@ def myfunction(myangle: u.arcsec): Using equivalencies:: import astropy.units as u - @u.quantity_input(myenergy=u.eV, equivalencies=u.spectral()) + @u.quantity_input(myenergy=u.eV, equivalencies=u.mass_energy()) def myfunction(myenergy): return myenergy**2 @@ -2342,28 +2345,31 @@ def myfunction(myenergy): def __init__(self, func=None, **kwargs): self.equivalencies = kwargs.pop('equivalencies', []) - self.f_kwargs = kwargs + self.decorator_kwargs = kwargs def __call__(self, wrapped_function): - # Update the annotations to include any kwargs passed to the decorator + # Extract the function signature for the function we are wrapping. wrapped_signature = funcsigs.signature(wrapped_function) # Define a new function to return in place of the wrapped one @wraps(wrapped_function) def wrapper(*func_args, **func_kwargs): - # Iterate through the parameters of the function and extract the - # decorator kwarg or the annotation. + # Bind the arguments to our new function to the signature of the original. bound_args = wrapped_signature.bind(*func_args, **func_kwargs) + # Iterate through the parameters of the original signature for param in wrapped_signature.parameters.values(): - if (param.name not in bound_args.arguments and param.default is not param.empty): + # Catch the (never triggered) case where bind relied on a default value. + if param.name not in bound_args.arguments and param.default is not param.empty: bound_args.arguments[param.name] = param.default + # Get the value of this parameter (argument to new function) arg = bound_args.arguments[param.name] - if param.name in self.f_kwargs: - target_unit = self.f_kwargs[param.name] + # Get target unit, either from decotrator kwargs or annotations + if param.name in self.decorator_kwargs: + target_unit = self.decorator_kwargs[param.name] else: target_unit = param.annotation @@ -2381,6 +2387,7 @@ def wrapper(*func_args, **func_kwargs): wrapped_function.__name__, target_unit.to_string())) + # Either there is no .unit or no .is_equivalent except AttributeError: if hasattr(arg, "unit"): error_msg = "a 'unit' attribute without an 'is_equivalent' method" @@ -2390,6 +2397,7 @@ def wrapper(*func_args, **func_kwargs): "You may want to pass in an astropy Quantity instead." .format(param.name, wrapped_function.__name__, error_msg)) + # Call the original function with any equivalencies in force. with add_enabled_equivalencies(self.equivalencies): return wrapped_function(*func_args, **func_kwargs) diff --git a/astropy/units/tests/test_quantity_decorator.py b/astropy/units/tests/test_quantity_decorator.py index 78d0ecc62f0..bebfcb33f03 100644 --- a/astropy/units/tests/test_quantity_decorator.py +++ b/astropy/units/tests/test_quantity_decorator.py @@ -146,3 +146,24 @@ def myfunc_args(solarx, solary=10*u.deg): return solarx, solary solarx, solary = myfunc_args(1*u.arcsec) + + assert isinstance(solarx, u.Quantity) + assert isinstance(solary, u.Quantity) + + assert solarx.unit == u.arcsec + assert solary.unit == u.deg + +def test_no_equivalent(): + class test_unit(object): + pass + class test_quantity(object): + unit = test_unit() + + @u.quantity_input(solarx=u.arcsec) + def myfunc_args(solarx): + return solarx + + with pytest.raises(TypeError) as e: + solarx, solary = myfunc_args(test_quantity()) + + assert str(e.value) == "Argument 'solarx' to function has 'myfunc_args' a 'unit' attribute without an 'is_equivalent' method. You may want to pass in an astropy Quantity instead." \ No newline at end of file From 50a89ca0aeeba7f11201aaaee2c120e7bd8615cf Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 17 Dec 2014 08:56:03 +0000 Subject: [PATCH 22/23] Move the code into a dedicated file. --- astropy/units/__init__.py | 1 + astropy/units/core.py | 118 +--------------------------------- astropy/units/decorators.py | 122 ++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 116 deletions(-) create mode 100644 astropy/units/decorators.py diff --git a/astropy/units/__init__.py b/astropy/units/__init__.py index 5a179879337..d5f38a58aad 100644 --- a/astropy/units/__init__.py +++ b/astropy/units/__init__.py @@ -13,6 +13,7 @@ from .core import * from .quantity import * +from .decorators import * from . import si from . import cgs diff --git a/astropy/units/core.py b/astropy/units/core.py index fc71b06b8fd..ec4ba2620de 100644 --- a/astropy/units/core.py +++ b/astropy/units/core.py @@ -11,7 +11,6 @@ from ..extern.six.moves import zip if six.PY2: import cmath -from ..utils.compat import funcsigs import inspect import collections @@ -19,7 +18,7 @@ import warnings import numpy as np -from ..utils.decorators import lazyproperty, wraps +from ..utils.decorators import lazyproperty from ..utils.exceptions import AstropyWarning from ..utils.misc import isiterable, InheritDocstrings from .utils import (is_effectively_unity, sanitize_scale, validate_power, @@ -34,7 +33,7 @@ 'PrefixUnit', 'UnrecognizedUnit', 'get_current_unit_registry', 'set_enabled_units', 'add_enabled_units', 'set_enabled_equivalencies', 'add_enabled_equivalencies', - 'dimensionless_unscaled', 'one', 'quantity_input'] + 'dimensionless_unscaled', 'one'] def _flatten_units_collection(items): @@ -2291,116 +2290,3 @@ def _condition_arg(value): dimensionless_unscaled = CompositeUnit(1, [], [], _error_check=False) # Abbreviation of the above, see #1980 one = dimensionless_unscaled - -class QuantityInput(object): - - @classmethod - def as_decorator(cls, func=None, **kwargs): - """ - A decorator for validating the units of arguments to functions. - - Unit specifications can be provided as keyword arguments to the decorator, - or by using Python 3's function annotation syntax. Arguments to the decorator - take precidence over any function annotations present. - - A `~astropy.units.UnitsError` will be raised if the unit attribute of - the argument is not equivalent to the unit specified to the decorator - or in the annotation. - If the argument has no unit attribute, i.e. it is not a Quantity object, a - `~exceptions.ValueError` will be raised. - - Where an equivalency is specified in the decorator, the function will be - executed with that equivalency in force. - - Examples - -------- - - Python 2 and 3:: - - import astropy.units as u - @u.quantity_input(myangle=u.arcsec) - def myfunction(myangle): - return myangle**2 - - Python 3 only:: - - import astropy.units as u - @u.quantity_input - def myfunction(myangle: u.arcsec): - return myangle**2 - - Using equivalencies:: - - import astropy.units as u - @u.quantity_input(myenergy=u.eV, equivalencies=u.mass_energy()) - def myfunction(myenergy): - return myenergy**2 - - """ - self = cls(**kwargs) - if func is not None and not kwargs: - return self(func) - else: - return self - - def __init__(self, func=None, **kwargs): - self.equivalencies = kwargs.pop('equivalencies', []) - self.decorator_kwargs = kwargs - - def __call__(self, wrapped_function): - - # Extract the function signature for the function we are wrapping. - wrapped_signature = funcsigs.signature(wrapped_function) - - # Define a new function to return in place of the wrapped one - @wraps(wrapped_function) - def wrapper(*func_args, **func_kwargs): - # Bind the arguments to our new function to the signature of the original. - bound_args = wrapped_signature.bind(*func_args, **func_kwargs) - - # Iterate through the parameters of the original signature - for param in wrapped_signature.parameters.values(): - # Catch the (never triggered) case where bind relied on a default value. - if param.name not in bound_args.arguments and param.default is not param.empty: - bound_args.arguments[param.name] = param.default - - # Get the value of this parameter (argument to new function) - arg = bound_args.arguments[param.name] - - # Get target unit, either from decotrator kwargs or annotations - if param.name in self.decorator_kwargs: - target_unit = self.decorator_kwargs[param.name] - else: - target_unit = param.annotation - - # If the target unit is empty, then no unit was specified so we - # move past it - if target_unit is not funcsigs.Parameter.empty: - try: - equivalent = arg.unit.is_equivalent(target_unit, - equivalencies=self.equivalencies) - - if not equivalent: - raise UnitsError("Argument '{0}' to function '{1}'" - " must be in units convertable to" - " '{2}'.".format(param.name, - wrapped_function.__name__, - target_unit.to_string())) - - # Either there is no .unit or no .is_equivalent - except AttributeError: - if hasattr(arg, "unit"): - error_msg = "a 'unit' attribute without an 'is_equivalent' method" - else: - error_msg = "no 'unit' attribute" - raise TypeError("Argument '{0}' to function has '{1}' {2}. " - "You may want to pass in an astropy Quantity instead." - .format(param.name, wrapped_function.__name__, error_msg)) - - # Call the original function with any equivalencies in force. - with add_enabled_equivalencies(self.equivalencies): - return wrapped_function(*func_args, **func_kwargs) - - return wrapper - -quantity_input = QuantityInput.as_decorator diff --git a/astropy/units/decorators.py b/astropy/units/decorators.py new file mode 100644 index 00000000000..8dc94e7aead --- /dev/null +++ b/astropy/units/decorators.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +__all__ = ['quantity_input'] + +from ..utils.decorators import wraps +from ..utils.compat import funcsigs + +from .core import UnitsError, add_enabled_equivalencies + +class QuantityInput(object): + + @classmethod + def as_decorator(cls, func=None, **kwargs): + """ + A decorator for validating the units of arguments to functions. + + Unit specifications can be provided as keyword arguments to the decorator, + or by using Python 3's function annotation syntax. Arguments to the decorator + take precidence over any function annotations present. + + A `~astropy.units.UnitsError` will be raised if the unit attribute of + the argument is not equivalent to the unit specified to the decorator + or in the annotation. + If the argument has no unit attribute, i.e. it is not a Quantity object, a + `~exceptions.ValueError` will be raised. + + Where an equivalency is specified in the decorator, the function will be + executed with that equivalency in force. + + Examples + -------- + + Python 2 and 3:: + + import astropy.units as u + @u.quantity_input(myangle=u.arcsec) + def myfunction(myangle): + return myangle**2 + + Python 3 only:: + + import astropy.units as u + @u.quantity_input + def myfunction(myangle: u.arcsec): + return myangle**2 + + Using equivalencies:: + + import astropy.units as u + @u.quantity_input(myenergy=u.eV, equivalencies=u.mass_energy()) + def myfunction(myenergy): + return myenergy**2 + + """ + self = cls(**kwargs) + if func is not None and not kwargs: + return self(func) + else: + return self + + def __init__(self, func=None, **kwargs): + self.equivalencies = kwargs.pop('equivalencies', []) + self.decorator_kwargs = kwargs + + def __call__(self, wrapped_function): + + # Extract the function signature for the function we are wrapping. + wrapped_signature = funcsigs.signature(wrapped_function) + + # Define a new function to return in place of the wrapped one + @wraps(wrapped_function) + def wrapper(*func_args, **func_kwargs): + # Bind the arguments to our new function to the signature of the original. + bound_args = wrapped_signature.bind(*func_args, **func_kwargs) + + # Iterate through the parameters of the original signature + for param in wrapped_signature.parameters.values(): + # Catch the (never triggered) case where bind relied on a default value. + if param.name not in bound_args.arguments and param.default is not param.empty: + bound_args.arguments[param.name] = param.default + + # Get the value of this parameter (argument to new function) + arg = bound_args.arguments[param.name] + + # Get target unit, either from decotrator kwargs or annotations + if param.name in self.decorator_kwargs: + target_unit = self.decorator_kwargs[param.name] + else: + target_unit = param.annotation + + # If the target unit is empty, then no unit was specified so we + # move past it + if target_unit is not funcsigs.Parameter.empty: + try: + equivalent = arg.unit.is_equivalent(target_unit, + equivalencies=self.equivalencies) + + if not equivalent: + raise UnitsError("Argument '{0}' to function '{1}'" + " must be in units convertable to" + " '{2}'.".format(param.name, + wrapped_function.__name__, + target_unit.to_string())) + + # Either there is no .unit or no .is_equivalent + except AttributeError: + if hasattr(arg, "unit"): + error_msg = "a 'unit' attribute without an 'is_equivalent' method" + else: + error_msg = "no 'unit' attribute" + raise TypeError("Argument '{0}' to function has '{1}' {2}. " + "You may want to pass in an astropy Quantity instead." + .format(param.name, wrapped_function.__name__, error_msg)) + + # Call the original function with any equivalencies in force. + with add_enabled_equivalencies(self.equivalencies): + return wrapped_function(*func_args, **func_kwargs) + + return wrapper + +quantity_input = QuantityInput.as_decorator From df4968fc7331858391bc7bfff41b457c6fa65ec2 Mon Sep 17 00:00:00 2001 From: Stuart Mumford Date: Wed, 17 Dec 2014 20:02:17 +0000 Subject: [PATCH 23/23] moves changelog entry to the right place --- CHANGES.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0cf7affef9d..0474f95ef07 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -143,6 +143,9 @@ New Features - When viewed in IPython, ``Quantity`` objects with array values now render using LaTeX and scientific notation. [#2271] + - Added units.quantity_input decorator to validate quantity inputs to a + function for unit compatibility. [#3072] + - ``astropy.utils`` - Added a new decorator ``astropy.utils.wraps`` which acts as a replacement @@ -191,9 +194,6 @@ New Features - Add ability to use ``WCS`` object to define projections in Matplotlib, using the ``WCSAxes`` package. [#3183] - - Added units.quantity_input decorator to validate quantity inputs to a - function for unit compatibility. [#3072] - API Changes ^^^^^^^^^^^