Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pull] V3/develop from Cog-Creators:V3/develop #106

Merged
merged 2 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 47 additions & 34 deletions redbot/cogs/mutes/converters.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,76 @@
from __future__ import annotations

import logging
import re
from typing import Union, Dict
from typing import Optional, TypedDict
from datetime import timedelta
from typing_extensions import Annotated

from discord.ext.commands.converter import Converter
from redbot.core import commands
from redbot.core import i18n
from redbot.core.commands.converter import TIME_RE

_ = i18n.Translator("Mutes", __file__)
log = logging.getLogger("red.cogs.mutes")

# the following regex is slightly modified from Red
# it's changed to be slightly more strict on matching with finditer
# this is to prevent "empty" matches when parsing the full reason
# This is also designed more to allow time interval at the beginning or the end of the mute
# to account for those times when you think of adding time *after* already typing out the reason
# https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/core/commands/converter.py#L55
TIME_RE_STRING = r"|".join(
[
r"((?P<weeks>\d+?)\s?(weeks?|w))",
r"((?P<days>\d+?)\s?(days?|d))",
r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))",
r"((?P<minutes>\d+?)\s?(minutes?|mins?|m(?!o)))", # prevent matching "months"
r"((?P<seconds>\d+?)\s?(seconds?|secs?|s))",
]
)
TIME_RE = re.compile(TIME_RE_STRING, re.I)
TIME_SPLIT = re.compile(r"t(?:ime)?=")
TIME_SPLIT = re.compile(r"t(?:ime\s?)?=\s*")

_ = i18n.Translator("Mutes", __file__)

def _edgematch(pattern: re.Pattern[str], argument: str) -> Optional[re.Match[str]]:
"""Internal utility to match at either end of the argument string"""
# precondition: pattern does not end in $
# precondition: argument does not end in whitespace
return pattern.match(argument) or re.search(
pattern.pattern + "$", argument, flags=pattern.flags
)


class MuteTime(Converter):
class _MuteTime(TypedDict, total=False):
duration: timedelta
reason: str


class _MuteTimeConverter(Converter):
"""
This will parse my defined multi response pattern and provide usable formats
to be used in multiple responses
"""

async def convert(
self, ctx: commands.Context, argument: str
) -> Dict[str, Union[timedelta, str, None]]:
time_split = TIME_SPLIT.split(argument)
result: Dict[str, Union[timedelta, str, None]] = {}
async def convert(self, ctx: commands.Context, argument: str) -> _MuteTime:
time_split = TIME_SPLIT.search(argument)
result: _MuteTime = {}
if time_split:
maybe_time = time_split[-1]
maybe_time = argument[time_split.end() :]
strategy = re.match
else:
maybe_time = argument
strategy = _edgematch

time_data = {}
for time in TIME_RE.finditer(maybe_time):
argument = argument.replace(time[0], "")
for k, v in time.groupdict().items():
if v:
time_data[k] = int(v)
if time_data:
match = strategy(TIME_RE, maybe_time)
if match:
time_data = {k: int(v) for k, v in match.groupdict().items() if v is not None}
for k in time_data:
if k in ("years", "months"):
raise commands.BadArgument(
_("`{unit}` is not a valid unit of time for this command").format(unit=k)
)
try:
result["duration"] = timedelta(**time_data)
result["duration"] = duration = timedelta(**time_data)
except OverflowError:
raise commands.BadArgument(
_("The time provided is too long; use a more reasonable time.")
)
if duration <= timedelta(seconds=0):
raise commands.BadArgument(_("The time provided must not be in the past."))
if time_split:
start, end = time_split.span()
end += match.end()
else:
start, end = match.span()
argument = argument[:start] + argument[end:]
result["reason"] = argument.strip()
return result


MuteTime = Annotated[_MuteTime, _MuteTimeConverter]
33 changes: 18 additions & 15 deletions redbot/core/commands/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,22 @@

