Repository: PlasmaPy/PlasmaPy

Issue:
Generate author list from `codemeta.json`
Right now we have authorship recorded in two locations:

 - `docs/about/credits.rst`
 - `codemeta.json`

We might also potentially create `.zenodo.json` again (#1340) for a third place.  The [CodeMeta](https://codemeta.github.io/) file has become more out-of-date than the author list in the docs.  

It's a lot of work to maintain multiple author lists, which is often work done around the time of releases.  Automating this (in conjunction with #1601) would help streamline the release process and reduce the amount of work that needs to be done manually.

A possibility would be to automagically generate the author list in `docs/credits/about.rst` from the information contained within `codemeta.json`.  I think there might be crosswalk tools out there between `codemeta.json` and `.zenodo.json` files too, and eventually (hopefully) Zenodo will start loading in data from CodeMeta files too.  If we do this, then `codemeta.json` would become the primary location for where authors are defined (which would mean changing #1601).  


Discussion:
github-actions[bot]: Thank you for submitting a pull request (PR) to PlasmaPy! ✨ The future of the project depends on contributors like you, so we deeply appreciate it! 🌱

  Our [**contributor guide**](https://docs.plasmapy.org/en/latest/contributing/index.html) has information on:

   - [Getting ready to contribute](https://docs.plasmapy.org/en/latest/contributing/getting_ready.html#getting-ready-to-contribute) and the [code contribution workflow](https://docs.plasmapy.org/en/latest/contributing/workflow.html#code-contribution-workflow)
   - Using [`astropy.units`](https://docs.plasmapy.org/en/latest/notebooks/getting_started/units.html#Using-Astropy-Units) and [`plasmapy.particles`](https://docs.plasmapy.org/en/latest/notebooks/getting_started/particles.html#Using-PlasmaPy-Particles)
   - [Coding tips and guidelines](https://docs.plasmapy.org/en/latest/contributing/coding_guide.html#coding-guide) (see also tips on [writing clean scientific software](https://doi.org/10.5281/zenodo.3922956))
   - [Writing documentation](https://docs.plasmapy.org/en/latest/contributing/doc_guide.html#writing-documentation) (in [reStructuredText](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#restructuredtext-primer) with [numpydoc style docstrings](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard)).
   - [Writing tests](https://docs.plasmapy.org/en/latest/contributing/testing_guide.html#testing-guide) using [pytest](https://docs.pytest.org)
   - [Adding a changelog entry](https://docs.plasmapy.org/en/latest/contributing/changelog_guide.html#adding-a-changelog-entry) (needed except for minor changes)

  The bottom of this page shows several checks that are run for every PR. Don't worry if something broke! We break stuff all the time. 😺 Click on "Details" to learn why a check didn't pass. Please also feel free to ask for help. We do that all the time as well. 🌸 You can find us in our [**chat room**](https://docs.plasmapy.org/en/latest/contributing/coding_guide.html) or weekly [**community meeting**](https://www.plasmapy.org/meetings/weekly) & [**office hours**](https://www.plasmapy.org/meetings/office_hours).

   - Try fixing *CI/Python 3.11* test failures first.
   - Most *pre-commit.ci* failures can be automagically fixed by commenting "pre-commit.ci autofix" below, followed by a `git pull`.
   - In the *CI/Documentation* ends with a cryptic error message, check out our [documentation troubleshooting guide](https://docs.plasmapy.org/en/latest/contributing/doc_guide.html#troubleshooting).
   - For a documentation preview, click on *Details* next to *docs/readthedocs.org:plasmapy*.

  If this PR is marked as ready for review, someone should stop by soon to provide a code review and offer suggestions soon. ✅ If you don't get a review within a few weekdays, please feel free to send us a reminder.

  Please also use SI units within PlasmaPy, except when there is strong justification otherwise (e.g., in some examples).

  We thank you once again!
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PlasmaPy/PlasmaPy/pull/2156?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) Report
Patch and project coverage have no change.
> Comparison is base [(`629f66c`)](https://app.codecov.io/gh/PlasmaPy/PlasmaPy/commit/629f66c7096ead005b63b3611a969414a5bdf1d4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) 98.31% compared to head [(`9fc6384`)](https://app.codecov.io/gh/PlasmaPy/PlasmaPy/pull/2156?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) 98.31%.

> :exclamation: Current head 9fc6384 differs from pull request most recent head 108ed55. Consider uploading reports for the commit 108ed55 to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #2156   +/-   ##
=======================================
  Coverage   98.31%   98.31%           
=======================================
  Files          98       98           
  Lines        8625     8625           
=======================================
  Hits         8480     8480           
  Misses        145      145           
```




</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PlasmaPy/PlasmaPy/pull/2156?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy).

namurphy:  - [ ] Double check that no authors got dropped (i.e., that all authors from `credits.rst` are in `CITATION.cff`)

Patch:
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 88bd5ee6..99d943d7 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -41,6 +41,10 @@ linters:
 - .sourcery.yaml
 - .pre-commit-config.yaml
 
+'No changelog entry needed':
+- any: ['.pre-commit-config.yaml', '.github/**/*.*', '.codecov.yml', '.editorconfig', '.gitignore', 'CODEOWNERS', 'requirements.txt']
+  all: ['!plasmapy/**/*.py', '!docs/**/*.*', '!pyproject.toml']
+
 notebooks:
 - docs/notebooks/**/*
 
diff --git a/.github/scripts/authors_in_cff.py b/.github/scripts/authors_in_cff.py
index e0f0a75d..1fc51ccc 100644
--- a/.github/scripts/authors_in_cff.py
+++ b/.github/scripts/authors_in_cff.py
@@ -11,7 +11,7 @@
 PR_NUMBER = os.getenv("PR_NUMBER")
 REPO = os.getenv("GITHUB_REPOSITORY")
 
-EXCLUDED_USERS = ["dependabot", "pre-commit-ci", "sourcery-ai"]
+EXCLUDED_USERS = ["dependabot", "pre-commit-ci[bot]", "sourcery-ai"]
 
 
 def get_pr_authors() -> set[str]:
diff --git a/.github/workflows/pr-size-labeler.yml b/.github/workflows/pr-size-labeler.yml
deleted file mode 100644
index b9123ec0..00000000
--- a/.github/workflows/pr-size-labeler.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Label PR size
-
-on:
-  pull_request:
-    types: [opened, synchronize]
-
-jobs:
-  update_labels:
-    runs-on: ubuntu-latest
-    steps:
-    - uses: actions/checkout@v3
-
-    - uses: PlasmaPy/action-size@v2023.5.0
-      id: size
-      with:
-        size_xs_label: 'size: very small'
-        size_s_label: 'size: small'
-        size_m_label: 'size: medium'
-        size_l_label: 'size: large'
-        size_xl_label: 'size: very large'
-        size_xxl_label: 'size: huge'
-        size_s_threshold: 10
-        size_m_threshold: 30
-        size_l_threshold: 100
-        size_xl_threshold: 500
-        size_xxl_threshold: 1000
-
-    - uses: actions-ecosystem/action-remove-labels@v1
-      with:
-        github_token: ${{ secrets.github_token }}
-        labels: ${{ steps.size.outputs.stale_labels }}
-
-    - uses: actions-ecosystem/action-add-labels@v1
-      with:
-        github_token: ${{ secrets.github_token }}
-        labels: ${{ steps.size.outputs.new_label }}
diff --git a/.gitignore b/.gitignore
index b218ea42..fa14a715 100644
--- a/.gitignore
+++ b/.gitignore
@@ -138,8 +138,9 @@ untitled*
 
 # Documentation files
 auto_examples
+docs/about/_authors.rst
+docs/notebooks/*.md
 
 # Version file
 plasmapy/_version.py
 tags
-docs/notebooks/*.md
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3f90b8ec..22f1151b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -58,7 +58,7 @@ repos:
     - tomli
 
 - repo: https://github.com/charliermarsh/ruff-pre-commit
-  rev: v0.0.269
+  rev: v0.0.270
   hooks:
   - id: ruff
     args: [--fix]
diff --git a/changelog/2156.doc.rst b/changelog/2156.doc.rst
new file mode 100644
index 00000000..43eeb143
--- /dev/null
+++ b/changelog/2156.doc.rst
@@ -0,0 +1,3 @@
+Added functionality to automatically generate the author list included
+in |docs/about/credits.rst|_ directly from |CITATION.cff|_. The script
+is located at :file:`docs/cff_to_rst.py`.
diff --git a/changelog/2175.trivial.rst b/changelog/2175.trivial.rst
new file mode 100644
index 00000000..260b6704
--- /dev/null
+++ b/changelog/2175.trivial.rst
@@ -0,0 +1,3 @@
+Expanded the variety of arguments that could be provided to a function
+decorated by `~plasmapy.utils.decorators.converter.angular_freq_to_hz`,
+and refactored this decorator to use ``wrapt``.
diff --git a/changelog/2176.doc.rst b/changelog/2176.doc.rst
new file mode 100644
index 00000000..f4dc3f5e
--- /dev/null
+++ b/changelog/2176.doc.rst
@@ -0,0 +1,1 @@
+Changed the order of sections in newly built changelogs.
diff --git a/docs/about/credits.rst b/docs/about/credits.rst
index db80054c..9146374e 100644
--- a/docs/about/credits.rst
+++ b/docs/about/credits.rst
@@ -21,126 +21,11 @@ PlasmaPy Contributors
 The people in the following list have contributed to PlasmaPy. Included
 in parentheses are `ORCID author identifiers <https://orcid.org>`__.
 
+.. include:: _authors.rst
+
 .. ORCID provides a unique persistent identifier that you own and control.
    To sign up, please go to: https://orcid.org/register
 
-* :user:`Aditya Magarde <adityamagarde>`
-* :user:`Afzal Rao <thecasuist>`
-* :user:`Alexis Jeandet <jeandet>` (:orcid:`0000-0003-2892-6924`)
-* :user:`Alf Köhn-Seemann <alfkoehn>` (:orcid:`0000-0002-1192-2057`)
-* :user:`Andrew <sandshrew118>`
-* :user:`Andrew Sheng <andrewsheng2>`
-* :user:`Angad Singh <singha95>`
-* :user:`Ankit Singh <Griffintaur>`
-* :user:`Ankur Chattopadhyay <chttrjeankr>`
-* :user:`Anna Lanteri <alanteriBW>`
-* :user:`Antoine Tavant <antoinetavant>`
-* :user:`Antonia Savcheva <savcheva>` (:orcid:`0000-0002-5598-046X`)
-* :user:`Apoorv Choubey <apooravc>`
-* :user:`Armando Salcido <aksalcido>`
-* :user:`BH4`
-* :user:`Brian Goodall <goodab>`
-* :user:`Brigitta Sipőcz <bsipocz>` (:orcid:`0000-0002-3713-6337`)
-* :user:`Bryan Foo <bryancfoo>`
-* :user:`Carlos Cartagena-Sanchez` (:orcid:`0000-0002-0486-1292`)
-* :user:`Carol Zhang <carolyz>`
-* :user:`cclauss`
-* :user:`Chengcai Shen <ionizationcalc>` (:orcid:`0000-0002-9258-4490`)
-* :user:`Chris Hoang <bucket420>`
-* :user:`Christopher Arran <ChrisArran>` (:orcid:`0000-0002-8644-8118`)
-* :user:`Chun Hei Yip <syip1>`
-* :user:`Cody Skinner <cskinner74>`
-* :user:`Colby Haggerty <colbych>` (:orcid:`0000-0002-2160-7288`)
-* :user:`David Drozdov <davemus>`
-* :user:`David Stansby <dstansby>` (:orcid:`0000-0002-1365-1908`)
-* :user:`Dawa Nurbu Sherpa <nurbu5>`
-* :user:`Dhawal Modi <Dhawal-Modi>`
-* :user:`Diego A. Diaz Riega <diego7319>`
-* :user:`Dominik Stańczak-Marikin <StanczakDominik>` (:orcid:`0000-0001-6291-8843`)
-* :user:`Drew Leonard <SolarDrew>` (:orcid:`0000-0001-5270-7487`)
-* :user:`Elliot Johnson <ejohnson-96>`
-* :user:`Erik Everson <rocco8773>` (:orcid:`0000-0001-6079-8307`)
-* :user:`Fionnlagh Mackenzie Dover <FinMacDov>` (:orcid:`0000-0002-1984-7303`)
-* :user:`flaixman`
-* :user:`Francisco Silva Pavon <silvafrancisco>`
-* :user:`Graham Goudeau <GrahamGoudeau>`
-* :user:`Haman Bagherianlemraski <haman80>` (:orcid:`0000-0001-7381-1996`)
-* :user:`Jacob Deal <Jac0bDeal>`
-* Jakub Polak
-* :user:`James Kent <jdkent>`
-* :user:`Jasper Beckers <jasperbeckers>`
-* :user:`Jayden Roberts <JaydenR2305>`
-* :user:`Joao Victor Martinelli <JvPy>`
-* :user:`Joshua Munn <jams2>`
-* :user:`Isaias McHardy <jota33>` (:orcid:`0000-0001-5394-9445`)
-* :user:`itsraashi`
-* :user:`Julien Hillairet <jhillairet>` (:orcid:`0000-0002-1073-6383`)
-* :user:`Justin Bergeron <Justin-Bergeron>`
-* :user:`Kevin Montes <kjmontes>` (:orcid:`0000-0002-0762-3708`)
-* :user:`Khalil Bryant <KhalilBryant>`
-* :user:`Leah Einhorn <leahein>`
-* :user:`Leo Murphy <LeoMurphyWM24>`
-* :user:`lgoenner`
-* :user:`Luciano Silvestri <lucianogsilvestri>` (:orcid:`0000-0003-3530-7910`)
-* :user:`Ludovico Bessi <ludoro>`
-* :user:`Mahima Pannala <mahimapannala>`
-* :user:`Manas Bedmutha <manasbedmutha98>`
-* :user:`Marcin Kastek <MKastek>`
-* :user:`Marco Gorelli <MarcoGorelli>`
-* :user:`Maria Isupova <misupova>`
-* Michael Fischer
-* :user:`Mohammed Haque <mohawk811>`
-* :user:`Nabil Humphrey <NabilHumphrey>`
-* :user:`Neil Patel <ministrike3>`
-* :user:`Nick Murphy <namurphy>` (:orcid:`0000-0001-6628-8033`)
-* :user:`Nicolas Lequette <Quettle>`
-* :user:`Nikita Smirnov <Nismirno>`
-* @nrb1324
-* :user:`Pawel Kozlowski <lemmatum>` (:orcid:`0000-0001-6849-3612`)
-* :user:`Peter Heuer <pheuer>` (:orcid:`0000-0001-5050-6606`)
-* :user:`Pey Lian Lim <pllim>` (:orcid:`0000-0003-0079-4114`)
-* :user:`Piotr Kuszaj <kuszaj>`
-* :user:`Poh Zi How <pohzipohzi>`
-* :user:`Raajit Raj <raajitr>`
-* :user:`Rajagopalan Gangadharan <RAJAGOPALAN-GANGADHARAN>`
-* :user:`Ramiz Qudsi <qudsiramiz>` (:orcid:`0000-0001-8358-0482`)
-* :user:`Raymon Skjørten Hansen <raymonshansen>`
-* :user:`Reynaldo Rojas Zelaya <userr2232>`
-* :user:`Riley Britten <riley-britten>`
-* :user:`Ritiek Malhotra <ritiek>`
-* :user:`Roberto Díaz Pérez <RoberTnf>`
-* :user:`Samaiyah I. Farid <samaiyahfarid>` (:orcid:`0000-0003-0223-7004`)
-* :user:`Samuel Langendorf <samurai688>` (:orcid:`0000-0002-7757-5879`)
-* :user:`Sarthak Choudhary <martha889>`
-* :user:`Sean Carroll <seanwilliamcarroll>`
-* :user:`Sean Chambers <schambers>`
-* :user:`seanjunheng2`
-* :user:`Shane Brown <Sjbrownian>`
-* :user:`Siddharth Kulshrestha <siddharthk07>`
-* `Silvina Guidoni <https://www.american.edu/cas/faculty/guidoni.cfm>`_
-* :user:`Sixue Xu <hzxusx>`
-* :user:`Steve Richardson <arichar6>` (:orcid:`0000-0002-3056-6334`)
-* :user:`Steve Vincena <svincena>` (:orcid:`0000-0002-6468-5710`)
-* :user:`Stuart Mumford <Cadair>` (:orcid:`0000-0003-4217-4642`)
-* :user:`Stuart Sobeske <Sobeskes>`
-* :user:`Suzanne Nie <suzannenie>`
-* :user:`Terrance Takho Lee <tlee0818>`
-* :user:`Thomas Fan <thomasjpfan>`
-* :user:`Thomas Ulrich <Elfhelm>`
-* :user:`Thomas Varnish <tvarnish>`
-* :user:`Tien Vo <tien-vo>` (:orcid:`0000-0002-8335-1441`)
-* :user:`Tiger Du <Tiger-Du>` (:orcid:`0000-0002-8676-1710`)
-* :user:`Tomás Stinson <14tstinson>`
-* :user:`Trestan Simon <TrestanSimon>`
-* :user:`Tulasi Parashar <tulasinandan>` (:orcid:`0000-0003-0602-8381`)
-* :user:`Veronica Tranquilino <tranqver>`
-* :user:`Vishwas Rajashekar <DarkAEther>` (:orcid:`0000-0002-4914-6612`)
-* :user:`WineDarkMoon`
-* :user:`Wu Tingfeng <elliotwutingfeng>` (:orcid:`0000-0001-8745-204X`)
-* :user:`Yi-Min Huang <yopology>` (:orcid:`0000-0002-4237-2211`)
-* :user:`Michaela Švancarová <mysakli>`
-
-
 This list contains contributors to PlasmaPy's core package and vision
 statement, including a few people who do not show up as `PlasmaPy
 contributors on GitHub
diff --git a/docs/cff_to_rst.py b/docs/cff_to_rst.py
new file mode 100644
index 00000000..18f69426
--- /dev/null
+++ b/docs/cff_to_rst.py
@@ -0,0 +1,112 @@
+"""
+Convert author information from :file:`CITATION.cff` into a
+reStructuredText-formatted list.
+"""
+import pathlib
+import yaml
+
+from typing import Union
+
+
+def parse_cff(filename: str) -> dict[str, Union[str, list[dict[str, str]]]]:
+    """
+    Parse a :file:`CITATION.cff` file into a dictionary.
+
+    Parameters
+    ----------
+    filename : `str`
+        The path to :file:`CITATION.cff`.
+
+    Returns
+    -------
+    `dict`
+        A dictionary containing the parsed data from :file:`CITATION.cff`.
+
+    Raises
+    ------
+    `yaml.YAMLError`
+        If :file:`CITATION.cff` cannot be parsed.
+    """
+    with pathlib.Path(filename).open() as stream:
+        try:
+            return yaml.safe_load(stream)
+        except yaml.YAMLError as e:
+            print(e)
+
+
+def sorting_key(author: dict[str, str]) -> str:
+    """
+    Provide sorting key for an author.
+
+    Parameters
+    ----------
+    author : `dict`
+        A dictionary containing author information.
+
+    Returns
+    -------
+    str
+        The key for sorting authors.
+    """
+    if "family-names" in author:
+        return author["family-names"]
+    elif "given-names" in author:
+        return author["given-names"]
+    elif "alias" in author:
+        return author["alias"]
+    else:
+        return ""
+
+
+def generate_reST(authors: list[dict[str, str]]) -> str:
+    """
+    Generate a reStructuredText formatted list of authors.
+
+    Parameters
+    ----------
+    authors : list of dict
+        A list of dictionaries, each containing information about an
+        author.
+
+    Returns
+    -------
+    str
+        The reStructuredText formatted list of authors.
+    """
+    authors = sorted(authors, key=sorting_key)
+    authors_reST = ""
+    for author in authors:
+        if "given-names" in author and "family-names" in author:
+            name = f'{author["given-names"]} {author["family-names"]}'
+        elif "alias" in author:
+            name = f'{author["alias"]}'
+        else:
+            continue
+
+        if "orcid" in author and "alias" in author:
+            orcid = author["orcid"].removeprefix("https://orcid.org/")
+            authors_reST += f'- :user:`{name} <{author["alias"]}>` (:orcid:`{orcid}`)\n'
+        elif "alias" in author:
+            authors_reST += f'- :user:`{name} <{author["alias"]}>`\n'
+        else:
+            authors_reST += f"- :user:`{name}`\n"
+    return authors_reST
+
+
+def main(cff_file="../CITATION.cff", rst_file="about/_authors.rst"):
+    """
+    Parse :file:`CITATION.cff` file and generate a reStructuredText
+    formatted list of authors.
+
+    Parameters
+    ----------
+    cff_file : str, default: ``"../CITATION.cff"``
+        The path to :file:`CITATION.cff`.
+
+    rst_file : str, default: ``about/_authors.rst``
+        The path to the output :file:`.rst` file.
+    """
+    cff_data = parse_cff(cff_file)
+    authors_reST = generate_reST(cff_data["authors"])
+    with pathlib.Path(rst_file).open("w") as file:
+        file.write(authors_reST)
diff --git a/docs/conf.py b/docs/conf.py
index 4d3d55a7..56ef31cd 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -9,12 +9,20 @@
 import os
 import sys
 
+# isort: off
+sys.path.insert(0, os.path.abspath(".."))  # noqa: PTH100
+sys.path.insert(0, os.path.abspath("."))  # noqa: PTH100
+# isort: on
+
+import cff_to_rst
+
 from datetime import datetime
 from pkg_resources import parse_version
 from sphinx.application import Sphinx
 
-sys.path.insert(0, os.path.abspath(".."))  # noqa: PTH100
-sys.path.insert(0, os.path.abspath("."))  # noqa: PTH100
+# Generate author list from CITATION.cff
+
+cff_to_rst.main()
 
 from plasmapy import __version__ as release  # noqa: E402
 
diff --git a/plasmapy/utils/decorators/converter.py b/plasmapy/utils/decorators/converter.py
index b282f92d..94ced36c 100644
--- 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
diff --git a/pyproject.toml b/pyproject.toml
index c29bcd01..61691097 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -315,7 +315,7 @@ max-complexity = 10
 [tool.ruff.per-file-ignores]
 "__init__.py" = ["D104", "E402", "F401", "F402", "F403"] # ignore import errors
 ".github/scripts/*.py" = ["D103", "INP001"]
-"docs/conf.py" = ["D100", "D103"]
+"docs/conf.py" = ["D100", "D103", "E402"]
 "plasmapy/analysis/fit_functions.py" = ["D301"]
 "plasmapy/formulary/braginskii.py" = ["C901"]
 "plasmapy/formulary/tests/test_distribution.py" = ["ARG005"]
@@ -323,6 +323,7 @@ max-complexity = 10
 "plasmapy/plasma/sources/*.py" = ["D102"]
 "plasmapy/utils/_pytest_helpers/pytest_helpers.py" = ["BLE001", "PLR"]
 "plasmapy/utils/_pytest_helpers/tests/test_pytest_helpers.py" = ["BLE001", "TRY002"]
+"plasmapy/utils/decorators/tests/test_converters.py" = ["ARG001", "ARG005"]
 "setup.py" = ["D100"]
 "test_*" = ["D100", "D101", "D102", "D103", "D104", "D209", "D400", "D401"]
 "*/*/tests/__init__.py" = ["D104"]
diff --git a/requirements.txt b/requirements.txt
index 7fd57ff8..393fd92f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,7 +6,7 @@
 #
 alabaster==0.7.13
     # via sphinx
-anyio==3.6.2
+anyio==3.7.0
     # via jupyter-server
 argon2-cffi==21.3.0
     # via jupyter-server
@@ -34,7 +34,7 @@ beautifulsoup4==4.12.2
     #   sphinx-codeautolink
 bleach==6.0.0
     # via nbconvert
-cachetools==5.3.0
+cachetools==5.3.1
     # via tox
 certifi==2023.5.7
     # via requests
@@ -105,7 +105,7 @@ future==0.18.3
     # via uncertainties
 h5py==3.8.0
     # via plasmapy (setup.py)
-hypothesis==6.75.3
+hypothesis==6.75.6
     # via plasmapy (setup.py)
 identify==2.5.24
     # via pre-commit
@@ -342,7 +342,7 @@ pyyaml==6.0
     #   pre-commit
     #   pybtex
     #   pytest-regressions
-pyzmq==25.0.2
+pyzmq==25.1.0
     # via
     #   ipykernel
     #   jupyter-client
@@ -448,7 +448,7 @@ towncrier==22.12.0
     # via
     #   plasmapy (setup.py)
     #   sphinx-changelog
-tox==4.5.1
+tox==4.5.2
     # via plasmapy (setup.py)
 tqdm==4.65.0
     # via plasmapy (setup.py)


Test patch:
diff --git a/plasmapy/utils/decorators/tests/test_converters.py b/plasmapy/utils/decorators/tests/test_converters.py
index 9b444784..06001304 100644
--- 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}"