Comparación de número de versión en Python

98

Quiero escribir una cmpfunción -como que compara dos números de versión y retornos -1, 0o 1en base a sus valuses comparación.

  • Devolver -1si la versión A es anterior a la versión B
  • Devolver 0si la versión A y B son equivalentes
  • Devolver 1si la versión A es más reciente que la versión B

Se supone que cada subsección debe interpretarse como un número, por lo tanto, 1,10> 1,1.

Las salidas de función deseadas son

mycmp('1.0', '1') == 0
mycmp('1.0.0', '1') == 0
mycmp('1', '1.0.0.1') == -1
mycmp('12.10', '11.0.0.0.0') == 1
...

Y aquí está mi implementación, abierta a mejoras:

def mycmp(version1, version2):
    parts1 = [int(x) for x in version1.split('.')]
    parts2 = [int(x) for x in version2.split('.')]

    # fill up the shorter version with zeros ...
    lendiff = len(parts1) - len(parts2)
    if lendiff > 0:
        parts2.extend([0] * lendiff)
    elif lendiff < 0:
        parts1.extend([0] * (-lendiff))

    for i, p in enumerate(parts1):
        ret = cmp(p, parts2[i])
        if ret: return ret
    return 0

Estoy usando Python 2.4.5 por cierto. (instalado en mi lugar de trabajo ...).

Aquí hay un pequeño 'conjunto de pruebas' que puede usar

assert mycmp('1', '2') == -1
assert mycmp('2', '1') == 1
assert mycmp('1', '1') == 0
assert mycmp('1.0', '1') == 0
assert mycmp('1', '1.000') == 0
assert mycmp('12.01', '12.1') == 0
assert mycmp('13.0.1', '13.00.02') == -1
assert mycmp('1.1.1.1', '1.1.1.1') == 0
assert mycmp('1.1.1.2', '1.1.1.1') == 1
assert mycmp('1.1.3', '1.1.3.000') == 0
assert mycmp('3.1.1.0', '3.1.2.10') == -1
assert mycmp('1.1', '1.10') == -1
Johannes Charra
fuente
No es una respuesta, sino una sugerencia: podría valer la pena implementar el algoritmo de Debian para la comparación de números de versión (básicamente, clasificación alterna de partes no numéricas y numéricas). El algoritmo se describe aquí (comenzando en "Las cadenas se comparan de izquierda a derecha").
Hobbs
Blargh. El subconjunto de rebajas admitido en los comentarios nunca deja de confundirme. El enlace funciona de todos modos, incluso si parece estúpido.
Hobbs
En caso de que los futuros lectores necesiten esto para el análisis de la versión del agente de usuario, recomiendo una biblioteca dedicada ya que la variación histórica es demasiado amplia.
James Broadhead
2
Posible duplicado de cadenas de versiones
John Y
1
Aunque la pregunta aquí es más antigua, parece que esta otra pregunta ha sido ungida como canónica, ya que muchas, muchas preguntas están cerradas como duplicados de esa.
John Y

Respuestas:

36

Elimine la parte no interesante de la cadena (ceros y puntos finales) y luego compare las listas de números.

import re

def mycmp(version1, version2):
    def normalize(v):
        return [int(x) for x in re.sub(r'(\.0+)*$','', v).split(".")]
    return cmp(normalize(version1), normalize(version2))

Este es el mismo enfoque que Pär Wieslander, pero un poco más compacto:

Aquí hay algunas pruebas, gracias a " ¿Cómo comparar dos cadenas en formato de versión separada por puntos en Bash? ":

