Repository: sopel-irc/sopel

Issue:
clock: Invalid timezone name silently uses default
This is kind of a bug in `tools.time.get_timezone()`, maybe, but it manifests like this:

```
<dgw>   !time this/doesnt_exist
<Pandy> Thursday, May 09, 2019 04:15:47 (EDT)
```

That's Sopel using the `core.default_timezone` set for the whole bot, silently, even though it was passed a timezone name. It should throw the error message that's defined for timezone names that can't be found by any means of lookup (whether nickname, channel name, or tzdata lookup):

https://github.com/sopel-irc/sopel/blob/eeb48f947f0421b70bcf4e472df1acfd5e8363c4/sopel/modules/clock.py#L65

Instead of a useful error, the calling user gets a misleading false-positive response.


Discussion:
Oh, I used `sopel.tools.time.get_timezone()` recently, and if I find its behavior a bit odd, I can see why it is like that.

Looking at the `clock` built-in plugin, I think we can change that command to be more strict, and rely on `get_timezone` if and only if one isn't provided by the trigger.
In #1592 I worked on the "what if the timezone is incorrect?" to raise an error, but I kept a lot of the initial behavior when it detects a user or a channel.

So I think the core issue is fixed, but as @dgw pointed out, we still use a default timezone if either the nick-based or channel-based timezone is not defined.

So, new issue? Or just a check-list at 50% completion?

Resolving patch:
diff --git a/sopel/modules/clock.py b/sopel/modules/clock.py
--- a/sopel/modules/clock.py
+++ b/sopel/modules/clock.py
@@ -9,16 +9,16 @@
 """
 from __future__ import unicode_literals, absolute_import, print_function, division
 
-from sopel.module import commands, example, OP
+from sopel import module, tools
+from sopel.config.types import StaticSection, ValidatedAttribute
 from sopel.tools.time import (
-    get_timezone, format_time, validate_format, validate_timezone
+    format_time,
+    get_channel_timezone,
+    get_nick_timezone,
+    get_timezone,
+    validate_format,
+    validate_timezone
 )
-from sopel.config.types import StaticSection, ValidatedAttribute
-
-try:
-    import pytz
-except ImportError:
-    pytz = None
 
 
 class TimeSection(StaticSection):
@@ -26,14 +26,12 @@ class TimeSection(StaticSection):
         'tz',
         parse=validate_timezone,
         serialize=validate_timezone,
-        default='UTC'
-    )
+        default='UTC')
     """Default time zone (see https://sopel.chat/tz)"""
     time_format = ValidatedAttribute(
         'time_format',
         parse=validate_format,
-        default='%Y-%m-%d - %T%Z'
-    )
+        default='%Y-%m-%d - %T%Z')
     """Default time format (see http://strftime.net)"""
 
 
@@ -55,72 +53,157 @@ def setup(bot):
     bot.config.define_section('clock', TimeSection)
 
 
-@commands('t', 'time')
-@example('.t America/New_York')
+@module.commands('t', 'time')
+@module.example('.t America/New_York')
+@module.example('.t Exirel')
+@module.example('.t #sopel')
 def f_time(bot, trigger):
-    """Returns the current time."""
-    if trigger.group(2):
-        zone = get_timezone(bot.db, bot.config, trigger.group(2).strip(), None, None)
-        if not zone:
-            bot.say('Could not find timezone %s.' % trigger.group(2).strip())
-            return
+    """Return the current time.
+
+    The command takes an optional parameter: it will try to guess if it's a
+    nick, a channel, or a timezone (in that order).
+
+    If it's a known nick or channel but there is no configured timezone, then
+    it will complain. If nothing can be found, it'll complain that the argument
+    is not a valid timezone.
+
+    .. seealso::
+
+        Function :func:`~sopel.tools.time.format_time` is used to format
+        the current datetime according to the timezone (if found).
+
+    """
+    argument = trigger.group(2)
+
+    if not argument:
+        # get default timezone from nick, or sender, or bot, or UTC
+        zone = get_timezone(
+            bot.db, bot.config, None, trigger.nick, trigger.sender)
     else:
