Comparação de número de versão em Python

98

Eu quero escrever uma cmpfunção -como o que compara dois números de versão e retornos -1, 0ou 1com base em suas valuses comparados.

  • Retorne -1se a versão A for anterior à versão B
  • Retorne 0se as versões A e B forem equivalentes
  • Retorne 1se a versão A for mais recente que a versão B

Cada subseção deve ser interpretada como um número, portanto 1,10> 1,1.

As saídas da função desejada são

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
...

E aqui está minha implementação, aberta para melhorias:

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

Estou usando o Python 2.4.5 btw. (instalado no meu local de trabalho ...).

Aqui está um pequeno 'conjunto de testes' que você pode 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
fonte
Não é uma resposta, mas uma sugestão - pode valer a pena implementar o algoritmo do Debian para comparação de número de versão (basicamente, ordenação alternada de partes não numéricas e numéricas). O algoritmo é descrito aqui (começando em "As strings são comparadas da esquerda para a direita").
Hobbs
Blargh. O subconjunto de marcação com suporte em comentários nunca deixa de me confundir. O link funciona de qualquer maneira, mesmo que pareça estúpido.
Hobbs
No caso de futuros leitores precisarem disso para a análise da versão do agente do usuário, recomendo uma biblioteca dedicada, pois a variação histórica é muito ampla.
James Broadhead,
2
Possível duplicata de strings
John Y
1
Mesmo que a questão aqui seja mais antiga, parece que essa outra questão foi ungida como canônica, pois muitas, muitas questões são fechadas como duplicatas daquela.
John Y

Respostas:

36

Remova a parte desinteressante da string (zeros e pontos à direita) e depois compare as 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))

Esta é a mesma abordagem de Pär Wieslander, mas um pouco mais compacta:

Aqui estão alguns testes, graças a " Como comparar duas strings no formato de versão separada por pontos no 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
fonte
2
Receio que isso não funcione, pois rstrip(".0")mudará de ".10" para ".1" em "1.0.10".
RedGlyph
Desculpe, mas com sua função: mycmp ('1.1', '1.10') == 0
Johannes Charra
Com o uso de regex, o problema mencionado acima foi corrigido.
gnud
Agora que você juntou todas as boas ideias dos outros em sua solução ... :-P ainda, isso é basicamente o que eu faria afinal. Vou aceitar essa resposta. Obrigado a todos
Johannes Charra
2
Observe que cmp () foi removido do Python 3: docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
Dominic Cleal
279

Que tal usar o Python distutils.version.StrictVersion?

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

Portanto, para sua cmpfunção:

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

Se você quiser comparar números de versão que são mais complexos distutils.version.LooseVersion, será mais útil, no entanto, certifique-se de comparar apenas os mesmos tipos.

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

LooseVersion não é a ferramenta mais inteligente e pode ser facilmente enganada:

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

Para ter sucesso com esta raça, você precisará sair da biblioteca padrão e usar o utilitário de análise do setuptoolsparse_version .

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

Portanto, dependendo do seu caso de uso específico, você precisará decidir se as distutilsferramentas integradas são suficientes ou se é necessário adicionar como uma dependência setuptools.

bradley.ayers
fonte
2
parece fazer mais sentido usar apenas o que já está lá :)
Patrick Wolf
2
Agradável! Você descobriu isso lendo a fonte? Não consigo encontrar documentos para distutils.version em lugar nenhum: - /
Adam Spires
3
Sempre que você não conseguir encontrar a documentação, tente importar o pacote e use help ().
rspeed de
13
Porém, esteja ciente de que funciona StrictVersion SOMENTE com uma versão de até três números. Ele falha para coisas como 0.4.3.6!
abergmeier
6
Cada instância de distributenesta resposta deve ser substituída por setuptools, que vem junto com o pkg_resourcespacote e tem desde ... tipo, sempre . Da mesma forma, esta é a documentação oficial para a pkg_resources.parse_version()função incluída no pacote setuptools.
Cecil Curry,
30

A reutilização é considerada elegância neste 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
fonte
7
Hmm, não é tão elegante quando você se refere a algo fora da biblioteca padrão sem explicar onde obtê-lo. Enviei uma edição para incluir o URL. Pessoalmente, prefiro usar distutils - não parece valer a pena o esforço de obter um software de terceiros para uma tarefa tão simples.
Adam Spires de
1
@ adam-spiers wut? Você ao menos leu o comentário? pkg_resourcesé um setuptoolspacote agrupado. Como setuptoolsé efetivamente obrigatório em todas as instalações do Python, pkg_resourcesestá efetivamente disponível em qualquer lugar. Dito isso, o distutils.versionsubpacote também é útil - embora consideravelmente menos inteligente do que a pkg_resources.parse_version()função de nível superior. O que você deve aproveitar depende do grau de insanidade que você espera nas strings de versão.
Cecil Curry,
@CecilCurry Sim, claro que li o comentário (ary), por isso editei para torná-lo melhor e depois declarei que tinha. Presumivelmente, você não discorda da minha afirmação de que setuptoolsestá fora da biblioteca padrão e, em vez disso, da minha preferência declarada por distutils neste caso . O que exatamente você quer dizer com "efetivamente obrigatório" e, por favor, você pode fornecer evidências de que era "efetivamente obrigatório" 4,5 anos atrás, quando escrevi este comentário?
Adam Spires,
12