assert mycmp("1", "1") == 0
assert mycmp("2.1", "2.2") < 0
assert mycmp("3.0.4.10", "3.0.4.2") > 0
assert mycmp("4.08", "4.08.01") < 0
assert mycmp("3.2.1.9.8144", "3.2") > 0
assert mycmp("3.2", "3.2.1.9.8144") < 0
assert mycmp("1.2", "2.1") < 0
assert mycmp("2.1", "1.2") > 0
assert mycmp("5.6.7", "5.6.7") == 0
assert mycmp("1.01.1", "1.1.1") == 0
assert mycmp("1.1.1", "1.01.1") == 0
assert mycmp("1", "1.0") == 0
assert mycmp("1.0", "1") == 0
assert mycmp("1.0", "1.0.1") < 0
assert mycmp("1.0.1", "1.0") > 0
assert mycmp("1.0.2.0", "1.0.2") == 0
gnud
fuente
2
Me temo que no funcionará, rstrip(".0")cambiará ".10" a ".1" en "1.0.10".
RedGlyph
Lo siento, pero con su función: mycmp ('1.1', '1.10') == 0
Johannes Charra
Con el uso de expresiones regulares, se soluciona el problema mencionado anteriormente.
gnud
Ahora que has fusionado todas las buenas ideas de los demás en tu solución ... :-P aún, esto es más o menos lo que haría después de todo. Aceptaré esta respuesta. Gracias a todos
Johannes Charra
2
Nota cmp () se ha eliminado en Python 3: docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
Dominic Cleal
279

¿Qué tal usar Python distutils.version.StrictVersion?

>>> from distutils.version import StrictVersion
>>> StrictVersion('10.4.10') > StrictVersion('10.4.9')
True

Entonces, para tu cmpfunción:

>>> cmp = lambda x, y: StrictVersion(x).__cmp__(y)
>>> cmp("10.4.10", "10.4.11")
-1

Si desea comparar números de versión que sean más complejos distutils.version.LooseVersion, será más útil, sin embargo, asegúrese de comparar solo los mismos tipos.

>>> from distutils.version import LooseVersion, StrictVersion
>>> LooseVersion('1.4c3') > LooseVersion('1.3')
True
>>> LooseVersion('1.4c3') > StrictVersion('1.3')  # different types
False

LooseVersion no es la herramienta más inteligente y se puede engañar fácilmente:

>>> LooseVersion('1.4') > LooseVersion('1.4-rc1')
False

Para tener éxito con esta raza, deberá salir de la biblioteca estándar y utilizar la utilidad de análisis de setuptoolsparse_version .

>>> from pkg_resources import parse_version
>>> parse_version('1.4') > parse_version('1.4-rc2')
True

Entonces, dependiendo de su caso de uso específico, deberá decidir si las distutilsherramientas integradas son suficientes o si se justifica agregar como dependencia setuptools.

bradley.ayers
fuente
2
parece tener más sentido usar lo que ya está allí :)
Patrick Wolf
2
¡Agradable! ¿Descubriste esto leyendo la fuente? No puedo encontrar documentos para distutils.version en ningún lugar: - /
Adam Spiers
3
Siempre que no pueda encontrar la documentación, intente importar el paquete y use la ayuda ().
rspeed
13
Sin embargo, tenga en cuenta que StrictVersion SOLO funciona con una versión de hasta tres números. ¡Fracasa en cosas como 0.4.3.6!
abergmeier
6
Cada instancia de distributeen esta respuesta debe reemplazarse por setuptools, que viene incluido con el pkg_resourcespaquete y tiene desde ... como, siempre . Asimismo, esta es la documentación oficial para la pkg_resources.parse_version()función incluida setuptools.
Cecil Curry
30

¿Se considera la reutilización como elegancia en este caso? :)

# pkg_resources is in setuptools
# See http://peak.telecommunity.com/DevCenter/PkgResources#parsing-utilities
def mycmp(a, b):
    from pkg_resources import parse_version as V
    return cmp(V(a),V(b))
conny
fuente
7
Hmm, no es tan elegante cuando te refieres a algo fuera de la biblioteca estándar sin explicar dónde conseguirlo. Envié una edición para incluir la URL. Personalmente, prefiero usar distutils; no parece que valga la pena el esfuerzo de incorporar software de terceros para una tarea tan simple.
Adam Spires
1
@ adam-spiers wut? ¿Leíste siquiera el comentario? pkg_resourceses un setuptoolspaquete incluido. Dado que setuptoolses obligatorio en todas las instalaciones de Python, pkg_resourcesestá disponible en todas partes. Dicho esto, el distutils.versionsubpaquete también es útil, aunque considerablemente menos inteligente que la pkg_resources.parse_version()función de nivel superior . Lo que debe aprovechar depende del grado de locura que espera en las cadenas de versiones.
Cecil Curry
@CecilCurry Sí, por supuesto, leí el comentario (ary), por eso lo edité para mejorarlo, y luego dije que sí. Es de suponer que no está en desacuerdo con mi declaración que setuptoolsestá fuera de la biblioteca estándar y, en cambio, con mi preferencia declarada distutils en este caso . Entonces, ¿qué quiere decir exactamente con "efectivamente obligatorio" y, por favor, puede proporcionar pruebas de que era "efectivamente obligatorio" hace 4,5 años cuando escribí este comentario?
Adam Spiers
12