# Taken with permission from
# 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?))?",
r"((?P<minutes>\d+?)\s?(minutes?|mins?|m(?!o)))?", # prevent matching "months"
r"((?P<seconds>\d+?)\s?(seconds?|secs?|s))?",
]
# with modifications
TIME_RE = re.compile(
r"""
(\s?( # match deliminators here to make word border below unambiguous
(?P<years>[\+-]?\d+)\s?(years?|y)
| (?P<months>[\+-]?\d+)\s?(months?|mo)
| (?P<weeks>[\+-]?\d+)\s?(weeks?|w)
| (?P<days>[\+-]?\d+)\s?(days?|d)
| (?P<hours>[\+-]?\d+)\s?(hours?|hrs|hr?)
| (?P<minutes>[\+-]?\d+)\s?(minutes?|mins?|m)
| (?P<seconds>[\+-]?\d+)\s?(seconds?|secs?|s)
))+\b
""",
flags=re.IGNORECASE | re.VERBOSE,
)

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]]:
"""
Expand All @@ -92,13 +94,13 @@ def parse_timedelta(
argument: str,
*,
maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None,
minimum: Optional[timedelta] = timedelta(seconds=0),
allowed_units: Optional[List[str]] = None,
) -> Optional[timedelta]:
"""
This converts a user provided string into a timedelta

The units should be in order from largest to smallest.
If a unit is specified multiple times, only the last is considered.
This works with or without whitespace.

Parameters
Expand All @@ -109,6 +111,7 @@ def parse_timedelta(
If provided, any parsed value higher than this will raise an exception
minimum : Optional[datetime.timedelta]
If provided, any parsed value lower than this will raise an exception
Defaults to 0 seconds, pass None explicitly to allow negative values
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
Expand Down Expand Up @@ -162,7 +165,7 @@ def parse_relativedelta(
"""
This converts a user provided string into a datetime with offset from NOW

The units should be in order from largest to smallest.
If a unit is specified multiple times, only the last is considered.
This works with or without whitespace.

Parameters
Expand Down
48 changes: 43 additions & 5 deletions redbot/core/utils/chat_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,11 @@ def format_perms_list(perms: discord.Permissions) -> str:


def humanize_timedelta(
*, timedelta: Optional[datetime.timedelta] = None, seconds: Optional[SupportsInt] = None
*,
timedelta: Optional[datetime.timedelta] = None,
seconds: Optional[SupportsInt] = None,
negative_format: Optional[str] = None,
maximum_units: Optional[int] = None,
) -> str:
"""
Get a locale aware human timedelta representation.
Expand All @@ -535,6 +539,11 @@ def humanize_timedelta(
A timedelta object
seconds: Optional[SupportsInt]
A number of seconds
negative_format: Optional[str]
How to format negative timedeltas, using %-formatting rules.
Defaults to "negative %s"
maximum_units: Optional[int]
The maximum number of different units to output in the final string.

Returns
-------
Expand All @@ -544,15 +553,33 @@ def humanize_timedelta(
Raises
------
ValueError
The function was called with neither a number of seconds nor a timedelta object
The function was called with neither a number of seconds nor a timedelta object,
or with a maximum_units less than 1.

Examples
--------
.. testsetup::

from datetime import timedelta
from redbot.core.utils.chat_formatting import humanize_timedelta

.. doctest::

>>> humanize_timedelta(seconds=314)
'5 minutes, 14 seconds'
>>> humanize_timedelta(timedelta=timedelta(minutes=3.14), maximum_units=1)
'3 minutes'
>>> humanize_timedelta(timedelta=timedelta(days=-3.14), negative_format="%s ago", maximum_units=3)
'3 days, 3 hours, 21 minutes ago'
"""

try:
obj = seconds if seconds is not None else timedelta.total_seconds()
except AttributeError:
raise ValueError("You must provide either a timedelta or a number of seconds")
if maximum_units is not None and maximum_units < 1:
raise ValueError("maximum_units must be >= 1")

seconds = int(obj)
periods = [
(_("year"), _("years"), 60 * 60 * 24 * 365),
(_("month"), _("months"), 60 * 60 * 24 * 30),
Expand All @@ -561,17 +588,28 @@ def humanize_timedelta(
(_("minute"), _("minutes"), 60),
(_("second"), _("seconds"), 1),
]

seconds = int(obj)
if seconds < 0:
seconds = -seconds
if negative_format and "%s" not in negative_format:
negative_format = negative_format + " %s"
else:
negative_format = negative_format or (_("negative") + " %s")
else:
negative_format = "%s"
strings = []
maximum_units = maximum_units or len(periods)
for period_name, plural_period_name, period_seconds in periods:
if seconds >= period_seconds:
period_value, seconds = divmod(seconds, period_seconds)
if period_value == 0:
continue
unit = plural_period_name if period_value > 1 else period_name
strings.append(f"{period_value} {unit}")
if len(strings) == maximum_units:
break

return ", ".join(strings)
return negative_format % humanize_list(strings, style="unit")


def humanize_number(val: Union[int, float], override_locale=None) -> str:
Expand Down
Loading