Não há necessidade de iterar nas tuplas de versão. O operador de comparação embutido em listas e tuplas já funciona exatamente como você deseja. Você só precisa estender de zero as listas de versões para o comprimento correspondente. Com o python 2.6, você pode usar izip_longest para preencher as sequências.

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)

Com versões anteriores, alguns hackeamentos de mapa são necessários.

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)
Formigas Aasma
fonte
Legal, mas difícil de entender para alguém que não consegue ler código como prosa. :) Bem, suponho que você só pode encurtar a solução à custa da legibilidade ...
Johannes Charra
10

Isso é um pouco mais compacto do que sua sugestão. Em vez de preencher a versão mais curta com zeros, estou removendo os zeros à direita das listas de versões após a divisão.

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
fonte
Boa, obrigado. Mas ainda estou esperando por uma linha ou duas ...;)
Johannes Charra
4
+1 @jellybean: dois liners nem sempre são os melhores para manutenção e legibilidade, este é um código muito claro e compacto ao mesmo tempo, além disso, você pode reutilizá-lo mycmppara outros fins em seu código, caso precise.
RedGlyph
@RedGlyph: Você tem razão. Deveria ter dito "um liner legível". :)
Johannes Charra
hi @ Pär Wieslander, quando uso esta solução para resolver o mesmo problema no problema Leetcode, recebo um erro no loop while dizendo "índice de lista fora do intervalo". Você pode ajudar por que isso ocorre? Aqui está o problema: leetcode.com/explore/interview/card/amazon/76/array-and-strings/…
YouHaveaBigEgo
7

Remova o final .0e .00com regex splite use a cmpfunção que compara as matrizes corretamente:

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)

E, é claro, você pode convertê-lo em uma linha se não se importar com as longas filas.

yu_sha
fonte
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('.')]))))

É um forro (dividido para legibilidade). Não tenho certeza sobre legível ...

mavnn
fonte
1
Sim! E encolheu ainda mais ( tuplenão é necessário aliás):cmp(*zip(*map(lambda x,y:(x or 0,y or 0), map(int,v1.split('.')), map(int,v2.split('.')) )))
Paulo
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

Implemente para php version_compare, exceto "=". Porque é ambíguo.

Ryan Fau
fonte
2

As listas são comparáveis ​​no Python, portanto, se alguém converter as strings que representam os números em inteiros, a comparação básica do Python pode ser usada com sucesso.

Eu precisei estender um pouco essa abordagem porque uso Python3x onde a cmpfunção não existe mais. Eu tive que emular cmp(a,b)com (a > b) - (a < b). E os números de versão não são tão claros e podem conter todos os tipos de outros caracteres alfanuméricos. Há casos em que a função não pode informar a ordem, então ela retorna False(veja o primeiro exemplo).

Então estou postando isso mesmo que a pergunta seja antiga e já respondida, pois pode economizar alguns minutos na vida de alguém.

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
fonte
2

Caso você não queira obter uma dependência externa, aqui está minha tentativa escrita para Python 3.x.

rc, rel(e possivelmente alguém poderia adicionar c) são considerados "candidatos à liberação" e dividem o número da versão em duas partes e, se estiver faltando, o valor da segunda parte é alto (999). Outras letras produzem uma divisão e são tratadas como subnúmeros por meio do código de 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
fonte
1

A solução mais difícil de ler, mas de uma linha! e 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)

isto é, para Python 2.6 e 3. + btw, Python 2.5 e anteriores precisam capturar a StopIteration.

Paulo
fonte
1

Fiz isso para poder analisar e comparar a string de versão do pacote Debian. Observe que não é estrito com a validação de caracteres.

Isso também pode 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
fonte
0

Outra solução:

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))

Também pode ser usado assim:

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
fonte
0

estou usando este no meu projeto:

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

Anos depois, mas essa questão ainda está no topo.

Aqui está minha função de classificação de versão. Ele divide a versão em seções de números e não números. Os números são comparados como intrestantes str(como partes de itens da 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))

Você pode usar a função keycomo tipo de tipo personalizado Versioncom operadores de comparação. Se realmente quiser usar, cmpvocê pode fazer como neste exemplo: 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

O conjunto de testes é aprovado.

rysson
fonte
-1

Minha solução preferida:

Preencher a string com zeros extras e usar apenas os quatro primeiros é fácil de entender, não requer nenhuma regex e o lambda é mais ou menos legível. Eu uso duas linhas para facilitar a leitura, para mim a elegância é curta e simples.

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
fonte
-1

Esta é a minha solução (escrita em C, desculpe). Espero que você ache útil

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
fonte