No es necesario iterar sobre las tuplas de versión. El operador de comparación integrado en listas y tuplas ya funciona exactamente como lo desea. Solo necesitará extender las listas de versiones a la longitud correspondiente. Con python 2.6 puede usar izip_longest para rellenar las secuencias.

from itertools import izip_longest
def version_cmp(v1, v2):
    parts1, parts2 = [map(int, v.split('.')) for v in [v1, v2]]
    parts1, parts2 = zip(*izip_longest(parts1, parts2, fillvalue=0))
    return cmp(parts1, parts2)

Con versiones inferiores, se requiere algo de piratería de mapas.

def version_cmp(v1, v2):
    parts1, parts2 = [map(int, v.split('.')) for v in [v1, v2]]
    parts1, parts2 = zip(*map(lambda p1,p2: (p1 or 0, p2 or 0), parts1, parts2))
    return cmp(parts1, parts2)
Hormigas Aasma
fuente
Genial, pero difícil de entender para alguien que no puede leer códigos como la prosa. :) Bueno, supongo que solo puede acortar la solución a costa de la legibilidad ...
Johannes Charra
10

Esto es un poco más compacto que tu sugerencia. En lugar de llenar la versión más corta con ceros, elimino los ceros finales de las listas de versiones después de dividir.

def normalize_version(v):
    parts = [int(x) for x in v.split(".")]
    while parts[-1] == 0:
        parts.pop()
    return parts

def mycmp(v1, v2):
    return cmp(normalize_version(v1), normalize_version(v2))
Pär Wieslander
fuente
Bonito, gracias. Pero todavía estoy esperando una o dos líneas ...;)
Johannes Charra
4
+1 @jellybean: las dos líneas no siempre son las mejores para el mantenimiento y la legibilidad, este es un código muy claro y compacto al mismo tiempo, además, puede reutilizarlo mycmppara otros propósitos en su código si lo necesita.
RedGlyph
@RedGlyph: Tienes razón. Debería haber dicho "dos líneas legibles". :)
Johannes Charra
hola @ Pär Wieslander, cuando uso esta solución para resolver el mismo problema en el problema de Leetcode, aparece un error en el ciclo while que dice "índice de lista fuera de rango". ¿Puede ayudarme a explicar por qué ocurre eso? Aquí está el problema: leetcode.com/explore/interview/card/amazon/76/array-and-strings/…
YouHaveaBigEgo
7

Elimine el seguimiento .0y .00con expresiones regulares, splity use la cmpfunción que compara matrices correctamente:

def mycmp(v1,v2):
 c1=map(int,re.sub('(\.0+)+\Z','',v1).split('.'))
 c2=map(int,re.sub('(\.0+)+\Z','',v2).split('.'))
 return cmp(c1,c2)

Y, por supuesto, puede convertirlo en una sola línea si no le importan las largas colas.

Yu_sha
fuente
2
def compare_version(v1, v2):
    return cmp(*tuple(zip(*map(lambda x, y: (x or 0, y or 0), 
           [int(x) for x in v1.split('.')], [int(y) for y in v2.split('.')]))))

Es un trazador de líneas (dividido por legibilidad). No estoy seguro de que sea legible ...

mavnn
fuente
1
¡Si! Y se encogió aún más ( tupleno es necesario por cierto):cmp(*zip(*map(lambda x,y:(x or 0,y or 0), map(int,v1.split('.')), map(int,v2.split('.')) )))
Paul
2
from distutils.version import StrictVersion
def version_compare(v1, v2, op=None):
    _map = {
        '<': [-1],
        'lt': [-1],
        '<=': [-1, 0],
        'le': [-1, 0],
        '>': [1],
        'gt': [1],
        '>=': [1, 0],
        'ge': [1, 0],
        '==': [0],
        'eq': [0],
        '!=': [-1, 1],
        'ne': [-1, 1],
        '<>': [-1, 1]
    }
    v1 = StrictVersion(v1)
    v2 = StrictVersion(v2)
    result = cmp(v1, v2)
    if op:
        assert op in _map.keys()
        return result in _map[op]
    return result

