Comment comparer les numéros de version en Python?

236

Je marche dans un répertoire qui contient des œufs pour ajouter ces œufs à la sys.path. S'il existe deux versions du même .egg dans le répertoire, je souhaite ajouter uniquement la dernière.

J'ai une expression régulière r"^(?P<eggName>\w+)-(?P<eggVersion>[\d\.]+)-.+\.egg$pour extraire le nom et la version du nom de fichier. Le problème est de comparer le numéro de version, qui est une chaîne comme 2.3.1.

Puisque je compare des chaînes, 2 sortes au-dessus de 10, mais ce n'est pas correct pour les versions.

>>> "2.3.1" > "10.1.1"
True

Je pourrais faire un fractionnement, une analyse, un cast en int, etc., et j'obtiendrais finalement une solution de contournement. Mais c'est Python, pas Java . Existe-t-il un moyen élégant de comparer les chaînes de version?

BorrajaX
la source

Réponses:

367

Utilisez packaging.version.parse.

>>> from packaging import version
>>> version.parse("2.3.1") < version.parse("10.1.2")
True
>>> version.parse("1.3.a4") < version.parse("10.1.2")
True
>>> isinstance(version.parse("1.3.a4"), version.Version)
True
>>> isinstance(version.parse("1.3.xy123"), version.LegacyVersion)
True
>>> version.Version("1.3.xy123")
Traceback (most recent call last):
...
packaging.version.InvalidVersion: Invalid version: '1.3.xy123'

packaging.version.parseest un utilitaire tiers mais est utilisé par setuptools (donc vous l'avez probablement déjà installé) et est conforme au PEP 440 actuel ; il renverra un packaging.version.Versionsi la version est conforme et un packaging.version.LegacyVersionsinon. Ces derniers seront toujours triés avant les versions valides.

Remarque : l'emballage a récemment été vendu dans setuptools .


Une ancienne alternative encore utilisée par de nombreux logiciels est distutils.version, intégrée mais non documentée et conforme uniquement au PEP 386 remplacé ;

>>> from distutils.version import LooseVersion, StrictVersion
>>> LooseVersion("2.3.1") < LooseVersion("10.1.2")
True
>>> StrictVersion("2.3.1") < StrictVersion("10.1.2")
True
>>> StrictVersion("1.3.a4")
Traceback (most recent call last):
...
ValueError: invalid version number '1.3.a4'

Comme vous pouvez le voir, il considère les versions valides du PEP 440 comme «non strictes» et ne correspond donc pas à la notion de Python moderne de ce qu'est une version valide.

Comme cela distutils.versionn'est pas documenté, voici les docstrings pertinents.

ecatmur
la source
2
Il semble que NormalizedVersion ne viendra pas, car il a été remplacé, et LooseVersion et StrictVersion ne sont donc plus obsolètes.
Taywee
12
C'est une honte qui pleure distutils.versionest sans papiers.
John Y
trouvé en utilisant le moteur de recherche et en trouvant directement le version.pycode source. Très bien mis!
Joël
@Taywee, ils le sont mieux, car ils ne sont pas conformes au PEP 440.
mouton volant le
2
IMHO packaging.version.parsene peut pas faire confiance pour comparer les versions. Essayez parse('1.0.1-beta.1') > parse('1.0.0')par exemple.
Trondh
104

La bibliothèque d' empaquetage contient des utilitaires pour travailler avec des versions et d'autres fonctionnalités liées à l'empaquetage. Cela implémente PEP 0440 - Identification de version et est également capable d'analyser les versions qui ne suivent pas le PEP. Il est utilisé par pip et d'autres outils Python courants pour fournir l'analyse et la comparaison des versions.

$ pip install packaging
from packaging.version import parse as parse_version
version = parse_version('1.0.3.dev')

Cela a été séparé du code d'origine dans setuptools et pkg_resources pour fournir un package plus léger et plus rapide.


Avant que la bibliothèque d'empaquetage n'existe, cette fonctionnalité était (et peut toujours être) trouvée dans pkg_resources, un paquet fourni par setuptools. Cependant, cela n'est plus préféré car setuptools n'est plus garanti d'être installé (d'autres outils de packaging existent), et pkg_resources utilise ironiquement beaucoup de ressources lors de l'importation. Cependant, tous les documents et discussions sont toujours pertinents.

