Module datetimecalc.functions

functions for working with simple temporal expressions

Functions

def format_temporal_object(obj: datetime.datetime | datetime.timedelta | datetime.tzinfo) ‑> str
Expand source code
def format_temporal_object(obj: TemporalObject) -> str:
    """
    prints a string representation of the given object - this may or may not
    use the object's built-in string representations
    """
    if isinstance(obj, bool):
        return "True" if obj else "False"
    if is_datetime(obj):
        assert isinstance(obj, datetime)  # nosec: for mypy's benefit
        return str(obj)
    if is_timedelta(obj):
        assert isinstance(obj, timedelta)  # nosec: for mypy's benefit
        return duration_to_string(obj)
    if is_timezone(obj):
        assert isinstance(obj, tzinfo)  # nosec: for mypy's benefit
        return str(obj)
    raise ValueError(f'no handler for object of type {type(obj)} with value "{obj}"')

prints a string representation of the given object - this may or may not use the object's built-in string representations

def is_datetime(obj: Any) ‑> bool
Expand source code
def is_datetime(obj: Any) -> bool:
    """
    Check if the given object is a datetime.

    Args:
        obj: The object to be checked.

    Returns:
        True if the object is a datetime, False otherwise.

    Examples:
        >>> is_datetime(datetime.now())
        True

        >>> is_datetime(timedelta(days=1))
        False
    """
    return isinstance(obj, datetime)

Check if the given object is a datetime.

Args

obj
The object to be checked.

Returns

True if the object is a datetime, False otherwise.

Examples

>>> is_datetime(datetime.now())
True
>>> is_datetime(timedelta(days=1))
False
def is_timedelta(obj: Any) ‑> bool
Expand source code
def is_timedelta(obj: Any) -> bool:
    """
    Check if the given object is a timedelta.

    Args:
        obj: The object to be checked.

    Returns:
        True if the object is a timedelta, False otherwise.

    Examples:
        >>> is_timedelta(timedelta(days=1))
        True

        >>> is_timedelta(datetime.now())
        False
    """
    return isinstance(obj, timedelta)

Check if the given object is a timedelta.

Args

obj
The object to be checked.

Returns

True if the object is a timedelta, False otherwise.

Examples

>>> is_timedelta(timedelta(days=1))
True
>>> is_timedelta(datetime.now())
False
def is_timezone(obj: Any) ‑> bool
Expand source code
def is_timezone(obj: Any) -> bool:
    """
    Check if the given object is a tzinfo.

    Args:
        obj: The object to be checked.

    Returns:
        True if the object is a tzinfo, False otherwise.

    Examples:
        >>> from datetime import timezone
        >>> is_timezone(timezone.utc)
        True

        >>> is_timezone(datetime.now())
        False
    """
    return isinstance(obj, tzinfo)

Check if the given object is a tzinfo.

Args

obj
The object to be checked.

Returns

True if the object is a tzinfo, False otherwise.

Examples

>>> from datetime import timezone
>>> is_timezone(timezone.utc)
True
>>> is_timezone(datetime.now())
False
def parse_datetime_str(input_str: str) ‑> datetime.datetime
Expand source code
def parse_datetime_str(input_str: str) -> datetime:
    """
    Parse the input string for a natural language reference to a date and
    return a datetime object.

    Args:
        input_str: The input string containing the natural language reference.

    Returns:
        A datetime object representing the parsed date.

    Raises:
        ValueError: If the input string cannot be parsed.

    Examples:
        >>> parse_datetime_str('tomorrow')
        datetime.datetime(...)
        >>> parse_datetime_str('next week')
        datetime.datetime(...)
        >>> parse_datetime_str("jan 6th 2022 9:00 AM UTC")
        datetime.datetime(2022, 1, 6, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
        >>> parse_datetime_str("2022-01-01 00:00 UTC")
        datetime.datetime(2022, 1, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
    """
    # scan for timezone
    tzinfo_obj, filtered_input = search_tz(input_str)
    if tzinfo_obj:
        logging.debug(
            'recognized timezone "%s"; proceeding to parse: "%s"',
            str(tzinfo_obj),
            filtered_input,
        )
    else:
        logging.debug("no recognized timezone")

    # Create a parsedatetime.Calendar instance
    cal = parsedatetime.Calendar(version=parsedatetime.VERSION_CONTEXT_STYLE)

    # Use parsedatetime.Calendar.parseDT to parse the input string and obtain a
    # datetime object
    parsed_date, parse_status = cal.parseDT(
        filtered_input if tzinfo_obj else input_str,
        tzinfo=tzinfo_obj,
        # sourceTime=datetime(1, 1, 1),
    )
    logging.debug("parsed_date: %s, accuracy: %s", parsed_date, parse_status.accuracy)
    if not parse_status or parse_status.accuracy == 0:
        raise ValueError("Unable to parse the input string.")

    # Make the datetime object timezone aware
    # parsed_date = parsed_date.replace(tzinfo=dateutil.tz.gettz())

    # Return the datetime object
    return parsed_date