-        zone = get_timezone(bot.db, bot.config, None, trigger.nick,
-                            trigger.sender)
+        # guess if the argument is a nick, a channel, or a timezone
+        zone = None
+        argument = argument.strip()
+        channel_or_nick = tools.Identifier(argument)
+
+        # first, try to get nick or channel's timezone
+        help_prefix = bot.config.core.help_prefix
+        if channel_or_nick.is_nick():
+            zone = get_nick_timezone(bot.db, channel_or_nick)
+            if zone is None and channel_or_nick in bot.users:
+                # zone not found for a known nick: error case
+                set_command = '%ssettz <zone>' % help_prefix
+                if channel_or_nick != trigger.nick:
+                    bot.say(
+                        'Could not find a timezone for this nick. '
+                        '%s can set a timezone with `%s`'
+                        % (argument, set_command))
+                else:
+                    bot.say(
+                        'Could not find a timezone for you. '
+                        'You can set your timezone with `%s`'
+                        % set_command)
+                return
+        else:
+            zone = get_channel_timezone(bot.db, channel_or_nick)
+            if zone is None and channel_or_nick in bot.channels:
+                # zone not found for an existing channel: error case
+                set_command = '%ssetctz <zone>' % help_prefix
+                bot.say(
+                    'Could not find timezone for channel %s. '
+                    'It can be set with `.setctz <zone>`. '
+                    '(requires OP privileges)'
+                    % argument)
+                return
+
+        # then, fallback on timezone detection
+        if zone is None:
+            # argument not found as nick or channel timezone
+            try:
+                zone = validate_timezone(argument)
+            except ValueError:
+                bot.say('Could not find timezone "%s".' % argument)
+                return
+
     time = format_time(bot.db, bot.config, zone, trigger.nick, trigger.sender)
     bot.say(time)
 
 
-@commands('settz', 'settimezone')
-@example('.settz America/New_York')
-def update_user(bot, trigger):
+@module.commands('tz', 'timez')
+@module.example('.tz America/New_York')
+def f_time_zone(bot, trigger):
+    """Return the current time in a timezone.
+
+    Unlike the ``.t`` command, it requires an argument, and that argument
+    must be a valid timezone.
     """
-    Set your preferred time zone. Most timezones will work, but it's best to
-    use one from <https://sopel.chat/tz>.
+    argument = trigger.group(2)
+    if not argument:
+        bot.say('Please provide a timezone.')
+        return
+
+    zone = None
+    argument = argument.strip()
+    try:
+        zone = validate_timezone(argument)
+    except ValueError:
+        bot.say(
+            'Cannot display time: "%s" is not a valid timezone.' % argument)
+        return
+
+    time = format_time(bot.db, bot.config, zone, trigger.nick, trigger.sender)
+    bot.say(time)
+
+
+@module.commands('settz', 'settimezone')
+@module.example('.settz America/New_York')
+def update_user(bot, trigger):
+    """Set your preferred timezone.
+
+    Most timezones will work, but it's best to use one from
+    <https://sopel.chat/tz>.
     """
-    if not pytz:
-        bot.reply("Sorry, I don't have timezone support installed.")
+    argument = trigger.group(2)
+    if not argument:
+        bot.reply("What timezone do you want to set? Try one from "
+                  "https://sopel.chat/tz")
+        return
+
+    try:
+        zone = validate_timezone(argument)
+    except ValueError:
+        bot.say(
+            'I don\'t know that timezone. Try one from https://sopel.chat/tz')
+        return
+
+    bot.db.set_nick_value(trigger.nick, 'timezone', zone)
+
+    if len(zone) < 4:
+        bot.say(
+            'Okay, %s, but you should use one from https://sopel.chat/tz '
+            'if you use DST.' % trigger.nick)
     else:
-        tz = trigger.group(2)
-        if not tz:
-            bot.reply("What timezone do you want to set? Try one from "
-                      "https://sopel.chat/tz")
-            return
-        if tz not in pytz.all_timezones:
-            bot.reply("I don't know that time zone. Try one from "
-                      "https://sopel.chat/tz")
-            return
-
-        bot.db.set_nick_value(trigger.nick, 'timezone', tz)
-        if len(tz) < 7:
-            bot.say("Okay, {}, but you should use one from https://sopel.chat/tz "
-                    "if you use DST.".format(trigger.nick))
-        else:
-            bot.reply('I now have you in the %s time zone.' % tz)
+        bot.reply('I now have you in the %s timezone.' % zone)
 
 
-@commands('gettz', 'gettimezone')
-@example('.gettz [nick]')
+@module.commands('gettz', 'gettimezone')
+@module.example('.gettz [nick]')
 def get_user_tz(bot, trigger):
     """Gets a user's preferred time zone; will show yours if no user specified."""
-    if not pytz:
-        bot.reply("Sorry, I don't have timezone support installed.")
-    else:
-        nick = trigger.group(2)
-        if not nick:
-            nick = trigger.nick
+    nick = trigger.group(2)
+    if not nick:
+        nick = trigger.nick
 
-        nick = nick.strip()
+    nick = nick.strip()
+    zone = get_nick_timezone(bot.db, nick)
 