Implementar para php version_compare, excepto "=". Porque es ambiguo.

Ryan Fau
fuente
2

Las listas son comparables en Python, por lo que si alguien convierte las cadenas que representan los números en enteros, la comparación básica de Python puede usarse con éxito.

Necesitaba extender un poco este enfoque porque uso Python3x donde la cmpfunción ya no existe. Tuve que emular cmp(a,b)con (a > b) - (a < b). Y los números de versión no son tan limpios en absoluto, y pueden contener todo tipo de otros caracteres alfanuméricos. Hay casos en los que la función no puede indicar el orden, por lo que regresaFalse (vea el primer ejemplo).

Así que estoy publicando esto incluso si la pregunta es antigua y ya está respondida, porque puede ahorrar algunos minutos en la vida de alguien.

import re

def _preprocess(v, separator, ignorecase):
    if ignorecase: v = v.lower()
    return [int(x) if x.isdigit() else [int(y) if y.isdigit() else y for y in re.findall("\d+|[a-zA-Z]+", x)] for x in v.split(separator)]

def compare(a, b, separator = '.', ignorecase = True):
    a = _preprocess(a, separator, ignorecase)
    b = _preprocess(b, separator, ignorecase)
    try:
        return (a > b) - (a < b)
    except:
        return False

print(compare('1.0', 'beta13'))    
print(compare('1.1.2', '1.1.2'))
print(compare('1.2.2', '1.1.2'))
print(compare('1.1.beta1', '1.1.beta2'))
sanyi
fuente
2

En caso de que no desee incorporar una dependencia externa, aquí está mi intento escrito para Python 3.x.

rc, rel(y posiblemente se podría agregar c) son considerados como "candidatos de lanzamiento" y dividen el número de versión en dos partes y si falta el valor de la segunda parte es alto (999). Las demás letras producen una división y se tratan como subnúmeros a través del código base 36.

import re
from itertools import chain
def compare_version(version1,version2):
    '''compares two version numbers
    >>> compare_version('1', '2') < 0
    True
    >>> compare_version('2', '1') > 0
    True
    >>> compare_version('1', '1') == 0
    True
    >>> compare_version('1.0', '1') == 0
    True
    >>> compare_version('1', '1.000') == 0
    True
    >>> compare_version('12.01', '12.1') == 0
    True
    >>> compare_version('13.0.1', '13.00.02') <0
    True
    >>> compare_version('1.1.1.1', '1.1.1.1') == 0
    True
    >>> compare_version('1.1.1.2', '1.1.1.1') >0
    True
    >>> compare_version('1.1.3', '1.1.3.000') == 0
    True
    >>> compare_version('3.1.1.0', '3.1.2.10') <0
    True
    >>> compare_version('1.1', '1.10') <0
    True
    >>> compare_version('1.1.2','1.1.2') == 0
    True
    >>> compare_version('1.1.2','1.1.1') > 0
    True
    >>> compare_version('1.2','1.1.1') > 0
    True
    >>> compare_version('1.1.1-rc2','1.1.1-rc1') > 0
    True
    >>> compare_version('1.1.1a-rc2','1.1.1a-rc1') > 0
    True
    >>> compare_version('1.1.10-rc1','1.1.1a-rc2') > 0
    True
    >>> compare_version('1.1.1a-rc2','1.1.2-rc1') < 0
    True
    >>> compare_version('1.11','1.10.9') > 0
    True
    >>> compare_version('1.4','1.4-rc1') > 0
    True
    >>> compare_version('1.4c3','1.3') > 0
    True
    >>> compare_version('2.8.7rel.2','2.8.7rel.1') > 0
    True
    >>> compare_version('2.8.7.1rel.2','2.8.7rel.1') > 0
    True

    '''
    chn = lambda x:chain.from_iterable(x)
    def split_chrs(strings,chars):
        for ch in chars:
            strings = chn( [e.split(ch) for e in strings] )
        return strings
    split_digit_char=lambda x:[s for s in re.split(r'([a-zA-Z]+)',x) if len(s)>0]
    splt = lambda x:[split_digit_char(y) for y in split_chrs([x],'.-_')]
    def pad(c1,c2,f='0'):
        while len(c1) > len(c2): c2+=[f]
        while len(c2) > len(c1): c1+=[f]
    def base_code(ints,base):
        res=0
        for i in ints:
            res=base*res+i
        return res
    ABS = lambda lst: [abs(x) for x in lst]
    def cmp(v1,v2):
        c1 = splt(v1)
        c2 = splt(v2)
        pad(c1,c2,['0'])
        for i in range(len(c1)): pad(c1[i],c2[i])
        cc1 = [int(c,36) for c in chn(c1)]
        cc2 = [int(c,36) for c in chn(c2)]
        maxint = max(ABS(cc1+cc2))+1
        return base_code(cc1,maxint) - base_code(cc2,maxint)
    v_main_1, v_sub_1 = version1,'999'
    v_main_2, v_sub_2 = version2,'999'
    try:
        v_main_1, v_sub_1 = tuple(re.split('rel|rc',version1))
    except:
        pass
    try:
        v_main_2, v_sub_2 = tuple(re.split('rel|rc',version2))
    except:
        pass
    cmp_res=[cmp(v_main_1,v_main_2),cmp(v_sub_1,v_sub_2)]
    res = base_code(cmp_res,max(ABS(cmp_res))+1)
    return res