Parse the input string for a natural language reference to a date and return a datetime object.

Args

input_str
The input string containing the natural language reference.

Returns

A datetime object representing the parsed date.

Raises

ValueError
If the input string cannot be parsed.

Examples

>>> parse_datetime_str('tomorrow')
datetime.datetime(...)
>>> parse_datetime_str('next week')
datetime.datetime(...)
>>> parse_datetime_str("jan 6th 2022 9:00 AM UTC")
datetime.datetime(2022, 1, 6, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
>>> parse_datetime_str("2022-01-01 00:00 UTC")
datetime.datetime(2022, 1, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
def parse_repr_str(repr_str: str) ‑> datetime.datetime | datetime.timedelta | datetime.tzinfo
Expand source code
def parse_repr_str(repr_str: str) -> TemporalObject:
    """
    Parses the repr forms of datetime and timedelta objects and returns
    equivalent objects.

    Args:
        repr_str: A string in the repr format of a datetime, or timedelta, object.

    Returns:
        The datetime, timedelta, or tzinfo object represented by the input string.

    Raises:
        ValueError: If the input string cannot be parsed to any of these types.

    Examples:
        >>> parse_repr_str("datetime.datetime(2023, 8, 21, 14, 30, 45, 780000, "\
        "tzinfo=datetime.timezone.utc)")
        datetime.datetime(2023, 8, 21, 14, 30, 45, 780000, tzinfo=datetime.timezone.utc)
        >>> parse_repr_str("datetime.datetime(2023, 8, 21, 14, 30, 45, 780000)")
        datetime.datetime(2023, 8, 21, 14, 30, 45, 780000)
        >>> parse_repr_str("datetime.timedelta(seconds=86400)")
        datetime.timedelta(days=1)
        >>> parse_repr_str("datetime.timedelta(days=2, seconds=3600, "\
        "microseconds=500000)")
        datetime.timedelta(days=2, seconds=3600, microseconds=500000)
    """
    match = _DATETIME_REPR_PATTERN.fullmatch(repr_str)
    if not match:
        raise ValueError("not a valid datetime repr")

    obj_class = match.group("class")
    arglist = match.group("arglist").split(", ")
    args, kwarg_strs = spliton(arglist, contains="=")
    logging.debug("obj_class: %s", repr(obj_class))
    logging.debug("arglist: %s", repr(arglist))
    logging.debug("args: %s", repr(args))
    logging.debug("kwargs: %s", repr(kwarg_strs))

    if obj_class == "datetime":
        tz_arg: Optional[tzinfo] = None
        for kwarg in kwarg_strs:
            k, valstr = kwarg.split("=", 2)
            if k != "tzinfo":
                raise ValueError(f"can't parse kwarg int datetime repr: {kwarg}")
            if valstr == "datetime.timezone.utc":
                tz_arg = timezone.utc
            elif match := _ZONEINFO_REPR_PATTERN.match(valstr):
                tz_arg = parse_timezone_str(match.group("key"))
        # https://github.com/python/mypy/issues/10348
        return datetime(*[int(arg) for arg in args[:7]], tzinfo=tz_arg)  # type: ignore

    if obj_class == "timedelta":
        return timedelta(
            **{
                k: int(v)
                for k, v in (kwarg_str.split("=", 2) for kwarg_str in kwarg_strs)
            }
        )

    raise ValueError(f"Cannot parse {repr_str!r} as datetime or timedelta")

Parses the repr forms of datetime and timedelta objects and returns equivalent objects.

Args

repr_str
A string in the repr format of a datetime, or timedelta, object.

Returns

The datetime, timedelta, or tzinfo object represented by the input string.

Raises

ValueError
If the input string cannot be parsed to any of these types.

Examples

>>> parse_repr_str("datetime.datetime(2023, 8, 21, 14, 30, 45, 780000, "        "tzinfo=datetime.timezone.utc)")
datetime.datetime(2023, 8, 21, 14, 30, 45, 780000, tzinfo=datetime.timezone.utc)
>>> parse_repr_str("datetime.datetime(2023, 8, 21, 14, 30, 45, 780000)")
datetime.datetime(2023, 8, 21, 14, 30, 45, 780000)
>>> parse_repr_str("datetime.timedelta(seconds=86400)")
datetime.timedelta(days=1)
>>> parse_repr_str("datetime.timedelta(days=2, seconds=3600, "        "microseconds=500000)")
datetime.timedelta(days=2, seconds=3600, microseconds=500000)
def parse_temporal_expr(expression: str) ‑> datetime.datetime | datetime.timedelta | datetime.tzinfo
Expand source code
def parse_temporal_expr(expression: str) -> TemporalObject:
    """
    Parse a temporal expression and perform the corresponding operation.

    The temporal expression should be in the format "<term> <operator> <term>",
    where <term> can be either a datetime or a timedelta object, and <operator>
    can be one of the following: '+', '-', '<', '<=', '>', '>=', '==', '!='.

    Args:
        expression: The temporal expression to parse.

    Returns:
        The result of the operation, which can be a datetime or a timedelta
        object.

    Raises:
        ValueError: If the expression is invalid or if the operation is not
        supported.

    Examples:
        >>> parse_temporal_expr('2022-01-01 12:00 UTC  + 1 day')
        datetime.datetime(2022, 1, 2, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
        >>> parse_temporal_expr('2022-01-01 00:00 - 1 week')
        datetime.datetime(2021, 12, 25, 0, 0)
        >>> parse_temporal_expr('2022-01-01 UTC < 2023-01-01 UTC')
        True
        >>> parse_temporal_expr('1 day == 24 hours')
        True
        >>> parse_temporal_expr('2025-01-02 - 2023-01-01')
        datetime.timedelta(days=732)
        >>> parse_temporal_expr('2022-01-01 + 2023-01-01')  # Raises ValueError
        Traceback (most recent call last):
            ...
        ValueError: Unsupported operation: datetime + datetime
    """
    match = _TEMPORAL_EXPR_PATTERN.match(expression)
    if not match:
        logging.debug("no match on time_op, trying as temporal_str")
        try:
            return parse_temporal_str(expression)
        except ValueError:
            raise ValueError("unable to parse the expression, generally") from None
    logging.debug("capture groups: %s", match.groupdict())
    a_term = parse_temporal_str(match.group("a").strip())
    b_term = parse_temporal_str(match.group("b").strip())
    op_string = match.group("op").strip()
    op_func = {
        "+": operator.add,
        "-": operator.sub,
        "<": operator.lt,
        "<=": operator.le,
        ">": operator.gt,
        ">=": operator.ge,
        "==": operator.eq,
        "!=": operator.ne,
        "@": lambda dt, tz: dt.astimezone(tz),
    }[op_string]

    logging.debug("%s %s %s", repr(a_term), repr(op_string), repr(b_term))

    match (a_term, op_string, b_term):
        # datetime +/- timedelta == datetime
        case (datetime(), "+" | "-", timedelta()):
            return op_func(a_term, b_term)

        # datetime - datetime == timedelta
        # datetime cmp datetime == bool
        case (datetime(), "-" | "<" | "<=" | ">" | ">=" | "==" | "!=", datetime()):
            return op_func(a_term, b_term)

        # datetime @ timezone == datetime (timezone conversion)
        case (datetime(), "@", tzinfo()):
            return op_func(a_term, b_term)

        # timedelta +/- timedelta == timedelta
        # timedelta cmp timedelta == bool
        case (timedelta(), _, timedelta()):
            return op_func(a_term, b_term)

        # timezone cmp timezone == bool (compare by UTC offset)
        case (tzinfo(), "-" | "<" | "<=" | ">" | ">=" | "==" | "!=", tzinfo()):
            now = datetime.now(timezone.utc)
            return op_func(a_term.utcoffset(now), b_term.utcoffset(now))

        case _:
            raise ValueError(
                f"Unsupported operation: "
                f"{type(a_term).__name__} {op_string} {type(b_term).__name__}"
            )

Parse a temporal expression and perform the corresponding operation.

The temporal expression should be in the format " ", where can be either a datetime or a timedelta object, and can be one of the following: '+', '-', '<', '<=', '>', '>=', '==', '!='.

Args

expression
The temporal expression to parse.

Returns

The result of the operation, which can be a datetime or a timedelta object.

Raises

ValueError
If the expression is invalid or if the operation is not

supported.

Examples

>>> parse_temporal_expr('2022-01-01 12:00 UTC  + 1 day')
datetime.datetime(2022, 1, 2, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
>>> parse_temporal_expr('2022-01-01 00:00 - 1 week')
datetime.datetime(2021, 12, 25, 0, 0)
>>> parse_temporal_expr('2022-01-01 UTC < 2023-01-01 UTC')
True
>>> parse_temporal_expr('1 day == 24 hours')
True
>>> parse_temporal_expr('2025-01-02 - 2023-01-01')
datetime.timedelta(days=732)
>>> parse_temporal_expr('2022-01-01 + 2023-01-01')  # Raises ValueError
Traceback (most recent call last):
    ...
ValueError: Unsupported operation: datetime + datetime
def parse_temporal_str(input_str: str) ‑> datetime.datetime | datetime.timedelta | datetime.tzinfo
Expand source code
def parse_temporal_str(input_str: str) -> TemporalObject:
    """
    Parse a string as a datetime, timedelta, or timezone

    Args:
        input_str: The input string to parse.

    Returns:
        A datetime object if the string represents a date, or a timedelta
        object if it represents a time delta.

    Raises:
        ValueError: If the input string cannot be parsed as either a datetime
        or a timedelta.

    Examples:
        >>> parse_temporal_str('1d')
        datetime.timedelta(...)
        >>> parse_temporal_str('tomorrow')
        datetime.datetime(...)
    """
    logging.debug('trying "%s" as timezone', input_str)
    try:
        return parse_timezone_str(input_str)
    except ValueError:
        pass

    logging.debug('trying "%s" as timedelta', input_str)
    try:
        return parse_timedelta_str(input_str)
    except ValueError:
        pass

    logging.debug('trying "%s" as datetime', input_str)
    try:
        return parse_datetime_str(input_str)
    except ValueError:
        pass

    logging.debug("no matches found")
    raise ValueError(
        f'unable to parse "{input_str}" as either a datetime nor a timedelta'
    )

Parse a string as a datetime, timedelta, or timezone

Args

input_str
The input string to parse.

Returns

A datetime object if the string represents a date, or a timedelta object if it represents a time delta.

Raises

ValueError
If the input string cannot be parsed as either a datetime

or a timedelta.

Examples

>>> parse_temporal_str('1d')
datetime.timedelta(...)
>>> parse_temporal_str('tomorrow')
datetime.datetime(...)
def parse_timedelta_str(input_str: str) ‑> datetime.timedelta
Expand source code
def parse_timedelta_str(input_str: str) -> timedelta:
    """
    Parses a string containing time delta information and returns a timedelta object.

    Args:
        input_str: A string containing the time delta information.

    Returns:
        A timedelta object representing the parsed time delta.

    Raises:
        ValueError: If the input_str is not a valid time delta string.

    Note:
        Years and months use fixed approximations, not calendar-accurate values:

        - 1 year = 365 days (ignores leap years)
        - 1 month = 30 days (ignores actual month lengths)

        This means "1 year" always equals exactly 365 days, regardless of
        whether it spans a leap year. Similarly, "1 month" is always 30 days,
        not the actual number of days in any particular month.

        For calendar-accurate arithmetic (e.g., "add 1 month" meaning the same
        day next month), use ``dateutil.relativedelta`` instead.

    Examples:
        Basic usage:

        >>> parse_timedelta_str('1d')
        datetime.timedelta(days=1)
        >>> parse_timedelta_str('1 day')
        datetime.timedelta(days=1)
        >>> parse_timedelta_str('2h 30m')
        datetime.timedelta(seconds=9000)
        >>> parse_timedelta_str('1.5h')
        datetime.timedelta(seconds=5400)
        >>> parse_timedelta_str('500ms')
        datetime.timedelta(microseconds=500000)

        Combining multiple units:

        >>> parse_timedelta_str('1 day 6.5 hours, 10 min 33s 3 year')
        datetime.timedelta(days=1096, seconds=24033)

        Year and month approximations:

        >>> parse_timedelta_str('1 year')
        datetime.timedelta(days=365)
        >>> parse_timedelta_str('1 month')
        datetime.timedelta(days=30)
        >>> parse_timedelta_str('12 months')  # Not exactly 1 year!
        datetime.timedelta(days=360)
        >>> parse_timedelta_str('1 year') == parse_timedelta_str('365 days')
        True
        >>> parse_timedelta_str('1 year') == parse_timedelta_str('12 months')
        False
    """
    match = _TIMEDELTA_PATTERN.match(input_str)
    if not match:
        raise ValueError("Invalid time delta string")
    parsed = match.groupdict()

    def fget(key: str) -> float:
        """
        return the float value corresponding to the given key in the parsed
        dict
        """
        val = parsed.get(key, "0")
        if val is None:
            val = "0"
        return float(val)

    return timedelta(
        days=(fget("years") * 365)
        + (fget("months") * 30)
        + (fget("weeks") * 7)
        + fget("days"),
        hours=fget("hours"),
        minutes=fget("minutes"),
        seconds=fget("seconds"),
        microseconds=fget("microseconds"),
        milliseconds=fget("milliseconds"),
    )

Parses a string containing time delta information and returns a timedelta object.

Args

input_str
A string containing the time delta information.

Returns

A timedelta object representing the parsed time delta.

Raises

ValueError
If the input_str is not a valid time delta string.

Note

Years and months use fixed approximations, not calendar-accurate values:

  • 1 year = 365 days (ignores leap years)
  • 1 month = 30 days (ignores actual month lengths)

This means "1 year" always equals exactly 365 days, regardless of whether it spans a leap year. Similarly, "1 month" is always 30 days, not the actual number of days in any particular month.

For calendar-accurate arithmetic (e.g., "add 1 month" meaning the same day next month), use dateutil.relativedelta instead.

Examples

Basic usage:

>>> parse_timedelta_str('1d')
datetime.timedelta(days=1)
>>> parse_timedelta_str('1 day')
datetime.timedelta(days=1)
>>> parse_timedelta_str('2h 30m')
datetime.timedelta(seconds=9000)
>>> parse_timedelta_str('1.5h')
datetime.timedelta(seconds=5400)
>>> parse_timedelta_str('500ms')
datetime.timedelta(microseconds=500000)

Combining multiple units:

>>> parse_timedelta_str('1 day 6.5 hours, 10 min 33s 3 year')
datetime.timedelta(days=1096, seconds=24033)

Year and month approximations:

>>> parse_timedelta_str('1 year')
datetime.timedelta(days=365)
>>> parse_timedelta_str('1 month')
datetime.timedelta(days=30)
>>> parse_timedelta_str('12 months')  # Not exactly 1 year!
datetime.timedelta(days=360)
>>> parse_timedelta_str('1 year') == parse_timedelta_str('365 days')
True
>>> parse_timedelta_str('1 year') == parse_timedelta_str('12 months')
False
def parse_timezone_str(input_str: str) ‑> datetime.tzinfo
Expand source code
def parse_timezone_str(input_str: str) -> tzinfo:
    """
    Parse the input string for a natural language reference to a timezone and
    return the corresponding tzinfo

    Args:
        input_str: The input string containing the natural language reference.

    Returns:
        A tzinfo object representing the parsed timezone string.

    Raises:
        ValueError: If the input string cannot be parsed.

    """
    tz_result, _ = search_tz(input_str, fullmatch=True)
    if tz_result:
        return tz_result
    raise ValueError("Invalid timezone string")

Parse the input string for a natural language reference to a timezone and return the corresponding tzinfo

Args

input_str
The input string containing the natural language reference.

Returns

A tzinfo object representing the parsed timezone string.

Raises

ValueError
If the input string cannot be parsed.
def spliton(seq: Sequence[~_T], **kwargs) ‑> Tuple[Sequence[~_T], Sequence[~_T]]
Expand source code
def spliton(seq: Sequence[_T], **kwargs) -> Tuple[Sequence[_T], Sequence[_T]]:
    """
    Splits a sequence into two sequences based on the first item matching a
    condition. The return type matches the input type.

    Args:
        seq: The sequence to be split (list, tuple, str, etc.).
        **kwargs: A single keyword argument specifying the predicate and the
          comparison value. The keyword must be one of the following strings:
          'lt', 'le', 'eq', 'ne', 'ge', 'gt', 'is_', 'is_not', 'contains',
          'isin', 'notin'.
          The value is the second term in the binary predicate operation.

    Returns:
        A tuple of two sequences of the same type as the input: the first
        contains items up to the first item matching the condition, the second
        contains the first item matching the condition and all items after it.

    Raises:
        TypeError: If the number of keyword arguments is not 1, or if the predicate
                   keyword is not supported.

    Examples:
        >>> spliton([1, 2, 3, 4, 5], gt=3)
        ([1, 2, 3], [4, 5])
    """
    # quick sanity check
    if len(kwargs) != 1:
        raise TypeError("a predicate keyword argument must be specified")

    def isin(item: Any, container: Container) -> bool:
        return item in container

    def notin(item: Any, container: Container) -> bool:
        return item not in container

    predicates: Final[Dict[str, Callable[[Any, Any], bool]]] = {
        "lt": operator.lt,
        "le": operator.le,
        "eq": operator.eq,
        "ne": operator.ne,
        "ge": operator.ge,
        "gt": operator.gt,
        "is_": operator.is_,
        "is_not": operator.is_not,
        "contains": operator.contains,
        "isin": isin,
        "notin": notin,
    }
    predicate_name, b_term = next(iter(kwargs.items()))
    try:
        predicate_func = predicates[predicate_name]
    except KeyError:
        raise TypeError(f'unsupported predicate: "{predicate_name}"') from None

    # finally we enumerate the sequence looking for the first hit
    for i, a_term in enumerate(seq):
        if predicate_func(a_term, b_term):
            return seq[:i], seq[i:]

    # return the whole sequence and an empty sequence if no item matches the
    # condition
    return seq, type(seq)()

Splits a sequence into two sequences based on the first item matching a condition. The return type matches the input type.

Args

seq
The sequence to be split (list, tuple, str, etc.).
**kwargs
A single keyword argument specifying the predicate and the comparison value. The keyword must be one of the following strings: 'lt', 'le', 'eq', 'ne', 'ge', 'gt', 'is_', 'is_not', 'contains', 'isin', 'notin'. The value is the second term in the binary predicate operation.

Returns

A tuple of two sequences of the same type as the input
the first

contains items up to the first item matching the condition, the second contains the first item matching the condition and all items after it.

Raises

TypeError
If the number of keyword arguments is not 1, or if the predicate keyword is not supported.

Examples

>>> spliton([1, 2, 3, 4, 5], gt=3)
([1, 2, 3], [4, 5])