Repository: PlasmaPy/PlasmaPy

Issue:
Incompatibility between `@angular_freq_to_hz` and var-keyword arguments
### Bug description

While trying to decorate `gyrofrequency` with `@particle_input` in #2026, I found an issue with `@angular_freq_to_hz`.  It appears that `@angular_freq_to_hz` cannot decorate functions that accept var-keyword arguments.

### Expected outcome

We should be able to use `@angular_freq_to_hz` to decorate functions with var-keyword parameters.

### Minimal complete verifiable example

When declaring this function:

```Python
from plasmapy.utils.decorators import angular_freq_to_hz
@angular_freq_to_hz
def f(**kwargs):
    return kwargs
```
I get:
```python
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[41], line 1
----> 1 @angular_freq_to_hz
      2 def f(**kwargs):
      3     return kwargs

File ~/Projects/PlasmaPy/plasmapy/utils/decorators/converter.py:101, in angular_freq_to_hz(fn)
     97 new_params = sig.parameters.copy()
     98 new_params["to_hz"] = inspect.Parameter(
     99     "to_hz", inspect.Parameter.POSITIONAL_OR_KEYWORD, default=False
    100 )
--> 101 new_sig = inspect.Signature(
    102     parameters=new_params.values(), return_annotation=sig.return_annotation
    103 )
    104 fn.__signature__ = new_sig
    106 @preserve_signature
    107 @functools.wraps(fn)
    108 def wrapper(*args, to_hz=False, **kwargs):

File ~/miniconda3/envs/pldev/lib/python3.11/inspect.py:2994, in Signature.__init__(self, parameters, return_annotation, __validate_parameters__)
   2988     msg = (
   2989         'wrong parameter order: {} parameter before {} '
   2990         'parameter'
   2991     )
   2992     msg = msg.format(top_kind.description,
   2993                      kind.description)
-> 2994     raise ValueError(msg)
   2995 elif kind > top_kind:
   2996     kind_defaults = False

ValueError: wrong parameter order: variadic keyword parameter before positional or keyword parameter
```


### Package versions

Development branch 

### Additional context

This is medium priority to address since it's blocking #2026 and possibly also #2022.


Discussion:

Resolving patch:
diff --git a/plasmapy/utils/decorators/converter.py b/plasmapy/utils/decorators/converter.py
--- a/plasmapy/utils/decorators/converter.py
+++ b/plasmapy/utils/decorators/converter.py
@@ -3,10 +3,8 @@
 __all__ = ["angular_freq_to_hz"]
 
 import astropy.units as u
-import functools
 import inspect
-
-from plasmapy.utils.decorators.helpers import preserve_signature
+import wrapt
 
 
 def angular_freq_to_hz(fn):
@@ -85,7 +83,6 @@ def foo(x: u.rad / u.s) -> u.rad / u.s
         <Quantity 0.07957747 Hz>
 
     """
-    # raise exception if fn uses the 'to_hz' kwarg
     sig = inspect.signature(fn)
     if "to_hz" in sig.parameters:
         raise ValueError(
@@ -94,32 +91,45 @@ def foo(x: u.rad / u.s) -> u.rad / u.s
         )
 
     # make new signature for fn
-    new_params = sig.parameters.copy()
-    new_params["to_hz"] = inspect.Parameter(
-        "to_hz", inspect.Parameter.POSITIONAL_OR_KEYWORD, default=False
+    new_params = []
+    var_keyword_param = None
+    for param in sig.parameters.values():
+        if param.kind == param.VAR_KEYWORD:
+            var_keyword_param = param
+        else:
+            new_params.append(param)
+
+    new_params.append(
+        inspect.Parameter("to_hz", inspect.Parameter.KEYWORD_ONLY, default=False)
     )
+
+    if var_keyword_param:
+        new_params.append(var_keyword_param)
+
     new_sig = inspect.Signature(
-        parameters=new_params.values(), return_annotation=sig.return_annotation
+        parameters=new_params, return_annotation=sig.return_annotation
     )
     fn.__signature__ = new_sig
 
-    @preserve_signature
-    @functools.wraps(fn)
-    def wrapper(*args, to_hz=False, **kwargs):
+    @wrapt.decorator
+    def wrapper(fn, instance, args, kwargs):  # noqa: ARG001
+        to_hz = kwargs.pop("to_hz", False)
         _result = fn(*args, **kwargs)
         if to_hz:
             return _result.to(u.Hz, equivalencies=[(u.cy / u.s, u.Hz)])
         return _result
 
+    fn = wrapper(fn)
+
     added_doc_bit = """
     Other Parameters
     ----------------
     to_hz: bool