import random
from functools import cmp_to_key
random.shuffle(versions)
versions.sort(key=cmp_to_key(compare_version))
Roland Puntaier
fuente
1

La solución más difícil de leer, ¡pero de una sola línea! y usar iteradores para ser rápido.

next((c for c in imap(lambda x,y:cmp(int(x or 0),int(y or 0)),
            v1.split('.'),v2.split('.')) if c), 0)

eso es para Python2.6 y 3. + por cierto, Python 2.5 y versiones anteriores necesitan capturar la StopIteration.

Pablo
fuente
1

Hice esto para poder analizar y comparar la cadena de la versión del paquete Debian. Tenga en cuenta que no es estricto con la validación de caracteres.

Esto también podría ser útil:

#!/usr/bin/env python

# Read <https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version> for further informations.

class CommonVersion(object):
    def __init__(self, version_string):
        self.version_string = version_string
        self.tags = []
        self.parse()

    def parse(self):
        parts = self.version_string.split('~')
        self.version_string = parts[0]
        if len(parts) > 1:
            self.tags = parts[1:]


    def __lt__(self, other):
        if self.version_string < other.version_string:
            return True
        for index, tag in enumerate(self.tags):
            if index not in other.tags:
                return True
            if self.tags[index] < other.tags[index]:
                return True

    @staticmethod
    def create(version_string):
        return UpstreamVersion(version_string)

class UpstreamVersion(CommonVersion):
    pass

class DebianMaintainerVersion(CommonVersion):
    pass

class CompoundDebianVersion(object):
    def __init__(self, epoch, upstream_version, debian_version):
        self.epoch = epoch
        self.upstream_version = UpstreamVersion.create(upstream_version)
        self.debian_version = DebianMaintainerVersion.create(debian_version)

    @staticmethod
    def create(version_string):
        version_string = version_string.strip()
        epoch = 0
        upstream_version = None
        debian_version = '0'

        epoch_check = version_string.split(':')
        if epoch_check[0].isdigit():
            epoch = int(epoch_check[0])
            version_string = ':'.join(epoch_check[1:])
        debian_version_check = version_string.split('-')
        if len(debian_version_check) > 1:
            debian_version = debian_version_check[-1]
            version_string = '-'.join(debian_version_check[0:-1])

        upstream_version = version_string

        return CompoundDebianVersion(epoch, upstream_version, debian_version)

    def __repr__(self):
        return '{} {}'.format(self.__class__.__name__, vars(self))

    def __lt__(self, other):
        if self.epoch < other.epoch:
            return True
        if self.upstream_version < other.upstream_version:
            return True
        if self.debian_version < other.debian_version:
            return True
        return False