De la parse_version()documentation :

Analyse la chaîne de version d'un projet telle que définie par PEP 440. La valeur renvoyée sera un objet qui représente la version. Ces objets peuvent être comparés les uns aux autres et triés. L'algorithme de tri est tel que défini par PEP 440 avec l'ajout que toute version qui n'est pas une version valide de PEP 440 sera considérée comme inférieure à toute version valide de PEP 440 et les versions invalides continueront de trier en utilisant l'algorithme d'origine.

L '"algorithme d'origine" référencé a été défini dans les anciennes versions de la documentation, avant l'existence du PEP 440.

Sémantiquement, le format est un croisement approximatif entre les distutils StrictVersionet les LooseVersionclasses; si vous lui donnez des versions qui fonctionneraient StrictVersion, alors elles se compareront de la même manière. Sinon, les comparaisons ressemblent davantage à une forme «plus intelligente» de LooseVersion. Il est possible de créer des schémas de codage de versions pathologiques qui tromperont cet analyseur, mais ils devraient être très rares dans la pratique.

La documentation fournit quelques exemples:

Si vous voulez être certain que le schéma de numérotation que vous avez choisi fonctionne comme vous le pensez, vous pouvez utiliser la pkg_resources.parse_version() fonction pour comparer différents numéros de version:

>>> from pkg_resources import parse_version
>>> parse_version('1.9.a.dev') == parse_version('1.9a0dev')
True
>>> parse_version('2.1-rc2') < parse_version('2.1')
True
>>> parse_version('0.6a9dev-r41475') < parse_version('0.6a9')
True
davidisme
la source
57
def versiontuple(v):
    return tuple(map(int, (v.split("."))))

>>> versiontuple("2.3.1") > versiontuple("10.1.1")
False
gentil
la source
10
Les autres réponses se trouvent dans la bibliothèque standard et suivent les normes PEP.
Chris
1
Dans ce cas, vous pouvez supprimer map()complètement la fonction, car le résultat de split()est déjà des chaînes. Mais vous ne voulez pas le faire de toute façon, car toute la raison de les changer intest pour qu'ils se comparent correctement en tant que nombres. Sinon "10" < "2".
kindall
6
Cela échouera pour quelque chose comme versiontuple("1.0") > versiontuple("1"). Les versions sont les mêmes, mais les tuples créés(1,)!=(1,0)
dawg
3
En quoi la version 1 et la version 1.0 sont-elles les mêmes? Les numéros de version ne sont pas flottants.
kindall
12
Non, cela ne devrait pas être la réponse acceptée. Heureusement, ce n'est pas le cas. Une analyse fiable des spécificateurs de version n'est pas triviale (sinon pratiquement impossible) dans le cas général. Ne réinventez pas la roue et continuez à la casser. Comme ecatmur le suggère ci - dessus , utilisez-le distutils.version.LooseVersion. C'est pour ça qu'il est là.
Cecil Curry
12

Qu'y a-t-il de mal à transformer la chaîne de version en un tuple et à partir de là? Semble assez élégant pour moi

>>> (2,3,1) < (10,1,1)
True
>>> (2,3,1) < (10,1,1,1)
True
>>> (2,3,1,10) < (10,1,1,1)
True
>>> (10,3,1,10) < (10,1,1,1)
False
>>> (10,3,1,10) < (10,4,1,1)
True

La solution de @ kindall est un exemple rapide de l'apparence du code.

Gabi Purcaru
la source
1
Je pense que cette réponse pourrait être développée en fournissant du code qui effectue la transformation d'une chaîne PEP440 en un tuple. Je pense que vous constaterez que ce n'est pas une tâche triviale. Je pense qu'il vaut mieux laisser au package qui effectue cette traduction setuptools, ce qui est pkg_resources.
@TylerGubala c'est une excellente réponse dans les situations où vous savez que la version est et sera toujours "simple". pkg_resources est un gros paquet et peut provoquer un gonflement plutôt exécutable d'un exécutable distribué.
Erik Aronesty
@Erik Aronesty Je pense que le contrôle de version à l'intérieur des exécutables distribués est quelque peu en dehors de la portée de la question, mais je suis d'accord, en général du moins. Je pense cependant qu'il y a quelque chose à dire sur la réutilisabilité de pkg_resources, et que les hypothèses de nommage simple des packages ne sont pas toujours idéales.
Cela fonctionne très bien pour vous assurer sys.version_info > (3, 6)ou autre chose.
Gqqnbig
7