-        tz = bot.db.get_nick_value(nick, 'timezone')
-        if tz:
-            bot.say('%s\'s time zone is %s.' % (nick, tz))
-        else:
-            bot.say('%s has not set their time zone' % nick)
+    if zone:
+        bot.say('%s\'s time zone is %s.' % (nick, zone))
+    else:
+        bot.say('%s has not set their time zone' % nick)
 
 
-@commands('settimeformat', 'settf')
-@example('.settf %Y-%m-%dT%T%z')
+@module.commands('settimeformat', 'settf')
+@module.example('.settf %Y-%m-%dT%T%z')
 def update_user_format(bot, trigger):
     """
     Sets your preferred format for time. Uses the standard strftime format. You
@@ -153,8 +236,8 @@ def update_user_format(bot, trigger):
               % timef)
 
 
-@commands('gettimeformat', 'gettf')
-@example('.gettf [nick]')
+@module.commands('gettimeformat', 'gettf')
+@module.example('.gettf [nick]')
 def get_user_format(bot, trigger):
     """Gets a user's preferred time format; will show yours if no user specified."""
     nick = trigger.group(2)
@@ -172,67 +255,63 @@ def get_user_format(bot, trigger):
         bot.say("%s hasn't set a custom time format" % nick)
 
 
-@commands('setchanneltz', 'setctz')
-@example('.setctz America/New_York')
+@module.commands('setchanneltz', 'setctz')
+@module.example('.setctz America/New_York')
+@module.require_privilege(module.OP)
 def update_channel(bot, trigger):
     """Set the preferred timezone for the channel."""
-    if bot.channels[trigger.sender].privileges[trigger.nick] < OP:
+    argument = trigger.group(2)
+    if not argument:
+        bot.reply("What timezone do you want to set? Try one from "
+                  "https://sopel.chat/tz")
         return
-    elif not pytz:
-        bot.reply("Sorry, I don't have timezone support installed.")
+
+    try:
+        zone = validate_timezone(argument)
+    except ValueError:
+        bot.say(
+            'I don\'t know that timezone. Try one from https://sopel.chat/tz')
+        return
+
+    channel = trigger.sender
+    bot.db.set_channel_value(channel, 'timezone', zone)
+
+    if len(zone) < 4:
+        bot.say(
+            'Okay, %s, but you should use one from https://sopel.chat/tz '
+            'if you use DST.' % trigger.nick)
     else:
-        tz = trigger.group(2)
-        if not tz:
-            bot.reply("What timezone do you want to set? Try one from "
-                      "https://sopel.chat/tz")
-            return
-        if tz not in pytz.all_timezones:
-            bot.reply("I don't know that time zone. Try one from "
-                      "https://sopel.chat/tz")
-            return
-
-        bot.db.set_channel_value(trigger.sender, 'timezone', tz)
-        if len(tz) < 7:
-            bot.say("Okay, {}, but you should use one from https://sopel.chat/tz "
-                    "if you use DST.".format(trigger.nick))
-        else:
-            bot.reply(
-                'I now have {} in the {} time zone.'.format(trigger.sender, tz))
+        bot.reply('I now have %s in the %s timezone.' % (channel, zone))
 
 
-@commands('getchanneltz', 'getctz')
-@example('.getctz [channel]')
+@module.commands('getchanneltz', 'getctz')
+@module.example('.getctz [channel]')
 def get_channel_tz(bot, trigger):
     """
     Gets the channel's preferred timezone; returns the current channel's
     if no channel name is given.
     """
-    if not pytz:
-        bot.reply("Sorry, I don't have timezone support installed.")
-    else:
-        channel = trigger.group(2)
-        if not channel:
-            channel = trigger.sender
+    channel = trigger.group(2)
+    if not channel:
+        channel = trigger.sender
 
-        channel = channel.strip()
+    channel = channel.strip()
+    zone = get_channel_timezone(bot.db, channel)
 
-        timezone = bot.db.get_channel_value(channel, 'timezone')
-        if timezone:
-            bot.say('%s\'s timezone: %s' % (channel, timezone))
-        else:
-            bot.say('%s has no preferred timezone' % channel)
+    if zone:
+        bot.say('%s\'s timezone: %s' % (channel, zone))
+    else:
+        bot.say('%s has no preferred timezone' % channel)
 
 
-@commands('setchanneltimeformat', 'setctf')
-@example('.setctf %Y-%m-%dT%T%z')
+@module.commands('setchanneltimeformat', 'setctf')
+@module.example('.setctf %Y-%m-%dT%T%z')
+@module.require_privilege(module.OP)
 def update_channel_format(bot, trigger):
     """
     Sets your preferred format for time. Uses the standard strftime format. You
     can use <http://strftime.net> or your favorite search engine to learn more.
     """
