Repository: Cog-Creators/Red-DiscordBot

Issue:
[Core] Expand TimeDeltaConverter to accept month and year as valid timedeltas or edit other means of reading human readable time
# Feature request

<!-- This template is for feature requests. Please fill out the following: -->


#### Select the type of feature you are requesting:

<!-- To check a box, replace the space between the [] with a x -->

- [ ] Cog
- [ ] Command
- [x] API functionality

#### Describe your requested feature

The commands.TimeDeltaConverter currently only supports inputs of up to ``x weeks``, however it would be very useful (especially for usecases like tempban) if it would also accept ``x months`` or ``x years``.


Discussion:
I would rather this not be part of the timedelta converter and part of a broader human time handling being added instead. The units supported right now all have fixed lengths, whereas months and years have to be calculated relative to current date and time for correctness.
Title updated to maybe reflect this?

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -218,6 +218,7 @@
     "dpy": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/", None),
     "motor": ("https://motor.readthedocs.io/en/stable/", None),
     "babel": ("http://babel.pocoo.org/en/stable/", None),
+    "dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
 }
 
 # Extlinks
diff --git a/redbot/core/commands/__init__.py b/redbot/core/commands/__init__.py
--- a/redbot/core/commands/__init__.py
+++ b/redbot/core/commands/__init__.py
@@ -21,9 +21,11 @@
 from .context import Context as Context, GuildContext as GuildContext, DMContext as DMContext
 from .converter import (
     DictConverter as DictConverter,
+    RelativedeltaConverter as RelativedeltaConverter,
     TimedeltaConverter as TimedeltaConverter,
     get_dict_converter as get_dict_converter,
     get_timedelta_converter as get_timedelta_converter,
+    parse_relativedelta as parse_relativedelta,
     parse_timedelta as parse_timedelta,
     NoParseOptional as NoParseOptional,
     UserInputOptional as UserInputOptional,
diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py
--- a/redbot/core/commands/converter.py
+++ b/redbot/core/commands/converter.py
@@ -8,6 +8,7 @@
 import functools
 import re
 from datetime import timedelta
+from dateutil.relativedelta import relativedelta
 from typing import (
     TYPE_CHECKING,
     Generic,
@@ -37,9 +38,11 @@
     "DictConverter",
     "UserInputOptional",
     "NoParseOptional",
+    "RelativedeltaConverter",
     "TimedeltaConverter",
     "get_dict_converter",
     "get_timedelta_converter",
+    "parse_relativedelta",
     "parse_timedelta",
     "Literal",
 ]
@@ -53,6 +56,8 @@
 # https://github.com/mikeshardmind/SinbadCogs/blob/816f3bc2ba860243f75112904b82009a8a9e1f99/scheduler/time_utils.py#L9-L19
 TIME_RE_STRING = r"\s?".join(
     [
+        r"((?P<years>\d+?)\s?(years?|y))?",
+        r"((?P<months>\d+?)\s?(months?|mo))?",
         r"((?P<weeks>\d+?)\s?(weeks?|w))?",
         r"((?P<days>\d+?)\s?(days?|d))?",
         r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))?",
@@ -64,6 +69,22 @@
 TIME_RE = re.compile(TIME_RE_STRING, re.I)
 
 
+def _parse_and_match(string_to_match: str, allowed_units: List[str]) -> Optional[Dict[str, int]]:
+    """
+    Local utility function to match TIME_RE string above to user input for both parse_timedelta and parse_relativedelta
+    """
+    matches = TIME_RE.match(string_to_match)
+    if matches:
+        params = {k: int(v) for k, v in matches.groupdict().items() if v is not None}
+        for k in params.keys():
+            if k not in allowed_units:
+                raise BadArgument(
+                    _("`{unit}` is not a valid unit of time for this command").format(unit=k)
+                )
+        return params
+    return None
+
+
 def parse_timedelta(
     argument: str,
     *,
@@ -81,9 +102,9 @@ def parse_timedelta(
     ----------
     argument : str
         The user provided input
-    maximum : Optional[timedelta]
+    maximum : Optional[datetime.timedelta]
         If provided, any parsed value higher than this will raise an exception
-    minimum : Optional[timedelta]
+    minimum : Optional[datetime.timedelta]
         If provided, any parsed value lower than this will raise an exception
     allowed_units : Optional[List[str]]
         If provided, you can constrain a user to expressing the amount of time
@@ -92,7 +113,7 @@ def parse_timedelta(
 
     Returns
     -------
-    Optional[timedelta]
+    Optional[datetime.timedelta]
         If matched, the timedelta which was parsed. This can return `None`
 
     Raises
@@ -101,35 +122,84 @@ def parse_timedelta(
         If the argument passed uses a unit not allowed, but understood
         or if the value is out of bounds.
     """
-    matches = TIME_RE.match(argument)
-    allowed_units = allowed_units or ["weeks", "days", "hours", "minutes", "seconds"]
-    if matches:
-        params = {k: int(v) for k, v in matches.groupdict().items() if v is not None}
-        for k in params.keys():
-            if k not in allowed_units:
-                raise BadArgument(
-                    _("`{unit}` is not a valid unit of time for this command").format(unit=k)
-                )
-        if params:
-            try:
-                delta = timedelta(**params)
-            except OverflowError:
-                raise BadArgument(
-                    _("The time set is way too high, consider setting something reasonable.")
-                )
-            if maximum and maximum < delta:
-                raise BadArgument(
-                    _(
-                        "This amount of time is too large for this command. (Maximum: {maximum})"
-                    ).format(maximum=humanize_timedelta(timedelta=maximum))
-                )
-            if minimum and delta < minimum:
-                raise BadArgument(
-                    _(
-                        "This amount of time is too small for this command. (Minimum: {minimum})"
-                    ).format(minimum=humanize_timedelta(timedelta=minimum))
-                )
-            return delta
+    allowed_units = allowed_units or [
+        "weeks",
+        "days",
+        "hours",
+        "minutes",
+        "seconds",
+    ]
+    params = _parse_and_match(argument, allowed_units)
+    if params:
+        try:
+            delta = timedelta(**params)
+        except OverflowError:
+            raise BadArgument(
+                _("The time set is way too high, consider setting something reasonable.")
+            )
+        if maximum and maximum < delta:
+            raise BadArgument(
+                _(
+                    "This amount of time is too large for this command. (Maximum: {maximum})"
+                ).format(maximum=humanize_timedelta(timedelta=maximum))
+            )
+        if minimum and delta < minimum:
+            raise BadArgument(
+                _(
+                    "This amount of time is too small for this command. (Minimum: {minimum})"
+                ).format(minimum=humanize_timedelta(timedelta=minimum))
+            )
+        return delta
+    return None
+
+
+def parse_relativedelta(
+    argument: str, *, allowed_units: Optional[List[str]] = None
+) -> Optional[relativedelta]:
+    """
+    This converts a user provided string into a datetime with offset from NOW
+
+    The units should be in order from largest to smallest.
+    This works with or without whitespace.
+
+    Parameters
+    ----------
+    argument : str
+        The user provided input
+    allowed_units : Optional[List[str]]
+        If provided, you can constrain a user to expressing the amount of time
+        in specific units. The units you can chose to provide are the same as the
+        parser understands. (``years``, ``months``, ``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
+
+    Returns
+    -------
+    Optional[dateutil.relativedelta.relativedelta]
+        If matched, the relativedelta which was parsed. This can return `None`
+
+    Raises
+    ------
+    BadArgument
+        If the argument passed uses a unit not allowed, but understood
+        or if the value is out of bounds.
+    """
+    allowed_units = allowed_units or [
+        "years",
+        "months",
+        "weeks",
+        "days",
+        "hours",
+        "minutes",
+        "seconds",
+    ]
+    params = _parse_and_match(argument, allowed_units)
+    if params:
+        try:
+            delta = relativedelta(**params)
+        except OverflowError:
+            raise BadArgument(
+                _("The time set is way too high, consider setting something reasonable.")
+            )
+        return delta
     return None
 
 
@@ -250,9 +320,9 @@ class TimedeltaConverter(dpy_commands.Converter):
 
         Attributes
         ----------
-        maximum : Optional[timedelta]
+        maximum : Optional[datetime.timedelta]
             If provided, any parsed value higher than this will raise an exception
-        minimum : Optional[timedelta]
+        minimum : Optional[datetime.timedelta]
             If provided, any parsed value lower than this will raise an exception
         allowed_units : Optional[List[str]]
             If provided, you can constrain a user to expressing the amount of time
@@ -315,9 +385,9 @@ def get_timedelta_converter(
 
         Parameters
         ----------
-        maximum : Optional[timedelta]
+        maximum : Optional[datetime.timedelta]
             If provided, any parsed value higher than this will raise an exception
-        minimum : Optional[timedelta]
+        minimum : Optional[datetime.timedelta]
             If provided, any parsed value lower than this will raise an exception
         allowed_units : Optional[List[str]]
             If provided, you can constrain a user to expressing the amount of time
@@ -349,6 +419,46 @@ class ValidatedConverter(TimedeltaConverter, metaclass=PartialMeta):
         return ValidatedConverter
 
 
+if TYPE_CHECKING:
+    RelativedeltaConverter = relativedelta
+else:
+
+    class RelativedeltaConverter(dpy_commands.Converter):
+        """
+        This is a converter for relative deltas.
+
+        The units should be in order from largest to smallest.
+        This works with or without whitespace.
+
+        See `parse_relativedelta` for more information about how this functions.
+
+        Attributes
+        ----------
+        allowed_units : Optional[List[str]]
+            If provided, you can constrain a user to expressing the amount of time
+            in specific units. The units you can choose to provide are the same as the
+            parser understands: (``years``, ``months``, ``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
+        default_unit : Optional[str]
+            If provided, it will additionally try to match integer-only input into
+            a timedelta, using the unit specified. Same units as in ``allowed_units``
+            apply.
+        """
+
+        def __init__(self, *, allowed_units=None, default_unit=None):
+            self.allowed_units = allowed_units
+            self.default_unit = default_unit
+
+        async def convert(self, ctx: "Context", argument: str) -> relativedelta:
+            if self.default_unit and argument.isdecimal():
+                argument = argument + self.default_unit
+
+            delta = parse_relativedelta(argument, allowed_units=self.allowed_units)
+
+            if delta is not None:
+                return delta
+            raise BadArgument()  # This allows this to be a required argument.
+
+
 if not TYPE_CHECKING:
 
     class NoParseOptional:


Test patch:
diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py
--- a/tests/core/test_commands.py
+++ b/tests/core/test_commands.py
@@ -1,9 +1,12 @@
 import inspect
+import datetime
+from dateutil.relativedelta import relativedelta
 
 import pytest
 from discord.ext import commands as dpy_commands
 
 from redbot.core import commands
+from redbot.core.commands import converter
 
 
 @pytest.fixture(scope="session")
@@ -49,3 +52,16 @@ def test_dpy_commands_reexports():
     missing_attrs = dpy_attrs - set(commands.__dict__.keys())
 
     assert not missing_attrs
+
+
+def test_converter_timedelta():
+    assert converter.parse_timedelta("1 day") == datetime.timedelta(days=1)
+    assert converter.parse_timedelta("1 minute") == datetime.timedelta(minutes=1)
+    assert converter.parse_timedelta("13 days 5 minutes") == datetime.timedelta(days=13, minutes=5)
+
+
+def test_converter_relativedelta():
+    assert converter.parse_relativedelta("1 year") == relativedelta(years=1)
+    assert converter.parse_relativedelta("1 year 10 days 3 seconds") == relativedelta(
+        years=1, days=10, seconds=3
+    )