Skip to content

Commit ed726e6

Browse files
authored
Merge pull request #2446 from Exirel/test-coverage-tool-time
tools.time: tweaks & tests
2 parents 1538ec8 + 7b9445f commit ed726e6

File tree

3 files changed

+357
-77
lines changed

3 files changed

+357
-77
lines changed

sopel/modules/remind.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,7 @@ def parse_regex_match(match, default_timezone=None):
427427
:rtype: :class:`TimeReminder`
428428
"""
429429
try:
430-
# Removing the `or` clause will BREAK the fallback to default_timezone!
431-
# We need some invalid value other than None to trigger the ValueError.
432-
# validate_timezone(None) excepting would be easier, but it doesn't.
433-
timezone = validate_timezone(match.group('tz') or '')
430+
timezone = validate_timezone(match.group('tz'))
434431
except ValueError:
435432
timezone = default_timezone or 'UTC'
436433

sopel/tools/time.py

Lines changed: 146 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@
99
from __future__ import annotations
1010

1111
import datetime
12+
from typing import cast, NamedTuple, Optional, Tuple, TYPE_CHECKING, Union
1213

1314
import pytz
1415

16+
if TYPE_CHECKING:
17+
from sopel.config import Config
18+
from sopel.db import SopelDB
19+
1520

1621
# various time units measured in seconds; approximated for months and years
1722
SECONDS = 1
@@ -22,12 +27,42 @@
2227
YEARS = 365 * DAYS
2328

2429

25-
def validate_timezone(zone):
30+
class Duration(NamedTuple):
31+
"""Named tuple representation of a duration.
32+
33+
This can be used as a tuple as well as an object::
34+
35+
>>> d = Duration(minutes=12, seconds=34)
36+
>>> d.minutes
37+
12
38+
>>> d.seconds
39+
34
40+
>>> years, months, days, hours, minutes, seconds = d
41+
>>> (years, months, days, hours, minutes, seconds)
42+
(0, 0, 0, 0, 12, 34)
43+
44+
"""
45+
years: int = 0
46+
"""Years spent."""
47+
months: int = 0
48+
"""Months spent."""
49+
days: int = 0
50+
"""Days spent."""
51+
hours: int = 0
52+
"""Hours spent."""
53+
minutes: int = 0
54+
"""Minutes spent."""
55+
seconds: int = 0
56+
"""Seconds spent."""
57+
58+
59+
def validate_timezone(zone: Optional[str]) -> str:
2660
"""Return an IETF timezone from the given IETF zone or common abbreviation.
2761
28-
:param str zone: in a strict or a human-friendly format
62+
:param zone: in a strict or a human-friendly format
2963
:return: the valid IETF timezone properly formatted
3064
:raise ValueError: when ``zone`` is not a valid timezone
65+
(including empty string and ``None`` value)
3166
3267
Prior to checking timezones, two transformations are made to make the zone
3368
names more human-friendly:
@@ -40,25 +75,32 @@ def validate_timezone(zone):
4075
becomes ``UTC``. In the majority of user-facing interactions, such
4176
case-insensitivity will be expected.
4277
43-
If the zone is not valid, ``ValueError`` will be raised.
78+
If the zone is not valid, :exc:`ValueError` will be raised.
4479
4580
.. versionadded:: 6.0
81+
82+
.. versionchanged:: 8.0
83+
84+
If ``zone`` is ``None``, raises a :exc:`ValueError` as if it was an
85+
empty string or an invalid timezone instead of returning ``None``.
86+
4687
"""
4788
if zone is None:
48-
return None
89+
raise ValueError('Invalid time zone.')
4990

5091
zone = '/'.join(reversed(zone.split(', '))).replace(' ', '_')
5192
try:
5293
tz = pytz.timezone(zone)
5394
except pytz.exceptions.UnknownTimeZoneError:
5495
raise ValueError('Invalid time zone.')
55-
return tz.zone
96+
97+
return cast(str, tz.zone)
5698

5799

58-
def validate_format(tformat):
100+
def validate_format(tformat: str) -> str:
59101
"""Validate a time format string.
60102
61-
:param str tformat: the format string to validate
103+
:param tformat: the format string to validate
62104
:return: the format string, if valid
63105
:raise ValueError: when ``tformat`` is not a valid time format string
64106
@@ -72,13 +114,11 @@ def validate_format(tformat):
72114
return tformat
73115

74116

