Adding more tests, working on the test harness.
authorScott Gasch <[email protected]>
Fri, 10 Sep 2021 20:23:31 +0000 (13:23 -0700)
committerScott Gasch <[email protected]>
Fri, 10 Sep 2021 20:23:31 +0000 (13:23 -0700)
conversion_utils.py
dateparse/dateparse_utils.py
datetime_utils.py
executors.py
text_utils.py
type/centcount.py
unittest_utils.py

index b83d62e4daf50ec76850e047672069a8e9f47c7b..d2225fdb2b2f37b78320ee03b07a27be6ade45ac 100644 (file)
@@ -7,11 +7,27 @@ import constants
 
 
 class Converter(object):
+    """A converter has a canonical name and a category.  The name defines
+    a unit of measurement in a category or class of measurements.
+    This framework will allow conversion between named units in the
+    same category.  e.g. name may be "meter", "inch", "mile" and their
+    category may be "length".
+
+    The way that conversions work is that we convert the magnitude
+    first to a canonical unit and then (if needed) from the canonical
+    unit to the desired destination unit.  e.g. if "meter" is the
+    canonical unit of measurement for the category "length", in order
+    to convert miles into inches we first convert miles into meters
+    and, from there, meters into inches.  This is potentially
+    dangerous because it requires two floating point operations which
+    each have the potential to overflow, underflow, or introduce
+    floating point errors.  Caveat emptor.
+    """
     def __init__(self,
                  name: str,
                  category: str,
-                 to_canonical: Callable,
-                 from_canonical: Callable,
+                 to_canonical: Callable,   # convert to canonical unit
+                 from_canonical: Callable, # convert from canonical unit
                  unit: str) -> None:
         self.name = name
         self.category = category
@@ -29,6 +45,7 @@ class Converter(object):
         return self.unit
 
 