Un emballage est disponible, ce qui vous permettra de comparer les versions selon PEP-440 , ainsi que les versions héritées.

>>> from packaging.version import Version, LegacyVersion
>>> Version('1.1') < Version('1.2')
True
>>> Version('1.2.dev4+deadbeef') < Version('1.2')
True
>>> Version('1.2.8.5') <= Version('1.2')
False
>>> Version('1.2.8.5') <= Version('1.2.8.6')
True

Prise en charge des versions héritées:

>>> LegacyVersion('1.2.8.5-5-gdeadbeef')
<LegacyVersion('1.2.8.5-5-gdeadbeef')>

Comparaison de la version héritée avec la version PEP-440.

>>> LegacyVersion('1.2.8.5-5-gdeadbeef') < Version('1.2.8.6')
True
sashk
la source
3
Pour ceux qui s'interrogent sur la différence entre packaging.version.Versionet packaging.version.parse: "[ version.parse] prend une chaîne de version et l'analysera comme un Versionsi la version est une version PEP 440 valide, sinon il l'analysera comme un LegacyVersion." (alors version.Versionqu'il augmenterait InvalidVersion; source )
Braham Snyder
5

Vous pouvez utiliser le package semver pour déterminer si une version satisfait une exigence de version sémantique . Ce n'est pas la même chose que de comparer deux versions réelles, mais c'est un type de comparaison.

Par exemple, la version 3.6.0 + 1234 devrait être la même que la version 3.6.0.

import semver
semver.match('3.6.0+1234', '==3.6.0')
# True

from packaging import version
version.parse('3.6.0+1234') == version.parse('3.6.0')
# False

from distutils.version import LooseVersion
LooseVersion('3.6.0+1234') == LooseVersion('3.6.0')
# False
Prikkeldraad
la source
3

Publier ma fonction complète basée sur la solution de Kindall. J'ai pu prendre en charge tous les caractères alphanumériques mélangés aux chiffres en remplissant chaque section de version avec des zéros en tête.