75-
def get_nick_timezone(db, nick):
117+
def get_nick_timezone(db: SopelDB, nick: str) -> Optional[str]:
76118
"""Get a nick's timezone from database.
77119
78120
:param db: Bot's database handler (usually ``bot.db``)
79-
:type db: :class:`~sopel.db.SopelDB`
80121
:param nick: IRC nickname
81-
:type nick: :class:`~sopel.tools.identifiers.Identifier`
82122
:return: the timezone associated with the ``nick``
83123
84124
If a timezone cannot be found for ``nick``, or if it is invalid, ``None``
@@ -92,13 +132,11 @@ def get_nick_timezone(db, nick):
92132
return None
93133

94134

95-
def get_channel_timezone(db, channel):
135+
def get_channel_timezone(db: SopelDB, channel: str) -> Optional[str]:
96136
"""Get a channel's timezone from database.
97137
98138
:param db: Bot's database handler (usually ``bot.db``)
99-
:type db: :class:`~sopel.db.SopelDB`
100139
:param channel: IRC channel name
101-
:type channel: :class:`~sopel.tools.identifiers.Identifier`
102140
:return: the timezone associated with the ``channel``
103141
104142
If a timezone cannot be found for ``channel``, or if it is invalid,
@@ -112,16 +150,20 @@ def get_channel_timezone(db, channel):
112150
return None
113151

114152

115-
def get_timezone(db=None, config=None, zone=None, nick=None, channel=None):
153+
def get_timezone(
154+
db: Optional[SopelDB] = None,
155+
config: Optional[Config] = None,
156+
zone: Optional[str] = None,
157+
nick: Optional[str] = None,
158+
channel: Optional[str] = None,
159+
) -> Optional[str]:
116160
"""Find, and return, the appropriate timezone.
117161
118162
:param db: bot database object (optional)
119-
:type db: :class:`~.db.SopelDB`
120163
:param config: bot config object (optional)
121-
:type config: :class:`~.config.Config`
122-
:param str zone: preferred timezone name (optional)
123-
:param str nick: nick whose timezone to use, if set (optional)
124-
:param str channel: channel whose timezone to use, if set (optional)
164+
:param zone: preferred timezone name (optional)
165+
:param nick: nick whose timezone to use, if set (optional)
166+
:param channel: channel whose timezone to use, if set (optional)
125167
126168
Timezone is pulled in the following priority:
127169
@@ -145,40 +187,50 @@ def get_timezone(db=None, config=None, zone=None, nick=None, channel=None):
145187
formatting of the timezone.
146188
147189
"""
148-
def _check(zone):
190+
def _check(zone: Optional[str]) -> Optional[str]:
149191
try:
150192
return validate_timezone(zone)
151193
except ValueError:
152194
return None
153195

154-
tz = None
196+
tz: Optional[str] = None
155197

156198
if zone:
157199
tz = _check(zone)
158-
if not tz:
200+
# zone might be a nick or a channel
201+
if not tz and db is not None:
159202
tz = _check(db.get_nick_or_channel_value(zone, 'timezone'))
160-
if not tz and nick:
161-
tz = _check(db.get_nick_value(nick, 'timezone'))
162-
if not tz and channel:
163-
tz = _check(db.get_channel_value(channel, 'timezone'))
164-
if not tz and config and config.core.default_timezone:
203+
204+
# get nick's timezone, and if none, get channel's timezone instead
205+
if not tz and db is not None:
206+
if nick:
207+
tz = _check(db.get_nick_value(nick, 'timezone'))
208+
if not tz and channel:
209+
tz = _check(db.get_channel_value(channel, 'timezone'))
210+
211+
# if still not found, default to core configuration
212+
if not tz and config is not None and config.core.default_timezone:
165213
tz = _check(config.core.default_timezone)
214+
166215
return tz
167216

168217

169-
def format_time(db=None, config=None, zone=None, nick=None, channel=None,
170-
time=None):
218+
def format_time(
219+
db: Optional[SopelDB] = None,
220+
config: Optional[Config] = None,
221+
zone: Optional[str] = None,
222+
nick: Optional[str] = None,
223+
channel: Optional[str] = None,
224+
time: Optional[datetime.datetime] = None,
225+
) -> str:
171226
"""Return a formatted string of the given time in the given zone.
172227
173228
:param db: bot database object (optional)
174-
:type db: :class:`~.db.SopelDB`
175229
:param config: bot config object (optional)
176-
:type config: :class:`~.config.Config`
177-
:param str zone: name of timezone to use for output (optional)
178-
:param str nick: nick whose time format to use, if set (optional)
179-
:param str channel: channel whose time format to use, if set (optional)
230+
:param zone: name of timezone to use for output (optional)
231+
:param nick: nick whose time format to use, if set (optional)
232+
:param channel: channel whose time format to use, if set (optional)
180233
:param time: the time value to format (optional)
181-
:type time: :class:`~datetime.datetime`
182234
183235
``time``, if given, should be a ``datetime.datetime`` object, and will be
184236
treated as being in the UTC timezone if it is :ref:`naïve
@@ -200,68 +252,92 @@ def format_time(db=None, config=None, zone=None, nick=None, channel=None,
200252
If ``db`` is not given or is not set up, steps 1 and 2 are skipped. If
201253
``config`` is not given, step 3 will be skipped.
202254
"""
203-
utc = pytz.timezone('UTC')
204-
tformat = None
255+
target_tz: datetime.tzinfo = pytz.utc
256+
tformat: Optional[str] = None
257+
258+
# get an aware datetime
259+
if not time:
260+
time = pytz.utc.localize(datetime.datetime.utcnow())
261+
elif not time.tzinfo:
262+
time = pytz.utc.localize(time)
263+
264+
# get target timezone
265+
if zone:
266+
target_tz = pytz.timezone(zone)
267+
268+
# get format for nick or channel
205269
if db:
206270
if nick:
207271
tformat = db.get_nick_value(nick, 'time_format')
208272
if not tformat and channel:
209273
tformat = db.get_channel_value(channel, 'time_format')
274+
275+
# get format from configuration
210276
if not tformat and config and config.core.default_time_format:
211277
tformat = config.core.default_time_format
278+
279+
# or default to hard-coded format
212280
if not tformat:
213281
tformat = '%Y-%m-%d - %T %z'
214282