-    if bot.channels[trigger.sender].privileges[trigger.nick] < OP:
-        return
-
     tformat = trigger.group(2)
     if not tformat:
         bot.reply("What format do you want me to use? Try using "
@@ -261,8 +340,8 @@ def update_channel_format(bot, trigger):
               "commands)" % timef)
 
 
-@commands('getchanneltimeformat', 'getctf')
-@example('.getctf [channel]')
+@module.commands('getchanneltimeformat', 'getctf')
+@module.example('.getctf [channel]')
 def get_channel_format(bot, trigger):
     """
     Gets the channel's preferred time format; will return current channel's if
diff --git a/sopel/tools/time.py b/sopel/tools/time.py
--- a/sopel/tools/time.py
+++ b/sopel/tools/time.py
@@ -3,31 +3,37 @@
 from __future__ import unicode_literals, absolute_import, print_function, division
 
 import datetime
-try:
-    import pytz
-except ImportError:
-    pytz = False
+
+import pytz
 
 
 def validate_timezone(zone):
     """Return an IETF timezone from the given IETF zone or common abbreviation.
 
-    If the length of the zone is 4 or less, it will be upper-cased before being
-    looked up; otherwise it will be title-cased. This is the expected
-    case-insensitivity behavior in the majority of cases. For example, ``'edt'``
-    and ``'america/new_york'`` will both return ``'America/New_York'``.
+    :param string zone: in a strict or a human-friendly format
+    :return: the valid IETF timezone properly formatted
+    :raise ValueError: when ``zone`` is not a valid timezone
+
+    Prior to checking timezones, two transformations are made to make the zone
+    names more human-friendly:
+
+    1. the string is split on ``', '``, the pieces reversed, and then joined
+       with ``/`` (*"New York, America"* becomes *"America/New York"*)
+    2. Remaining spaces are replaced with ``_``
+    3. Finally, strings longer than 4 characters are made title-case,
+       and those 4 characters and shorter are made upper-case.
+
+    This means ``new york, america`` becomes ``America/New_York``, and ``utc``
+    becomes ``UTC``.
 
-    If the zone is not valid, ``ValueError`` will be raised. If ``pytz`` is not
-    available, and the given zone is anything other than ``'UTC'``,
-    ``ValueError`` will be raised.
+    This is the expected case-insensitivity behavior in the majority of cases.
+    For example, ``edt`` and ``america/new_york`` will both return
+    ``America/New_York``.
+
+    If the zone is not valid, ``ValueError`` will be raised.
     """
     if zone is None:
         return None
-    if not pytz:
-        if zone.upper() != 'UTC':
-            raise ValueError('Only UTC available, since pytz is not installed.')
-        else:
-            return zone
 
     zone = '/'.join(reversed(zone.split(', '))).replace(' ', '_')
     if len(zone) <= 4:
@@ -50,31 +56,66 @@ def validate_format(tformat):
     return tformat
 
 
+def get_nick_timezone(db, nick):
+    """Get a nick's timezone from database.
+
+    :param db: Bot's database handler (usually ``bot.db``)
+    :type db: :class:`~sopel.db.SopelDB`
+    :param nick: IRC nickname
+    :type nick: :class:`~sopel.tools.Identifier`
+    :return: the timezone associated with the ``nick``
+
+    If a timezone cannot be found for ``nick``, or if it is invalid, ``None``
+    will be returned.
+    """
+    try:
+        return validate_timezone(db.get_nick_value(nick, 'timezone'))
+    except ValueError:
+        return None
+
+
+def get_channel_timezone(db, channel):
+    """Get a channel's timezone from database.
+
+    :param db: Bot's database handler (usually ``bot.db``)
+    :type db: :class:`~sopel.db.SopelDB`
+    :param channel: IRC channel name
+    :type channel: :class:`~sopel.tools.Identifier`
+    :return: the timezone associated with the ``channel``
+
+    If a timezone cannot be found for ``channel``, or if it is invalid,
+    ``None`` will be returned.
+    """
+    try:
+        return validate_timezone(db.get_channel_value(channel, 'timezone'))
+    except ValueError:
+        return None
+
+
 def get_timezone(db=None, config=None, zone=None, nick=None, channel=None):
     """Find, and return, the approriate timezone
 
     Time zone is pulled in the following priority:
-    1. `zone`, if it is valid
-    2. The timezone for the channel or nick `zone` in `db` if one is set and
-    valid.
-    3. The timezone for the nick `nick` in `db`, if one is set and valid.
-    4. The timezone for the channel  `channel` in `db`, if one is set and valid.
-    5. The default timezone in `config`, if one is set and valid.
-
-    If `db` is not given, or given but not set up, steps 2 and 3 will be
-    skipped. If `config` is not given, step 4 will be skipped. If no step
-    yeilds a valid timezone, `None` is returned.
-
-    Valid timezones are those present in the IANA Time Zone Database. Prior to
-    checking timezones, two translations are made to make the zone names more
-    human-friendly. First, the string is split on `', '`, the pieces reversed,
-    and then joined with `'/'`. Next, remaining spaces are replaced with `'_'`.
-    Finally, strings longer than 4 characters are made title-case, and those 4
-    characters and shorter are made upper-case. This means "new york, america"
-    becomes "America/New_York", and "utc" becomes "UTC".
-
-    This function relies on `pytz` being available. If it is not available,
-    `None` will always be returned.
+
+    1. ``zone``, if it is valid
+    2. The timezone for the channel or nick ``zone`` in ``db`` if one is set
+       and valid.
+    3. The timezone for the nick ``nick`` in ``db``, if one is set and valid.
+    4. The timezone for the channel ``channel`` in ``db``, if one is set and
+       valid.
+    5. The default timezone in ``config``, if one is set and valid.
+
+    If ``db`` is not given, or given but not set up, steps 2 and 3 will be
+    skipped. If ``config`` is not given, step 4 will be skipped. If no step
+    yeilds a valid timezone, ``None`` is returned.
+
+    Valid timezones are those present in the IANA Time Zone Database.
+
+    .. seealso::
+
+       The :func:`validate_timezone` function handles the validation and
+       formatting of the timezone.
+
     """
     def _check(zone):
         try:
@@ -82,8 +123,6 @@ def _check(zone):
         except ValueError:
             return None
 
-    if not pytz:
-        return None
     tz = None
 
     if zone:
@@ -104,22 +143,23 @@ def format_time(db=None, config=None, zone=None, nick=None, channel=None,
                 time=None):
     """Return a formatted string of the given time in the given zone.
 
-    `time`, if given, should be a naive `datetime.datetime` object and will be
-    treated as being in the UTC timezone. If it is not given, the current time
-    will be used. If `zone` is given and `pytz` is available, `zone` must be
-    present in the IANA Time Zone Database; `get_timezone` can be helpful for
-    this. If `zone` is not given or `pytz` is not available, UTC will be
-    assumed.
+    ``time``, if given, should be a naive ``datetime.datetime`` object and will
+    be treated as being in the UTC timezone. If it is not given, the current
+    time will be used. If ``zone`` is given it must be present in the IANA Time
+    Zone Database; ``get_timezone`` can be helpful for this. If ``zone`` is not
+    given, UTC will be assumed.
 
     The format for the string is chosen in the following order:
 
-    1. The format for the nick `nick` in `db`, if one is set and valid.
-    2. The format for the channel `channel` in `db`, if one is set and valid.
-    3. The default format in `config`, if one is set and valid.
+    1. The format for the nick ``nick`` in ``db``, if one is set and valid.
+    2. The format for the channel ``channel`` in ``db``, if one is set and
+       valid.
+    3. The default format in ``config``, if one is set and valid.
     4. ISO-8601
 
-    If `db` is not given or is not set up, steps 1 and 2 are skipped. If config
-    is not given, step 3 will be skipped."""
+    If ``db`` is not given or is not set up, steps 1 and 2 are skipped. If
+    config is not given, step 3 will be skipped.
+    """
     tformat = None
     if db:
         if nick:
@@ -134,7 +174,7 @@ def format_time(db=None, config=None, zone=None, nick=None, channel=None,
     if not time:
         time = datetime.datetime.utcnow()
 
-    if not pytz or not zone:
+    if not zone:
         return time.strftime(tformat)
     else:
         if not time.tzinfo:
@@ -151,12 +191,14 @@ def seconds_to_human(secs):
     Inspiration for function structure from:
     https://gist.github.com/Highstaker/280a09591df4a5fb1363b0bbaf858f0d
 
-    Example outputs are:
-    2 years, 1 month ago
-    in 4 hours, 45 minutes
-    in 8 days, 5 hours
-    1 year ago"""
+    Example outputs are::
+
+        2 years, 1 month ago
+        in 4 hours, 45 minutes
+        in 8 days, 5 hours
+        1 year ago
 
+    """
     if isinstance(secs, datetime.timedelta):
         secs = secs.total_seconds()
 


Test patch: