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>
This commit is contained in:
Zoë F 2021-09-05 18:50:21 -06:00 committed by GitHub
parent 86649e897f
commit ed9bb77eec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 165 additions and 36 deletions

View File

@ -218,6 +218,7 @@ intersphinx_mapping = {
"dpy": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/", None), "dpy": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/", None),
"motor": ("https://motor.readthedocs.io/en/stable/", None), "motor": ("https://motor.readthedocs.io/en/stable/", None),
"babel": ("http://babel.pocoo.org/en/stable/", None), "babel": ("http://babel.pocoo.org/en/stable/", None),
"dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
} }
# Extlinks # Extlinks

View File

@ -21,9 +21,11 @@ from .commands import (
from .context import Context as Context, GuildContext as GuildContext, DMContext as DMContext from .context import Context as Context, GuildContext as GuildContext, DMContext as DMContext
from .converter import ( from .converter import (
DictConverter as DictConverter, DictConverter as DictConverter,
RelativedeltaConverter as RelativedeltaConverter,
TimedeltaConverter as TimedeltaConverter, TimedeltaConverter as TimedeltaConverter,
get_dict_converter as get_dict_converter, get_dict_converter as get_dict_converter,
get_timedelta_converter as get_timedelta_converter, get_timedelta_converter as get_timedelta_converter,
parse_relativedelta as parse_relativedelta,
parse_timedelta as parse_timedelta, parse_timedelta as parse_timedelta,
NoParseOptional as NoParseOptional, NoParseOptional as NoParseOptional,
UserInputOptional as UserInputOptional, UserInputOptional as UserInputOptional,

View File

@ -8,6 +8,7 @@ Some of the converters within are included provisionally and are marked as such.
import functools import functools
import re import re
from datetime import timedelta from datetime import timedelta
from dateutil.relativedelta import relativedelta
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Generic, Generic,
@ -37,9 +38,11 @@ __all__ = [
"DictConverter", "DictConverter",
"UserInputOptional", "UserInputOptional",
"NoParseOptional", "NoParseOptional",
"RelativedeltaConverter",
"TimedeltaConverter", "TimedeltaConverter",
"get_dict_converter", "get_dict_converter",
"get_timedelta_converter", "get_timedelta_converter",
"parse_relativedelta",
"parse_timedelta", "parse_timedelta",
"Literal", "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 # https://github.com/mikeshardmind/SinbadCogs/blob/816f3bc2ba860243f75112904b82009a8a9e1f99/scheduler/time_utils.py#L9-L19
TIME_RE_STRING = r"\s?".join( 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<weeks>\d+?)\s?(weeks?|w))?",
r"((?P<days>\d+?)\s?(days?|d))?", r"((?P<days>\d+?)\s?(days?|d))?",
r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))?", r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))?",
@ -64,6 +69,22 @@ TIME_RE_STRING = r"\s?".join(
TIME_RE = re.compile(TIME_RE_STRING, re.I) 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( def parse_timedelta(
argument: str, argument: str,
*, *,
@ -81,9 +102,9 @@ def parse_timedelta(
---------- ----------
argument : str argument : str
The user provided input The user provided input
maximum : Optional[timedelta] maximum : Optional[datetime.timedelta]
If provided, any parsed value higher than this will raise an exception 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 If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]] allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time If provided, you can constrain a user to expressing the amount of time
@ -92,7 +113,7 @@ def parse_timedelta(
Returns Returns
------- -------
Optional[timedelta] Optional[datetime.timedelta]
If matched, the timedelta which was parsed. This can return `None` If matched, the timedelta which was parsed. This can return `None`
Raises Raises
@ -101,15 +122,14 @@ def parse_timedelta(
If the argument passed uses a unit not allowed, but understood If the argument passed uses a unit not allowed, but understood
or if the value is out of bounds. or if the value is out of bounds.
""" """
matches = TIME_RE.match(argument) allowed_units = allowed_units or [
allowed_units = allowed_units or ["weeks", "days", "hours", "minutes", "seconds"] "weeks",
if matches: "days",
params = {k: int(v) for k, v in matches.groupdict().items() if v is not None} "hours",
for k in params.keys(): "minutes",
if k not in allowed_units: "seconds",
raise BadArgument( ]
_("`{unit}` is not a valid unit of time for this command").format(unit=k) params = _parse_and_match(argument, allowed_units)
)
if params: if params:
try: try:
delta = timedelta(**params) delta = timedelta(**params)
@ -133,6 +153,56 @@ def parse_timedelta(
return None 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
class _GuildConverter(discord.Guild): class _GuildConverter(discord.Guild):
"""Converts to a `discord.Guild` object. """Converts to a `discord.Guild` object.
@ -250,9 +320,9 @@ else:
Attributes Attributes
---------- ----------
maximum : Optional[timedelta] maximum : Optional[datetime.timedelta]
If provided, any parsed value higher than this will raise an exception 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 If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]] allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time If provided, you can constrain a user to expressing the amount of time
@ -315,9 +385,9 @@ else:
Parameters Parameters
---------- ----------
maximum : Optional[timedelta] maximum : Optional[datetime.timedelta]
If provided, any parsed value higher than this will raise an exception 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 If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]] allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time If provided, you can constrain a user to expressing the amount of time
@ -349,6 +419,46 @@ else:
return ValidatedConverter 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: if not TYPE_CHECKING:
class NoParseOptional: class NoParseOptional:

View File

@ -1,9 +1,12 @@
import inspect import inspect
import datetime
from dateutil.relativedelta import relativedelta
import pytest import pytest
from discord.ext import commands as dpy_commands from discord.ext import commands as dpy_commands
from redbot.core import commands from redbot.core import commands
from redbot.core.commands import converter
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -49,3 +52,16 @@ def test_dpy_commands_reexports():
missing_attrs = dpy_attrs - set(commands.__dict__.keys()) missing_attrs = dpy_attrs - set(commands.__dict__.keys())
assert not missing_attrs 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
)