if __name__ == '__main__':
    def lt(a, b):
        assert(CompoundDebianVersion.create(a) < CompoundDebianVersion.create(b))

    # test epoch
    lt('1:44.5.6', '2:44.5.6')
    lt('1:44.5.6', '1:44.5.7')
    lt('1:44.5.6', '1:44.5.7')
    lt('1:44.5.6', '2:44.5.6')
    lt('  44.5.6', '1:44.5.6')

    # test upstream version (plus tags)
    lt('1.2.3~rc7',          '1.2.3')
    lt('1.2.3~rc1',          '1.2.3~rc2')
    lt('1.2.3~rc1~nightly1', '1.2.3~rc1')
    lt('1.2.3~rc1~nightly2', '1.2.3~rc1')
    lt('1.2.3~rc1~nightly1', '1.2.3~rc1~nightly2')
    lt('1.2.3~rc1~nightly1', '1.2.3~rc2~nightly1')

    # test debian maintainer version
    lt('44.5.6-lts1', '44.5.6-lts12')
    lt('44.5.6-lts1', '44.5.7-lts1')
    lt('44.5.6-lts1', '44.5.7-lts2')
    lt('44.5.6-lts1', '44.5.6-lts2')
    lt('44.5.6-lts1', '44.5.6-lts2')
    lt('44.5.6',      '44.5.6-lts1')
Pius Raeder
fuente
0

Otra solución:

def mycmp(v1, v2):
    import itertools as it
    f = lambda v: list(it.dropwhile(lambda x: x == 0, map(int, v.split('.'))[::-1]))[::-1]
    return cmp(f(v1), f(v2))

También se puede usar así:

import itertools as it
f = lambda v: list(it.dropwhile(lambda x: x == 0, map(int, v.split('.'))[::-1]))[::-1]
f(v1) <  f(v2)
f(v1) == f(v2)
f(v1) >  f(v2)
pedrormjunior
fuente
0

estoy usando este en mi proyecto:

cmp(v1.split("."), v2.split(".")) >= 0
Keyrr Perino
fuente
0

Años más tarde, pero todavía esta pregunta está en la cima.

Aquí está mi función de clasificación de versión. Divide la versión en secciones numéricas y no numéricas. Los números se comparan como intresto str(como parte de los elementos de la lista).

def sort_version_2(data):
    def key(n):
        a = re.split(r'(\d+)', n)
        a[1::2] = map(int, a[1::2])
        return a
    return sorted(data, key=lambda n: key(n))

Puede usar la función keycomo tipo de Versiontipo personalizado con operadores de comparación. Si realmente quieres usarlo cmp, puedes hacerlo como en este ejemplo: https://stackoverflow.com/a/22490617/9935708

def Version(s):
    s = re.sub(r'(\.0*)*$', '', s)  # to avoid ".0" at end
    a = re.split(r'(\d+)', s)
    a[1::2] = map(int, a[1::2])
    return a

def mycmp(a, b):
    a, b = Version(a), Version(b)
    return (a > b) - (a < b)  # DSM's answer

Pasa la suite de pruebas.

rysson
fuente
-1

Mi solución preferida:

Rellenar la cadena con ceros adicionales y usar los cuatro primeros es fácil de entender, no requiere ninguna expresión regular y la lambda es más o menos legible. Utilizo dos líneas para facilitar la lectura, para mí la elegancia es breve y simple.

def mycmp(version1,version2):
  tup = lambda x: [int(y) for y in (x+'.0.0.0.0').split('.')][:4]
  return cmp(tup(version1),tup(version2))
daramarak
fuente
-1

Esta es mi solución (escrita en C, lo siento). Espero que te sea de utilidad

int compare_versions(const char *s1, const char *s2) {
    while(*s1 && *s2) {
        if(isdigit(*s1) && isdigit(*s2)) {
            /* compare as two decimal integers */
            int s1_i = strtol(s1, &s1, 10);
            int s2_i = strtol(s2, &s2, 10);

            if(s1_i != s2_i) return s1_i - s2_i;
        } else {
            /* compare as two strings */
            while(*s1 && !isdigit(*s1) && *s2 == *s1) {
                s1++;
                s2++;
            }

            int s1_i = isdigit(*s1) ? 0 : *s1;
            int s2_i = isdigit(*s2) ? 0 : *s2;

            if(s1_i != s2_i) return s1_i - s2_i;
        }
    }

    return 0;
}
e_asphyx
fuente