-        Set `True` to to convert function output from angular frequency to Hz
+        Set `True` to convert function output from angular frequency to Hz
     """
-    if wrapper.__doc__ is not None:
-        wrapper.__doc__ += added_doc_bit
+    if fn.__doc__ is not None:
+        fn.__doc__ += added_doc_bit
     else:
-        wrapper.__doc__ = added_doc_bit
+        fn.__doc__ = added_doc_bit
 
-    return wrapper
+    return fn


Test patch:
diff --git a/plasmapy/utils/decorators/tests/test_converters.py b/plasmapy/utils/decorators/tests/test_converters.py
--- a/plasmapy/utils/decorators/tests/test_converters.py
+++ b/plasmapy/utils/decorators/tests/test_converters.py
@@ -1,23 +1,88 @@
 import astropy.units as u
+import inspect
 import numpy as np
 
+from typing import Optional
+
+from plasmapy.particles import particle_input, ParticleLike
+from plasmapy.utils.decorators import validate_quantities
 from plasmapy.utils.decorators.converter import angular_freq_to_hz
 
 
-def test_conversion():
+def test_to_hz():
     @angular_freq_to_hz
-    def test_function():
+    def func():
         return 2 * np.pi * u.rad / u.s
 
-    assert test_function().unit == (
+    assert func().unit == (
         u.rad / u.s
-    ), f"Unit expected is {(u.rad / u.s)} instead of {test_function().unit}"
+    ), f"Unit expected is {u.rad / u.s} instead of {func().unit}"
+    assert np.isclose(
+        func().value, 2 * np.pi
+    ), f"Value expected is {2 * np.pi} instead of {func().value}"
+    assert (
+        func(to_hz=True).unit == u.Hz
+    ), f"Unit expected is {u.Hz} instead of {func(to_hz=True).unit}"
+    assert (
+        func(to_hz=True).value == 1
+    ), f"Value expected is 1 instead of {func(to_hz=True).value}"
+
+
+def test_to_hz_complicated_signature():
+    """
+    Test that `angular_freq_to_hz` can decorate a function with
+    positional-only, postional, var-positional, keyword, keyword-only,
+    or var-keyword.
+    """
+
+    @angular_freq_to_hz
+    def func2(a, /, b, *args, c, d=2, **kwargs):
+        return 2 * np.pi * u.rad / u.s
+
+    result_rad_per_s = func2(1, 2, 3, 4, c=5, d=6, e=7)
+    result_hz = func2(1, 2, 3, 4, c=5, d=6, e=7, to_hz=True)
+
     assert (
-        test_function().value == 2 * np.pi
-    ), f"Value expected is {2 * np.pi} instead of {test_function().value}"
+        result_rad_per_s.unit == u.rad / u.s
+    ), f"Unit expected is {(u.rad / u.s)} instead of {result_rad_per_s.unit}"
+    assert np.isclose(
+        result_rad_per_s.value, 2 * np.pi
+    ), f"Value expected is {2 * np.pi} instead of {result_rad_per_s.value}"
+
     assert (
-        test_function(to_hz=True).unit == u.Hz
-    ), f"Unit expected is {u.Hz} instead of {test_function(to_hz=True).unit}"
+        result_hz.unit == u.Hz
+    ), f"Unit expected is {u.Hz} instead of {result_hz.unit}"
+    assert result_hz.value == 1, f"Value expected is 1 instead of {result_hz.value}"
+
+
+def test_to_hz_stacked_decorators():
+    """Test that @angular_freq_to_hz can be stacked with multiple decorators."""
+
+    @particle_input
+    @validate_quantities
+    @angular_freq_to_hz
+    def func(particle: Optional[ParticleLike] = None):
+        return 2 * np.pi * u.rad / u.s
+
+    assert u.isclose(func(), 2 * np.pi * u.rad / u.s)
+    assert u.isclose(func(to_hz=True), 1 * u.Hz)
+
+
+def test_angular_freq_to_hz_preserves_signature():
+    """
+    Tests if the angular_freq_to_hz decorator preserves the signature of
+    the wrapped function.
+    """
+
+    @angular_freq_to_hz
+    def test_func(pos_only, /, arg, *args, required_kwarg, optional_kwarg=2, **kwargs):
+        return 2 * u.rad / u.s
+
+    original_signature = inspect.signature(test_func)
+    expected_signature = inspect.signature(
+        lambda pos_only, /, arg, *args, required_kwarg, optional_kwarg=2, to_hz=False, **kwargs: None
+    )
+
     assert (
-        test_function(to_hz=True).value == 1
-    ), f"Value expected is 1 instead of {test_function(to_hz=True).value}"
+        original_signature == expected_signature
+    ), f"Expected signature: {expected_signature}, but got: {original_signature}"