Module Gnumed.pycommon.gmDateTime

GNUmed date/time handling.

This modules provides access to date/time handling and offers an fuzzy timestamp implementation

It utilizes

    - Python time
    - Python datetime

Note that if you want locale-aware formatting you need to call

    locale.setlocale(locale.LC_ALL, '')

somewhere before importing this script.

Note Regarding Utc Offsets

Looking from Greenwich: WEST (IOW "behind"): negative values EAST (IOW "ahead"): positive values

This is in compliance with what datetime.tzinfo.utcoffset() does but NOT what time.altzone/time.timezone do !

This module also implements a class which allows the programmer to define the degree of fuzziness, uncertainty or imprecision of the timestamp contained within.

This is useful in fields such as medicine where only partial timestamps may be known for certain events.

Other useful links:

    <http://joda-time.sourceforge.net/key_instant.html>
Expand source code
# -*- coding: utf-8 -*-

"""GNUmed date/time handling.

This modules provides access to date/time handling
and offers an fuzzy timestamp implementation

It utilizes

        - Python time
        - Python datetime

Note that if you want locale-aware formatting you need to call

        locale.setlocale(locale.LC_ALL, '')

somewhere before importing this script.

Note regarding UTC offsets
--------------------------

Looking from Greenwich:
        WEST (IOW "behind"): negative values
        EAST (IOW "ahead"):  positive values

This is in compliance with what datetime.tzinfo.utcoffset()
does but NOT what time.altzone/time.timezone do !

This module also implements a class which allows the
programmer to define the degree of fuzziness, uncertainty
or imprecision of the timestamp contained within.

This is useful in fields such as medicine where only partial
timestamps may be known for certain events.

Other useful links:

        http://joda-time.sourceforge.net/key_instant.html
"""
#===========================================================================
__author__ = "K. Hilbert <Karsten.Hilbert@gmx.net>"
__license__ = "GPL v2 or later (details at https://www.gnu.org)"

# stdlib
import sys, datetime as pyDT, time, os, re as regex, logging
from typing import Callable


if __name__ == '__main__':
        sys.path.insert(0, '../../')
        _ = lambda x:x


_log = logging.getLogger('gm.datetime')

dst_locally_in_use = None
dst_currently_in_effect = None

py_timezone_name = None
py_dst_timezone_name = None
current_local_utc_offset_in_seconds = None
current_local_iso_numeric_timezone_string = None
current_local_timezone_name = None

gmCurrentLocalTimezone = None

(       acc_years,
        acc_months,
        acc_weeks,
        acc_days,
        acc_hours,
        acc_minutes,
        acc_seconds,
        acc_subseconds
) = range(1,9)

_accuracy_strings = {
        1: 'years',
        2: 'months',
        3: 'weeks',
        4: 'days',
        5: 'hours',
        6: 'minutes',
        7: 'seconds',
        8: 'subseconds'
}

gregorian_month_length = {
        1: 31,
        2: 28,          # FIXME: make leap year aware
        3: 31,
        4: 30,
        5: 31,
        6: 30,
        7: 31,
        8: 31,
        9: 30,
        10: 31,
        11: 30,
        12: 31
}

avg_days_per_gregorian_year = 365
avg_days_per_gregorian_month = 30
avg_seconds_per_day = 24 * 60 * 60
days_per_week = 7

#===========================================================================
# module init
#---------------------------------------------------------------------------
def init():
        """Initialize date/time handling and log date/time environment."""

        _log.debug('datetime.now()   : [%s]' % pyDT.datetime.now())
        _log.debug('time.localtime() : [%s]' % str(time.localtime()))
        _log.debug('time.gmtime()    : [%s]' % str(time.gmtime()))

        try:
                _log.debug('$TZ: [%s]' % os.environ['TZ'])
        except KeyError:
                _log.debug('$TZ not defined')

        _log.debug('time.daylight           : [%s] (whether or not DST is locally used at all)', time.daylight)
        _log.debug('time.timezone           : [%s] seconds (+/-: WEST/EAST of Greenwich)', time.timezone)
        _log.debug('time.altzone            : [%s] seconds (+/-: WEST/EAST of Greenwich)', time.altzone)
        _log.debug('time.tzname             : [%s / %s] (non-DST / DST)' % time.tzname)
        _log.debug('time.localtime.tm_zone  : [%s]', time.localtime().tm_zone)
        _log.debug('time.localtime.tm_gmtoff: [%s]', time.localtime().tm_gmtoff)

        global py_timezone_name
        py_timezone_name = time.tzname[0]

        global py_dst_timezone_name
        py_dst_timezone_name = time.tzname[1]

        global dst_locally_in_use
        dst_locally_in_use = (time.daylight != 0)

        global dst_currently_in_effect
        dst_currently_in_effect = bool(time.localtime()[8])
        _log.debug('DST currently in effect: [%s]' % dst_currently_in_effect)

        if (not dst_locally_in_use) and dst_currently_in_effect:
                _log.error('system inconsistency: DST not in use - but DST currently in effect ?')

        global current_local_utc_offset_in_seconds
        msg = 'DST currently%sin effect: using UTC offset of [%s] seconds instead of [%s] seconds'
        if dst_currently_in_effect:
                current_local_utc_offset_in_seconds = time.altzone * -1
                _log.debug(msg % (' ', time.altzone * -1, time.timezone * -1))
        else:
                current_local_utc_offset_in_seconds = time.timezone * -1
                _log.debug(msg % (' not ', time.timezone * -1, time.altzone * -1))

        if current_local_utc_offset_in_seconds < 0:
                _log.debug('UTC offset is negative, assuming WEST of Greenwich (clock is "behind")')
        elif current_local_utc_offset_in_seconds > 0:
                _log.debug('UTC offset is positive, assuming EAST of Greenwich (clock is "ahead")')
        else:
                _log.debug('UTC offset is ZERO, assuming Greenwich Time')

        global current_local_iso_numeric_timezone_string
        current_local_iso_numeric_timezone_string = '%s' % current_local_utc_offset_in_seconds
        _log.debug('ISO numeric timezone string: [%s]' % current_local_iso_numeric_timezone_string)

        global current_local_timezone_name
        try:
                current_local_timezone_name = os.environ['TZ']
        except KeyError:
                if dst_currently_in_effect:
                        current_local_timezone_name = time.tzname[1]
                else:
                        current_local_timezone_name = time.tzname[0]

        global gmCurrentLocalTimezone
        gmCurrentLocalTimezone = cPlatformLocalTimezone()
        _log.debug('local-timezone class: %s', cPlatformLocalTimezone)
        _log.debug('local-timezone instance: %s', gmCurrentLocalTimezone)
#       _log.debug('')
#               print (" (total) UTC offset:", gmCurrentLocalTimezone.utcoffset(pyDT.datetime.now()))
#               print (" DST adjustment:", gmCurrentLocalTimezone.dst(pyDT.datetime.now()))
#               print (" timezone name:", gmCurrentLocalTimezone.tzname(pyDT.datetime.now()))

#===========================================================================
class cPlatformLocalTimezone(pyDT.tzinfo):
        """Local timezone implementation (lifted from the docs).

        A class capturing the platform's idea of local time.

        May result in wrong values on historical times in
        timezones where UTC offset and/or the DST rules had
        changed in the past."""
        def __init__(self):
                self._SECOND = pyDT.timedelta(seconds = 1)
                self._nonDST_OFFSET_FROM_UTC = pyDT.timedelta(seconds = -time.timezone)
                if time.daylight:
                        self._DST_OFFSET_FROM_UTC = pyDT.timedelta(seconds = -time.altzone)
                else:
                        self._DST_OFFSET_FROM_UTC = self._nonDST_OFFSET_FROM_UTC
                self._DST_SHIFT = self._DST_OFFSET_FROM_UTC - self._nonDST_OFFSET_FROM_UTC
                _log.debug('[%s]: UTC->non-DST offset [%s], UTC->DST offset [%s], DST shift [%s]', self.__class__.__name__, self._nonDST_OFFSET_FROM_UTC, self._DST_OFFSET_FROM_UTC, self._DST_SHIFT)

        #-----------------------------------------------------------------------
        def fromutc(self, dt):
                assert dt.tzinfo is self
                stamp = (dt - pyDT.datetime(1970, 1, 1, tzinfo = self)) // self._SECOND
                args = time.localtime(stamp)[:6]
                dst_diff = self._DST_SHIFT // self._SECOND
                # Detect fold
                fold = (args == time.localtime(stamp - dst_diff))
                return pyDT.datetime(*args, microsecond = dt.microsecond, tzinfo = self, fold = fold)

        #-----------------------------------------------------------------------
        def utcoffset(self, dt):
                if self._isdst(dt):
                        return self._DST_OFFSET_FROM_UTC
                return self._nonDST_OFFSET_FROM_UTC

        #-----------------------------------------------------------------------
        def dst(self, dt):
                if self._isdst(dt):
                        return self._DST_SHIFT
                return pyDT.timedelta(0)

        #-----------------------------------------------------------------------
        def tzname(self, dt):
                return time.tzname[self._isdst(dt)]

        #-----------------------------------------------------------------------
        def _isdst(self, dt):
                tt = (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.weekday(), 0, 0)
                try:
                        stamp = time.mktime(tt)
                except (OverflowError, ValueError):
                        _log.exception('overflow in time.mktime(%s)', tt)
                        return False

                tt = time.localtime(stamp)
                return tt.tm_isdst > 0

#===========================================================================
# convenience functions
#---------------------------------------------------------------------------
def get_next_month(dt:pyDT.datetime):
        next_month = dt.month + 1
        if next_month == 13:
                return 1
        return next_month

#---------------------------------------------------------------------------
def get_last_month(dt:pyDT.datetime):
        last_month = dt.month - 1
        if last_month == 0:
                return 12
        return last_month

#---------------------------------------------------------------------------
def get_date_of_weekday_in_week_of_date(weekday, base_dt:pyDT.datetime=None) -> pyDT.datetime:
        # weekday:
        # 0 = Sunday
        # 1 = Monday ...
        assert weekday in [0,1,2,3,4,5,6,7], 'weekday must be in 0 (Sunday) to 7 (Sunday, again)'

        if base_dt is None:
                base_dt = pydt_now_here()
        dt_weekday = base_dt.isoweekday()               # 1 = Mon
        day_diff = dt_weekday - weekday
        days2add = (-1 * day_diff)
        return pydt_add(base_dt, days = days2add)

#---------------------------------------------------------------------------
def get_date_of_weekday_following_date(weekday, base_dt:pyDT.datetime=None):
        # weekday:
        # 0 = Sunday            # will be wrapped to 7
        # 1 = Monday ...
        if weekday not in [0,1,2,3,4,5,6,7]:
                raise ValueError('weekday must be in 0 (Sunday) to 7 (Sunday, again)')
        if weekday == 0:
                weekday = 7
        if base_dt is None:
                base_dt = pydt_now_here()
        dt_weekday = base_dt.isoweekday()               # 1 = Mon
        days2add = weekday - dt_weekday
        if days2add == 0:
                days2add = 7
        elif days2add < 0:
                days2add += 7
        return pydt_add(base_dt, days = days2add)

#---------------------------------------------------------------------------
def format_dob(dob:pyDT.datetime, format='%Y %b %d', none_string=None, dob_is_estimated=False):
        if dob is None:
                if none_string is None:
                        return _('** DOB unknown **')
                return none_string

        dob_txt = pydt_strftime(dob, format = format, accuracy = acc_days)
        if dob_is_estimated:
                return '%s%s' % ('\u2248', dob_txt)

        return dob_txt

#---------------------------------------------------------------------------
def pydt_strftime(dt=None, format='%Y %b %d  %H:%M.%S', accuracy=None, none_str=None):

        if dt is None:
                if none_str is not None:
                        return none_str
                raise ValueError('must provide <none_str> if <dt>=None is to be dealt with')

        try:
                return dt.strftime(format)

        except ValueError:
                _log.exception()
                return 'strftime() error'

#---------------------------------------------------------------------------
def pydt_add(dt:pyDT.datetime, years=0, months=0, weeks=0, days=0, hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0):
        """Add some time to a given datetime."""
        if months > 11 or months < -11:
                raise ValueError('pydt_add(): months must be within [-11..11]')

        dt = dt + pyDT.timedelta (
                weeks = weeks,
                days = days,
                hours = hours,
                minutes = minutes,
                seconds = seconds,
                milliseconds = milliseconds,
                microseconds = microseconds
        )
        if (years == 0) and (months == 0):
                return dt

        target_year = dt.year + years
        target_month = dt.month + months
        if target_month > 12:
                target_year += 1
                target_month -= 12
        elif target_month < 1:
                target_year -= 1
                target_month += 12
        return pydt_replace(dt, year = target_year, month = target_month, strict = False)

#---------------------------------------------------------------------------
def pydt_replace(dt:pyDT.datetime, strict=True, year=None, month=None, day=None, hour=None, minute=None, second=None, microsecond=None, tzinfo=None):
        # normalization required because .replace() does not
        # deal with keyword arguments being None ...
        if year is None:
                year = dt.year
        if month is None:
                month = dt.month
        if day is None:
                day = dt.day
        if hour is None:
                hour = dt.hour
        if minute is None:
                minute = dt.minute
        if second is None:
                second = dt.second
        if microsecond is None:
                microsecond = dt.microsecond
        if tzinfo is None:
                tzinfo = dt.tzinfo              # can fail on naive dt's

        if strict:
                return dt.replace(year = year, month = month, day = day, hour = hour, minute = minute, second = second, microsecond = microsecond, tzinfo = tzinfo)

        try:
                return dt.replace(year = year, month = month, day = day, hour = hour, minute = minute, second = second, microsecond = microsecond, tzinfo = tzinfo)
        except ValueError:
                _log.debug('error replacing datetime member(s): %s', locals())

        # (target/existing) day did not exist in target month (which raised the exception)
        if month == 2:
                if day > 28:
                        if is_leap_year(year):
                                day = 29
                        else:
                                day = 28
        else:
                if day == 31:
                        day = 30

        return dt.replace(year = year, month = month, day = day, hour = hour, minute = minute, second = second, microsecond = microsecond, tzinfo = tzinfo)

#---------------------------------------------------------------------------
def pydt_is_same_day(dt1, dt2):
        if dt1.day != dt2.day:
                return False
        if dt1.month != dt2.month:
                return False
        if dt1.year != dt2.year:
                return False

        return True

#---------------------------------------------------------------------------
def pydt_is_today(dt):
        """Check wheter <dt> is today."""
        if not dt:
                return None

        now = pyDT.datetime.now(gmCurrentLocalTimezone)
        return pydt_is_same_day(dt, now)

#---------------------------------------------------------------------------
def pydt_is_yesterday(dt):
        """Check wheter <dt> is yesterday."""
        if not dt:
                return None

        yesterday = pyDT.datetime.now(gmCurrentLocalTimezone) - pyDT.timedelta(days = 1)
        return pydt_is_same_day(dt, yesterday)

#---------------------------------------------------------------------------
def pydt_now_here():
        """Returns NOW @ HERE (IOW, in the local timezone."""
        return pyDT.datetime.now(gmCurrentLocalTimezone)

#---------------------------------------------------------------------------
def pydt_max_here():
        return pyDT.datetime.max.replace(tzinfo = gmCurrentLocalTimezone)

#===========================================================================
# wxPython conversions
#---------------------------------------------------------------------------
def wxDate2py_dt(wxDate=None):
        if not wxDate.IsValid():
                raise ValueError ('invalid wxDate: %s-%s-%s %s:%s %s.%s',
                        wxDate.GetYear(),
                        wxDate.GetMonth(),
                        wxDate.GetDay(),
                        wxDate.GetHour(),
                        wxDate.GetMinute(),
                        wxDate.GetSecond(),
                        wxDate.GetMillisecond()
                )

        try:
                return pyDT.datetime (
                        year = wxDate.GetYear(),
                        month = wxDate.GetMonth() + 1,
                        day = wxDate.GetDay(),
                        tzinfo = gmCurrentLocalTimezone
                )
        except Exception:
                _log.debug ('error converting wxDateTime to Python: %s-%s-%s %s:%s %s.%s',
                        wxDate.GetYear(),
                        wxDate.GetMonth(),
                        wxDate.GetDay(),
                        wxDate.GetHour(),
                        wxDate.GetMinute(),
                        wxDate.GetSecond(),
                        wxDate.GetMillisecond()
                )
                raise