+# A catalog of converters.
 conversion_catalog = {
     "Second": Converter("Second",
                         "time",
@@ -94,90 +111,225 @@ def _convert(magnitude: Number,
 
 
 def sec_to_min(s: float) -> float:
+    """
+    Convert seconds into minutes.
+
+    >>> sec_to_min(120)
+    2.0
+    """
     return convert(s, "Second", "Minute")
 
 
 def sec_to_hour(s: float) -> float:
+    """
+    Convert seconds into hours.
+
+    >>> sec_to_hour(1800)
+    0.5
+    """
     return convert(s, "Second", "Hour")
 
 
 def sec_to_day(s: float) -> float:
+    """
+    Convert seconds into days.
+
+    >>> sec_to_day(1800)
+    0.020833333333333332
+    """
     return convert(s, "Second", "Day")
 
 
 def sec_to_week(s: float) -> float:
+    """
+    Convert seconds into weeks.
+
+    >>> sec_to_week(1800)
+    0.002976190476190476
+    """
     return convert(s, "Second", "Week")
 
 
 def min_to_sec(m: float) -> float:
+    """
+    Convert minutes into seconds.
+
+    >>> min_to_sec(5)
+    300.0
+    """
     return convert(m, "Minute", "Second")
 
 
 def min_to_hour(m: float) -> float:
+    """
+    Convert minutes into hours.
+
+    >>> min_to_hour(120)
+    2.0
+    """
     return convert(m, "Minute", "Hour")
 
 
 def min_to_day(m: float) -> float:
+    """
+    Convert minutes into days.
+
+    >>> min_to_day(60 * 12)
+    0.5
+    """
     return convert(m, "Minute", "Day")
 
 
 def min_to_week(m: float) -> float:
+    """
+    Convert minutes into weeks.
+
+    >>> min_to_week(60 * 24 * 3)
+    0.42857142857142855
+    """
     return convert(m, "Minute", "Week")
 
 
 def hour_to_sec(h: float) -> float:
+    """
+    Convert hours into seconds.
+
+    >>> hour_to_sec(1)
+    3600.0
+    """
     return convert(h, "Hour", "Second")
 
 
 def hour_to_min(h: float) -> float:
+    """
+    Convert hours into minutes.
+
+    >>> hour_to_min(2)
+    120.0
+    """
     return convert(h, "Hour", "Minute")
 
 
 def hour_to_day(h: float) -> float:
+    """
+    Convert hours into days.
+
+    >>> hour_to_day(36)
+    1.5
+    """
     return convert(h, "Hour", "Day")
 
 
 def hour_to_week(h: float) -> float:
+    """
+    Convert hours into weeks.
+
+    >>> hour_to_week(24)
+    0.14285714285714285
+    """
     return convert(h, "Hour", "Week")
 
 
 def day_to_sec(d: float) -> float:
+    """
+    Convert days into seconds.
+
+    >>> day_to_sec(1)
+    86400.0
+    """
     return convert(d, "Day", "Second")
 
 
 def day_to_min(d: float) -> float:
+    """
+    Convert days into minutes.
+
+    >>> day_to_min(0.1)
+    144.0
+    """
     return convert(d, "Day", "Minute")
 
 
 def day_to_hour(d: float) -> float:
+    """
+    Convert days into hours.
+
+    >>> day_to_hour(1)
+    24.0
+    """
     return convert(d, "Day", "Hour")
 
 
 def day_to_week(d: float) -> float:
+    """
+    Convert days into weeks.
+
+    >>> day_to_week(14)
+    2.0
+    """
     return convert(d, "Day", "Week")
 
 
 def week_to_sec(w: float) -> float:
+    """
+    Convert weeks into seconds.
+
+    >>> week_to_sec(10)
+    6048000.0
+    """
     return convert(w, "Week", "Second")
 
 
 def week_to_min(w: float) -> float:
+    """
+    Convert weeks into minutes.
+
+    >>> week_to_min(1)
+    10080.0
+    """
     return convert(w, "Week", "Minute")
 
 
 def week_to_hour(w: float) -> float:
+    """
+    Convert weeks into hours.
+
+    >>> week_to_hour(1)
+    168.0
+    """
     return convert(w, "Week", "Hour")
 
 
 def week_to_day(w: float) -> float:
+    """
+    Convert weeks into days.
+
+    >>> week_to_day(1)
+    7.0
+    """
     return convert(w, "Week", "Day")
 
 
 def f_to_c(temp_f: float) -> float:
-    """Fahrenheit to Celsius."""
+    """
+    Convert Fahrenheit into Celsius.
+
+    >>> f_to_c(32.0)
+    0.0
+    """
     return convert(temp_f, "Fahrenheit", "Celsius")
 
 
 def c_to_f(temp_c: float) -> float:
-    """Celsius to Fahrenheit."""
+    """
+    Convert Celsius to Fahrenheit.
+
+    >>> c_to_f(0.0)
+    32.0
+    """
     return convert(temp_c, "Celsius", "Fahrenheit")
+
+
+if __name__ == '__main__':
+    import doctest
+    doctest.testmod()
index e5e7e76b2a7016cc2142beebbdda3694e879a396..f354ad09ccbefc1f3227ab7e011e7a634b37336b 100755 (executable)
@@ -271,9 +271,9 @@ class DateParser(dateparse_utilsListener):
             return TimeUnit.MONTHS
         txt = orig.lower()[:3]
         if txt in self.day_name_to_number:
-            return(self.day_name_to_number[txt])
+            return(TimeUnit(self.day_name_to_number[txt]))
         elif txt in self.delta_unit_to_constant:
-            return(self.delta_unit_to_constant[txt])
+            return(TimeUnit(self.delta_unit_to_constant[txt]))
         raise ParseException(f'Invalid date unit: {orig}')
 
     def _figure_out_time_unit(self, orig: str) -> int:
@@ -509,7 +509,7 @@ class DateParser(dateparse_utilsListener):
         unit = self.context['delta_unit']
         dt = n_timeunits_from_base(
             count,
-            unit,
+            TimeUnit(unit),
             date_to_datetime(self.date)
         )
         self.date = datetime_to_date(dt)
@@ -890,7 +890,7 @@ class DateParser(dateparse_utilsListener):
         unit = self._figure_out_date_unit(unit)
         d = n_timeunits_from_base(
             count,
-            unit,
+            TimeUnit(unit),
             d)
         self.context['year'] = d.year
         self.context['month'] = d.month
@@ -920,7 +920,7 @@ class DateParser(dateparse_utilsListener):
         unit = self._figure_out_date_unit(unit)
         d = n_timeunits_from_base(
             count,
-            unit,
+            TimeUnit(unit),
             d)
         self.context['year'] = d.year
         self.context['month'] = d.month
index 7787c6f0b0b74b84b84fca28bba8d2381e1d8668..db5b2b5e19ba8e74fdf192dc122e518e90ec6bf9 100644 (file)
@@ -18,18 +18,44 @@ logger = logging.getLogger(__name__)
 
 def replace_timezone(dt: datetime.datetime,
                      tz: datetime.tzinfo) -> datetime.datetime:
+    """
+    Replaces the timezone on a datetime object.
+
+    >>> from pytz import UTC
+    >>> d = now_pacific()
+    >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
+    'P'
+    >>> o = replace_timezone(d, UTC)
+    >>> o.tzinfo.tzname(o)
+    'UTC'
+
+    """
     return dt.replace(tzinfo=None).astimezone(tz=tz)
 
 
 def now() -> datetime.datetime:
+    """
+    What time is it?  Result returned in UTC
+    """
     return datetime.datetime.now()
 
 
-def now_pst() -> datetime.datetime:
+def now_pacific() -> datetime.datetime:
+    """
+    What time is it?  Result in US/Pacifit time (PST/PDT)
+    """
     return replace_timezone(now(), pytz.timezone("US/Pacific"))
 
 
 def date_to_datetime(date: datetime.date) -> datetime.datetime:
+    """
+    Given a date, return a datetime with hour/min/sec zero (midnight)
+
+    >>> import datetime
+    >>> date_to_datetime(datetime.date(2021, 12, 25))
+    datetime.datetime(2021, 12, 25, 0, 0)
+
+    """
     return datetime.datetime(
         date.year,
         date.month,
@@ -40,6 +66,16 @@ def date_to_datetime(date: datetime.date) -> datetime.datetime:
 
 def date_and_time_to_datetime(date: datetime.date,
                               time: datetime.time) -> datetime.datetime:
+    """
+    Given a date and time, merge them and return a datetime.
+
+    >>> import datetime
+    >>> d = datetime.date(2021, 12, 25)
+    >>> t = datetime.time(12, 30, 0, 0)
+    >>> date_and_time_to_datetime(d, t)
+    datetime.datetime(2021, 12, 25, 12, 30)
+
+    """
     return datetime.datetime(
         date.year,
         date.month,
@@ -47,26 +83,53 @@ def date_and_time_to_datetime(date: datetime.date,
         time.hour,
         time.minute,
         time.second,
-        time.millisecond
+        time.microsecond,
     )
 
 
 def datetime_to_date_and_time(
         dt: datetime.datetime
 ) -> Tuple[datetime.date, datetime.time]:
+    """Return the component date and time objects of a datetime.
+
+    >>> import datetime
+    >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
+    >>> (d, t) = datetime_to_date_and_time(dt)
+    >>> d
+    datetime.date(2021, 12, 25)
+    >>> t
+    datetime.time(12, 30)
+
+    """
     return (dt.date(), dt.timetz())
 
 
 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
+    """Return the date part of a datetime.
+
+    >>> import datetime
+    >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
+    >>> datetime_to_date(dt)
+    datetime.date(2021, 12, 25)
+
+    """
     return datetime_to_date_and_time(dt)[0]
 
 
 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
+    """Return the time part of a datetime.
+
+    >>> import datetime
+    >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
+    >>> datetime_to_time(dt)
+    datetime.time(12, 30)
+
+    """
     return datetime_to_date_and_time(dt)[1]
 
 
-# An enum to represent units with which we can compute deltas.
 class TimeUnit(enum.Enum):
+    """An enum to represent units with which we can compute deltas."""
     MONDAYS = 0
     TUESDAYS = 1
     WEDNESDAYS = 2
@@ -101,6 +164,47 @@ def n_timeunits_from_base(
     unit: TimeUnit,
     base: datetime.datetime
 ) -> datetime.datetime:
+    """Return a datetime that is N units before/after a base datetime.
+    e.g.  3 Wednesdays from base datetime, 2 weeks from base date, 10
+    years before base datetime, 13 minutes after base datetime, etc...
+    Note: to indicate before/after the base date, use a positive or
+    negative count.
+
+    >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
+
+    The next (1) Monday from the base datetime:
+    >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
+    datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Ten (10) years after the base datetime:
+    >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
+    datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Fifty (50) working days (M..F, not counting holidays) after base datetime:
+    >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
+    datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Fifty (50) days (including weekends and holidays) after base datetime:
+    >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
+    datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Fifty (50) months before (note negative count) base datetime:
+    >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
+    datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Fifty (50) hours after base datetime:
+    >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
+    datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Fifty (50) minutes before base datetime:
+    >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
+    datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    Fifty (50) seconds from base datetime:
+    >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
+    datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    """
     assert TimeUnit.is_valid(unit)
     if count == 0:
         return base
@@ -110,6 +214,21 @@ def n_timeunits_from_base(
         timedelta = datetime.timedelta(days=count)
         return base + timedelta
 
+    # N hours from base
+    elif unit == TimeUnit.HOURS:
+        timedelta = datetime.timedelta(hours=count)
+        return base + timedelta
+
+    # N minutes from base
+    elif unit == TimeUnit.MINUTES:
+        timedelta = datetime.timedelta(minutes=count)
+        return base + timedelta
+
+    # N seconds from base
+    elif unit == TimeUnit.SECONDS:
+        timedelta = datetime.timedelta(seconds=count)
+        return base + timedelta
+
     # N workdays from base
     elif unit == TimeUnit.WORKDAYS:
         if count < 0:
@@ -155,6 +274,7 @@ def n_timeunits_from_base(
             base.minute,
             base.second,
             base.microsecond,
+            base.tzinfo,
         )
 
     # N years from base
@@ -168,8 +288,18 @@ def n_timeunits_from_base(
             base.minute,
             base.second,
             base.microsecond,
+            base.tzinfo,
         )
 
+    if unit not in set([TimeUnit.MONDAYS,
+                        TimeUnit.TUESDAYS,
+                        TimeUnit.WEDNESDAYS,
+                        TimeUnit.THURSDAYS,
+                        TimeUnit.FRIDAYS,
+                        TimeUnit.SATURDAYS,
+                        TimeUnit.SUNDAYS]):
+        raise ValueError(unit)
+
     # N weekdays from base (e.g. 4 wednesdays from today)
     direction = 1 if count > 0 else -1
     count = abs(count)
@@ -177,7 +307,7 @@ def n_timeunits_from_base(
     start = base
     while True:
         dow = base.weekday()
-        if dow == unit and start != base:
+        if dow == unit.value and start != base:
             count -= 1
             if count == 0:
                 return base
@@ -194,14 +324,31 @@ def get_format_string(
         include_fractional=False,
         twelve_hour=True,
 ) -> str:
+    """
+    Helper to return a format string without looking up the documentation
+    for strftime.
+
+    >>> get_format_string()
+    '%Y/%m/%d %I:%M:%S%p%z'
+
+    >>> get_format_string(date_time_separator='@')
+    '%Y/%m/%d@%I:%M:%S%p%z'
+
+    >>> get_format_string(include_dayname=True)
+    '%a/%Y/%m/%d %I:%M:%S%p%z'
+
+    >>> get_format_string(include_dayname=True, twelve_hour=False)
+    '%a/%Y/%m/%d %H:%M:%S%z'
+
+    """
     fstring = ""
     if include_dayname:
         fstring += "%a/"
 
     if use_month_abbrevs:
-        fstring = f"%Y/%b/%d{date_time_separator}"
+        fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
     else:
-        fstring = f"%Y/%m/%d{date_time_separator}"
+        fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
     if twelve_hour:
         fstring += "%I:%M"
         if include_seconds:
@@ -229,7 +376,21 @@ def datetime_to_string(
     include_fractional=False,
     twelve_hour=True,
 ) -> str:
-    """A nice way to convert a datetime into a string."""
+    """
+    A nice way to convert a datetime into a string; arguably better than
+    just printing it and relying on it __repr__().
+
+    >>> d = string_to_datetime(
+    ...                        "2021/09/10 11:24:51AM-0700",
+    ...                       )[0]
+    >>> d
+    datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+    >>> datetime_to_string(d)
+    '2021/09/10 11:24:51AM-0700'
+    >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
+    'Fri/2021/09/10 11:24AM-0700'
+
+    """
     fstring = get_format_string(
         date_time_separator=date_time_separator,
         include_timezone=include_timezone,
@@ -251,8 +412,16 @@ def string_to_datetime(
         include_fractional=False,
         twelve_hour=True,
 ) -> Tuple[datetime.datetime, str]:
-    """A nice way to convert a string into a datetime.  Also consider
-    dateparse.dateparse_utils for a full parser.
+    """A nice way to convert a string into a datetime.  Returns both the
+    datetime and the format string used to parse it.  Also consider
+    dateparse.dateparse_utils for a full parser alternative.
+
+    >>> d = string_to_datetime(
+    ...                        "2021/09/10 11:24:51AM-0700",
+    ...                       )
+    >>> d
+    (datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))), '%Y/%m/%d %I:%M:%S%p%z')
+
     """
     fstring = get_format_string(
         date_time_separator=date_time_separator,
@@ -268,7 +437,7 @@ def string_to_datetime(
 
 
 def timestamp() -> str:
-    """Return a timestamp for now in Pacific timezone."""
+    """Return a timestamp for right now in Pacific timezone."""
     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
     return datetime_to_string(ts, include_timezone=True)
 
@@ -281,7 +450,25 @@ def time_to_string(
     include_timezone=False,
     twelve_hour=True,
 ) -> str:
-    """A nice way to convert a datetime into a time (only) string."""
+    """A nice way to convert a datetime into a time (only) string.
+    This ignores the date part of the datetime.
+
+    >>> d = string_to_datetime(
+    ...                        "2021/09/10 11:24:51AM-0700",
+    ...                       )[0]
+    >>> d
+    datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
+
+    >>> time_to_string(d)
+    '11:24:51AM'
+
+    >>> time_to_string(d, include_seconds=False)
+    '11:24AM'
+
+    >>> time_to_string(d, include_seconds=False, include_timezone=True)
+    '11:24AM-0700'
+
+    """
     fstring = ""
     if twelve_hour:
         fstring += "%l:%M"
@@ -308,18 +495,62 @@ MinuteOfDay = NewType("MinuteOfDay", int)
 
 
 def minute_number(hour: int, minute: int) -> MinuteOfDay:
-    """Convert hour:minute into minute number from start of day."""
+    """
+    Convert hour:minute into minute number from start of day.
+
+    >>> minute_number(0, 0)
+    0
+
+    >>> minute_number(9, 15)
+    555
+
+    >>> minute_number(23, 59)
+    1439
+
+    """
     return MinuteOfDay(hour * 60 + minute)
 
 
 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
-    """Convert a datetime into a minute number (of the day)"""
+    """
+    Convert a datetime into a minute number (of the day).  Note that
+    this ignores the date part of the datetime and only uses the time
+    part.
+
+    >>> d = string_to_datetime(
+    ...                        "2021/09/10 11:24:51AM-0700",
+    ...                       )[0]
+
+    >>> datetime_to_minute_number(d)
+    684
+
+    """
     return minute_number(dt.hour, dt.minute)
 
 
+def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
+    """
+    Convert a datetime.time into a minute number.
+
+    >>> t = datetime.time(5, 15)
+    >>> time_to_minute_number(t)
+    315
+
+    """
+    return minute_number(t.hour, t.minute)
+
+
 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
-    """Convert minute number from start of day into hour:minute am/pm
+    """
+    Convert minute number from start of day into hour:minute am/pm
     string.
+
+    >>> minute_number_to_time_string(315)
+    ' 5:15a'
+
+    >>> minute_number_to_time_string(684)
+    '11:24a'
+
     """
     hour = minute_num // 60
     minute = minute_num % 60
@@ -335,7 +566,22 @@ def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
 
 
 def parse_duration(duration: str) -> int:
-    """Parse a duration in string form."""
+    """
+    Parse a duration in string form into a delta seconds.
+
+    >>> parse_duration('15 days, 2 hours')
+    1303200
+
+    >>> parse_duration('15d 2h')
+    1303200
+
+    >>> parse_duration('100s')
+    100
+
+    >>> parse_duration('3min 2sec')
+    182
+
+    """
     if duration.isdigit():
         return int(duration)
     seconds = 0
@@ -354,9 +600,24 @@ def parse_duration(duration: str) -> int:
     return seconds
 
 
-def describe_duration(age: int) -> str:
-    """Describe a duration."""
-    days = divmod(age, constants.SECONDS_PER_DAY)
+def describe_duration(seconds: int, *, include_seconds = False) -> str:
+    """
+    Describe a duration represented as a count of seconds nicely.
+
+    >>> describe_duration(182)
+    '3 minutes'
+
+    >>> describe_duration(182, include_seconds=True)
+    '3 minutes, and 2 seconds'
+
+    >>> describe_duration(100, include_seconds=True)
+    '1 minute, and 40 seconds'
+
+    describe_duration(1303200)
+    '15 days, 2 hours'
+
+    """
+    days = divmod(seconds, constants.SECONDS_PER_DAY)
     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
 
@@ -365,30 +626,65 @@ def describe_duration(age: int) -> str:
         descr = f"{int(days[0])} days, "
     elif days[0] == 1:
         descr = "1 day, "
+
     if hours[0] > 1:
         descr = descr + f"{int(hours[0])} hours, "
     elif hours[0] == 1:
         descr = descr + "1 hour, "
-    if len(descr) > 0:
+
+    if not include_seconds and len(descr) > 0:
         descr = descr + "and "
+
     if minutes[0] == 1:
         descr = descr + "1 minute"
     else:
         descr = descr + f"{int(minutes[0])} minutes"
+
+    if include_seconds:
+        descr = descr + ', '
+        if len(descr) > 0:
+            descr = descr + 'and '
+        s = minutes[1]
+        if s == 1:
+            descr = descr + '1 second'
+        else:
+            descr = descr + f'{s} seconds'
     return descr
 
 
-def describe_duration_briefly(age: int) -> str:
-    """Describe a duration briefly."""
-    days = divmod(age, constants.SECONDS_PER_DAY)
+def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
+    """
+    Describe a duration briefly.
+
+    >>> describe_duration_briefly(182)
+    '3m'
+
+    >>> describe_duration_briefly(182, include_seconds=True)
+    '3m 2s'
+
+    >>> describe_duration_briefly(100, include_seconds=True)
+    '1m 40s'
+
+    describe_duration_briefly(1303200)
+    '15d 2h'
+
+    """
+    days = divmod(seconds, constants.SECONDS_PER_DAY)
     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
 
-    descr = ""
+    descr = ''
     if days[0] > 0:
-        descr = f"{int(days[0])}d "
+        descr = f'{int(days[0])}d '
     if hours[0] > 0:
-        descr = descr + f"{int(hours[0])}h "
-    if minutes[0] > 0 or len(descr) == 0:
-        descr = descr + f"{int(minutes[0])}m"
+        descr = descr + f'{int(hours[0])}h '
+    if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
+        descr = descr + f'{int(minutes[0])}m '
+    if minutes[1] > 0 and include_seconds:
+        descr = descr + f'{int(minutes[1])}s'
     return descr.strip()
+
+
+if __name__ == '__main__':
+    import doctest
+    doctest.testmod()
index e074c306f08fe676e8a64aa695cdbb92fc75c29e..b243edd29b02cfa4b4689e515f3901e4bef2fd5c 100644 (file)
@@ -64,7 +64,7 @@ SSH = 'ssh -oForwardX11=no'
 
 
 def make_cloud_pickle(fun, *args, **kwargs):
-    logger.info(f"Making cloudpickled bundle at {fun.__name__}")
+    logger.debug(f"Making cloudpickled bundle at {fun.__name__}")
     return cloudpickle.dumps((fun, args, kwargs))
 
 
index 8ea6e196001e795daec223e166e01d9aed33a009..49ff9b979e2db0e26adfd6dc7b6f0e4663e62289 100644 (file)
@@ -268,6 +268,28 @@ class Indenter:
         print(self.pad_prefix + self.padding * self.level + text, end='')
 
 
+def header(title: str, *, width: int = 80, color: str = ''):
+    """
+    Returns a nice header line with a title.
+
+    >>> header('title', width=60, color='')
+    '----[ title ]-----------------------------------------------'
+
+    """
+    w = width
+    w -= (len(title) + 4)
+    if w >= 4:
+        left = 4 * '-'
+        right = (w - 4) * '-'
+        if color != '' and color is not None:
+            r = reset()
+        else:
+            r = ''
+        return f'{left}[ {color}{title}{r} ]{right}'
+    else:
+        return ''
+
+
 if __name__ == '__main__':
     import doctest
     doctest.testmod()
index ce18975167573a236cf2a9ee3f04604333d9c82d..4e5b8a6aa6b4b8ef61671b93b0284825cdf75cbc 100644 (file)
@@ -11,7 +11,8 @@ T = TypeVar('T', bound='CentCount')
 
 class CentCount(object):
     """A class for representing monetary amounts potentially with
-    different currencies.
+    different currencies meant to avoid floating point rounding
+    issues by treating amount as a simple integral count of cents.
     """
 
     def __init__ (
index 2dc8cfe231e5aad03d877fac8116472e0c4b6c3d..8a0556bb52bd8a0e6a135c3923bc0749bdb167d8 100644 (file)
@@ -148,15 +148,19 @@ def check_all_methods_for_perf_regressions(prefix='test_'):
 
 
 def breakpoint():
+    """Hard code a breakpoint somewhere; drop into pdb."""
     import pdb
     pdb.set_trace()
 
 
 class RecordStdout(object):
     """
-        with uu.RecordStdout() as record:
-            print("This is a test!")
-        print({record().readline()})
+    Record what is emitted to stdout.
+
+    >>> with RecordStdout() as record:
+    ...     print("This is a test!")
+    >>> print({record().readline()})
+    {'This is a test!\\n'}
     """
 
     def __init__(self) -> None:
@@ -176,9 +180,13 @@ class RecordStdout(object):
 
 class RecordStderr(object):
     """
-        with uu.RecordStderr() as record:
-            print("This is a test!", file=sys.stderr)
-        print({record().readline()})
+    Record what is emitted to stderr.
+
+    >>> import sys
+    >>> with RecordStderr() as record:
+    ...     print("This is a test!", file=sys.stderr)
+    >>> print({record().readline()})
+    {'This is a test!\\n'}
     """
 
     def __init__(self) -> None:
@@ -198,12 +206,9 @@ class RecordStderr(object):
 
 class RecordMultipleStreams(object):
     """
-        with uu.RecordStreams(sys.stderr, sys.stdout) as record:
-            print("This is a test!")
-            print("This is one too.", file=sys.stderr)
-
-        print(record().readlines())
+    Record the output to more than one stream.
     """
+
     def __init__(self, *files) -> None:
         self.files = [*files]
         self.destination = tempfile.SpooledTemporaryFile(mode='r+')
@@ -219,3 +224,8 @@ class RecordMultipleStreams(object):
         for f in self.files:
             f.write = self.saved_writes.pop()
         self.destination.seek(0)
+
+
+if __name__ == '__main__':
+    import doctest
+    doctest.testmod()