215-
if not time:
216-
time = datetime.datetime.now(tz=utc)
217-
elif not time.tzinfo:
218-
time = utc.localize(time)
219-
220-
if not zone:
221-
zone = utc
222-
else:
223-
zone = pytz.timezone(zone)
224-
225-
return time.astimezone(zone).strftime(tformat)
283+
# format local time with format
284+
return time.astimezone(target_tz).strftime(tformat)
226285

227286

228-
def seconds_to_split(seconds):
287+
def seconds_to_split(seconds: int) -> Duration:
229288
"""Split an amount of ``seconds`` into years, months, days, etc.
230289
231-
:param int seconds: amount of time in seconds
232-
:return: the time split into a tuple of years, months, days, hours,
290+
:param seconds: amount of time in seconds
291+
:return: the time split into a named tuple of years, months, days, hours,
233292
minutes, and seconds
234-
:rtype: :class:`tuple`
235293
236294
Examples::
237295
238296
>>> seconds_to_split(7800)
239-
(0, 0, 0, 2, 10, 0)
297+
Duration(years=0, months=0, days=0, hours=2, minutes=10, seconds=0)
240298
>>> seconds_to_split(143659)
241-
(0, 0, 1, 15, 54, 19)
299+
Duration(years=0, months=0, days=1, hours=15, minutes=54, seconds=19)
242300
243301
.. versionadded:: 7.1
302+
303+
.. versionchanged:: 8.0
304+
305+
This function returns a :class:`Duration` named tuple.
306+
244307
"""
245308
years, seconds_left = divmod(int(seconds), YEARS)
246309
months, seconds_left = divmod(seconds_left, MONTHS)
247310
days, seconds_left = divmod(seconds_left, DAYS)
248311
hours, seconds_left = divmod(seconds_left, HOURS)
249312
minutes, seconds_left = divmod(seconds_left, MINUTES)
250313

251-
return years, months, days, hours, minutes, seconds_left
252-
253-
254-
def get_time_unit(years=0, months=0, days=0, hours=0, minutes=0, seconds=0):
314+
return Duration(years, months, days, hours, minutes, seconds_left)
315+
316+
317+
def get_time_unit(
318+
years: int = 0,
319+
months: int = 0,
320+
days: int = 0,
321+
hours: int = 0,
322+
minutes: int = 0,
323+
seconds: int = 0,
324+
) -> Tuple[
325+
Tuple[int, str],
326+
Tuple[int, str],
327+
Tuple[int, str],
328+
Tuple[int, str],
329+
Tuple[int, str],
330+
Tuple[int, str],
331+
]:
255332
"""Map a time in (y, m, d, h, min, s) to its labels.
256333
257-
:param int years: number of years
258-
:param int months: number of months
259-
:param int days: number of days
260-
:param int hours: number of hours
261-
:param int minutes: number of minutes
262-
:param int seconds: number of seconds
334+
:param years: number of years
335+
:param months: number of months
336+
:param days: number of days
337+
:param hours: number of hours
338+
:param minutes: number of minutes
339+
:param seconds: number of seconds
263340
:return: a tuple of 2-value tuples, each for a time amount and its label
264-
:rtype: :class:`tuple`
265341
266342
This helper function takes a time split into years, months, days, hours,
267343
minutes, and seconds to return a tuple with the correct label for each
@@ -308,12 +384,14 @@ def get_time_unit(years=0, months=0, days=0, hours=0, minutes=0, seconds=0):
308384
)
309385

310386

311-
def seconds_to_human(secs, granularity=2):
387+
def seconds_to_human(
388+
secs: Union[datetime.timedelta, float, int],
389+
granularity: int = 2,
390+
) -> str:
312391
"""Format :class:`~datetime.timedelta` as a human-readable relative time.
313392
314393
:param secs: time difference to format
315-
:type secs: :class:`~datetime.timedelta` or integer
316-
:param int granularity: number of time units to return (default to 2)
394+
:param granularity: number of time units to return (default to 2)
317395
318396
Inspiration for function structure from:
319397
https://gist.github.com/Highstaker/280a09591df4a5fb1363b0bbaf858f0d

0 commit comments

Comments
 (0)