#===========================================================================
# interval related
#---------------------------------------------------------------------------
def format_interval(interval=None, accuracy_wanted=None, none_string=None, verbose=False):

        if accuracy_wanted is None:
                accuracy_wanted = acc_seconds

        if interval is None:
                if none_string is not None:
                        return none_string

        years, days = divmod(interval.days, avg_days_per_gregorian_year)
        months, days = divmod(days, avg_days_per_gregorian_month)
        weeks, days = divmod(days, days_per_week)
        days, secs = divmod((days * avg_seconds_per_day) + interval.seconds, avg_seconds_per_day)
        hours, secs = divmod(secs, 3600)
        mins, secs = divmod(secs, 60)

        if verbose:
                years_tag = ' ' + (_('year') if years == 1 else _('years'))
                months_tag = ' ' + (_('month') if months == 1 else _('months'))
                weeks_tag = ' ' + (_('week') if weeks == 1 else _('weeks'))
                days_tag = ' ' + (_('day') if days == 1 else _('days'))
                hours_tag = ' ' + (_('hour') if hours == 1 else _('hours'))
                minutes_tag = ' ' + (_('minute') if mins == 1 else _('minutes'))
                seconds_tag = ' ' + (_('second') if secs == 1 else _('seconds'))
        else:
                years_tag = _('interval_format_tag::years::y')[-1:]
                months_tag = _('interval_format_tag::months::m')[-1:]
                weeks_tag = _('interval_format_tag::weeks::w')[-1:]
                days_tag = _('interval_format_tag::days::d')[-1:]
                hours_tag = '/24'
                minutes_tag = '/60'
                seconds_tag = 's'

        # special cases
        if years == 0:
                if accuracy_wanted < acc_months:
                        return _('0 years') if verbose else '0%s' % years_tag

        if years + months == 0:
                if accuracy_wanted < acc_weeks:
                        return _('0 months') if verbose else '0%s' % months_tag

        if years + months + weeks == 0:
                if accuracy_wanted < acc_days:
                        return _('0 weeks') if verbose else '0%s' % weeks_tag

        if years + months + weeks + days == 0:
                if accuracy_wanted < acc_hours:
                        return _('0 days') if verbose else '0%s' % days_tag

        if years + months + weeks + days + hours == 0:
                if accuracy_wanted < acc_minutes:
                        return _('0 hours') if verbose else '0/24'

        if years + months + weeks + days + hours + mins == 0:
                if accuracy_wanted < acc_seconds:
                        return _('0 minutes') if verbose else '0/60'

        if years + months + weeks + days + hours + mins + secs == 0:
                return _('0 seconds') if verbose else '0s'

        # normal cases
        formatted_intv = ''
        if years > 0:
                formatted_intv += '%s%s' % (int(years), years_tag)
        if accuracy_wanted < acc_months:
                return formatted_intv.strip()

        if months > 0:
                formatted_intv += ' %s%s' % (int(months), months_tag)
        if accuracy_wanted < acc_weeks:
                return formatted_intv.strip()

        if weeks > 0:
                formatted_intv += ' %s%s' % (int(weeks), weeks_tag)
        if accuracy_wanted < acc_days:
                return formatted_intv.strip()

        if days > 0:
                formatted_intv += ' %s%s' % (int(days), days_tag)
        if accuracy_wanted < acc_hours:
                return formatted_intv.strip()

        if hours > 0:
                formatted_intv += ' %s%s' % (int(hours), hours_tag)
        if accuracy_wanted < acc_minutes:
                return formatted_intv.strip()

        if mins > 0:
                formatted_intv += ' %s%s' % (int(mins), minutes_tag)
        if accuracy_wanted < acc_seconds:
                return formatted_intv.strip()

        if secs > 0:
                formatted_intv += ' %s%s' % (int(secs), seconds_tag)
        return formatted_intv.strip()