Bien que certainement pas aussi joli que sa fonction à une ligne, il semble bien fonctionner avec les numéros de version alphanumériques. (Assurez-vous simplement de définir la zfill(#)valeur de manière appropriée si vous avez de longues chaînes dans votre système de gestion des versions.)

def versiontuple(v):
   filled = []
   for point in v.split("."):
      filled.append(point.zfill(8))
   return tuple(filled)

.

>>> versiontuple("10a.4.5.23-alpha") > versiontuple("2a.4.5.23-alpha")
True


>>> "10a.4.5.23-alpha" > "2a.4.5.23-alpha"
False
Phaxmohdem
la source
2

La façon dont setuptoolsil le fait, il utilise la pkg_resources.parse_versionfonction. Ce devrait être PEP440 conforme au .

Exemple:

#! /usr/bin/python
# -*- coding: utf-8 -*-
"""Example comparing two PEP440 formatted versions
"""
import pkg_resources

VERSION_A = pkg_resources.parse_version("1.0.1-beta.1")
VERSION_B = pkg_resources.parse_version("v2.67-rc")
VERSION_C = pkg_resources.parse_version("2.67rc")
VERSION_D = pkg_resources.parse_version("2.67rc1")
VERSION_E = pkg_resources.parse_version("1.0.0")

print(VERSION_A)
print(VERSION_B)
print(VERSION_C)
print(VERSION_D)

print(VERSION_A==VERSION_B) #FALSE
print(VERSION_B==VERSION_C) #TRUE
print(VERSION_C==VERSION_D) #FALSE
print(VERSION_A==VERSION_E) #FALSE

la source
pkg_resourcesfait partie de setuptools, qui dépend packaging. Voir les autres réponses qui discutent packaging.version.parse, qui a une implémentation identique à pkg_resources.parse_version.
Jed
0

Je cherchais une solution qui n'ajouterait aucune nouvelle dépendance. Découvrez la solution suivante (Python 3):

class VersionManager:

    @staticmethod
    def compare_version_tuples(
            major_a, minor_a, bugfix_a,
            major_b, minor_b, bugfix_b,
    ):

        """
        Compare two versions a and b, each consisting of 3 integers
        (compare these as tuples)

        version_a: major_a, minor_a, bugfix_a
        version_b: major_b, minor_b, bugfix_b

        :param major_a: first part of a
        :param minor_a: second part of a
        :param bugfix_a: third part of a

        :param major_b: first part of b
        :param minor_b: second part of b
        :param bugfix_b: third part of b

        :return:    1 if a  > b
                    0 if a == b
                   -1 if a  < b
        """
        tuple_a = major_a, minor_a, bugfix_a
        tuple_b = major_b, minor_b, bugfix_b
        if tuple_a > tuple_b:
            return 1
        if tuple_b > tuple_a:
            return -1
        return 0

    @staticmethod
    def compare_version_integers(
            major_a, minor_a, bugfix_a,
            major_b, minor_b, bugfix_b,
    ):
        """
        Compare two versions a and b, each consisting of 3 integers
        (compare these as integers)

        version_a: major_a, minor_a, bugfix_a
        version_b: major_b, minor_b, bugfix_b

        :param major_a: first part of a
        :param minor_a: second part of a
        :param bugfix_a: third part of a

        :param major_b: first part of b
        :param minor_b: second part of b
        :param bugfix_b: third part of b

        :return:    1 if a  > b
                    0 if a == b
                   -1 if a  < b
        """
        # --
        if major_a > major_b:
            return 1
        if major_b > major_a:
            return -1
        # --
        if minor_a > minor_b:
            return 1
        if minor_b > minor_a:
            return -1
        # --
        if bugfix_a > bugfix_b:
            return 1
        if bugfix_b > bugfix_a:
            return -1
        # --
        return 0

    @staticmethod
    def test_compare_versions():
        functions = [
            (VersionManager.compare_version_tuples, "VersionManager.compare_version_tuples"),
            (VersionManager.compare_version_integers, "VersionManager.compare_version_integers"),
        ]
        data = [
            # expected result, version a, version b
            (1, 1, 0, 0, 0, 0, 1),
            (1, 1, 5, 5, 0, 5, 5),
            (1, 1, 0, 5, 0, 0, 5),
            (1, 0, 2, 0, 0, 1, 1),
            (1, 2, 0, 0, 1, 1, 0),
            (0, 0, 0, 0, 0, 0, 0),
            (0, -1, -1, -1, -1, -1, -1),  # works even with negative version numbers :)
            (0, 2, 2, 2, 2, 2, 2),
            (-1, 5, 5, 0, 6, 5, 0),
            (-1, 5, 5, 0, 5, 9, 0),
            (-1, 5, 5, 5, 5, 5, 6),
            (-1, 2, 5, 7, 2, 5, 8),
        ]
        count = len(data)
        index = 1
        for expected_result, major_a, minor_a, bugfix_a, major_b, minor_b, bugfix_b in data:
            for function_callback, function_name in functions:
                actual_result = function_callback(
                    major_a=major_a, minor_a=minor_a, bugfix_a=bugfix_a,
                    major_b=major_b, minor_b=minor_b, bugfix_b=bugfix_b,
                )
                outcome = expected_result == actual_result
                message = "{}/{}: {}: {}: a={}.{}.{} b={}.{}.{} expected={} actual={}".format(
                    index, count,
                    "ok" if outcome is True else "fail",
                    function_name,
                    major_a, minor_a, bugfix_a,
                    major_b, minor_b, bugfix_b,
                    expected_result, actual_result
                )
                print(message)
                assert outcome is True
                index += 1
        # test passed!


if __name__ == '__main__':
    VersionManager.test_compare_versions()

EDIT: variante ajoutée avec comparaison de tuple. Bien sûr, la variante avec comparaison de tuple est plus agréable, mais je cherchais la variante avec comparaison entière

Stefan Saru
la source
Je suis curieux de savoir dans quelle situation cela évite-t-il d'ajouter des dépendances? N'avez-vous pas besoin de la bibliothèque d'empaquetage (utilisée par setuptools) pour créer un paquet python?
Josiah L.