-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9 from Hawkpath/time-conversion
Time conversion
- Loading branch information
Showing
13 changed files
with
352 additions
and
90 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import datetime as dt | ||
import re | ||
from typing import Union, cast | ||
|
||
import pytz | ||
import tzlocal | ||
|
||
__all__ = ['TimezoneType', 'TimeParsingError', 'time_format', 'parse_time', | ||
'utc_now'] | ||
|
||
TimezoneType = Union[pytz.tzinfo.StaticTzInfo, pytz.tzinfo.DstTzInfo] | ||
|
||
time_pattern = re.compile( | ||
r'^(?P<hour>[0-2]?\d)' | ||
r'(?::(?P<minute>\d{2}))?' | ||
r'\s*' | ||
r'(?:(?P<period_am>a|am)|(?P<period_pm>p|pm))?$', | ||
re.I | ||
) | ||
|
||
try: | ||
# Unix strip zero-padding | ||
time_format = '%-I:%M %p (%H:%M)' | ||
dt.datetime.now().strftime(time_format) | ||
except ValueError: | ||
try: | ||
# Windows strip zero-padding | ||
time_format = '%#I:%M %p (%H:%M)' | ||
dt.datetime.now().strftime(time_format) | ||
except ValueError: | ||
# Fallback without stripping zero-padding | ||
time_format = '%I:%M %p (%H:%M)' | ||
|
||
|
||
class TimeParsingError(Exception): | ||
pass | ||
|
||
|
||
def utc_now() -> dt.datetime: | ||
# Get the system-local timezone and use it to localize dt.datetime.now() | ||
local_tz = cast(TimezoneType, tzlocal.get_localzone()) | ||
return local_tz.localize(dt.datetime.now()) | ||
|
||
|
||
def parse_time(string: str, basis_tz: TimezoneType) -> dt.datetime: | ||
""" | ||
Parse a string as a time specifier of the general format "12:34 PM". | ||
:raises: TimeParsingError if parsing failed in an expected way | ||
""" | ||
|
||
# Convert UTC now to the basis timezone | ||
now_basis = utc_now().astimezone(basis_tz) | ||
|
||
match = time_pattern.match(string) | ||
if not match: | ||
raise TimeParsingError('No match') | ||
|
||
hour = int(match.group('hour')) | ||
minute = int(match.group('minute') or 0) | ||
|
||
if (0 > hour > 23) or (0 > minute > 59): | ||
raise TimeParsingError('Hour or minute is out of range') | ||
|
||
if match.group('period_pm'): | ||
if hour < 12: | ||
# This is PM and we use 24 hour times in datetime, so add 12 hours | ||
hour += 12 | ||
elif hour == 12: | ||
# 12 PM is 12:00 | ||
pass | ||
else: | ||
raise TimeParsingError('24 hour times do not use AM or PM') | ||
elif match.group('period_am'): | ||
if hour < 12: | ||
# AM, so no change | ||
pass | ||
elif hour == 12: | ||
# 12 AM is 00:00 | ||
hour = 0 | ||
else: | ||
raise TimeParsingError('24 hour times do not use AM or PM') | ||
|
||
# Create the datetime we think the user is trying to specify by using | ||
# their current local day and adding the hour and minute arguments. | ||
# Return the localized datetime | ||
basis_time = dt.datetime(now_basis.year, now_basis.month, now_basis.day, | ||
hour, minute) | ||
return basis_tz.localize(basis_time) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from discord.ext.commands import Bot | ||
|
||
from .cog import Conversion | ||
|
||
|
||
def setup(bot: Bot): | ||
bot.add_cog(Conversion(bot)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import logging | ||
import re | ||
from typing import List | ||
|
||
import discord | ||
import discord.ext.commands as commands | ||
|
||
from ..common.embeds import Embeds | ||
from ..common.time import time_format | ||
from .time_conversion import * | ||
from .unit_conversion import * | ||
|
||
logger = logging.getLogger('sandpiper.unit_conversion') | ||
|
||
conversion_pattern = re.compile(r'{(.+?)}') | ||
|
||
|
||
class Conversion(commands.Cog): | ||
|
||
def __init__(self, bot: commands.Bot): | ||
self.bot = bot | ||
|
||
@commands.Cog.listener(name='on_message') | ||
async def conversions(self, msg: discord.Message): | ||
""" | ||
Scan a message for conversion strings. | ||
:param msg: Discord message to scan for conversions | ||
""" | ||
if msg.author == self.bot.user: | ||
return | ||
|
||
conversion_strs = conversion_pattern.findall(msg.content) | ||
if not conversion_strs: | ||
return | ||
|
||
conversion_strs = await self.convert_time(msg, conversion_strs) | ||
await self.convert_imperial_metric(msg.channel, conversion_strs) | ||
|
||
async def convert_time(self, msg: discord.Message, | ||
time_strs: List[str]) -> List[str]: | ||
""" | ||
Convert a list of time strings (like "5:45 PM") to different users' | ||
timezones and reply with the conversions. | ||
:param msg: Discord message that triggered the conversion | ||
:param time_strs: a list of strings that may be valid times | ||
:returns: a list of strings that could not be converted | ||
""" | ||
|
||
user_data = self.bot.get_cog('UserData') | ||
if user_data is None: | ||
# User data cog couldn't be retrieved, so consider all conversions | ||
# failed | ||
return time_strs | ||
|
||
try: | ||
localized_times, failed = convert_time_to_user_timezones( | ||
user_data, msg.author.id, msg.guild, time_strs | ||
) | ||
except UserTimezoneUnset: | ||
cmd_prefix = self.bot.command_prefix(self.bot, msg)[-1] | ||
await Embeds.error( | ||
msg.channel, | ||
f"You haven't set your timezone yet. Type " | ||
f"`{cmd_prefix}help timezone set` for more info." | ||
) | ||
return time_strs | ||
|
||
if localized_times: | ||
output = [] | ||
for tz_name, times in localized_times: | ||
times = ' | '.join(f'`{time.strftime(time_format)}`' | ||
for time in times) | ||
output.append(f'**{tz_name}**: {times}') | ||
await msg.channel.send('\n'.join(output)) | ||
|
||
return failed | ||
|
||
async def convert_imperial_metric( | ||
self, channel: discord.TextChannel, | ||
quantity_strs: List[str]) -> List[str]: | ||
""" | ||
Convert a list of quantity strings (like "5 km") between imperial and | ||
metric and reply with the conversions. | ||
:param channel: Discord channel to send conversions message to | ||
:param quantity_strs: a list of strings that may be valid quantities | ||
:returns: a list of strings that could not be converted | ||
""" | ||
|
||
conversions = [] | ||
failed = [] | ||
for qstr in quantity_strs: | ||
q = imperial_metric(qstr) | ||
if q is not None: | ||
conversions.append(f'`{q[0]:.2f~P}` = `{q[1]:.2f~P}`') | ||
else: | ||
failed.append(qstr) | ||
|
||
if conversions: | ||
await channel.send('\n'.join(conversions)) | ||
|
||
return failed |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import datetime as dt | ||
import logging | ||
from typing import List, Tuple | ||
|
||
import discord | ||
|
||
from ..common.time import * | ||
from ..user_info import UserData | ||
|
||
__all__ = ['UserTimezoneUnset', 'convert_time_to_user_timezones'] | ||
|
||
logger = logging.getLogger('sandpiper.conversion.time_conversion') | ||
|
||
|
||
class UserTimezoneUnset(Exception): | ||
pass | ||
|
||
|
||
def convert_time_to_user_timezones( | ||
user_data: UserData, user_id: int, guild: discord.Guild, | ||
time_strs: List[str] | ||
) -> Tuple[List[Tuple[str, List[dt.datetime]]], List[str]]: | ||
""" | ||
Convert times. | ||
:param user_data: the UserData cog for interacting with the database | ||
:param user_id: the id of the user asking for a time conversion | ||
:param guild: the guild the conversion is occurring in | ||
:param time_strs: a list of strings that may be time specifiers | ||
:returns: A tuple of (conversions, failed). | ||
``failed`` is a list of strings that could not be converted. | ||
``conversions`` is a list of tuples of (tz_name, converted_times). | ||
``tz_name`` is the name of the timezone the following times are in. | ||
``converted_times`` is a list of datetimes localized to every timezone | ||
occupied by users in the guild. | ||
""" | ||
|
||
db = user_data.get_database() | ||
basis_tz = db.get_timezone(user_id) | ||
if basis_tz is None: | ||
raise UserTimezoneUnset() | ||
user_timezones = [tz for user_id, tz in db.get_all_timezones() | ||
if guild.get_member(user_id)] | ||
|
||
parsed_times = [] | ||
failed = [] | ||
for tstr in time_strs: | ||
try: | ||
parsed_times.append(parse_time(tstr, basis_tz)) | ||
except TimeParsingError as e: | ||
logger.debug(f"Failed to parse time string (string={tstr}, " | ||
f"reason={e})") | ||
failed.append(tstr) | ||
except: | ||
logger.warning(f"Unhandled exception while parsing time string " | ||
f"(string={tstr})", exc_info=True) | ||
|
||
if not parsed_times: | ||
return [], failed | ||
|
||
conversions = [] | ||
for tz in user_timezones: | ||
tz_name: str = tz.zone | ||
times = [time.astimezone(tz) for time in parsed_times] | ||
conversions.append((tz_name, times)) | ||
conversions.sort(key=lambda conv: conv[1][0].utcoffset()) | ||
|
||
return conversions, failed |
Oops, something went wrong.