#---------------------------------------------------------------------------
def format_interval_medically(interval:pyDT.timedelta=None, terse:bool=False, approximation_prefix:str=None, zero_duration_strings:list[str]=None):
        """Formats an interval.

                This isn't mathematically correct but close enough for display.

        Args:
                interval: the interval to format
                terse: output terse formatting or not
                approximation_mark: an approxiation mark to apply in the formatting, if any
                zero_duration_strings: a list of two strings, terse and verbose form, to return if a zero duration interval is to be formatted
        """
        assert interval is not None, '<interval> must be given'

        if interval.total_seconds() == 0:
                if not zero_duration_strings:
                        zero_duration_strings = [
                                _('zero_duration_symbol::\u2300').removeprefix('zero_duration_symbol::'),
                                _('zero_duration_text::no duration').removeprefix('zero_duration_text::')
                        ]
                if terse:
                        return zero_duration_strings[0]
                return zero_duration_strings[1]

        spacer = '' if terse else ' '
        prefix = approximation_prefix if approximation_prefix else ''
        # more than 1 year ?
        if interval.days > 364:
                years, days = divmod(interval.days, avg_days_per_gregorian_year)
                months, day = divmod(days, 30.33)
                if int(months) == 0:
                        return '%s%s%s%s' % (
                                prefix,
                                spacer,
                                int(years),
                                _('interval_format_tag::years::y')[-1:]
                        )

                return '%s%s%s%s%s%s%s' % (
                        prefix,
                        spacer,
                        int(years),
                        _('interval_format_tag::years::y')[-1:],
                        spacer,
                        int(months),
                        _('interval_format_tag::months::m')[-1:]
                )

        # more than 30 days / 1 month ?
        if interval.days > 30:
                months, days = divmod(interval.days, 30.33)                                     # type: ignore [assignment]
                weeks, days = divmod(days, 7)
                result = '%s%s%s%s' % (
                        prefix,
                        spacer,
                        int(months),
                        _('interval_format_tag::months::m')[-1:]
                )
                if int(weeks) != 0:
                        result += '%s%s%s' % (spacer, int(weeks), _('interval_format_tag::weeks::w')[-1:])
                if int(days) != 0:
                        result += '%s%s%s' % (spacer, int(days), _('interval_format_tag::days::d')[-1:])
                return result

        # between 7 and 30 days ?
        if interval.days > 7:
                return '%s%s%s%s' % (prefix, spacer, interval.days, _('interval_format_tag::days::d')[-1:])

        # between 1 and 7 days ?
        if interval.days > 0:
                hours, seconds = divmod(interval.seconds, 3600)
                if hours == 0:
                        return '%s%s%s%s' % (prefix, spacer, interval.days, _('interval_format_tag::days::d')[-1:])

                return "%s%s%s%s%s%sh" % (
                        prefix,
                        spacer,
                        interval.days,
                        _('interval_format_tag::days::d')[-1:],
                        spacer,
                        int(hours)
                )

        # between 5 hours and 1 day
        if interval.seconds > (5*3600):
                return '%s%s%sh' % (prefix, spacer, int(interval.seconds // 3600))

        # between 1 and 5 hours
        if interval.seconds > 3600:
                hours, seconds = divmod(interval.seconds, 3600)
                minutes = seconds // 60
                if minutes == 0:
                        return '%s%s%sh' % (prefix, spacer, int(hours))
                return '%s:%02d' % (int(hours), int(minutes))

        # minutes only
        if interval.seconds > (5*60):
                return "0:%02d" % (int(interval.seconds // 60))

        # seconds
        minutes, seconds = divmod(interval.seconds, 60)
        if minutes == 0:
                return '%ss' % int(seconds)
        if seconds == 0:
                return '0:%02d' % int(minutes)
        return '%s.%ss' % (int(minutes), int(seconds))

#---------------------------------------------------------------------------
def format_pregnancy_weeks(age):
        weeks, days = divmod(age.days, 7)
        return '%s%s%s%s' % (
                int(weeks),
                _('interval_format_tag::weeks::w')[-1:],
                int(days),
                _('interval_format_tag::days::d')[-1:]
        )

#---------------------------------------------------------------------------
def format_pregnancy_months(age):
        months, remainder = divmod(age.days, 28)
        return '%s%s' % (
                int(months) + 1,
                _('interval_format_tag::months::m')[-1:]
        )

#---------------------------------------------------------------------------
def is_leap_year(year):
        if year < 1582:         # no leap years before Gregorian Reform
                _log.debug('%s: before Gregorian Reform', year)
                return False

        # year is multiple of 4 ?
        div, remainder = divmod(year, 4)
        # * NOT divisible by 4
        # -> common year
        if remainder > 0:
                return False

        # year is a multiple of 100 ?
        div, remainder = divmod(year, 100)
        # * divisible by 4
        # * NOT divisible by 100
        # -> leap year
        if remainder > 0:
                return True

        # year is a multiple of 400 ?
        div, remainder = divmod(year, 400)
        # * divisible by 4
        # * divisible by 100, so, perhaps not leaping ?
        # * but ALSO divisible by 400
        # -> leap year
        if remainder == 0:
                return True

        # all others
        # -> common year
        return False

#---------------------------------------------------------------------------
def calculate_apparent_age(start=None, end=None) -> tuple:
        """Calculate age in a way humans naively expect it.

        This does *not* take into account time zones which may
        shift the result by up to one day.

        Args:
                start: the beginning of the period-to-be-aged, the 'birth' if you will
                end: the end of the period, default *now*

        Returns:
                A tuple (years, ..., seconds) as simple differences
                between the fields:

                        (years, months, days, hours, minutes, seconds)
        """
        assert not((end is None) and (start is None)), 'one of <start> or <end> must be given'

        now = pyDT.datetime.now(gmCurrentLocalTimezone)
        if end is None:
                if start <= now:
                        end = now
                else:
                        end = start
                        start = now
        if end < start:
                raise ValueError('calculate_apparent_age(): <end> (%s) before <start> (%s)' % (end, start))
        if end == start:
                return (0, 0, 0, 0, 0, 0)

        # steer clear of leap years
        if end.month == 2:
                if end.day == 29:
                        if not is_leap_year(start.year):
                                end = end.replace(day = 28)

        # years
        years = end.year - start.year
        end = end.replace(year = start.year)
        if end < start:
                years = years - 1

        # months
        if end.month == start.month:
                if end < start:
                        months = 11
                else:
                        months = 0
        else:
                months = end.month - start.month
                if months < 0:
                        months = months + 12
                if end.day > gregorian_month_length[start.month]:
                        end = end.replace(month = start.month, day = gregorian_month_length[start.month])
                else:
                        end = end.replace(month = start.month)
                if end < start:
                        months = months - 1

        # days
        if end.day == start.day:
                if end < start:
                        days = gregorian_month_length[start.month] - 1
                else:
                        days = 0
        else:
                days = end.day - start.day
                if days < 0:
                        days = days + gregorian_month_length[start.month]
                end = end.replace(day = start.day)
                if end < start:
                        days = days - 1

        # hours
        if end.hour == start.hour:
                hours = 0
        else:
                hours = end.hour - start.hour
                if hours < 0:
                        hours = hours + 24
                end = end.replace(hour = start.hour)
                if end < start:
                        hours = hours - 1

        # minutes
        if end.minute == start.minute:
                minutes = 0
        else:
                minutes = end.minute - start.minute
                if minutes < 0:
                        minutes = minutes + 60
                end = end.replace(minute = start.minute)
                if end < start:
                        minutes = minutes - 1

        # seconds
        if end.second == start.second:
                seconds = 0
        else:
                seconds = end.second - start.second
                if seconds < 0:
                        seconds = seconds + 60
                end = end.replace(second = start.second)
                if end < start:
                        seconds = seconds - 1

        return (years, months, days, hours, minutes, seconds)

#---------------------------------------------------------------------------
def format_apparent_age_medically(age=None):
        """<age> must be a tuple as created by calculate_apparent_age()"""

        (years, months, days, hours, minutes, seconds) = age

        # at least 1 year ?
        if years > 0:
                if months == 0:
                        return '%s%s' % (
                                years,
                                _('y::year_abbreviation').replace('::year_abbreviation', '')
                        )
                return '%s%s %s%s' % (
                        years,
                        _('y::year_abbreviation').replace('::year_abbreviation', ''),
                        months,
                        _('m::month_abbreviation').replace('::month_abbreviation', '')
                )

        # at least 1 month ?
        if months > 0:
                if days == 0:
                        return '%s%s' % (
                                months,
                                _('mo::month_only_abbreviation').replace('::month_only_abbreviation', '')
                        )

                result = '%s%s' % (
                        months,
                        _('m::month_abbreviation').replace('::month_abbreviation', '')
                )

                weeks, days = divmod(days, 7)
                if int(weeks) != 0:
                        result += '%s%s' % (
                                int(weeks),
                                _('w::week_abbreviation').replace('::week_abbreviation', '')
                        )
                if int(days) != 0:
                        result += '%s%s' % (
                                int(days),
                                _('d::day_abbreviation').replace('::day_abbreviation', '')
                        )

                return result

        # between 7 days and 1 month
        if days > 7:
                return "%s%s" % (
                        days,
                        _('d::day_abbreviation').replace('::day_abbreviation', '')
                )

        # between 1 and 7 days ?
        if days > 0:
                if hours == 0:
                        return '%s%s' % (
                                days,
                                _('d::day_abbreviation').replace('::day_abbreviation', '')
                        )
                return '%s%s (%s%s)' % (
                        days,
                        _('d::day_abbreviation').replace('::day_abbreviation', ''),
                        hours,
                        _('h::hour_abbreviation').replace('::hour_abbreviation', '')
                )

        # between 5 hours and 1 day
        if hours > 5:
                return '%s%s' % (
                        hours,
                        _('h::hour_abbreviation').replace('::hour_abbreviation', '')
                )

        # between 1 and 5 hours
        if hours > 1:
                if minutes == 0:
                        return '%s%s' % (
                                hours,
                                _('h::hour_abbreviation').replace('::hour_abbreviation', '')
                        )
                return '%s:%02d' % (
                        hours,
                        minutes
                )

        # between 5 and 60 minutes
        if minutes > 5:
                return "0:%02d" % minutes

        # less than 5 minutes
        if minutes == 0:
                return '%s%s' % (
                        seconds,
                        _('s::second_abbreviation').replace('::second_abbreviation', '')
                )
        if seconds == 0:
                return "0:%02d" % minutes
        return "%s.%s%s" % (
                minutes,
                seconds,
                _('s::second_abbreviation').replace('::second_abbreviation', '')
        )
#---------------------------------------------------------------------------
def str2interval(str_interval=None):

        unit_keys = {
                'year': _('yYaA_keys_year'),
                'month': _('mM_keys_month'),
                'week': _('wW_keys_week'),
                'day': _('dD_keys_day'),
                'hour': _('hH_keys_hour')
        }

        str_interval = str_interval.strip()

        # "(~)35(yY)"   - at age 35 years
        keys = '|'.join(list(unit_keys['year'].replace('_keys_year', '')))
        if regex.match(r'^~*(\s|\t)*\d+(%s)*$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(days = (int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]) * avg_days_per_gregorian_year))

        # "(~)12mM" - at age 12 months
        keys = '|'.join(list(unit_keys['month'].replace('_keys_month', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                years, months = divmod (
                        int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]),
                        12
                )
                return pyDT.timedelta(days = ((years * avg_days_per_gregorian_year) + (months * avg_days_per_gregorian_month)))

        # weeks
        keys = '|'.join(list(unit_keys['week'].replace('_keys_week', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(weeks = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # days
        keys = '|'.join(list(unit_keys['day'].replace('_keys_day', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(days = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # hours
        keys = '|'.join(list(unit_keys['hour'].replace('_keys_hour', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(hours = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/12 - months
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*12$', str_interval, flags = regex.UNICODE):
                years, months = divmod (
                        int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]),
                        12
                )
                return pyDT.timedelta(days = ((years * avg_days_per_gregorian_year) + (months * avg_days_per_gregorian_month)))

        # x/52 - weeks
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*52$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(weeks = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/7 - days
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*7$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(days = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/24 - hours
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*24$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(hours = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/60 - minutes
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*60$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(minutes = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # nYnM - years, months
        keys_year = '|'.join(list(unit_keys['year'].replace('_keys_year', '')))
        keys_month = '|'.join(list(unit_keys['month'].replace('_keys_month', '')))
        if regex.match(r'^~*(\s|\t)*\d+(%s|\s|\t)+\d+(\s|\t)*(%s)+$' % (keys_year, keys_month), str_interval, flags = regex.UNICODE):
                parts = regex.findall(r'\d+', str_interval, flags = regex.UNICODE)
                years, months = divmod(int(parts[1]), 12)
                years += int(parts[0])
                return pyDT.timedelta(days = ((years * avg_days_per_gregorian_year) + (months * avg_days_per_gregorian_month)))

        # nMnW - months, weeks
        keys_month = '|'.join(list(unit_keys['month'].replace('_keys_month', '')))
        keys_week = '|'.join(list(unit_keys['week'].replace('_keys_week', '')))
        if regex.match(r'^~*(\s|\t)*\d+(%s|\s|\t)+\d+(\s|\t)*(%s)+$' % (keys_month, keys_week), str_interval, flags = regex.UNICODE):
                parts = regex.findall(r'\d+', str_interval, flags = regex.UNICODE)
                months, weeks = divmod(int(parts[1]), 4)
                months += int(parts[0])
                return pyDT.timedelta(days = ((months * avg_days_per_gregorian_month) + (weeks * days_per_week)))

        return None

#===========================================================================
# string -> python datetime parser
#---------------------------------------------------------------------------
def __single_char2py_dt(str2parse, trigger_chars=None):
        """This matches on single characters.

        Spaces and tabs are discarded.

        Default is 'ndmy':
                n - _N_ow
                d - to_D_ay
                m - to_M_orrow  Someone please suggest a synonym ! ("2" does not cut it ...)
                y - _Y_esterday

        This also defines the significance of the order of the characters.
        """
        str2parse = str2parse.strip().casefold()
        if len(str2parse) != 1:
                return []

        if trigger_chars is None:
                trigger_chars = _('ndmy (single character date triggers)')[:4].casefold()

        if str2parse not in trigger_chars:
                return []

        now = pydt_now_here()

        # FIXME: handle uebermorgen/vorgestern ?

        # right now
        if str2parse == trigger_chars[0]:
                return [{
                        'data': now,
                        'label': _('right now (%s, %s)') % (now.strftime('%A'), now)
                }]
        # today
        if str2parse == trigger_chars[1]:
                return [{
                        'data': now,
                        'label': _('today (%s)') % now.strftime('%A, %Y-%m-%d')
                }]
        # tomorrow
        if str2parse == trigger_chars[2]:
                ts = pydt_add(now, days = 1)
                return [{
                        'data': ts,
                        'label': _('tomorrow (%s)') % ts.strftime('%A, %Y-%m-%d')
                }]
        # yesterday
        if str2parse == trigger_chars[3]:
                ts = pydt_add(now, days = -1)
                return [{
                        'data': ts,
                        'label': _('yesterday (%s)') % ts.strftime('%A, %Y-%m-%d')
                }]
        return []

#---------------------------------------------------------------------------
def __single_dot2py_dt(str2parse):
        """Expand fragments containing a single dot.

        Standard colloquial date format in Germany: day.month.year

        "14."
                - the 14th of the current month
                - the 14th of next month
        "-14."
                - the 14th of last month
        """
        str2parse = str2parse.replace(' ', '').replace('\t', '')

        if not str2parse.endswith('.'):
                return []
        try:
                day_val = int(str2parse[:-1])
        except ValueError:
                return []
        if (day_val < -31) or (day_val > 31) or (day_val == 0):
                return []

        now = pydt_now_here()
        matches = []

        # day X of last month only
        if day_val < 0:
                ts = pydt_replace(pydt_add(now, months = -1), day = abs(day_val), strict = False)
                if ts.day == day_val:
                        matches.append ({
                                'data': ts,
                                'label': _('%s-%s-%s: a %s last month') % (ts.year, ts.month, ts.day, ts.strftime('%A'))
                        })

        # day X of ...
        if day_val > 0:
                # ... this month
                try:
                        ts = pydt_replace(now, day = day_val, strict = False)
                        matches.append ({
                                'data': ts,
                                'label': _('%s-%s-%s: a %s this month') % (ts.year, ts.month, ts.day, ts.strftime('%A'))
                        })
                except ValueError:
                        pass
                # ... next month
                try:
                        ts = pydt_replace(pydt_add(now, months = 1), day = day_val, strict = False)
                        if ts.day == day_val:
                                matches.append ({
                                        'data': ts,
                                        'label': _('%s-%s-%s: a %s next month') % (ts.year, ts.month, ts.day, ts.strftime('%A'))
                                })
                except ValueError:
                        pass
                # ... last month
                try:
                        ts = pydt_replace(pydt_add(now, months = -1), day = day_val, strict = False)
                        if ts.day == day_val:
                                matches.append ({
                                        'data': ts,
                                        'label': _('%s-%s-%s: a %s last month') % (ts.year, ts.month, ts.day, ts.strftime('%A'))
                                })
                except ValueError:
                        pass

        return matches

#---------------------------------------------------------------------------
def __single_slash2py_dt(str2parse):
        """Expand fragments containing a single slash.

        "5/"
                - 2005/                                 (2000 - 2025)
                - 1995/                                 (1990 - 1999)
                - Mai/current year
                - Mai/next year
                - Mai/last year
                - Mai/200x
                - Mai/20xx
                - Mai/199x
                - Mai/198x
                - Mai/197x
                - Mai/19xx

        5/1999
        6/2004
        """
        str2parse = str2parse.strip()

        now = pydt_now_here()

        # 5/1999
        if regex.match(r"^\d{1,2}(\s|\t)*/+(\s|\t)*\d{4}$", str2parse, flags = regex.UNICODE):
                month, year = regex.findall(r'\d+', str2parse, flags = regex.UNICODE)
                ts = pydt_replace(now, year = int(year), month = int(month), strict = False)
                return [{
                        'data': ts,
                        'label': ts.strftime('%Y-%m-%d')
                }]

        matches = []
        # 5/
        if regex.match(r"^\d{1,2}(\s|\t)*/+$", str2parse, flags = regex.UNICODE):
                val = int(str2parse.rstrip('/').strip())

                # "55/" -> "1955"
                if val < 100 and val >= 0:
                        matches.append ({
                                'data': None,
                                'label': '%s-' % (val + 1900)
                        })
                # "11/" -> "2011"
                if val < 26 and val >= 0:
                        matches.append ({
                                'data': None,
                                'label': '%s-' % (val + 2000)
                        })
                # "5/" -> "1995"
                if val < 10 and val >= 0:
                        matches.append ({
                                'data': None,
                                'label': '%s-' % (val + 1990)
                        })
                if val < 13 and val > 0:
                        # "11/" -> "11/this year"
                        matches.append ({
                                'data': None,
                                'label': '%s-%.2d-' % (now.year, val)
                        })
                        # "11/" -> "11/next year"
                        ts = pydt_add(now, years = 1)
                        matches.append ({
                                'data': None,
                                'label': '%s-%.2d-' % (ts.year, val)
                        })
                        # "11/" -> "11/last year"
                        ts = pydt_add(now, years = -1)
                        matches.append ({
                                'data': None,
                                'label': '%s-%.2d-' % (ts.year, val)
                        })
                        # "11/" -> "201?-11-"
                        matches.append ({
                                'data': None,
                                'label': '201?-%.2d-' % val
                        })
                        # "11/" -> "200?-11-"
                        matches.append ({
                                'data': None,
                                'label': '200?-%.2d-' % val
                        })
                        # "11/" -> "20??-11-"
                        matches.append ({
                                'data': None,
                                'label': '20??-%.2d-' % val
                        })
                        # "11/" -> "199?-11-"
                        matches.append ({
                                'data': None,
                                'label': '199?-%.2d-' % val
                        })
                        # "11/" -> "198?-11-"
                        matches.append ({
                                'data': None,
                                'label': '198?-%.2d-' % val
                        })
                        # "11/" -> "198?-11-"
                        matches.append ({
                                'data': None,
                                'label': '197?-%.2d-' % val
                        })
                        # "11/" -> "19??-11-"
                        matches.append ({
                                'data': None,
                                'label': '19??-%.2d-' % val
                        })

        return matches

#---------------------------------------------------------------------------
def __numbers_only2py_dt(str2parse):
        """This matches on single numbers.

        Spaces or tabs are discarded.
        """
        try:
                val = int(str2parse.strip())
        except ValueError:
                return []

        now = pydt_now_here()

        matches = []

        # that year
        if (1850 < val) and (val < 2100):
                ts = pydt_replace(now, year = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': ts.strftime('%Y-%m-%d')
                })
        # day X of this month
        if (val > 0) and (val <= gregorian_month_length[now.month]):
                ts = pydt_replace(now, day = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': _('%d. of %s (this month): a %s') % (val, ts.strftime('%B'), ts.strftime('%A'))
                })
        # day X of ...
        if (val > 0) and (val < 32):
                # ... next month
                ts = pydt_replace(pydt_add(now, months = 1), day = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': _('%d. of %s (next month): a %s') % (val, ts.strftime('%B'), ts.strftime('%A'))
                })
                # ... last month
                ts = pydt_replace(pydt_add(now, months = -1), day = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': _('%d. of %s (last month): a %s') % (val, ts.strftime('%B'), ts.strftime('%A'))
                })
        # X days from now
        if (val > 0) and (val <= 400):                          # more than a year ahead in days ?? nah !
                ts = pydt_add(now, days = val)
                matches.append ({
                        'data': ts,
                        'label': _('in %d day(s): %s') % (val, ts.strftime('%A, %Y-%m-%d'))
                })
        if (val < 0) and (val >= -400):                         # more than a year back in days ?? nah !
                ts = pydt_add(now, days = val)
                matches.append ({
                        'data': ts,
                        'label': _('%d day(s) ago: %s') % (abs(val), ts.strftime('%A, %Y-%m-%d'))
                })
        # X weeks from now
        if (val > 0) and (val <= 50):                           # pregnancy takes about 40 weeks :-)
                ts = pydt_add(now, weeks = val)
                matches.append ({
                        'data': ts,
                        'label': _('in %d week(s): %s') % (val, ts.strftime('%A, %Y-%m-%d'))
                })
        if (val < 0) and (val >= -50):                          # pregnancy takes about 40 weeks :-)
                ts = pydt_add(now, weeks = val)
                matches.append ({
                        'data': ts,
                        'label': _('%d week(s) ago: %s') % (abs(val), ts.strftime('%A, %Y-%m-%d'))
                })

        # month X of ...
        if (val < 13) and (val > 0):
                # ... this year
                ts = pydt_replace(now, month = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': _('%s (%s this year)') % (ts.strftime('%Y-%m-%d'), ts.strftime('%B'))
                })
                # ... next year
                ts = pydt_replace(pydt_add(now, years = 1), month = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': _('%s (%s next year)') % (ts.strftime('%Y-%m-%d'), ts.strftime('%B'))
                })
                # ... last year
                ts = pydt_replace(pydt_add(now, years = -1), month = val, strict = False)
                matches.append ({
                        'data': ts,
                        'label': _('%s (%s last year)') % (ts.strftime('%Y-%m-%d'), ts.strftime('%B'))
                })
                # fragment expansion
                matches.append ({
                        'data': None,
                        'label': '200?-%s' % val
                })
                matches.append ({
                        'data': None,
                        'label': '199?-%s' % val
                })
                matches.append ({
                        'data': None,
                        'label': '198?-%s' % val
                })
                matches.append ({
                        'data': None,
                        'label': '19??-%s' % val
                })

        # needs mxDT
#       # day X of ...
#       if (val < 8) and (val > 0):
#               # ... this week
#               ts = now + mxDT.RelativeDateTime(weekday = (val-1, 0))
#               matches.append ({
#                       'data': mxdt2py_dt(ts),
#                       'label': _('%s this week (%s of %s)') % (ts.strftime('%A'), ts.day, ts.strftime('%B'))
#               })
#               # ... next week
#               ts = now + mxDT.RelativeDateTime(weeks = +1, weekday = (val-1, 0))
#               matches.append ({
#                       'data': mxdt2py_dt(ts),
#                       'label': _('%s next week (%s of %s)') % (ts.strftime('%A'), ts.day, ts.strftime('%B'))
#               })
#               # ... last week
#               ts = now + mxDT.RelativeDateTime(weeks = -1, weekday = (val-1, 0))
#               matches.append ({
#                       'data': mxdt2py_dt(ts),
#                       'label': _('%s last week (%s of %s)') % (ts.strftime('%A'), ts.day, ts.strftime('%B'))
#               })

        if (val < 100) and (val > 0):
                matches.append ({
                        'data': None,
                        'label': '%s-' % (1900 + val)
                })

        if val == 201:
                matches.append ({
                        'data': now,
                        'label': now.strftime('%Y-%m-%d')
                })
                matches.append ({
                        'data': None,
                        'label': now.strftime('%Y-%m')
                })
                matches.append ({
                        'data': None,
                        'label': now.strftime('%Y')
                })
                matches.append ({
                        'data': None,
                        'label': '%s-' % (now.year + 1)
                })
                matches.append ({
                        'data': None,
                        'label': '%s-' % (now.year - 1)
                })

        if val < 200 and val >= 190:
                for i in range(10):
                        matches.append ({
                                'data': None,
                                'label': '%s%s-' % (val, i)
                        })

        return matches

#---------------------------------------------------------------------------
def __explicit_offset2py_dt(str2parse, offset_chars=None):
        """Default is 'hdwmy':
                        h - hours
                        d - days
                        w - weeks
                        m - months
                        y - years

                This also defines the significance of the order of the characters.
        """
        if offset_chars is None:
                offset_chars = _('hdwmy (single character date offset triggers)')[:5].casefold()

        str2parse = str2parse.replace(' ', '').replace('\t', '')
        # "+/-XXXh/d/w/m/t"
        if regex.fullmatch(r"(\+|-){,1}\d{1,3}[%s]" % offset_chars, str2parse) is None:
                return []

        offset_val = int(str2parse[:-1])
        offset_char = str2parse[-1:]
        is_past = str2parse.startswith('-')
        now = pydt_now_here()
        ts = None

        # hours
        if offset_char == offset_chars[0]:
                ts = pydt_add(now, hours = offset_val)
                if is_past:
                        label = _('%d hour(s) ago: %s') % (abs(offset_val), ts.strftime('%H:%M'))
                else:
                        label = _('in %d hour(s): %s') % (offset_val, ts.strftime('%H:%M'))
        # days
        elif offset_char == offset_chars[1]:
                ts = pydt_add(now, days = offset_val)
                if is_past:
                        label = _('%d day(s) ago: %s') % (abs(offset_val), ts.strftime('%A, %Y-%m-%d'))
                else:
                        label = _('in %d day(s): %s') % (offset_val, ts.strftime('%A, %Y-%m-%d'))
        # weeks
        elif offset_char == offset_chars[2]:
                ts = pydt_add(now, weeks = offset_val)
                if is_past:
                        label = _('%d week(s) ago: %s') % (abs(offset_val), ts.strftime('%A, %Y-%m-%d'))
                else:
                        label = _('in %d week(s): %s') % (offset_val, ts.strftime('%A, %Y-%m-%d'))
        # months
        elif offset_char == offset_chars[3]:
                ts = pydt_add(now, months = offset_val)
                if is_past:
                        label = _('%d month(s) ago: %s') % (abs(offset_val), ts.strftime('%A, %Y-%m-%d'))
                else:
                        label = _('in %d month(s): %s') % (offset_val, ts.strftime('%A, %Y-%m-%d'))
        # years
        elif offset_char == offset_chars[4]:
                ts = pydt_add(now, years = offset_val)
                if is_past:
                        label = _('%d year(s) ago: %s') % (abs(offset_val), ts.strftime('%A, %Y-%m-%d'))
                else:
                        label = _('in %d year(s): %s') % (offset_val, ts.strftime('%A, %Y-%m-%d'))

        if ts is None:
                return []

        return [{'data': ts, 'label': label}]

#---------------------------------------------------------------------------
STR2PYDT_DEFAULT_PATTERNS = [
        '%Y-%m-%d',
        '%y-%m-%d',
        '%Y/%m/%d',
        '%y/%m/%d',
        '%d-%m-%Y',
        '%d-%m-%y',
        '%d/%m/%Y',
        '%d/%m/%y',
        '%d.%m.%Y',
        '%m-%d-%Y',
        '%m-%d-%y',
        '%m/%d/%Y',
        '%m/%d/%y',
        '%Y.%m.%d'
]
"""Default patterns being passed to strptime()."""

STR2PYDT_PARSERS:list[Callable[[str], dict]] = [
        __single_dot2py_dt,
        __numbers_only2py_dt,
        __single_slash2py_dt,
        __single_char2py_dt,
        __explicit_offset2py_dt
]
"""Specialized parsers for string -> datetime conversion."""

#---------------------------------------------------------------------------
def str2pydt_matches(str2parse:str=None, patterns:list=None) -> list:
        """Turn a string into candidate datetimes.

        Args:
                str2parse: string to turn into candidate datetimes
                patterns: additional patterns to try with strptime()

        A number of default patterns will be tried. Also, a few
        specialized parsers will be run. See the source for
        details.

        If the input contains a space followed by more characters
        matching either hour:minute or hour:minute:second that
        will be used as the time part of the datetime returned.
        Otherwise 11:11:11 will be used as default.

        Note: You must have previously called

                locale.setlocale(locale.LC_ALL, '')

        somewhere in your code.

        Returns:
                List of Python datetimes the input could be parsed as.
        """
        matches:list[dict] = []
        for parser in STR2PYDT_PARSERS:
                matches.extend(parser(str2parse))
        parts = str2parse.split(maxsplit = 1)
        hour = 11
        minute = 11
        second = 11
        acc = acc_days
        lbl_fmt = '%Y-%m-%d'
        if len(parts) > 1:
                for pattern in ['%H:%M', '%H:%M:%S']:
                        try:
                                date = pyDT.datetime.strptime(parts[1], pattern)
                                hour = date.hour
                                minute = date.minute
                                second = date.second
                                acc = acc_minutes
                                lbl_fmt = '%Y-%m-%d %H:%M'
                                break
                        except ValueError:
                                # C-level overflow
                                continue
        if patterns is None:
                patterns = []
        patterns.extend(STR2PYDT_DEFAULT_PATTERNS)
        for pattern in patterns:
                try:
                        date = pyDT.datetime.strptime(parts[0], pattern).replace (
                                hour = hour,
                                minute = minute,
                                second = second,
                                tzinfo = gmCurrentLocalTimezone
                        )
                        matches.append ({
                                'data': date,
                                'label': pydt_strftime(date, format = lbl_fmt, accuracy = acc)
                        })
                except ValueError:
                        # C-level overflow
                        continue
        return matches

#===========================================================================
# string -> fuzzy timestamp parser
#---------------------------------------------------------------------------
def __single_slash(str2parse):
        """Expand fragments containing a single slash.

        "5/"
                - 2005/                                 (2000 - 2025)
                - 1995/                                 (1990 - 1999)
                - Mai/current year
                - Mai/next year
                - Mai/last year
                - Mai/200x
                - Mai/20xx
                - Mai/199x
                - Mai/198x
                - Mai/197x
                - Mai/19xx
        """
        matches = []
        now = pydt_now_here()
        # "xx/yyyy"
        if regex.match(r"^(\s|\t)*\d{1,2}(\s|\t)*/+(\s|\t)*\d{4}(\s|\t)*$", str2parse, flags = regex.UNICODE):
                parts = regex.findall(r'\d+', str2parse, flags = regex.UNICODE)
                month = int(parts[0])
                if month in range(1, 13):
                        fts = cFuzzyTimestamp (
                                timestamp = now.replace(year = int(parts[1], month = month)),
                                accuracy = acc_months
                        )
                        matches.append ({
                                'data': fts,
                                'label': fts.format_accurately()
                        })
        # "xx/"
        elif regex.match(r"^(\s|\t)*\d{1,2}(\s|\t)*/+(\s|\t)*$", str2parse, flags = regex.UNICODE):
                val = int(regex.findall(r'\d+', str2parse, flags = regex.UNICODE)[0])

                if val < 100 and val >= 0:
                        matches.append ({
                                'data': None,
                                'label': '%s/' % (val + 1900)
                        })

                if val < 26 and val >= 0:
                        matches.append ({
                                'data': None,
                                'label': '%s/' % (val + 2000)
                        })

                if val < 10 and val >= 0:
                        matches.append ({
                                'data': None,
                                'label': '%s/' % (val + 1990)
                        })

                if val < 13 and val > 0:
                        matches.append ({
                                'data': cFuzzyTimestamp(timestamp = now, accuracy = acc_months),
                                'label': '%.2d/%s' % (val, now.year)
                        })
                        ts = now.replace(year = now.year + 1)
                        matches.append ({
                                'data': cFuzzyTimestamp(timestamp = ts, accuracy = acc_months),
                                'label': '%.2d/%s' % (val, ts.year)
                        })
                        ts = now.replace(year = now.year - 1)
                        matches.append ({
                                'data': cFuzzyTimestamp(timestamp = ts, accuracy = acc_months),
                                'label': '%.2d/%s' % (val, ts.year)
                        })
                        matches.append ({
                                'data': None,
                                'label': '%.2d/200' % val
                        })
                        matches.append ({
                                'data': None,
                                'label': '%.2d/20' % val
                        })
                        matches.append ({
                                'data': None,
                                'label': '%.2d/199' % val
                        })
                        matches.append ({
                                'data': None,
                                'label': '%.2d/198' % val
                        })
                        matches.append ({
                                'data': None,
                                'label': '%.2d/197' % val
                        })
                        matches.append ({
                                'data': None,
                                'label': '%.2d/19' % val
                        })

        return matches

#---------------------------------------------------------------------------
def __numbers_only(str2parse):
        """This matches on single numbers.

        Spaces or tabs are discarded.
        """
        if not regex.match(r"^(\s|\t)*\d{1,4}(\s|\t)*$", str2parse, flags = regex.UNICODE):
                return []

        val = int(regex.findall(r'\d{1,4}', str2parse, flags = regex.UNICODE)[0])
        if val == 0:
                return []

        now = pydt_now_here()
        matches = []
        # today in that year
        if (1850 < val) and (val < 2100):
                target_date = cFuzzyTimestamp (
                        timestamp = now.replace(year = val),
                        accuracy = acc_years
                )
                tmp = {
                        'data': target_date,
                        'label': '%s' % target_date
                }
                matches.append(tmp)

        # day X of this month
        if val <= gregorian_month_length[now.month]:
                ts = now.replace(day = val)
                target_date = cFuzzyTimestamp (
                        timestamp = ts,
                        accuracy = acc_days
                )
                tmp = {
                        'data': target_date,
                        'label': _('%d. of %s (this month) - a %s') % (val, ts.strftime('%B'), ts.strftime('%A'))
                }
                matches.append(tmp)

        # day X of next month
        next_month = get_next_month(now)
        if val <= gregorian_month_length[next_month]:
                ts = now.replace(day = val, month = next_month)
                target_date = cFuzzyTimestamp (
                        timestamp = ts,
                        accuracy = acc_days
                )
                tmp = {
                        'data': target_date,
                        'label': _('%d. of %s (next month) - a %s') % (val, ts.strftime('%B'), ts.strftime('%A'))
                }
                matches.append(tmp)

        # day X of last month
        last_month = get_last_month(now)
        if val <= gregorian_month_length[last_month]:
                ts = now.replace(day = val, month = last_month)
                target_date = cFuzzyTimestamp (
                        timestamp = ts,
                        accuracy = acc_days
                )
                tmp = {
                        'data': target_date,
                        'label': _('%d. of %s (last month) - a %s') % (val, ts.strftime('%B'), ts.strftime('%A'))
                }
                matches.append(tmp)

        # X days from now
        if val <= 400:                          # more than a year ahead in days ?? nah !
                target_date = cFuzzyTimestamp(timestamp = now + pyDT.timedelta(days = val))
                tmp = {
                        'data': target_date,
                        'label': _('in %d day(s) - %s') % (val, target_date.timestamp.strftime('%A, %Y-%m-%d'))
                }
                matches.append(tmp)

        # X weeks from now
        if val <= 50:                           # pregnancy takes about 40 weeks :-)
                target_date = cFuzzyTimestamp(timestamp = now + pyDT.timedelta(weeks = val))
                tmp = {
                        'data': target_date,
                        'label': _('in %d week(s) - %s') % (val, target_date.timestamp.strftime('%A, %Y-%m-%d'))
                }
                matches.append(tmp)

        # month X of ...
        if val < 13:
                # ... this year
                target_date = cFuzzyTimestamp (
                        timestamp = pydt_replace(now, month = val, strict = False),
                        accuracy = acc_months
                )
                tmp = {
                        'data': target_date,
                        'label': _('%s (%s this year)') % (target_date, ts.strftime('%B'))
                }
                matches.append(tmp)

                # ... next year
                target_date = cFuzzyTimestamp (
                        timestamp = pydt_add(pydt_replace(now, month = val, strict = False), years = 1),
                        accuracy = acc_months
                )
                tmp = {
                        'data': target_date,
                        'label': _('%s (%s next year)') % (target_date, ts.strftime('%B'))
                }
                matches.append(tmp)

                # ... last year
                target_date = cFuzzyTimestamp (
                        timestamp = pydt_add(pydt_replace(now, month = val, strict = False), years = -1),
                        accuracy = acc_months
                )
                tmp = {
                        'data': target_date,
                        'label': _('%s (%s last year)') % (target_date, ts.strftime('%B'))
                }
                matches.append(tmp)

                # fragment expansion
                matches.append ({
                        'data': None,
                        'label': '%s/200' % val
                })
                matches.append ({
                        'data': None,
                        'label': '%s/199' % val
                })
                matches.append ({
                        'data': None,
                        'label': '%s/198' % val
                })
                matches.append ({
                        'data': None,
                        'label': '%s/19' % val
                })

        # reactivate when mxDT becomes available on py3k
#       # day X of ...
#       if val < 8:
#               # ... this week
#               ts = now + mxDT.RelativeDateTime(weekday = (val-1, 0))
#               target_date = cFuzzyTimestamp (
#                       timestamp = ts,
#                       accuracy = acc_days
#               )
#               tmp = {
#                       'data': target_date,
#                       'label': _('%s this week (%s of %s)') % (ts.strftime('%A'), ts.day, ts.strftime('%B'))
#               }
#               matches.append(tmp)
#
#               # ... next week
#               ts = now + mxDT.RelativeDateTime(weeks = +1, weekday = (val-1, 0))
#               target_date = cFuzzyTimestamp (
#                       timestamp = ts,
#                       accuracy = acc_days
#               )
#               tmp = {
#                       'data': target_date,
#                       'label': _('%s next week (%s of %s)') % (ts.strftime('%A'), ts.day, ts.strftime('%B'))
#               }
#               matches.append(tmp)

#               # ... last week
#               ts = now + mxDT.RelativeDateTime(weeks = -1, weekday = (val-1, 0))
#               target_date = cFuzzyTimestamp (
#                       timestamp = ts,
#                       accuracy = acc_days
#               )
#               tmp = {
#                       'data': target_date,
#                       'label': _('%s last week (%s of %s)') % (ts.strftime('%A'), ts.day, ts.strftime('%B'))
#               }
#               matches.append(tmp)

        if val < 100:
                matches.append ({
                        'data': None,
                        'label': '%s/' % (1900 + val)
                })

        # year 2k
        if val == 200:
                tmp = {
                        'data': cFuzzyTimestamp(timestamp = now, accuracy = acc_days),
                        'label': '%s' % target_date
                }
                matches.append(tmp)
                matches.append ({
                        'data': cFuzzyTimestamp(timestamp = now, accuracy = acc_months),
                        'label': '%.2d/%s' % (now.month, now.year)
                })
                matches.append ({
                        'data': None,
                        'label': '%s/' % now.year
                })
                matches.append ({
                        'data': None,
                        'label': '%s/' % (now.year + 1)
                })
                matches.append ({
                        'data': None,
                        'label': '%s/' % (now.year - 1)
                })

        if val < 200 and val >= 190:
                for i in range(10):
                        matches.append ({
                                'data': None,
                                'label': '%s%s/' % (val, i)
                        })

        return matches

#---------------------------------------------------------------------------
def str2fuzzy_timestamp_matches(str2parse=None, default_time=None, patterns=None):
        """
        Turn a string into candidate fuzzy timestamps and auto-completions the user is likely to type.

        You MUST have called locale.setlocale(locale.LC_ALL, '')
        somewhere in your code previously.

        @param default_time: if you want to force the time part of the time
                stamp to a given value and the user doesn't type any time part
                this value will be used
        @type default_time: an mx.DateTime.DateTimeDelta instance

        @param patterns: list of [time.strptime compatible date/time pattern, accuracy]
        @type patterns: list
        """
        matches = []

        matches.extend(__numbers_only(str2parse))
        matches.extend(__single_slash(str2parse))

        matches.extend ([
                {       'data': cFuzzyTimestamp(timestamp = m['data'], accuracy = acc_days),
                        'label': m['label']
                } for m in __single_dot2py_dt(str2parse)
        ])
        matches.extend ([
                {       'data': cFuzzyTimestamp(timestamp = m['data'], accuracy = acc_days),
                        'label': m['label']
                } for m in __single_char2py_dt(str2parse)
        ])
        matches.extend ([
                {       'data': cFuzzyTimestamp(timestamp = m['data'], accuracy = acc_days),
                        'label': m['label']
                } for m in __explicit_offset2py_dt(str2parse)
        ])

        if patterns is None:
                patterns = []
        patterns.extend([
                '%Y-%m-%d',
                '%y-%m-%d',
                '%Y/%m/%d',
                '%y/%m/%d',
                '%d-%m-%Y',
                '%d-%m-%y',
                '%d/%m/%Y',
                '%d/%m/%y',
                '%d.%m.%Y',
                '%m-%d-%Y',
                '%m-%d-%y',
                '%m/%d/%Y',
                '%m/%d/%y'
        ])

        parts = str2parse.split(maxsplit = 1)
        hour = 11
        minute = 11
        second = 11
        acc = acc_days
        if len(parts) > 1:
                for pattern in ['%H:%M', '%H:%M:%S']:
                        try:
                                date = pyDT.datetime.strptime(parts[1], pattern)
                                hour = date.hour
                                minute = date.minute
                                second = date.second
                                acc = acc_minutes
                                break
                        except ValueError:
                                # C-level overflow
                                continue
        for pattern in patterns:
                try:
                        ts = pyDT.datetime.strptime(parts[0], pattern).replace (
                                hour = hour,
                                minute = minute,
                                second = second,
                                tzinfo = gmCurrentLocalTimezone
                        )
                        fts = cFuzzyTimestamp(timestamp = ts, accuracy = acc)
                        matches.append ({
                                'data': fts,
                                'label': fts.format_accurately()
                        })
                except ValueError:
                        # C-level overflow
                        continue
        return matches

#===========================================================================
# fuzzy timestamp class
#---------------------------------------------------------------------------
class cFuzzyTimestamp:

        # FIXME: add properties for year, month, ...

        """A timestamp implementation with definable inaccuracy.

        This class contains an datetime.datetime instance to
        hold the actual timestamp. It adds an accuracy attribute
        to allow the programmer to set the precision of the
        timestamp.

        The timestamp will have to be initialized with a fully
        precise value (which may, of course, contain partially
        fake data to make up for missing values). One can then
        set the accuracy value to indicate up to which part of
        the timestamp the data is valid. Optionally a modifier
        can be set to indicate further specification of the
        value (such as "summer", "afternoon", etc).

        accuracy values:
                1: year only
                ...
                7: everything including milliseconds value

        Unfortunately, one cannot directly derive a class from mx.DateTime.DateTime :-(
        """
        #-----------------------------------------------------------------------
        def __init__(self, timestamp=None, accuracy=acc_subseconds, modifier=''):

                if timestamp is None:
                        timestamp = pydt_now_here()
                        accuracy = acc_subseconds
                        modifier = ''

                if (accuracy < 1) or (accuracy > 8):
                        raise ValueError('%s.__init__(): <accuracy> must be between 1 and 8' % self.__class__.__name__)

                if not isinstance(timestamp, pyDT.datetime):
                        raise TypeError('%s.__init__(): <timestamp> must be of datetime.datetime type, but is %s' % self.__class__.__name__, type(timestamp))

                if timestamp.tzinfo is None:
                        raise ValueError('%s.__init__(): <tzinfo> must be defined' % self.__class__.__name__)

                self.timestamp = timestamp
                self.accuracy = accuracy
                self.modifier =  modifier

        #-----------------------------------------------------------------------
        # magic API
        #-----------------------------------------------------------------------
        def __str__(self):
                """Return string representation meaningful to a user, also for %s formatting."""
                return self.format_accurately()

        #-----------------------------------------------------------------------
        def __repr__(self):
                """Return string meaningful to a programmer to aid in debugging."""
                tmp = '<[%s]: timestamp [%s], accuracy [%s] (%s), modifier [%s] at %s>' % (
                        self.__class__.__name__,
                        repr(self.timestamp),
                        self.accuracy,
                        _accuracy_strings[self.accuracy],
                        self.modifier,
                        id(self)
                )
                return tmp

        #-----------------------------------------------------------------------
        # external API
        #-----------------------------------------------------------------------
        def strftime(self, format_string):
                if self.accuracy == 7:
                        return self.timestamp.strftime(format_string)
                return self.format_accurately()

        #-----------------------------------------------------------------------
        def Format(self, format_string):
                return self.strftime(format_string)

        #-----------------------------------------------------------------------
        def format_accurately(self, accuracy=None):
                if accuracy is None:
                        accuracy = self.accuracy

                if accuracy == acc_years:
                        return str(self.timestamp.year)

                if accuracy == acc_months:
                        return self.timestamp.strftime('%m/%Y') # FIXME: use 3-letter month ?

                if accuracy == acc_weeks:
                        return self.timestamp.strftime('%m/%Y') # FIXME: use 3-letter month ?

                if accuracy == acc_days:
                        return self.timestamp.strftime('%Y-%m-%d')

                if accuracy == acc_hours:
                        return self.timestamp.strftime("%Y-%m-%d %I%p")

                if accuracy == acc_minutes:
                        return self.timestamp.strftime("%Y-%m-%d %H:%M")

                if accuracy == acc_seconds:
                        return self.timestamp.strftime("%Y-%m-%d %H:%M:%S")

                if accuracy == acc_subseconds:
                        return self.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")

                raise ValueError('%s.format_accurately(): <accuracy> (%s) must be between 1 and 7' % (
                        self.__class__.__name__,
                        accuracy
                ))

        #-----------------------------------------------------------------------
        def get_pydt(self):
                return self.timestamp

#===========================================================================
# main
#---------------------------------------------------------------------------
if __name__ == '__main__':

        if len(sys.argv) < 2:
                sys.exit()

        if sys.argv[1] != "test":
                sys.exit()

        from Gnumed.pycommon import gmI18N
        del _
        gmI18N.activate_locale()
        gmI18N.install_domain()

        #-----------------------------------------------------------------------
        intervals_as_str = [
                '7', '12', ' 12', '12 ', ' 12 ', '      12      ', '0', '~12', '~ 12', ' ~ 12', '       ~       12 ',
                '12a', '12 a', '12      a', '12j', '12J', '12y', '12Y', '       ~       12      a        ', '~0a',
                '12m', '17 m', '12      m', '17M', '    ~       17      m        ', ' ~ 3       / 12    ', '7/12', '0/12',
                '12w', '17 w', '12      w', '17W', '    ~       17      w        ', ' ~ 15      / 52', '2/52', '0/52',
                '12d', '17 d', '12      t', '17D', '    ~       17      T        ', ' ~ 12      / 7', '3/7', '0/7',
                '12h', '17 h', '12      H', '17H', '    ~       17      h        ', ' ~ 36      / 24', '7/24', '0/24',
                ' ~ 36  / 60', '7/60', '190/60', '0/60',
                '12a1m', '12 a 1  M', '12       a17m', '12j             12m', '12J7m', '12y7m', '12Y7M', '      ~       12      a        37 m   ', '~0a0m',
                '10m1w',
                'invalid interval input'
        ]
        #-----------------------------------------------------------------------
        def test_format_interval():
                intv = pyDT.timedelta(minutes=1, seconds=2)
                for acc in _accuracy_strings:
                        print ('[%s]: "%s" -> "%s"' % (acc, intv, format_interval(intv, acc)))
                return

                for tmp in intervals_as_str:
                        intv = str2interval(str_interval = tmp)
                        if intv is None:
                                print(tmp, '->', intv)
                                continue
                        for acc in _accuracy_strings:
                                print ('[%s]: "%s" -> "%s"' % (acc, tmp, format_interval(intv, acc)))

        #-----------------------------------------------------------------------
        def test_format_interval_medically():

                now = pydt_now_here()
                print(format_interval_medically(now - now, terse = False))
                print(format_interval_medically(now - now, terse = True))
                return

                intervals = [
                        pyDT.timedelta(seconds = 1),
                        pyDT.timedelta(seconds = 5),
                        pyDT.timedelta(seconds = 30),
                        pyDT.timedelta(seconds = 60),
                        pyDT.timedelta(seconds = 94),
                        pyDT.timedelta(seconds = 120),
                        pyDT.timedelta(minutes = 5),
                        pyDT.timedelta(minutes = 30),
                        pyDT.timedelta(minutes = 60),
                        pyDT.timedelta(minutes = 90),
                        pyDT.timedelta(minutes = 120),
                        pyDT.timedelta(minutes = 200),
                        pyDT.timedelta(minutes = 400),
                        pyDT.timedelta(minutes = 600),
                        pyDT.timedelta(minutes = 800),
                        pyDT.timedelta(minutes = 1100),
                        pyDT.timedelta(minutes = 2000),
                        pyDT.timedelta(minutes = 3500),
                        pyDT.timedelta(minutes = 4000),
                        pyDT.timedelta(hours = 1),
                        pyDT.timedelta(hours = 2),
                        pyDT.timedelta(hours = 4),
                        pyDT.timedelta(hours = 8),
                        pyDT.timedelta(hours = 12),
                        pyDT.timedelta(hours = 20),
                        pyDT.timedelta(hours = 23),
                        pyDT.timedelta(hours = 24),
                        pyDT.timedelta(hours = 25),
                        pyDT.timedelta(hours = 30),
                        pyDT.timedelta(hours = 48),
                        pyDT.timedelta(hours = 98),
                        pyDT.timedelta(hours = 120),
                        pyDT.timedelta(days = 1),
                        pyDT.timedelta(days = 2),
                        pyDT.timedelta(days = 4),
                        pyDT.timedelta(days = 16),
                        pyDT.timedelta(days = 29),
                        pyDT.timedelta(days = 30),
                        pyDT.timedelta(days = 31),
                        pyDT.timedelta(days = 37),
                        pyDT.timedelta(days = 40),
                        pyDT.timedelta(days = 47),
                        pyDT.timedelta(days = 126),
                        pyDT.timedelta(days = 127),
                        pyDT.timedelta(days = 128),
                        pyDT.timedelta(days = 300),
                        pyDT.timedelta(days = 359),
                        pyDT.timedelta(days = 360),
                        pyDT.timedelta(days = 361),
                        pyDT.timedelta(days = 362),
                        pyDT.timedelta(days = 363),
                        pyDT.timedelta(days = 364),
                        pyDT.timedelta(days = 365),
                        pyDT.timedelta(days = 366),
                        pyDT.timedelta(days = 367),
                        pyDT.timedelta(days = 400),
                        pyDT.timedelta(weeks = 53 * 30),
                        pyDT.timedelta(weeks = 53 * 79, days = 33),
                        pyDT.timedelta(days = 3650)
                ]
                idx = 1
                for intv in intervals:
                        print('%s) %s:' % (idx, intv))
                        print(' -> %s // %s // %s // %s' % (
                                format_interval_medically(intv, terse = False),
                                format_interval_medically(intv, terse = False, approximation_prefix = '\u2248'),
                                format_interval_medically(intv, terse = True),
                                format_interval_medically(intv, terse = True, approximation_prefix = '\u2248')
                        ))
                        idx += 1
                        if idx / 20 in [1.0, 2.0, 3.0]:
                                input('next')
                #intv = pyDT.timedelta(days = 3650)
                #print ('%s -> %s' % (intv, format_interval_medically(intv)))

        #-----------------------------------------------------------------------
        def test_str2interval():
                print ("testing str2interval()")
                print ("----------------------")

                for interval_as_str in intervals_as_str:
                        print ("input: <%s>" % interval_as_str)
                        print ("  ==>", str2interval(str_interval=interval_as_str))

                return True
        #-------------------------------------------------
        def test_date_time():
                print ("DST currently in effect:", dst_currently_in_effect)
                print ("current UTC offset:", current_local_utc_offset_in_seconds, "seconds")
                print ("current timezone (ISO conformant numeric string):", current_local_iso_numeric_timezone_string)
                print ("local timezone class:", cPlatformLocalTimezone)
                print ("")
                tz = cPlatformLocalTimezone()
                print ("local timezone instance:", tz)
                print (" (total) UTC offset:", tz.utcoffset(pyDT.datetime.now()))
                print (" DST adjustment:", tz.dst(pyDT.datetime.now()))
                print (" timezone name:", tz.tzname(pyDT.datetime.now()))
                print ("")
                print ("current local timezone:", gmCurrentLocalTimezone)
                print (" (total) UTC offset:", gmCurrentLocalTimezone.utcoffset(pyDT.datetime.now()))
                print (" DST adjustment:", gmCurrentLocalTimezone.dst(pyDT.datetime.now()))
                print (" timezone name:", gmCurrentLocalTimezone.tzname(pyDT.datetime.now()))
                print ("")
                print ("now here:", pydt_now_here())
                print ("")

        #-------------------------------------------------
        def test_str2fuzzy_timestamp_matches():
                print ("testing function str2fuzzy_timestamp_matches")
                print ("--------------------------------------------")

                val = None
                while val != 'exit':
                        val = input('Enter date fragment ("exit" quits): ')
                        matches = str2fuzzy_timestamp_matches(str2parse = val)
                        for match in matches:
                                print ('label shown  :', match['label'])
                                print ('data attached:', match['data'], match['data'].timestamp)
                                print ("")
                        print ("---------------")

        #-------------------------------------------------
        def test_cFuzzyTimeStamp():
                print ("testing fuzzy timestamp class")
                print ("-----------------------------")

                fts = cFuzzyTimestamp()
                print ("\nfuzzy timestamp <%s '%s'>" % ('class', fts.__class__.__name__))
                for accuracy in range(1,8):
                        fts.accuracy = accuracy
                        print ("  accuracy         : %s (%s)" % (accuracy, _accuracy_strings[accuracy]))
                        print ("  format_accurately:", fts.format_accurately())
                        print ("  strftime()       :", fts.strftime('%Y %b %d  %H:%M:%S'))
                        print ("  print ...        :", fts)
                        print ("  print '%%s' %% ... : %s" % fts)
                        print ("  str()            :", str(fts))
                        print ("  repr()           :", repr(fts))
                        input('press ENTER to continue')

        #-------------------------------------------------
        def test_get_pydt():
                print ("testing platform for handling dates before 1970")
                print ("-----------------------------------------------")
                #ts = mxDT.DateTime(1935, 4, 2)
                #fts = cFuzzyTimestamp(timestamp=ts)
                #print ("fts           :", fts)
                #print ("fts.get_pydt():", fts.get_pydt())
        #-------------------------------------------------
        def test_calculate_apparent_age():
                # test leap year glitches
                start = pydt_now_here().replace(year = 2000).replace(month = 2).replace(day = 29)
                end = pydt_now_here().replace(year = 2012).replace(month = 2).replace(day = 27)
                print ("start is leap year: 29.2.2000")
                print (" ", calculate_apparent_age(start = start, end = end))
                print (" ", format_apparent_age_medically(calculate_apparent_age(start = start)))

                start = pydt_now_here().replace(month = 10).replace(day = 23).replace(year = 1974)
                end = pydt_now_here().replace(year = 2012).replace(month = 2).replace(day = 29)
                print ("end is leap year: 29.2.2012")
                print (" ", calculate_apparent_age(start = start, end = end))
                print (" ", format_apparent_age_medically(calculate_apparent_age(start = start)))

                start = pydt_now_here().replace(year = 2000).replace(month = 2).replace(day = 29)
                end = pydt_now_here().replace(year = 2012).replace(month = 2).replace(day = 29)
                print ("start is leap year: 29.2.2000")
                print ("end is leap year: 29.2.2012")
                print (" ", calculate_apparent_age(start = start, end = end))
                print (" ", format_apparent_age_medically(calculate_apparent_age(start = start)))

                print ("leap year tests worked")

                start = pydt_now_here().replace(month = 10).replace(day = 23).replace(year = 1974)
                print (calculate_apparent_age(start = start))
                print (format_apparent_age_medically(calculate_apparent_age(start = start)))

                start = pydt_now_here().replace(month = 3).replace(day = 13).replace(year = 1979)
                print (calculate_apparent_age(start = start))
                print (format_apparent_age_medically(calculate_apparent_age(start = start)))

                start = pydt_now_here().replace(month = 2, day = 2).replace(year = 1979)
                end = pydt_now_here().replace(month = 3).replace(day = 31).replace(year = 1979)
                print (calculate_apparent_age(start = start, end = end))

                start = pydt_now_here().replace(month = 7, day = 21).replace(year = 2009)
                print (format_apparent_age_medically(calculate_apparent_age(start = start)))

                print ("-------")
                start = pydt_now_here().replace(month = 1).replace(day = 23).replace(hour = 12).replace(minute = 11).replace(year = 2011)
                print (calculate_apparent_age(start = start))
                print (format_apparent_age_medically(calculate_apparent_age(start = start)))
        #-------------------------------------------------
        def test_str2pydt_matches():
                print ("testing function str2pydt_matches")
                print ("---------------------------------")

                val = None
                while val != 'exit':
                        val = input('Enter date fragment ("exit" quits): ')
                        matches = str2pydt_matches(str2parse = val)
                        for match in matches:
                                print ('label shown  :', match['label'])
                                print ('data attached:', match['data'])
                                print ("")
                        print ("---------------")

        #-------------------------------------------------
        def test_pydt_strftime():
                dt = pydt_now_here()
                print (pydt_strftime(dt, '-(%Y %b %d)-'))
                print (pydt_strftime(dt))
                print (pydt_strftime(dt, accuracy = acc_days))
                print (pydt_strftime(dt, accuracy = acc_minutes))
                print (pydt_strftime(dt, accuracy = acc_seconds))
                dt = dt.replace(year = 1899)
                print (pydt_strftime(dt))
                print (pydt_strftime(dt, accuracy = acc_days))
                print (pydt_strftime(dt, accuracy = acc_minutes))
                print (pydt_strftime(dt, accuracy = acc_seconds))
                dt = dt.replace(year = 198)
                print (pydt_strftime(dt, accuracy = acc_seconds))
        #-------------------------------------------------
        def test_is_leap_year():
                for idx in range(120):
                        year = 1993 + idx
                        tmp, offset = divmod(idx, 4)
                        if is_leap_year(year):
                                print (offset+1, '--', year, 'leaps')
                        else:
                                print (offset+1, '--', year)

        #-------------------------------------------------
        def test_get_date_of_weekday_in_week_of_date():
                dt = pydt_now_here()
                print('weekday', dt.isoweekday(), '(2day):', dt)
                for weekday in range(8):
                        dt = get_date_of_weekday_in_week_of_date(weekday)
                        print('weekday', weekday, '(same):', dt)
                        dt = get_date_of_weekday_following_date(weekday)
                        print('weekday', weekday, '(next):', dt)
                try:
                        get_date_of_weekday_in_week_of_date(8)
                except ValueError as exc:
                        print(exc)
                try:
                        get_date_of_weekday_following_date(8)
                except ValueError as exc:
                        print(exc)

        #-------------------------------------------------
        def test__numbers_only():
                for val in range(-1, 35):
                        matches = __numbers_only(str(val))
                        print(val, ':')
                        for m in matches:
                                print('  ', m)
                        input()

        #-------------------------------------------------
        # GNUmed libs
        gmI18N.activate_locale()
        gmI18N.install_domain('gnumed')
        init()
        #test_date_time()
        #test_str2fuzzy_timestamp_matches()
        #test_str2pydt_matches()
        #test_get_date_of_weekday_in_week_of_date()
        #test_cFuzzyTimeStamp()
        #test_get_pydt()
        #test_str2interval()
        #test_format_interval()
        test_format_interval_medically()
        #test_pydt_strftime()
        #test_calculate_apparent_age()
        #test_is_leap_year()
        #test__numbers_only()

#===========================================================================

Global variables

var STR2PYDT_DEFAULT_PATTERNS

Default patterns being passed to strptime().

var STR2PYDT_PARSERS : list[typing.Callable[[str], dict]]

Specialized parsers for string -> datetime conversion.

Functions

def calculate_apparent_age(start=None, end=None) ‑> tuple

Calculate age in a way humans naively expect it.

This does not take into account time zones which may shift the result by up to one day.

Args

start
the beginning of the period-to-be-aged, the 'birth' if you will
end
the end of the period, default now

Returns

A tuple (years, …, seconds) as simple differences between the fields:

    (years, months, days, hours, minutes, seconds)
Expand source code
def calculate_apparent_age(start=None, end=None) -> tuple:
        """Calculate age in a way humans naively expect it.

        This does *not* take into account time zones which may
        shift the result by up to one day.

        Args:
                start: the beginning of the period-to-be-aged, the 'birth' if you will
                end: the end of the period, default *now*

        Returns:
                A tuple (years, ..., seconds) as simple differences
                between the fields:

                        (years, months, days, hours, minutes, seconds)
        """
        assert not((end is None) and (start is None)), 'one of <start> or <end> must be given'

        now = pyDT.datetime.now(gmCurrentLocalTimezone)
        if end is None:
                if start <= now:
                        end = now
                else:
                        end = start
                        start = now
        if end < start:
                raise ValueError('calculate_apparent_age(): <end> (%s) before <start> (%s)' % (end, start))
        if end == start:
                return (0, 0, 0, 0, 0, 0)

        # steer clear of leap years
        if end.month == 2:
                if end.day == 29:
                        if not is_leap_year(start.year):
                                end = end.replace(day = 28)

        # years
        years = end.year - start.year
        end = end.replace(year = start.year)
        if end < start:
                years = years - 1

        # months
        if end.month == start.month:
                if end < start:
                        months = 11
                else:
                        months = 0
        else:
                months = end.month - start.month
                if months < 0:
                        months = months + 12
                if end.day > gregorian_month_length[start.month]:
                        end = end.replace(month = start.month, day = gregorian_month_length[start.month])
                else:
                        end = end.replace(month = start.month)
                if end < start:
                        months = months - 1

        # days
        if end.day == start.day:
                if end < start:
                        days = gregorian_month_length[start.month] - 1
                else:
                        days = 0
        else:
                days = end.day - start.day
                if days < 0:
                        days = days + gregorian_month_length[start.month]
                end = end.replace(day = start.day)
                if end < start:
                        days = days - 1

        # hours
        if end.hour == start.hour:
                hours = 0
        else:
                hours = end.hour - start.hour
                if hours < 0:
                        hours = hours + 24
                end = end.replace(hour = start.hour)
                if end < start:
                        hours = hours - 1

        # minutes
        if end.minute == start.minute:
                minutes = 0
        else:
                minutes = end.minute - start.minute
                if minutes < 0:
                        minutes = minutes + 60
                end = end.replace(minute = start.minute)
                if end < start:
                        minutes = minutes - 1

        # seconds
        if end.second == start.second:
                seconds = 0
        else:
                seconds = end.second - start.second
                if seconds < 0:
                        seconds = seconds + 60
                end = end.replace(second = start.second)
                if end < start:
                        seconds = seconds - 1

        return (years, months, days, hours, minutes, seconds)
def format_apparent_age_medically(age=None)

must be a tuple as created by calculate_apparent_age()

Expand source code
def format_apparent_age_medically(age=None):
        """<age> must be a tuple as created by calculate_apparent_age()"""

        (years, months, days, hours, minutes, seconds) = age

        # at least 1 year ?
        if years > 0:
                if months == 0:
                        return '%s%s' % (
                                years,
                                _('y::year_abbreviation').replace('::year_abbreviation', '')
                        )
                return '%s%s %s%s' % (
                        years,
                        _('y::year_abbreviation').replace('::year_abbreviation', ''),
                        months,
                        _('m::month_abbreviation').replace('::month_abbreviation', '')
                )

        # at least 1 month ?
        if months > 0:
                if days == 0:
                        return '%s%s' % (
                                months,
                                _('mo::month_only_abbreviation').replace('::month_only_abbreviation', '')
                        )

                result = '%s%s' % (
                        months,
                        _('m::month_abbreviation').replace('::month_abbreviation', '')
                )

                weeks, days = divmod(days, 7)
                if int(weeks) != 0:
                        result += '%s%s' % (
                                int(weeks),
                                _('w::week_abbreviation').replace('::week_abbreviation', '')
                        )
                if int(days) != 0:
                        result += '%s%s' % (
                                int(days),
                                _('d::day_abbreviation').replace('::day_abbreviation', '')
                        )

                return result

        # between 7 days and 1 month
        if days > 7:
                return "%s%s" % (
                        days,
                        _('d::day_abbreviation').replace('::day_abbreviation', '')
                )

        # between 1 and 7 days ?
        if days > 0:
                if hours == 0:
                        return '%s%s' % (
                                days,
                                _('d::day_abbreviation').replace('::day_abbreviation', '')
                        )
                return '%s%s (%s%s)' % (
                        days,
                        _('d::day_abbreviation').replace('::day_abbreviation', ''),
                        hours,
                        _('h::hour_abbreviation').replace('::hour_abbreviation', '')
                )

        # between 5 hours and 1 day
        if hours > 5:
                return '%s%s' % (
                        hours,
                        _('h::hour_abbreviation').replace('::hour_abbreviation', '')
                )

        # between 1 and 5 hours
        if hours > 1:
                if minutes == 0:
                        return '%s%s' % (
                                hours,
                                _('h::hour_abbreviation').replace('::hour_abbreviation', '')
                        )
                return '%s:%02d' % (
                        hours,
                        minutes
                )

        # between 5 and 60 minutes
        if minutes > 5:
                return "0:%02d" % minutes

        # less than 5 minutes
        if minutes == 0:
                return '%s%s' % (
                        seconds,
                        _('s::second_abbreviation').replace('::second_abbreviation', '')
                )
        if seconds == 0:
                return "0:%02d" % minutes
        return "%s.%s%s" % (
                minutes,
                seconds,
                _('s::second_abbreviation').replace('::second_abbreviation', '')
        )
def format_dob(dob: datetime.datetime, format='%Y %b %d', none_string=None, dob_is_estimated=False)
Expand source code
def format_dob(dob:pyDT.datetime, format='%Y %b %d', none_string=None, dob_is_estimated=False):
        if dob is None:
                if none_string is None:
                        return _('** DOB unknown **')
                return none_string

        dob_txt = pydt_strftime(dob, format = format, accuracy = acc_days)
        if dob_is_estimated:
                return '%s%s' % ('\u2248', dob_txt)

        return dob_txt
def format_interval(interval=None, accuracy_wanted=None, none_string=None, verbose=False)
Expand source code
def format_interval(interval=None, accuracy_wanted=None, none_string=None, verbose=False):

        if accuracy_wanted is None:
                accuracy_wanted = acc_seconds

        if interval is None:
                if none_string is not None:
                        return none_string

        years, days = divmod(interval.days, avg_days_per_gregorian_year)
        months, days = divmod(days, avg_days_per_gregorian_month)
        weeks, days = divmod(days, days_per_week)
        days, secs = divmod((days * avg_seconds_per_day) + interval.seconds, avg_seconds_per_day)
        hours, secs = divmod(secs, 3600)
        mins, secs = divmod(secs, 60)

        if verbose:
                years_tag = ' ' + (_('year') if years == 1 else _('years'))
                months_tag = ' ' + (_('month') if months == 1 else _('months'))
                weeks_tag = ' ' + (_('week') if weeks == 1 else _('weeks'))
                days_tag = ' ' + (_('day') if days == 1 else _('days'))
                hours_tag = ' ' + (_('hour') if hours == 1 else _('hours'))
                minutes_tag = ' ' + (_('minute') if mins == 1 else _('minutes'))
                seconds_tag = ' ' + (_('second') if secs == 1 else _('seconds'))
        else:
                years_tag = _('interval_format_tag::years::y')[-1:]
                months_tag = _('interval_format_tag::months::m')[-1:]
                weeks_tag = _('interval_format_tag::weeks::w')[-1:]
                days_tag = _('interval_format_tag::days::d')[-1:]
                hours_tag = '/24'
                minutes_tag = '/60'
                seconds_tag = 's'

        # special cases
        if years == 0:
                if accuracy_wanted < acc_months:
                        return _('0 years') if verbose else '0%s' % years_tag

        if years + months == 0:
                if accuracy_wanted < acc_weeks:
                        return _('0 months') if verbose else '0%s' % months_tag

        if years + months + weeks == 0:
                if accuracy_wanted < acc_days:
                        return _('0 weeks') if verbose else '0%s' % weeks_tag

        if years + months + weeks + days == 0:
                if accuracy_wanted < acc_hours:
                        return _('0 days') if verbose else '0%s' % days_tag

        if years + months + weeks + days + hours == 0:
                if accuracy_wanted < acc_minutes:
                        return _('0 hours') if verbose else '0/24'

        if years + months + weeks + days + hours + mins == 0:
                if accuracy_wanted < acc_seconds:
                        return _('0 minutes') if verbose else '0/60'

        if years + months + weeks + days + hours + mins + secs == 0:
                return _('0 seconds') if verbose else '0s'

        # normal cases
        formatted_intv = ''
        if years > 0:
                formatted_intv += '%s%s' % (int(years), years_tag)
        if accuracy_wanted < acc_months:
                return formatted_intv.strip()

        if months > 0:
                formatted_intv += ' %s%s' % (int(months), months_tag)
        if accuracy_wanted < acc_weeks:
                return formatted_intv.strip()

        if weeks > 0:
                formatted_intv += ' %s%s' % (int(weeks), weeks_tag)
        if accuracy_wanted < acc_days:
                return formatted_intv.strip()

        if days > 0:
                formatted_intv += ' %s%s' % (int(days), days_tag)
        if accuracy_wanted < acc_hours:
                return formatted_intv.strip()

        if hours > 0:
                formatted_intv += ' %s%s' % (int(hours), hours_tag)
        if accuracy_wanted < acc_minutes:
                return formatted_intv.strip()

        if mins > 0:
                formatted_intv += ' %s%s' % (int(mins), minutes_tag)
        if accuracy_wanted < acc_seconds:
                return formatted_intv.strip()

        if secs > 0:
                formatted_intv += ' %s%s' % (int(secs), seconds_tag)
        return formatted_intv.strip()
def format_interval_medically(interval: datetime.timedelta = None, terse: bool = False, approximation_prefix: str = None, zero_duration_strings: list[str] = None)

Formats an interval.

    This isn't mathematically correct but close enough for display.

Args

interval
the interval to format
terse
output terse formatting or not
approximation_mark
an approxiation mark to apply in the formatting, if any
zero_duration_strings
a list of two strings, terse and verbose form, to return if a zero duration interval is to be formatted
Expand source code
def format_interval_medically(interval:pyDT.timedelta=None, terse:bool=False, approximation_prefix:str=None, zero_duration_strings:list[str]=None):
        """Formats an interval.

                This isn't mathematically correct but close enough for display.

        Args:
                interval: the interval to format
                terse: output terse formatting or not
                approximation_mark: an approxiation mark to apply in the formatting, if any
                zero_duration_strings: a list of two strings, terse and verbose form, to return if a zero duration interval is to be formatted
        """
        assert interval is not None, '<interval> must be given'

        if interval.total_seconds() == 0:
                if not zero_duration_strings:
                        zero_duration_strings = [
                                _('zero_duration_symbol::\u2300').removeprefix('zero_duration_symbol::'),
                                _('zero_duration_text::no duration').removeprefix('zero_duration_text::')
                        ]
                if terse:
                        return zero_duration_strings[0]
                return zero_duration_strings[1]

        spacer = '' if terse else ' '
        prefix = approximation_prefix if approximation_prefix else ''
        # more than 1 year ?
        if interval.days > 364:
                years, days = divmod(interval.days, avg_days_per_gregorian_year)
                months, day = divmod(days, 30.33)
                if int(months) == 0:
                        return '%s%s%s%s' % (
                                prefix,
                                spacer,
                                int(years),
                                _('interval_format_tag::years::y')[-1:]
                        )

                return '%s%s%s%s%s%s%s' % (
                        prefix,
                        spacer,
                        int(years),
                        _('interval_format_tag::years::y')[-1:],
                        spacer,
                        int(months),
                        _('interval_format_tag::months::m')[-1:]
                )

        # more than 30 days / 1 month ?
        if interval.days > 30:
                months, days = divmod(interval.days, 30.33)                                     # type: ignore [assignment]
                weeks, days = divmod(days, 7)
                result = '%s%s%s%s' % (
                        prefix,
                        spacer,
                        int(months),
                        _('interval_format_tag::months::m')[-1:]
                )
                if int(weeks) != 0:
                        result += '%s%s%s' % (spacer, int(weeks), _('interval_format_tag::weeks::w')[-1:])
                if int(days) != 0:
                        result += '%s%s%s' % (spacer, int(days), _('interval_format_tag::days::d')[-1:])
                return result

        # between 7 and 30 days ?
        if interval.days > 7:
                return '%s%s%s%s' % (prefix, spacer, interval.days, _('interval_format_tag::days::d')[-1:])

        # between 1 and 7 days ?
        if interval.days > 0:
                hours, seconds = divmod(interval.seconds, 3600)
                if hours == 0:
                        return '%s%s%s%s' % (prefix, spacer, interval.days, _('interval_format_tag::days::d')[-1:])

                return "%s%s%s%s%s%sh" % (
                        prefix,
                        spacer,
                        interval.days,
                        _('interval_format_tag::days::d')[-1:],
                        spacer,
                        int(hours)
                )

        # between 5 hours and 1 day
        if interval.seconds > (5*3600):
                return '%s%s%sh' % (prefix, spacer, int(interval.seconds // 3600))

        # between 1 and 5 hours
        if interval.seconds > 3600:
                hours, seconds = divmod(interval.seconds, 3600)
                minutes = seconds // 60
                if minutes == 0:
                        return '%s%s%sh' % (prefix, spacer, int(hours))
                return '%s:%02d' % (int(hours), int(minutes))

        # minutes only
        if interval.seconds > (5*60):
                return "0:%02d" % (int(interval.seconds // 60))

        # seconds
        minutes, seconds = divmod(interval.seconds, 60)
        if minutes == 0:
                return '%ss' % int(seconds)
        if seconds == 0:
                return '0:%02d' % int(minutes)
        return '%s.%ss' % (int(minutes), int(seconds))
def format_pregnancy_months(age)
Expand source code
def format_pregnancy_months(age):
        months, remainder = divmod(age.days, 28)
        return '%s%s' % (
                int(months) + 1,
                _('interval_format_tag::months::m')[-1:]
        )
def format_pregnancy_weeks(age)
Expand source code
def format_pregnancy_weeks(age):
        weeks, days = divmod(age.days, 7)
        return '%s%s%s%s' % (
                int(weeks),
                _('interval_format_tag::weeks::w')[-1:],
                int(days),
                _('interval_format_tag::days::d')[-1:]
        )
def get_date_of_weekday_following_date(weekday, base_dt: datetime.datetime = None)
Expand source code
def get_date_of_weekday_following_date(weekday, base_dt:pyDT.datetime=None):
        # weekday:
        # 0 = Sunday            # will be wrapped to 7
        # 1 = Monday ...
        if weekday not in [0,1,2,3,4,5,6,7]:
                raise ValueError('weekday must be in 0 (Sunday) to 7 (Sunday, again)')
        if weekday == 0:
                weekday = 7
        if base_dt is None:
                base_dt = pydt_now_here()
        dt_weekday = base_dt.isoweekday()               # 1 = Mon
        days2add = weekday - dt_weekday
        if days2add == 0:
                days2add = 7
        elif days2add < 0:
                days2add += 7
        return pydt_add(base_dt, days = days2add)
def get_date_of_weekday_in_week_of_date(weekday, base_dt: datetime.datetime = None) ‑> datetime.datetime
Expand source code
def get_date_of_weekday_in_week_of_date(weekday, base_dt:pyDT.datetime=None) -> pyDT.datetime:
        # weekday:
        # 0 = Sunday
        # 1 = Monday ...
        assert weekday in [0,1,2,3,4,5,6,7], 'weekday must be in 0 (Sunday) to 7 (Sunday, again)'

        if base_dt is None:
                base_dt = pydt_now_here()
        dt_weekday = base_dt.isoweekday()               # 1 = Mon
        day_diff = dt_weekday - weekday
        days2add = (-1 * day_diff)
        return pydt_add(base_dt, days = days2add)
def get_last_month(dt: datetime.datetime)
Expand source code
def get_last_month(dt:pyDT.datetime):
        last_month = dt.month - 1
        if last_month == 0:
                return 12
        return last_month
def get_next_month(dt: datetime.datetime)
Expand source code
def get_next_month(dt:pyDT.datetime):
        next_month = dt.month + 1
        if next_month == 13:
                return 1
        return next_month
def init()

Initialize date/time handling and log date/time environment.

Expand source code
def init():
        """Initialize date/time handling and log date/time environment."""

        _log.debug('datetime.now()   : [%s]' % pyDT.datetime.now())
        _log.debug('time.localtime() : [%s]' % str(time.localtime()))
        _log.debug('time.gmtime()    : [%s]' % str(time.gmtime()))

        try:
                _log.debug('$TZ: [%s]' % os.environ['TZ'])
        except KeyError:
                _log.debug('$TZ not defined')

        _log.debug('time.daylight           : [%s] (whether or not DST is locally used at all)', time.daylight)
        _log.debug('time.timezone           : [%s] seconds (+/-: WEST/EAST of Greenwich)', time.timezone)
        _log.debug('time.altzone            : [%s] seconds (+/-: WEST/EAST of Greenwich)', time.altzone)
        _log.debug('time.tzname             : [%s / %s] (non-DST / DST)' % time.tzname)
        _log.debug('time.localtime.tm_zone  : [%s]', time.localtime().tm_zone)
        _log.debug('time.localtime.tm_gmtoff: [%s]', time.localtime().tm_gmtoff)

        global py_timezone_name
        py_timezone_name = time.tzname[0]

        global py_dst_timezone_name
        py_dst_timezone_name = time.tzname[1]

        global dst_locally_in_use
        dst_locally_in_use = (time.daylight != 0)

        global dst_currently_in_effect
        dst_currently_in_effect = bool(time.localtime()[8])
        _log.debug('DST currently in effect: [%s]' % dst_currently_in_effect)

        if (not dst_locally_in_use) and dst_currently_in_effect:
                _log.error('system inconsistency: DST not in use - but DST currently in effect ?')

        global current_local_utc_offset_in_seconds
        msg = 'DST currently%sin effect: using UTC offset of [%s] seconds instead of [%s] seconds'
        if dst_currently_in_effect:
                current_local_utc_offset_in_seconds = time.altzone * -1
                _log.debug(msg % (' ', time.altzone * -1, time.timezone * -1))
        else:
                current_local_utc_offset_in_seconds = time.timezone * -1
                _log.debug(msg % (' not ', time.timezone * -1, time.altzone * -1))

        if current_local_utc_offset_in_seconds < 0:
                _log.debug('UTC offset is negative, assuming WEST of Greenwich (clock is "behind")')
        elif current_local_utc_offset_in_seconds > 0:
                _log.debug('UTC offset is positive, assuming EAST of Greenwich (clock is "ahead")')
        else:
                _log.debug('UTC offset is ZERO, assuming Greenwich Time')

        global current_local_iso_numeric_timezone_string
        current_local_iso_numeric_timezone_string = '%s' % current_local_utc_offset_in_seconds
        _log.debug('ISO numeric timezone string: [%s]' % current_local_iso_numeric_timezone_string)

        global current_local_timezone_name
        try:
                current_local_timezone_name = os.environ['TZ']
        except KeyError:
                if dst_currently_in_effect:
                        current_local_timezone_name = time.tzname[1]
                else:
                        current_local_timezone_name = time.tzname[0]

        global gmCurrentLocalTimezone
        gmCurrentLocalTimezone = cPlatformLocalTimezone()
        _log.debug('local-timezone class: %s', cPlatformLocalTimezone)
        _log.debug('local-timezone instance: %s', gmCurrentLocalTimezone)
def is_leap_year(year)
Expand source code
def is_leap_year(year):
        if year < 1582:         # no leap years before Gregorian Reform
                _log.debug('%s: before Gregorian Reform', year)
                return False

        # year is multiple of 4 ?
        div, remainder = divmod(year, 4)
        # * NOT divisible by 4
        # -> common year
        if remainder > 0:
                return False

        # year is a multiple of 100 ?
        div, remainder = divmod(year, 100)
        # * divisible by 4
        # * NOT divisible by 100
        # -> leap year
        if remainder > 0:
                return True

        # year is a multiple of 400 ?
        div, remainder = divmod(year, 400)
        # * divisible by 4
        # * divisible by 100, so, perhaps not leaping ?
        # * but ALSO divisible by 400
        # -> leap year
        if remainder == 0:
                return True

        # all others
        # -> common year
        return False
def pydt_add(dt: datetime.datetime, years=0, months=0, weeks=0, days=0, hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0)

Add some time to a given datetime.

Expand source code
def pydt_add(dt:pyDT.datetime, years=0, months=0, weeks=0, days=0, hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0):
        """Add some time to a given datetime."""
        if months > 11 or months < -11:
                raise ValueError('pydt_add(): months must be within [-11..11]')

        dt = dt + pyDT.timedelta (
                weeks = weeks,
                days = days,
                hours = hours,
                minutes = minutes,
                seconds = seconds,
                milliseconds = milliseconds,
                microseconds = microseconds
        )
        if (years == 0) and (months == 0):
                return dt

        target_year = dt.year + years
        target_month = dt.month + months
        if target_month > 12:
                target_year += 1
                target_month -= 12
        elif target_month < 1:
                target_year -= 1
                target_month += 12
        return pydt_replace(dt, year = target_year, month = target_month, strict = False)
def pydt_is_same_day(dt1, dt2)
Expand source code
def pydt_is_same_day(dt1, dt2):
        if dt1.day != dt2.day:
                return False
        if dt1.month != dt2.month:
                return False
        if dt1.year != dt2.year:
                return False

        return True
def pydt_is_today(dt)

Check wheter

is today.

Expand source code
def pydt_is_today(dt):
        """Check wheter <dt> is today."""
        if not dt:
                return None

        now = pyDT.datetime.now(gmCurrentLocalTimezone)
        return pydt_is_same_day(dt, now)
def pydt_is_yesterday(dt)

Check wheter

is yesterday.

Expand source code
def pydt_is_yesterday(dt):
        """Check wheter <dt> is yesterday."""
        if not dt:
                return None

        yesterday = pyDT.datetime.now(gmCurrentLocalTimezone) - pyDT.timedelta(days = 1)
        return pydt_is_same_day(dt, yesterday)
def pydt_max_here()
Expand source code
def pydt_max_here():
        return pyDT.datetime.max.replace(tzinfo = gmCurrentLocalTimezone)
def pydt_now_here()

Returns NOW @ HERE (IOW, in the local timezone.

Expand source code
def pydt_now_here():
        """Returns NOW @ HERE (IOW, in the local timezone."""
        return pyDT.datetime.now(gmCurrentLocalTimezone)
def pydt_replace(dt: datetime.datetime, strict=True, year=None, month=None, day=None, hour=None, minute=None, second=None, microsecond=None, tzinfo=None)
Expand source code
def pydt_replace(dt:pyDT.datetime, strict=True, year=None, month=None, day=None, hour=None, minute=None, second=None, microsecond=None, tzinfo=None):
        # normalization required because .replace() does not
        # deal with keyword arguments being None ...
        if year is None:
                year = dt.year
        if month is None:
                month = dt.month
        if day is None:
                day = dt.day
        if hour is None:
                hour = dt.hour
        if minute is None:
                minute = dt.minute
        if second is None:
                second = dt.second
        if microsecond is None:
                microsecond = dt.microsecond
        if tzinfo is None:
                tzinfo = dt.tzinfo              # can fail on naive dt's

        if strict:
                return dt.replace(year = year, month = month, day = day, hour = hour, minute = minute, second = second, microsecond = microsecond, tzinfo = tzinfo)

        try:
                return dt.replace(year = year, month = month, day = day, hour = hour, minute = minute, second = second, microsecond = microsecond, tzinfo = tzinfo)
        except ValueError:
                _log.debug('error replacing datetime member(s): %s', locals())

        # (target/existing) day did not exist in target month (which raised the exception)
        if month == 2:
                if day > 28:
                        if is_leap_year(year):
                                day = 29
                        else:
                                day = 28
        else:
                if day == 31:
                        day = 30

        return dt.replace(year = year, month = month, day = day, hour = hour, minute = minute, second = second, microsecond = microsecond, tzinfo = tzinfo)
def pydt_strftime(dt=None, format='%Y %b %d %H:%M.%S', accuracy=None, none_str=None)
Expand source code
def pydt_strftime(dt=None, format='%Y %b %d  %H:%M.%S', accuracy=None, none_str=None):

        if dt is None:
                if none_str is not None:
                        return none_str
                raise ValueError('must provide <none_str> if <dt>=None is to be dealt with')

        try:
                return dt.strftime(format)

        except ValueError:
                _log.exception()
                return 'strftime() error'
def str2fuzzy_timestamp_matches(str2parse=None, default_time=None, patterns=None)

Turn a string into candidate fuzzy timestamps and auto-completions the user is likely to type.

You MUST have called locale.setlocale(locale.LC_ALL, '') somewhere in your code previously.

@param default_time: if you want to force the time part of the time stamp to a given value and the user doesn't type any time part this value will be used @type default_time: an mx.DateTime.DateTimeDelta instance

@param patterns: list of [time.strptime compatible date/time pattern, accuracy] @type patterns: list

Expand source code
def str2fuzzy_timestamp_matches(str2parse=None, default_time=None, patterns=None):
        """
        Turn a string into candidate fuzzy timestamps and auto-completions the user is likely to type.

        You MUST have called locale.setlocale(locale.LC_ALL, '')
        somewhere in your code previously.

        @param default_time: if you want to force the time part of the time
                stamp to a given value and the user doesn't type any time part
                this value will be used
        @type default_time: an mx.DateTime.DateTimeDelta instance

        @param patterns: list of [time.strptime compatible date/time pattern, accuracy]
        @type patterns: list
        """
        matches = []

        matches.extend(__numbers_only(str2parse))
        matches.extend(__single_slash(str2parse))

        matches.extend ([
                {       'data': cFuzzyTimestamp(timestamp = m['data'], accuracy = acc_days),
                        'label': m['label']
                } for m in __single_dot2py_dt(str2parse)
        ])
        matches.extend ([
                {       'data': cFuzzyTimestamp(timestamp = m['data'], accuracy = acc_days),
                        'label': m['label']
                } for m in __single_char2py_dt(str2parse)
        ])
        matches.extend ([
                {       'data': cFuzzyTimestamp(timestamp = m['data'], accuracy = acc_days),
                        'label': m['label']
                } for m in __explicit_offset2py_dt(str2parse)
        ])

        if patterns is None:
                patterns = []
        patterns.extend([
                '%Y-%m-%d',
                '%y-%m-%d',
                '%Y/%m/%d',
                '%y/%m/%d',
                '%d-%m-%Y',
                '%d-%m-%y',
                '%d/%m/%Y',
                '%d/%m/%y',
                '%d.%m.%Y',
                '%m-%d-%Y',
                '%m-%d-%y',
                '%m/%d/%Y',
                '%m/%d/%y'
        ])

        parts = str2parse.split(maxsplit = 1)
        hour = 11
        minute = 11
        second = 11
        acc = acc_days
        if len(parts) > 1:
                for pattern in ['%H:%M', '%H:%M:%S']:
                        try:
                                date = pyDT.datetime.strptime(parts[1], pattern)
                                hour = date.hour
                                minute = date.minute
                                second = date.second
                                acc = acc_minutes
                                break
                        except ValueError:
                                # C-level overflow
                                continue
        for pattern in patterns:
                try:
                        ts = pyDT.datetime.strptime(parts[0], pattern).replace (
                                hour = hour,
                                minute = minute,
                                second = second,
                                tzinfo = gmCurrentLocalTimezone
                        )
                        fts = cFuzzyTimestamp(timestamp = ts, accuracy = acc)
                        matches.append ({
                                'data': fts,
                                'label': fts.format_accurately()
                        })
                except ValueError:
                        # C-level overflow
                        continue
        return matches
def str2interval(str_interval=None)
Expand source code
def str2interval(str_interval=None):

        unit_keys = {
                'year': _('yYaA_keys_year'),
                'month': _('mM_keys_month'),
                'week': _('wW_keys_week'),
                'day': _('dD_keys_day'),
                'hour': _('hH_keys_hour')
        }

        str_interval = str_interval.strip()

        # "(~)35(yY)"   - at age 35 years
        keys = '|'.join(list(unit_keys['year'].replace('_keys_year', '')))
        if regex.match(r'^~*(\s|\t)*\d+(%s)*$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(days = (int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]) * avg_days_per_gregorian_year))

        # "(~)12mM" - at age 12 months
        keys = '|'.join(list(unit_keys['month'].replace('_keys_month', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                years, months = divmod (
                        int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]),
                        12
                )
                return pyDT.timedelta(days = ((years * avg_days_per_gregorian_year) + (months * avg_days_per_gregorian_month)))

        # weeks
        keys = '|'.join(list(unit_keys['week'].replace('_keys_week', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(weeks = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # days
        keys = '|'.join(list(unit_keys['day'].replace('_keys_day', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(days = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # hours
        keys = '|'.join(list(unit_keys['hour'].replace('_keys_hour', '')))
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*(%s)+$' % keys, str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(hours = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/12 - months
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*12$', str_interval, flags = regex.UNICODE):
                years, months = divmod (
                        int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]),
                        12
                )
                return pyDT.timedelta(days = ((years * avg_days_per_gregorian_year) + (months * avg_days_per_gregorian_month)))

        # x/52 - weeks
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*52$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(weeks = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/7 - days
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*7$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(days = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/24 - hours
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*24$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(hours = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # x/60 - minutes
        if regex.match(r'^~*(\s|\t)*\d+(\s|\t)*/(\s|\t)*60$', str_interval, flags = regex.UNICODE):
                return pyDT.timedelta(minutes = int(regex.findall(r'\d+', str_interval, flags = regex.UNICODE)[0]))

        # nYnM - years, months
        keys_year = '|'.join(list(unit_keys['year'].replace('_keys_year', '')))
        keys_month = '|'.join(list(unit_keys['month'].replace('_keys_month', '')))
        if regex.match(r'^~*(\s|\t)*\d+(%s|\s|\t)+\d+(\s|\t)*(%s)+$' % (keys_year, keys_month), str_interval, flags = regex.UNICODE):
                parts = regex.findall(r'\d+', str_interval, flags = regex.UNICODE)
                years, months = divmod(int(parts[1]), 12)
                years += int(parts[0])
                return pyDT.timedelta(days = ((years * avg_days_per_gregorian_year) + (months * avg_days_per_gregorian_month)))

        # nMnW - months, weeks
        keys_month = '|'.join(list(unit_keys['month'].replace('_keys_month', '')))
        keys_week = '|'.join(list(unit_keys['week'].replace('_keys_week', '')))
        if regex.match(r'^~*(\s|\t)*\d+(%s|\s|\t)+\d+(\s|\t)*(%s)+$' % (keys_month, keys_week), str_interval, flags = regex.UNICODE):
                parts = regex.findall(r'\d+', str_interval, flags = regex.UNICODE)
                months, weeks = divmod(int(parts[1]), 4)
                months += int(parts[0])
                return pyDT.timedelta(days = ((months * avg_days_per_gregorian_month) + (weeks * days_per_week)))

        return None
def str2pydt_matches(str2parse: str = None, patterns: list = None) ‑> list

Turn a string into candidate datetimes.

Args

str2parse
string to turn into candidate datetimes
patterns
additional patterns to try with strptime()

A number of default patterns will be tried. Also, a few specialized parsers will be run. See the source for details.

If the input contains a space followed by more characters matching either hour:minute or hour:minute:second that will be used as the time part of the datetime returned. Otherwise 11:11:11 will be used as default.

Note: You must have previously called

    locale.setlocale(locale.LC_ALL, '')

somewhere in your code.

Returns

List of Python datetimes the input could be parsed as.

Expand source code
def str2pydt_matches(str2parse:str=None, patterns:list=None) -> list:
        """Turn a string into candidate datetimes.

        Args:
                str2parse: string to turn into candidate datetimes
                patterns: additional patterns to try with strptime()

        A number of default patterns will be tried. Also, a few
        specialized parsers will be run. See the source for
        details.

        If the input contains a space followed by more characters
        matching either hour:minute or hour:minute:second that
        will be used as the time part of the datetime returned.
        Otherwise 11:11:11 will be used as default.

        Note: You must have previously called

                locale.setlocale(locale.LC_ALL, '')

        somewhere in your code.

        Returns:
                List of Python datetimes the input could be parsed as.
        """
        matches:list[dict] = []
        for parser in STR2PYDT_PARSERS:
                matches.extend(parser(str2parse))
        parts = str2parse.split(maxsplit = 1)
        hour = 11
        minute = 11
        second = 11
        acc = acc_days
        lbl_fmt = '%Y-%m-%d'
        if len(parts) > 1:
                for pattern in ['%H:%M', '%H:%M:%S']:
                        try:
                                date = pyDT.datetime.strptime(parts[1], pattern)
                                hour = date.hour
                                minute = date.minute
                                second = date.second
                                acc = acc_minutes
                                lbl_fmt = '%Y-%m-%d %H:%M'
                                break
                        except ValueError:
                                # C-level overflow
                                continue
        if patterns is None:
                patterns = []
        patterns.extend(STR2PYDT_DEFAULT_PATTERNS)
        for pattern in patterns:
                try:
                        date = pyDT.datetime.strptime(parts[0], pattern).replace (
                                hour = hour,
                                minute = minute,
                                second = second,
                                tzinfo = gmCurrentLocalTimezone
                        )
                        matches.append ({
                                'data': date,
                                'label': pydt_strftime(date, format = lbl_fmt, accuracy = acc)
                        })
                except ValueError:
                        # C-level overflow
                        continue
        return matches
def wxDate2py_dt(wxDate=None)
Expand source code
def wxDate2py_dt(wxDate=None):
        if not wxDate.IsValid():
                raise ValueError ('invalid wxDate: %s-%s-%s %s:%s %s.%s',
                        wxDate.GetYear(),
                        wxDate.GetMonth(),
                        wxDate.GetDay(),
                        wxDate.GetHour(),
                        wxDate.GetMinute(),
                        wxDate.GetSecond(),
                        wxDate.GetMillisecond()
                )

        try:
                return pyDT.datetime (
                        year = wxDate.GetYear(),
                        month = wxDate.GetMonth() + 1,
                        day = wxDate.GetDay(),
                        tzinfo = gmCurrentLocalTimezone
                )
        except Exception:
                _log.debug ('error converting wxDateTime to Python: %s-%s-%s %s:%s %s.%s',
                        wxDate.GetYear(),
                        wxDate.GetMonth(),
                        wxDate.GetDay(),
                        wxDate.GetHour(),
                        wxDate.GetMinute(),
                        wxDate.GetSecond(),
                        wxDate.GetMillisecond()
                )
                raise

Classes

class cFuzzyTimestamp (timestamp=None, accuracy=8, modifier='')

A timestamp implementation with definable inaccuracy.

This class contains an datetime.datetime instance to hold the actual timestamp. It adds an accuracy attribute to allow the programmer to set the precision of the timestamp.

The timestamp will have to be initialized with a fully precise value (which may, of course, contain partially fake data to make up for missing values). One can then set the accuracy value to indicate up to which part of the timestamp the data is valid. Optionally a modifier can be set to indicate further specification of the value (such as "summer", "afternoon", etc).

accuracy values: 1: year only … 7: everything including milliseconds value

Unfortunately, one cannot directly derive a class from mx.DateTime.DateTime :-(

Expand source code
class cFuzzyTimestamp:

        # FIXME: add properties for year, month, ...

        """A timestamp implementation with definable inaccuracy.

        This class contains an datetime.datetime instance to
        hold the actual timestamp. It adds an accuracy attribute
        to allow the programmer to set the precision of the
        timestamp.

        The timestamp will have to be initialized with a fully
        precise value (which may, of course, contain partially
        fake data to make up for missing values). One can then
        set the accuracy value to indicate up to which part of
        the timestamp the data is valid. Optionally a modifier
        can be set to indicate further specification of the
        value (such as "summer", "afternoon", etc).

        accuracy values:
                1: year only
                ...
                7: everything including milliseconds value

        Unfortunately, one cannot directly derive a class from mx.DateTime.DateTime :-(
        """
        #-----------------------------------------------------------------------
        def __init__(self, timestamp=None, accuracy=acc_subseconds, modifier=''):

                if timestamp is None:
                        timestamp = pydt_now_here()
                        accuracy = acc_subseconds
                        modifier = ''

                if (accuracy < 1) or (accuracy > 8):
                        raise ValueError('%s.__init__(): <accuracy> must be between 1 and 8' % self.__class__.__name__)

                if not isinstance(timestamp, pyDT.datetime):
                        raise TypeError('%s.__init__(): <timestamp> must be of datetime.datetime type, but is %s' % self.__class__.__name__, type(timestamp))

                if timestamp.tzinfo is None:
                        raise ValueError('%s.__init__(): <tzinfo> must be defined' % self.__class__.__name__)

                self.timestamp = timestamp
                self.accuracy = accuracy
                self.modifier =  modifier

        #-----------------------------------------------------------------------
        # magic API
        #-----------------------------------------------------------------------
        def __str__(self):
                """Return string representation meaningful to a user, also for %s formatting."""
                return self.format_accurately()

        #-----------------------------------------------------------------------
        def __repr__(self):
                """Return string meaningful to a programmer to aid in debugging."""
                tmp = '<[%s]: timestamp [%s], accuracy [%s] (%s), modifier [%s] at %s>' % (
                        self.__class__.__name__,
                        repr(self.timestamp),
                        self.accuracy,
                        _accuracy_strings[self.accuracy],
                        self.modifier,
                        id(self)
                )
                return tmp

        #-----------------------------------------------------------------------
        # external API
        #-----------------------------------------------------------------------
        def strftime(self, format_string):
                if self.accuracy == 7:
                        return self.timestamp.strftime(format_string)
                return self.format_accurately()

        #-----------------------------------------------------------------------
        def Format(self, format_string):
                return self.strftime(format_string)

        #-----------------------------------------------------------------------
        def format_accurately(self, accuracy=None):
                if accuracy is None:
                        accuracy = self.accuracy

                if accuracy == acc_years:
                        return str(self.timestamp.year)

                if accuracy == acc_months:
                        return self.timestamp.strftime('%m/%Y') # FIXME: use 3-letter month ?

                if accuracy == acc_weeks:
                        return self.timestamp.strftime('%m/%Y') # FIXME: use 3-letter month ?

                if accuracy == acc_days:
                        return self.timestamp.strftime('%Y-%m-%d')

                if accuracy == acc_hours:
                        return self.timestamp.strftime("%Y-%m-%d %I%p")

                if accuracy == acc_minutes:
                        return self.timestamp.strftime("%Y-%m-%d %H:%M")

                if accuracy == acc_seconds:
                        return self.timestamp.strftime("%Y-%m-%d %H:%M:%S")

                if accuracy == acc_subseconds:
                        return self.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")

                raise ValueError('%s.format_accurately(): <accuracy> (%s) must be between 1 and 7' % (
                        self.__class__.__name__,
                        accuracy
                ))

        #-----------------------------------------------------------------------
        def get_pydt(self):
                return self.timestamp

Methods

def Format(self, format_string)
Expand source code
def Format(self, format_string):
        return self.strftime(format_string)
def format_accurately(self, accuracy=None)
Expand source code
def format_accurately(self, accuracy=None):
        if accuracy is None:
                accuracy = self.accuracy

        if accuracy == acc_years:
                return str(self.timestamp.year)

        if accuracy == acc_months:
                return self.timestamp.strftime('%m/%Y') # FIXME: use 3-letter month ?

        if accuracy == acc_weeks:
                return self.timestamp.strftime('%m/%Y') # FIXME: use 3-letter month ?

        if accuracy == acc_days:
                return self.timestamp.strftime('%Y-%m-%d')

        if accuracy == acc_hours:
                return self.timestamp.strftime("%Y-%m-%d %I%p")

        if accuracy == acc_minutes:
                return self.timestamp.strftime("%Y-%m-%d %H:%M")

        if accuracy == acc_seconds:
                return self.timestamp.strftime("%Y-%m-%d %H:%M:%S")

        if accuracy == acc_subseconds:
                return self.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")

        raise ValueError('%s.format_accurately(): <accuracy> (%s) must be between 1 and 7' % (
                self.__class__.__name__,
                accuracy
        ))
def get_pydt(self)
Expand source code
def get_pydt(self):
        return self.timestamp
def strftime(self, format_string)
Expand source code
def strftime(self, format_string):
        if self.accuracy == 7:
                return self.timestamp.strftime(format_string)
        return self.format_accurately()
class cPlatformLocalTimezone

Local timezone implementation (lifted from the docs).

A class capturing the platform's idea of local time.

May result in wrong values on historical times in timezones where UTC offset and/or the DST rules had changed in the past.

Expand source code
class cPlatformLocalTimezone(pyDT.tzinfo):
        """Local timezone implementation (lifted from the docs).

        A class capturing the platform's idea of local time.

        May result in wrong values on historical times in
        timezones where UTC offset and/or the DST rules had
        changed in the past."""
        def __init__(self):
                self._SECOND = pyDT.timedelta(seconds = 1)
                self._nonDST_OFFSET_FROM_UTC = pyDT.timedelta(seconds = -time.timezone)
                if time.daylight:
                        self._DST_OFFSET_FROM_UTC = pyDT.timedelta(seconds = -time.altzone)
                else:
                        self._DST_OFFSET_FROM_UTC = self._nonDST_OFFSET_FROM_UTC
                self._DST_SHIFT = self._DST_OFFSET_FROM_UTC - self._nonDST_OFFSET_FROM_UTC
                _log.debug('[%s]: UTC->non-DST offset [%s], UTC->DST offset [%s], DST shift [%s]', self.__class__.__name__, self._nonDST_OFFSET_FROM_UTC, self._DST_OFFSET_FROM_UTC, self._DST_SHIFT)

        #-----------------------------------------------------------------------
        def fromutc(self, dt):
                assert dt.tzinfo is self
                stamp = (dt - pyDT.datetime(1970, 1, 1, tzinfo = self)) // self._SECOND
                args = time.localtime(stamp)[:6]
                dst_diff = self._DST_SHIFT // self._SECOND
                # Detect fold
                fold = (args == time.localtime(stamp - dst_diff))
                return pyDT.datetime(*args, microsecond = dt.microsecond, tzinfo = self, fold = fold)

        #-----------------------------------------------------------------------
        def utcoffset(self, dt):
                if self._isdst(dt):
                        return self._DST_OFFSET_FROM_UTC
                return self._nonDST_OFFSET_FROM_UTC

        #-----------------------------------------------------------------------
        def dst(self, dt):
                if self._isdst(dt):
                        return self._DST_SHIFT
                return pyDT.timedelta(0)

        #-----------------------------------------------------------------------
        def tzname(self, dt):
                return time.tzname[self._isdst(dt)]

        #-----------------------------------------------------------------------
        def _isdst(self, dt):
                tt = (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.weekday(), 0, 0)
                try:
                        stamp = time.mktime(tt)
                except (OverflowError, ValueError):
                        _log.exception('overflow in time.mktime(%s)', tt)
                        return False

                tt = time.localtime(stamp)
                return tt.tm_isdst > 0

Ancestors

  • datetime.tzinfo

Methods

def dst(self, dt)

datetime -> DST offset as timedelta positive east of UTC.

Expand source code
def dst(self, dt):
        if self._isdst(dt):
                return self._DST_SHIFT
        return pyDT.timedelta(0)
def fromutc(self, dt)

datetime in UTC -> datetime in local time.

Expand source code
def fromutc(self, dt):
        assert dt.tzinfo is self
        stamp = (dt - pyDT.datetime(1970, 1, 1, tzinfo = self)) // self._SECOND
        args = time.localtime(stamp)[:6]
        dst_diff = self._DST_SHIFT // self._SECOND
        # Detect fold
        fold = (args == time.localtime(stamp - dst_diff))
        return pyDT.datetime(*args, microsecond = dt.microsecond, tzinfo = self, fold = fold)
def tzname(self, dt)

datetime -> string name of time zone.

Expand source code
def tzname(self, dt):
        return time.tzname[self._isdst(dt)]
def utcoffset(self, dt)

datetime -> timedelta showing offset from UTC, negative values indicating West of UTC

Expand source code
def utcoffset(self, dt):
        if self._isdst(dt):
                return self._DST_OFFSET_FROM_UTC
        return self._nonDST_OFFSET_FROM_UTC