From ed9bb77eec289ea8e98e087d9c33e4490863b29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB=20F?= Date: Sun, 5 Sep 2021 18:50:21 -0600 Subject: [PATCH] Add `RelativedeltaConverter` and `parse_relativedelta` (#5000) * Added years and months to parse_timedelta * Added new parse_datetimedelta along with classes for relative dates * Switched datetime as dt to just datetime for clarity * Changed to returning relativedelta instead of datetime * Fixed single char typo * After some digging, removed min and max from relative delta b/c of https://github.com/dateutil/dateutil/issues/350 * Add dateutil to intersphinx mapping * Change uppercase D in RelativeDeltaConverter to a lowercase D * Fix cross-references in docstrings * Add new class and methods to __all__ * Remove get_relativedelta_converter() * style * Fix name of parse_relativedelta test * more style * Re-export new class and function in `redbot.core.commands` Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com> --- docs/conf.py | 1 + redbot/core/commands/__init__.py | 2 + redbot/core/commands/converter.py | 182 ++++++++++++++++++++++++------ tests/core/test_commands.py | 16 +++ 4 files changed, 165 insertions(+), 36 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index ed24bdc6b..9058c5855 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -218,6 +218,7 @@ intersphinx_mapping = { "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 index afb48b2c3..36700c0bf 100644 --- a/redbot/core/commands/__init__.py +++ b/redbot/core/commands/__init__.py @@ -21,9 +21,11 @@ from .commands import ( 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 index 820bbcc04..7eb1dd9f0 100644 --- a/redbot/core/commands/converter.py +++ b/redbot/core/commands/converter.py @@ -8,6 +8,7 @@ Some of the converters within are included provisionally and are marked as such. import functools import re from datetime import timedelta +from dateutil.relativedelta import relativedelta from typing import ( TYPE_CHECKING, Generic, @@ -37,9 +38,11 @@ __all__ = [ "DictConverter", "UserInputOptional", "NoParseOptional", + "RelativedeltaConverter", "TimedeltaConverter", "get_dict_converter", "get_timedelta_converter", + "parse_relativedelta", "parse_timedelta", "Literal", ] @@ -53,6 +56,8 @@ ID_REGEX = re.compile(r"([0-9]{15,20})") # https://github.com/mikeshardmind/SinbadCogs/blob/816f3bc2ba860243f75112904b82009a8a9e1f99/scheduler/time_utils.py#L9-L19 TIME_RE_STRING = r"\s?".join( [ + r"((?P\d+?)\s?(years?|y))?", + r"((?P\d+?)\s?(months?|mo))?", r"((?P\d+?)\s?(weeks?|w))?", r"((?P\d+?)\s?(days?|d))?", r"((?P\d+?)\s?(hours?|hrs|hr?))?", @@ -64,6 +69,22 @@ TIME_RE_STRING = r"\s?".join( 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 @@ else: 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 @@ else: 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 @@ else: 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: diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index a0241f4f2..229d482c2 100644 --- 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 + )