In python, how can you compare two CalVer strings to determine if one is greater than, lesser than, or equal to, the other?

Viewed 139

I have the occasional need to adjust my python scripts based on the versions of various dependencies. Most often in my case, a python codebase works alongside front-end javascript that may be running releases spanning multiple years. If a javascript dependency has a version greater than A, the python should do B. If the dependency has a version less than X, the python should do Y, etc.

These dependencies are calendar versioned (CalVer). While I've located many tools for maintaining a project's own CalVer, I was unable to find a ready-made solution to evaluate CalVers in this fashion.

if "YY.MM.DD" > "YY.MM.DD.MICRO":
    # Do this thing
else:
    # Do that thing

Comparing dates is easy enough, but when MICRO versions come into the mix, things get more complex.

2 Answers

I ended up writing my own solution to allow me to compare CalVer strings like below.

subject = "2021.01.31"
test = "2021.01.30.dev1"

if calver_evaluate(operator="gte", subject=subject, test=test):
   # if "2021.01.31" >= "2021.01.30.dev1"
   result = True

subject = "2021.01.31.0012"
test = "2021.01.31.1012"

if calver_evaluate(operator="gte", subject=subject, test=test):
   # if "2021.01.31.0012" >= "2021.01.30.1012"
   result = False

Full details on the operations are included the function's docstring. Note some of the limited rules around evaluating micros that cannot be converted to integers.

import datetime

def calver_evaluate(operator=None, subject=None, test=None):
    """Evaluates two calver strings based on the operator.

    Params
    ------
    operator : str
        Defines how to evaluate the subject and test params. Acceptable values are:
        - "gt" or ">" for greater than
        - "gte" or ">=" for greater than or equal to
        - "e", "eq", "equal", "=", or "==" for equal to
        - "lt" or "<" for less than
        - "lte" or "<=" for less than or equal to
    subject : str
        A calver string formatted as YYYY.0M.0D.MICRO (recommended) or YY.MM.DD.MICRO.
        https://calver.org/calendar_versioning.html
    test : str
        A calver string to evaluate against the subject, formatted as YYYY.0M.0D.MICRO 
        (recommended) or YY.MM.DD.MICRO.
        https://calver.org/calendar_versioning.html

    Returns
    -------
    bool
        The results of the `subject`:`test` evaluation using the `operator`.

    Notes
    -----
    The MICRO segment of the calver strings are only considered in the following
    scenarios.
        1. One calver has a MICRO value and the other does not. The calver without a 
        MICRO value is evaluated as `0`, making the calver *with* the MICRO, no matter
        what the value, as the greater of the two.
        `2021.01.01 == 2021.01.01.0`, therefore `2021.01.01.2 > 2021.01.01` and
        `2021.01.01.dev1 > 2021.01.01`
        2. Both calvers have MICRO values that are numeric and able to be converted to
        integers.
        3. Both calvers have string MICRO values **and** the operator selected is 
        "equals".

    """
    if not operator or not subject or not test:
        raise Exception("calver_evaluate: Missing keyword argument.")

    allowed = ["lt","<","lte","<=","e","eq","equal","=","==","gte",">=","gt",">"]
    if operator not in allowed:
        raise Exception("calver_evaluate: Unrecognized evaluation operator.")

    sparts = subject.split(".")
    syear = int(sparts[0]) if int(sparts[0]) > 100 else int(sparts[0]) + 2000
    smonth = int(sparts[1])
    sday = int(sparts[2])
    sdate = datetime.date(syear, smonth, sday)
    smicro = sparts[3] if len(sparts) > 3 else 0

    tparts = test.split(".")
    tyear = int(tparts[0]) if int(tparts[0]) > 100 else int(tparts[0]) + 2000
    tmonth = int(tparts[1])
    tday = int(tparts[2])
    tdate = datetime.date(tyear, tmonth, tday)
    tmicro = tparts[3] if len(tparts) > 3 else 0

    if unicode(smicro).isnumeric() and unicode(tmicro).isnumeric():
        smicro = int(smicro)
        tmicro = int(tmicro)
    elif smicro == 0:
        tmicro = 1
    elif tmicro == 0:
        smicro = 1

    lt = ["lt","<"]
    lte = ["lte","<="]
    equal = ["e","eq","equal","=","=="]
    gte = ["gte",">="]
    gt = ["gt",">"]

    check_micro = (
        (
            isinstance(smicro, int) and isinstance(tmicro, int) and
            (smicro > 0 or tmicro > 0)
        ) or
        (
            operator in equal and 
            not isinstance(smicro, int) and 
            not isinstance(tmicro, int)
        )
    )

    def evaluate_micro(operator, smicro, tmicro):
        if operator in lt:
            if smicro < tmicro:
                return True
        elif operator in lte:
            if smicro <= tmicro:
                return True
        elif operator in equal:
            if smicro == tmicro:
                return True
        elif operator in gte:
            if smicro >= tmicro:
                return True
        elif operator in gt:
            if smicro > tmicro:
                return True

        return False

    if operator in lt and sdate <= tdate:
        if sdate < tdate:
            return True
        elif sdate == tdate and check_micro:
            return evaluate_micro(operator, smicro, tmicro) 

    elif operator in lte and sdate <= tdate:
        if sdate == tdate and check_micro:
            return evaluate_micro(operator, smicro, tmicro) 
        return True

    elif operator in equal:
        if sdate == tdate:
            if check_micro:
                return evaluate_micro(operator, smicro, tmicro) 
            return True

    elif operator in gte and sdate >= tdate:
        if sdate == tdate and check_micro:
            return evaluate_micro(operator, smicro, tmicro) 
        return True

    elif operator in gt and sdate >= tdate:
        if sdate > tdate:
            return True
        elif sdate == tdate and check_micro:
            return evaluate_micro(operator, smicro, tmicro) 

    return False

The Python Packaging Authority (PyPA) maintains the packaging library, which, among other things, implements version handling according to PEP 440 ("Version Identification and Dependency Specification"), including Calendar Versioning.

Examples (taken from Dennis's answer):

>>> from packaging import version
>>> version.parse('2021.01.31') >= version.parse('2021.01.30.dev1')
True
>>> version.parse('2021.01.31.0012') >= version.parse('2021.01.31.1012')
False
Related