Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-distlib for openSUSE:Factory
checked in at 2026-11-10 14:38:23
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-distlib (Old)
and /work/SRC/openSUSE:Factory/.python-distlib.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-distlib"
Tue Nov 10 14:38:23 2026 rev:20 rq:1396188 version:0.4.4
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-distlib/python-distlib.changes
2026-07-08 17:32:08.172265540 +0200
+++
/work/SRC/openSUSE:Factory/.python-distlib.new.3759290/python-distlib.changes
2026-11-10 14:38:29.842736410 +0100
@@ -1,0 +2,33 @@
+Sun Nov 8 21:08:40 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 0.4.4:
+ * Fixed a missing import.
+ * Fixed #273: Enforce compatible-release bounds in environment
+ markers. Thanks to Luochenchen for the patch.
+ * Fix #194: Add an urls property to expose project and home
+ URLs. Thanks to Sinkleberg for the patch.
+ * Improve performance of export entry search to take linear
+ time. Thanks to Brian Willows for the patch.
+ * Use tarfile.data_filter rather than tarfile.tar_filter in
+ unarchive() when extraction filters are available, matching
+ the Python 3.14 default. Path and link containment checks now
+ use os.path.realpath and no longer fail with IndexError for
+ members such as . or ./. Thanks to Kobain for the patch.
+ * unarchive() now resolves member paths through symlinks seen
+ earlier in the same archive, so a chain of individually
+ harmless-looking links cannot be used to write outside the
+ destination on Pythons without extraction filters. Thanks to
+ Kobain for the patch.
+ * version Improve NormalizedVersion by addressing correctness
+ and performance gaps found by comparison with packaging.
+ Thanks to Damian Shaw for the patch. Improve SemanticVersion
+ by comparing numeric prerelease identifiers, including those
+ longer than 8 digits. Thanks to Muneeb Sheikh for the patch.
+ * Improve NormalizedVersion by addressing correctness and
+ performance gaps found by comparison with packaging. Thanks
+ to Damian Shaw for the patch.
+ * Improve SemanticVersion by comparing numeric prerelease
+ identifiers, including those longer than 8 digits. Thanks to
+ Muneeb Sheikh for the patch.
+
+-------------------------------------------------------------------
Old:
----
distlib-0.4.3.tar.gz
New:
----
distlib-0.4.4.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-distlib.spec ++++++
--- /var/tmp/diff_new_pack.E1nkuW/_old 2026-11-10 14:38:30.769775208 +0100
+++ /var/tmp/diff_new_pack.E1nkuW/_new 2026-11-10 14:38:30.771775292 +0100
@@ -18,7 +18,7 @@
%{?sle15_python_module_pythons}
Name: python-distlib
-Version: 0.4.3
+Version: 0.4.4
Release: 0
Summary: Distribution utilities
License: Python-2.0
++++++ distlib-0.4.3.tar.gz -> distlib-0.4.4.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/CHANGES.rst
new/distlib-0.4.4/CHANGES.rst
--- old/distlib-0.4.3/CHANGES.rst 2026-06-12 09:55:42.000000000 +0200
+++ new/distlib-0.4.4/CHANGES.rst 2026-10-16 12:29:33.860959800 +0200
@@ -1,15 +1,67 @@
Change log for ``distlib``
--------------------------
-0.4.4 (future)
+0.4.5 (future)
~~~~~~~~~~~~~~
Released: Not yet.
+
+0.4.4
+~~~~~
+
+Released: 2026-10-16
+
+- compat
+
+ - Fixed a missing import. Thanks to Andrej730 for the patch.
+
+- markers
+
+ - Fixed #273: Enforce compatible-release bounds in environment markers.
+ Thanks to Luochenchen for the patch.
+
+- metadata
+
+ - Fix #194: Add an urls property to expose project and home URLs. Thanks to
+ Sinkleberg for the patch.
+
+- scripts
+
+ - Raise the correct exception when a launcher can't be written. Thanks to
+ candygoogy for the patch.
+
+ - Fix #144: Detect shell-script Python executables in ScriptMaker shebangs.
+ Thanks to Alex Chen for the patch.
+
+- util
+
+ - Improve performance of export entry search to take linear time. Thanks to
+ Brian Willows for the patch.
+
+ - Use ``tarfile.data_filter`` rather than ``tarfile.tar_filter`` in
``unarchive()``
+ when extraction filters are available, matching the Python 3.14 default.
Path and
+ link containment checks now use ``os.path.realpath`` and no longer fail
with
+ ``IndexError`` for members such as ``.`` or ``./``. Thanks to Kobain for
the patch.
+
+ - ``unarchive()`` now resolves member paths through symlinks seen earlier
in the
+ same archive, so a chain of individually harmless-looking links cannot
be used
+ to write outside the destination on Pythons without extraction filters.
Thanks to
+ Kobain for the patch.
+
+- version
+
+ - Improve NormalizedVersion by addressing correctness and performance gaps
+ found by comparison with packaging. Thanks to Damian Shaw for the patch.
+
+ - Improve SemanticVersion by comparing numeric prerelease identifiers,
+ including those longer than 8 digits. Thanks to Muneeb Sheikh for the
+ patch.
+
0.4.3
~~~~~
-Released: Not yet.
+Released: 2026-06-12
- resources
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/CONTRIBUTORS.txt
new/distlib-0.4.4/CONTRIBUTORS.txt
--- old/distlib-0.4.3/CONTRIBUTORS.txt 2019-04-09 09:00:51.000000000 +0200
+++ new/distlib-0.4.4/CONTRIBUTORS.txt 2026-10-16 09:17:18.070675000 +0200
@@ -39,6 +39,7 @@
- Filip Gruszczyński
- Walker Hale IV
- Alexandre Hamelin
+- harbinresearcher
- Kelsey Hightower
- Thomas Holmes
- Preston Holmes
@@ -78,6 +79,8 @@
- Erik Rose
- Brian Rosner
- Vinay Sajip
+- Damian Shaw
+- Sinkleberg
- Victor Stinner
- Alexandre Vassalotti
- Nadeem Vawda
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/PKG-INFO new/distlib-0.4.4/PKG-INFO
--- old/distlib-0.4.3/PKG-INFO 2026-06-12 10:02:21.523537600 +0200
+++ new/distlib-0.4.4/PKG-INFO 2026-10-16 12:33:23.143569700 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: distlib
-Version: 0.4.3
+Version: 0.4.4
Summary: Distribution utilities
Home-page: https://github.com/pypa/distlib
Author: Vinay Sajip
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/__init__.py
new/distlib-0.4.4/distlib/__init__.py
--- old/distlib-0.4.3/distlib/__init__.py 2026-06-12 09:57:15.000000000
+0200
+++ new/distlib-0.4.4/distlib/__init__.py 2026-10-16 12:28:42.236261100
+0200
@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
#
-# Copyright (C) 2012-2024 Vinay Sajip.
+# Copyright (C) 2012-2026 Vinay Sajip.
# Licensed to the Python Software Foundation under a contributor agreement.
# See LICENSE.txt and CONTRIBUTORS.txt.
#
import logging
-__version__ = '0.4.3'
+__version__ = '0.4.4'
class DistlibException(Exception):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/compat.py
new/distlib-0.4.4/distlib/compat.py
--- old/distlib-0.4.3/distlib/compat.py 2026-06-08 17:49:09.000000000 +0200
+++ new/distlib-0.4.4/distlib/compat.py 2026-10-15 10:10:20.850259800 +0200
@@ -471,15 +471,40 @@
else:
from html import unescape
+_get_ident = None
+
+
+def _thread_get_ident():
+ global _get_ident
+ if _get_ident is None:
+ try:
+ # Python 2
+ from thread import get_ident as _get_ident
+ except ImportError:
+ try:
+ from dummy_thread import get_ident as _get_ident
+ except ImportError:
+ # Python >= 3
+ try:
+ from _thread import get_ident as _get_ident
+ except ImportError:
+ from _dummy_thread import get_ident as _get_ident
+ return _get_ident
+
+
try:
+ # Python >= 3.3
from collections import ChainMap
except ImportError: # pragma: no cover
from collections import MutableMapping
try:
+ # Python >= 3.2
from reprlib import recursive_repr as _recursive_repr
except ImportError:
+ _get_ident = _thread_get_ident()
+
def _recursive_repr(fillvalue='...'):
'''
Decorator to make a repr function return fillvalue for a recursive
@@ -490,7 +515,7 @@
repr_running = set()
def wrapper(self):
- key = id(self), get_ident()
+ key = id(self), _get_ident()
if key in repr_running:
return fillvalue
repr_running.add(key)
@@ -623,15 +648,13 @@
try:
+ # Python >= 3.1
from collections import OrderedDict
except ImportError: # pragma: no cover
# {{{ http://code.activestate.com/recipes/576693/ (r9)
# Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7
and pypy.
# Passes Python2.7's test suite and incorporates all the latest updates.
- try:
- from thread import get_ident as _get_ident
- except ImportError:
- from dummy_thread import get_ident as _get_ident
+ _get_ident = _thread_get_ident()
try:
from _abcoll import KeysView, ValuesView, ItemsView
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/markers.py
new/distlib-0.4.4/distlib/markers.py
--- old/distlib-0.4.3/distlib/markers.py 2024-10-15 20:10:51.000000000
+0200
+++ new/distlib-0.4.4/distlib/markers.py 2026-10-16 09:17:18.138686400
+0200
@@ -20,6 +20,7 @@
from .compat import string_types
from .util import in_venv, parse_marker
from .version import LegacyVersion as LV
+from .version import NormalizedVersion as NV
__all__ = ['interpret']
@@ -41,6 +42,13 @@
return {LV(m.groups()[0]) for m in _VERSION_PATTERN.finditer(s)}
+def _match_compatible(lhs, rhs):
+ version, constraint = NV(str(lhs)), NV(str(rhs))
+ prefix = constraint._release_clause[:-1]
+ release = version._release_clause + (0, ) * len(prefix)
+ return (version >= constraint and version._parts[0] ==
constraint._parts[0] and release[:len(prefix)] == prefix)
+
+
class Evaluator(object):
"""
This class is used to evaluate marker expressions.
@@ -49,7 +57,7 @@
operations = {
'==': lambda x, y: x == y,
'===': lambda x, y: x == y,
- '~=': lambda x, y: x == y or x > y,
+ '~=': _match_compatible,
'!=': lambda x, y: x != y,
'<': lambda x, y: x < y,
'<=': lambda x, y: x == y or x < y,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/metadata.py
new/distlib-0.4.4/distlib/metadata.py
--- old/distlib-0.4.3/distlib/metadata.py 2026-06-06 09:59:53.000000000
+0200
+++ new/distlib-0.4.4/distlib/metadata.py 2026-10-15 10:20:08.360439300
+0200
@@ -866,6 +866,28 @@
return result
@property
+ def urls(self):
+ """Return project URLs and home/download URLs as separate dictionaries.
+
+ Each access returns new dictionaries. If a project URL label occurs
+ more than once, the last value is used.
+ """
+ if self._legacy:
+ project_urls = dict((label.strip(), url.strip())
+ for label, url in
self._legacy.get('Project-URL', []))
+ other_urls = {}
+ for key, field in (('home_page', 'Home-page'), ('download_url',
'Download-URL')):
+ value = self._legacy.get(field, None)
+ if value:
+ other_urls[key] = value
+ else:
+ project = self._data.get('extensions', {}).get('python.project',
{})
+ project_urls = dict(project.get('project_urls', {}))
+ other_urls = dict((key, self._data[key]) for key in ('home_page',
'download_url')
+ if self._data.get(key))
+ return project_urls, other_urls
+
+ @property
def dictionary(self):
if self._legacy:
return self._from_legacy()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/scripts.py
new/distlib-0.4.4/distlib/scripts.py
--- old/distlib-0.4.3/distlib/scripts.py 2026-06-02 12:48:59.000000000
+0200
+++ new/distlib-0.4.4/distlib/scripts.py 2026-10-16 09:17:18.138686400
+0200
@@ -120,19 +120,19 @@
executable = os.path.join(dn, fn)
return executable
- if sys.platform.startswith('java'): # pragma: no cover
+ def _is_shell(self, executable):
+ """
+ Determine if the specified executable is a script
+ (contains a #! line)
+ """
+ try:
+ with open(executable, 'rb') as fp:
+ return fp.read(2) == b'#!'
+ except (OSError, IOError):
+ logger.warning('Failed to open %s', executable)
+ return False
- def _is_shell(self, executable):
- """
- Determine if the specified executable is a script
- (contains a #! line)
- """
- try:
- with open(executable) as fp:
- return fp.read(2) == '#!'
- except (OSError, IOError):
- logger.warning('Failed to open %s', executable)
- return False
+ if sys.platform.startswith('java'): # pragma: no cover
def _fix_jython_executable(self, executable):
if self._is_shell(executable):
@@ -172,7 +172,9 @@
max_shebang_length = 512
else:
max_shebang_length = 127
- simple_shebang = ((b' ' not in executable) and (shebang_length <=
max_shebang_length))
+ simple_shebang = ((b' ' not in executable) and
+ (shebang_length <= max_shebang_length) and
+ not self._is_shell(executable.decode('utf-8')))
if simple_shebang:
result = b'#!' + executable + post_interp + b'\n'
@@ -290,6 +292,10 @@
try:
self._fileop.write_binary_file(outname, script_bytes)
except Exception:
+ if not os.path.exists(outname):
+ # Nothing to move aside, so the original error
+ # (e.g. no write permission) is the real one.
+ raise
# Failed writing an executable - it might be in use.
logger.warning('Failed to write executable - trying to '
'use .deleteme logic')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/util.py
new/distlib-0.4.4/distlib/util.py
--- old/distlib-0.4.3/distlib/util.py 2026-06-04 11:44:28.000000000 +0200
+++ new/distlib-0.4.4/distlib/util.py 2026-10-16 12:03:10.053095600 +0200
@@ -720,9 +720,35 @@
\s*(\[\s*(?P<flags>[\w-]+(=\w+)?(,\s*\w+(=\w+)?)*)\s*\])?
''', re.VERBOSE)
+# ENTRY_RE, but only starting at the first or last character of a whitespace
+# run. See _search_export_entry.
+_ENTRY_RE_AT_SPACE = re.compile(r'(?:(?<!\s)(?=\s)|(?=\s\S))' +
+ ENTRY_RE.pattern, re.VERBOSE)
+
+
+def _search_export_entry(specification):
+ """
+ Return ENTRY_RE.search(specification), in linear rather than quadratic
+ time.
+
+ search() tries every start position, and each attempt can rescan the rest
+ of a long name. Most positions cannot hold the leftmost match: a match that
+ starts inside a run of non-space characters implies one starting earlier in
+ the run (or at the whitespace just before it, since a name may start with
+ whitespace), and a match starting inside a whitespace run, other than at
+ its last character, implies one at its first. So the leftmost match starts
+ at the first character that is not '[' (a name cannot start with '['), or
+ at the first or last character of a whitespace run.
+ """
+ start = len(specification) - len(specification.lstrip('['))
+ m = ENTRY_RE.match(specification, start)
+ if not m:
+ m = _ENTRY_RE_AT_SPACE.search(specification, start)
+ return m
+
def get_export_entry(specification):
- m = ENTRY_RE.search(specification)
+ m = _search_export_entry(specification)
if not m:
result = None
if '[' in specification or ']' in specification:
@@ -1227,14 +1253,33 @@
def unarchive(archive_filename, dest_dir, format=None, check=True):
+ # Symlinks seen so far in the archive, mapping the link's resolved path
+ # to its resolved target. Earlier members don't exist on disk while we
+ # check, so later member paths are resolved through this map lexically.
+ links = {}
+
+ def resolve(path):
+ cur = dest_dir
+ if not (path == dest_dir or path.startswith(dest_dir + os.sep)):
+ return os.path.realpath(path)
+ for part in path[len(dest_dir):].split(os.sep):
+ if part in ('', '.'):
+ continue
+ if part == '..':
+ cur = os.path.dirname(cur)
+ else:
+ cur = links.get(os.path.join(cur, part), os.path.join(cur,
part))
+ return os.path.realpath(cur)
+
def check_path(path, base=None):
if not isinstance(path, text_type):
path = path.decode('utf-8')
if base is None:
base = dest_dir
- p = os.path.abspath(os.path.join(base, path))
- if not p.startswith(dest_dir) or p[plen] != os.sep:
+ p = resolve(os.path.join(base, path))
+ if not (p == dest_dir or p.startswith(dest_dir + os.sep)):
raise ValueError('path outside destination: %r' % p)
+ return p
def check_link(member):
# A symlink/hardlink member's name is validated like any other
@@ -1246,14 +1291,14 @@
# relative to the archive root (i.e. dest_dir).
if not (member.issym() or member.islnk()):
return
- if member.issym():
- link_base = os.path.dirname(os.path.join(dest_dir, member.name))
- else:
- link_base = dest_dir
- check_path(member.linkname, base=link_base)
+ if member.islnk():
+ check_path(member.linkname)
+ return
+ name = member.name.rstrip('/')
+ parent = check_path(os.path.dirname(name) or '.')
+ links[os.path.join(parent, os.path.basename(name))] =
check_path(member.linkname, base=parent)
- dest_dir = os.path.abspath(dest_dir)
- plen = len(dest_dir)
+ dest_dir = os.path.realpath(dest_dir)
archive = None
if format is None:
if archive_filename.endswith(('.zip', '.whl')):
@@ -1294,11 +1339,13 @@
# Limit extraction of dangerous items, if this Python
# allows it easily. If not, just trust the input.
# See:
https://docs.python.org/3/library/tarfile.html#extraction-filters
+ # data_filter is the stdlib default from Python 3.14 onwards; using the
+ # laxer tar_filter here would downgrade that secure default.
def extraction_filter(member, path):
- """Run tarfile.tar_filter, but raise the expected ValueError"""
+ """Run tarfile.data_filter, but raise the expected ValueError"""
# This is only called if the current Python has tarfile filters
try:
- return tarfile.tar_filter(member, path)
+ return tarfile.data_filter(member, path)
except tarfile.FilterError as exc:
raise ValueError(str(exc))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib/version.py
new/distlib-0.4.4/distlib/version.py
--- old/distlib-0.4.3/distlib/version.py 2024-03-19 11:10:43.000000000
+0100
+++ new/distlib-0.4.4/distlib/version.py 2026-10-16 09:17:18.142687000
+0200
@@ -176,18 +176,21 @@
return self._string
-PEP440_VERSION_RE =
re.compile(r'^v?(\d+!)?(\d+(\.\d+)*)((a|alpha|b|beta|c|rc|pre|preview)(\d+)?)?'
- r'(\.(post|r|rev)(\d+)?)?([._-]?(dev)(\d+)?)?'
- r'(\+([a-zA-Z\d]+(\.[a-zA-Z\d]+)?))?$', re.I)
+PEP440_VERSION_RE =
re.compile(r'^v?([0-9]+!)?([0-9]+(\.[0-9]+)*)((a|alpha|b|beta|c|rc|pre|preview)([0-9]+)?)?'
+
r'(\.(post|r|rev)([0-9]+)?)?([._-]?(dev)([0-9]+)?)?'
+ r'(\+([a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*))?$',
re.I | getattr(re, 'ASCII', 0))
+_PRE_RELEASE_TAGS = {'alpha': 'a', 'beta': 'b', 'c': 'rc', 'pre': 'rc',
'preview': 'rc'}
-def _pep_440_key(s):
+
+def _pep_440_key(s, match=None, release=None):
+ """Return the comparison key, optionally reusing the parsed release tuple
and regex match."""
s = s.strip()
- m = PEP440_VERSION_RE.match(s)
- if not m:
+ m = match if match is not None else PEP440_VERSION_RE.match(s)
+ if m is None:
raise UnsupportedVersionError('Not a valid version: %s' % s)
groups = m.groups()
- nums = tuple(int(v) for v in groups[1].split('.'))
+ nums = release if release is not None else tuple(map(int,
groups[1].split('.')))
while len(nums) > 1 and nums[-1] == 0:
nums = nums[:-1]
@@ -202,29 +205,31 @@
if pre == (None, None):
pre = ()
else:
+ letter = pre[0].lower()
+ letter = _PRE_RELEASE_TAGS.get(letter, letter)
if pre[1] is None:
- pre = pre[0], 0
+ pre = letter, 0
else:
- pre = pre[0], int(pre[1])
+ pre = letter, int(pre[1])
if post == (None, None):
post = ()
else:
if post[1] is None:
- post = post[0], 0
+ post = 'post', 0
else:
- post = post[0], int(post[1])
+ post = 'post', int(post[1])
if dev == (None, None):
dev = ()
else:
if dev[1] is None:
- dev = dev[0], 0
+ dev = 'dev', 0
else:
- dev = dev[0], int(dev[1])
+ dev = 'dev', int(dev[1])
if local is None:
local = ()
else:
parts = []
- for part in local.split('.'):
+ for part in local.lower().replace('_', '.').replace('-',
'.').split('.'):
# to ensure that numeric compares as > lexicographic, avoid
# comparing them directly, but encode a tuple which ensures
# correct sorting
@@ -272,21 +277,23 @@
1.2.3b
"""
def parse(self, s):
- result = _normalized_key(s)
+ m = PEP440_VERSION_RE.match(s)
+ if not m:
+ raise UnsupportedVersionError('Not a valid version: %s' % s)
+ release = tuple(map(int, m.groups()[1].split('.')))
+ result = _normalized_key(s, match=m, release=release)
# _normalized_key loses trailing zeroes in the release
# clause, since that's needed to ensure that X.Y == X.Y.0 == X.Y.0.0
# However, PEP 440 prefix matching needs it: for example,
# (~= 1.4.5.0) matches differently to (~= 1.4.5.0.0).
- m = PEP440_VERSION_RE.match(s) # must succeed
- groups = m.groups()
- self._release_clause = tuple(int(v) for v in groups[1].split('.'))
+ self._release_clause = release
return result
PREREL_TAGS = set(['a', 'b', 'c', 'rc', 'dev'])
@property
def is_prerelease(self):
- return any(t[0] in self.PREREL_TAGS for t in self._parts if t)
+ return self._parts[2][0] in self.PREREL_TAGS or self._parts[4][0] ==
'dev'
def _match_prefix(x, y):
@@ -676,8 +683,14 @@
raise UnsupportedVersionError(s)
groups = m.groups()
major, minor, patch = [int(i) for i in groups[:3]]
- # choose the '|' and '*' so that versions sort correctly
- pre, build = make_tuple(groups[3], '|'), make_tuple(groups[5], '*')
+ # Numeric prerelease identifiers compare numerically, below nonnumeric
ones.
+ # An absent prerelease sorts above either kind of identifier.
+ if groups[3] is None:
+ pre = ((2,),)
+ else:
+ pre = tuple((0, int(p)) if p.isdigit() else (1, p)
+ for p in groups[3][1:].split('.'))
+ build = make_tuple(groups[5], '*')
return (major, minor, patch), pre, build
@@ -687,7 +700,7 @@
@property
def is_prerelease(self):
- return self._parts[1][0] != '|'
+ return self._parts[1][0] != (2,)
class SemanticMatcher(Matcher):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/distlib.egg-info/PKG-INFO
new/distlib-0.4.4/distlib.egg-info/PKG-INFO
--- old/distlib-0.4.3/distlib.egg-info/PKG-INFO 2026-06-12 10:02:21.000000000
+0200
+++ new/distlib-0.4.4/distlib.egg-info/PKG-INFO 2026-10-16 12:33:22.595477600
+0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: distlib
-Version: 0.4.3
+Version: 0.4.4
Summary: Distribution utilities
Home-page: https://github.com/pypa/distlib
Author: Vinay Sajip
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/tests/test_markers.py
new/distlib-0.4.4/tests/test_markers.py
--- old/distlib-0.4.3/tests/test_markers.py 2024-03-19 12:46:18.000000000
+0100
+++ new/distlib-0.4.4/tests/test_markers.py 2026-10-16 09:17:18.182693700
+0200
@@ -18,6 +18,80 @@
class MarkersTestCase(DistlibTestCase):
+ def _check_compatible(self, marker, variable, cases):
+ for version, expected in cases:
+ self.assertEqual(interpret(marker, {variable: version}), expected,
version)
+
+ def test_compatible_major_bound(self):
+ self._check_compatible('python_version ~= "3.10"', 'python_version', [
+ ('4.0', False),
+ ('3.9', False),
+ ('3.10', True),
+ ('3.11', True),
+ ])
+
+ def test_compatible_minor_bound(self):
+ self._check_compatible('python_full_version ~= "3.10.1"',
'python_full_version', [
+ ('3.11.0', False),
+ ('3.10.0', False),
+ ('3.10.1', True),
+ ('3.10.9', True),
+ ])
+
+ def test_compatible_precision(self):
+ self._check_compatible('python_full_version ~= "3.10.0.0"',
'python_full_version', [
+ ('3.10.1', False),
+ ('3.10', True),
+ ('3.10.0', True),
+ ('3.10.0.1', True),
+ ])
+
+ def test_compatible_normalized_release(self):
+ self._check_compatible('python_full_version ~= "3.010.1"',
'python_full_version', [
+ ('3.11.0', False),
+ ('3.10.1', True),
+ ('3.010.2', True),
+ ('v3.10.2', True),
+ ])
+
+ def test_compatible_epoch(self):
+ self._check_compatible('implementation_version ~= "1!3.10"',
'implementation_version', [
+ ('2!3.11', False),
+ ('1!3.9', False),
+ ('1!3.10', True),
+ ('1!3.11', True),
+ ])
+
+ def test_compatible_implementation_version(self):
+ self._check_compatible('implementation_version ~= "3.10"',
'implementation_version', [
+ ('3.9', False),
+ ('4.0', False),
+ ('3.10', True),
+ ('3.11', True),
+ ])
+
+ def test_compatible_reversed_operands(self):
+ self._check_compatible('"4.0" ~= python_version', 'python_version',
[('3.10', False)])
+ self._check_compatible('"3.10.9" ~= python_full_version',
'python_full_version', [
+ ('3.10.1', True),
+ ('3.11.0', False),
+ ])
+
+ def test_compatible_prerelease(self):
+ self._check_compatible('python_full_version ~= "3.10rc1"',
'python_full_version', [
+ ('4.0rc1', False),
+ ('3.10b1', False),
+ ('3.10rc1', True),
+ ('3.10', True),
+ ('3.11rc1', True),
+ ])
+
+ def test_other_version_operators_keep_legacy_labels(self):
+ context = {'python_full_version': '3.13.0a1+'}
+ self.assertTrue(interpret('python_full_version == "3.13.0a1+"',
context))
+ self.assertTrue(interpret('python_full_version >= "3.12.0"', context))
+ self.assertFalse(interpret('python_full_version < "3.12.0"', context))
+
def test_interpret(self):
sys_platform = sys.platform
version = sys.version.split()[0]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/tests/test_metadata.py
new/distlib-0.4.4/tests/test_metadata.py
--- old/distlib-0.4.3/tests/test_metadata.py 2026-06-06 10:00:26.000000000
+0200
+++ new/distlib-0.4.4/tests/test_metadata.py 2026-10-15 10:20:08.364440000
+0200
@@ -511,6 +511,70 @@
self.assertEqual(md.version, '0.1')
self.assertEqual(md.provides, ['foobar (0.1)'])
+ def test_urls_legacy(self):
+ md = Metadata(fileobj=StringIO(dedent("""\
+ Metadata-Version: 2.1
+ Name: example
+ Version: 1.0
+ Summary: Example
+ Home-page: https://example.com
+ Download-URL: https://example.com/archive
+ Project-URL: Source, https://example.com/src?a=1
+ Project-URL: Documentation, https://example.com/guide
+ """)))
+ self.assertEqual(md.urls, (
+ {'Source': 'https://example.com/src?a=1',
+ 'Documentation': 'https://example.com/guide'},
+ {'home_page': 'https://example.com',
+ 'download_url': 'https://example.com/archive'},
+ ))
+
+ def test_urls_preserve_commas_in_mapping(self):
+ md = Metadata(mapping={
+ 'metadata_version': '2.1', 'name': 'example', 'version': '1.0',
+ 'summary': 'Example',
+ 'project_url': [('Source', 'https://example.com/src?a=1,2')],
+ })
+ self.assertEqual(md.urls, ({'Source':
'https://example.com/src?a=1,2'}, {}))
+
+ def test_urls_missing(self):
+ md = Metadata(mapping={
+ 'metadata_version': '1.0', 'name': 'example', 'version': '1.0',
+ 'summary': 'Example',
+ })
+ self.assertEqual(md.urls, ({}, {}))
+ self.assertEqual(Metadata().urls, ({}, {}))
+
+ def test_urls_json_metadata(self):
+ md = Metadata(mapping={
+ 'metadata_version': '2.0', 'name': 'example', 'version': '1.0',
+ 'summary': 'Example',
+ 'home_page': 'https://example.com',
+ 'download_url': 'https://example.com/archive',
+ 'extensions': {'python.project': {'project_urls': {
+ 'Documentation': 'https://example.com/guide',
+ }}},
+ })
+ self.assertEqual(md.urls, (
+ {'Documentation': 'https://example.com/guide'},
+ {'home_page': 'https://example.com',
+ 'download_url': 'https://example.com/archive'},
+ ))
+
+ def test_urls_are_independent_dictionaries(self):
+ md = Metadata(mapping={
+ 'metadata_version': '2.1', 'name': 'example', 'version': '1.0',
+ 'summary': 'Example', 'home_page': 'https://example.com',
+ 'project_url': [('Source', 'https://example.com/old'),
+ ('Source', 'https://example.com/new')],
+ })
+ project_urls, other_urls = md.urls
+ self.assertEqual(project_urls, {'Source': 'https://example.com/new'})
+ project_urls.clear()
+ other_urls.clear()
+ self.assertEqual(md.urls, ({'Source': 'https://example.com/new'},
+ {'home_page': 'https://example.com'}))
+
def test_add_requirements(self):
md = Metadata()
md.name = 'bar'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/tests/test_scripts.py
new/distlib-0.4.4/tests/test_scripts.py
--- old/distlib-0.4.3/tests/test_scripts.py 2026-06-02 22:17:20.000000000
+0200
+++ new/distlib-0.4.4/tests/test_scripts.py 2026-10-16 09:17:18.182693700
+0200
@@ -6,6 +6,7 @@
#
from __future__ import unicode_literals
+import errno
import os
import shutil
import subprocess
@@ -144,6 +145,39 @@
b'"$0" "$@"\n')
self.assertEqual(third_line, b"' '''\n")
+ @unittest.skipIf(os.name != 'posix', 'Test only appropriate for '
+ 'POSIX systems')
+ def test_shell_script_executable_shebang(self):
+ srcdir = tempfile.mkdtemp(prefix='distlib-test-')
+ self.addCleanup(shutil.rmtree, srcdir)
+ fake_python = os.path.join(srcdir, 'nix-python')
+ with open(fake_python, 'w') as f:
+ f.write('#!/bin/sh\n')
+ f.write('export NIX_PYTHONPREFIX=/tmp/fake-nix-env\n')
+ f.write('exec "%s" "$@"\n' % sys.executable)
+ os.chmod(fake_python, 0o755)
+
+ self.assertTrue(self.maker._is_shell(fake_python))
+ self.assertFalse(self.maker._is_shell(sys.executable))
+ self.assertFalse(self.maker._is_shell(os.path.join(srcdir, 'missing')))
+
+ self.maker.executable = fake_python
+ filenames = self.maker.make('script8.py')
+ with open(filenames[0], 'rb') as f:
+ first_line = f.readline()
+ second_line = f.readline()
+ third_line = f.readline()
+ self.assertEqual(first_line, b'#!/bin/sh\n')
+ expected_second = b"'''exec' " + fake_python.encode('utf-8') + b' "$0"
"$@"\n'
+ self.assertEqual(second_line, expected_second)
+ self.assertEqual(third_line, b"' '''\n")
+ p = subprocess.Popen([filenames[0]], stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
+ stdout, stderr = p.communicate()
+ self.assertEqual(p.returncode, 0)
+ self.assertEqual(stderr, b'')
+ self.assertEqual(os.path.realpath(stdout.strip()),
+ os.path.realpath(sys.executable).encode('utf-8'))
+
def test_multiple(self):
specs = ('foo.py', 'script1.py', 'script2.py', 'script3.py',
'shell.sh', 'uwsgi_part')
files = self.maker.make_multiple(specs)
@@ -217,6 +251,43 @@
self.assertTrue(data.startswith(tlauncher))
@unittest.skipIf(os.name != 'nt', 'Test is Windows-specific')
+ def test_launcher_write_error(self): # pragma: no cover
+ # With no existing executable to move aside, the original error must
+ # be raised, not a confusing one from the .deleteme logic.
+ self.maker.add_launchers = True
+ error = OSError(errno.EACCES, 'Permission denied')
+
+ def write_binary_file(path, data):
+ raise error
+
+ self.maker._fileop.write_binary_file = write_binary_file
+ with self.assertRaises(OSError) as cm:
+ self.maker.make('foo = foo:main')
+ self.assertIs(cm.exception, error)
+ self.assertEqual(os.listdir(self.maker.target_dir), [])
+
+ @unittest.skipIf(os.name != 'nt', 'Test is Windows-specific')
+ def test_launcher_in_use(self): # pragma: no cover
+ # An existing executable that can't be overwritten (e.g. because it's
+ # running) is moved aside and replaced.
+ self.maker.add_launchers = True
+ files = self.maker.make('foo = foo:main')
+ write = self.maker._fileop.write_binary_file
+ failed = []
+
+ def write_binary_file(path, data):
+ if not failed:
+ failed.append(path)
+ raise OSError(errno.EACCES, 'Permission denied')
+ write(path, data)
+
+ self.maker._fileop.write_binary_file = write_binary_file
+ self.assertEqual(self.maker.make('foo = foo:main'), files)
+ self.assertEqual(failed, files[:1])
+ self.assertEqual(sorted(os.listdir(self.maker.target_dir)),
+ sorted(os.path.basename(f) for f in files))
+
+ @unittest.skipIf(os.name != 'nt', 'Test is Windows-specific')
def test_launcher_run(self):
self.maker.add_launchers = True
files = self.maker.make('script6.py')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/tests/test_util.py
new/distlib-0.4.4/tests/test_util.py
--- old/distlib-0.4.3/tests/test_util.py 2026-06-05 17:19:30.000000000
+0200
+++ new/distlib-0.4.4/tests/test_util.py 2026-10-16 12:03:10.057096200
+0200
@@ -6,6 +6,7 @@
#
from io import BytesIO
from itertools import islice
+import io
import os
import re
import shutil
@@ -14,6 +15,7 @@
except ImportError:
ssl = None
import sys
+import tarfile
import tempfile
import textwrap
import time
@@ -78,6 +80,18 @@
self.assertRaises(DistlibException, get_export_entry, 'foo=foo.bar:x
[a,,b]')
self.assertRaises(DistlibException, get_export_entry, 'foo=foo.bar:x
[a b]')
+ def test_export_entry_long_invalid_name(self):
+ # Searching for an entry used to rescan a long name from every position
+ # in it when the value was not a valid callable: ~10 s for 32000 chars.
+ for spec in ('a' * 32000 + ' = -', ' ' * 32000 + 'x', '=' * 32000):
+ start = time.time()
+ self.assertIsNone(get_export_entry(spec))
+ self.assertLess(time.time() - start, 1)
+ start = time.time()
+ self.assertRaises(DistlibException, get_export_entry, '[a' * 16000 + '
= -')
+ self.assertLess(time.time() - start, 1)
+ self.check_entry(get_export_entry('a' * 32000 + ' = foo:main'), 'a' *
32000, 'foo', 'main', [])
+
def test_resolve(self):
import logging
import logging.handlers
@@ -487,6 +501,100 @@
finally:
shutil.rmtree(work)
+ def _make_tar(self, path, members):
+ with tarfile.open(path, 'w') as tf:
+ for ti, data in members:
+ tf.addfile(ti, io.BytesIO(data) if data is not None else None)
+
+ def test_unarchive_dot_member(self):
+ # GNU tar commonly emits a './' directory member.
+ work = tempfile.mkdtemp()
+ try:
+ arc = os.path.join(work, 'dot.tar')
+ d = tarfile.TarInfo('./')
+ d.type = tarfile.DIRTYPE
+ d.mode = 0o755
+ f = tarfile.TarInfo('./hello.txt')
+ f.size = 5
+ self._make_tar(arc, [(d, None), (f, b'hello')])
+ dest = os.path.join(work, 'dest')
+ os.mkdir(dest)
+ unarchive(arc, dest)
+ self.assertTrue(os.path.isfile(os.path.join(dest, 'hello.txt')))
+ finally:
+ shutil.rmtree(work)
+
+ def test_unarchive_escaping_symlink(self):
+ work = tempfile.mkdtemp()
+ try:
+ arc = os.path.join(work, 'sym.tar')
+ ti = tarfile.TarInfo('sub/link')
+ ti.type = tarfile.SYMTYPE
+ ti.linkname = '../../outside'
+ self._make_tar(arc, [(ti, None)])
+ dest = os.path.join(work, 'dest')
+ os.mkdir(dest)
+ self.assertRaises((OSError, ValueError), unarchive, arc, dest)
+ self.assertFalse(os.path.lexists(os.path.join(dest, 'sub',
'link')))
+ finally:
+ shutil.rmtree(work)
+
+ @unittest.skipUnless(hasattr(tarfile, 'data_filter'), 'requires tarfile
extraction filters')
+ def test_unarchive_rejects_special_files(self):
+ for label, ttype in (('fifo', tarfile.FIFOTYPE), ('chardev',
tarfile.CHRTYPE),
+ ('blockdev', tarfile.BLKTYPE)):
+ work = tempfile.mkdtemp()
+ try:
+ arc = os.path.join(work, 'special.tar')
+ ti = tarfile.TarInfo('special')
+ ti.type = ttype
+ self._make_tar(arc, [(ti, None)])
+ dest = os.path.join(work, 'dest')
+ os.mkdir(dest)
+ self.assertRaises(ValueError, unarchive, arc, dest)
+ self.assertFalse(os.path.lexists(os.path.join(dest,
'special')), label)
+ finally:
+ shutil.rmtree(work)
+
+ @unittest.skipUnless(hasattr(tarfile, 'data_filter'), 'requires tarfile
extraction filters')
+ def test_unarchive_absolute_symlink_unchecked(self):
+ # Even with check=False, data_filter must reject absolute links.
+ work = tempfile.mkdtemp()
+ try:
+ arc = os.path.join(work, 'abs.tar')
+ ti = tarfile.TarInfo('abslink')
+ ti.type = tarfile.SYMTYPE
+ ti.linkname = os.path.join(work, 'outside')
+ self._make_tar(arc, [(ti, None)])
+ dest = os.path.join(work, 'dest')
+ os.mkdir(dest)
+ self.assertRaises(ValueError, unarchive, arc, dest, check=False)
+ self.assertFalse(os.path.lexists(os.path.join(dest, 'abslink')))
+ finally:
+ shutil.rmtree(work)
+
+ def test_unarchive_chained_symlink(self):
+ # a -> . then a/b -> .. looks safe lexically, but a/b really points
+ # at dest's parent; a/b/file must be rejected even without filters.
+ work = tempfile.mkdtemp()
+ try:
+ arc = os.path.join(work, 'chain.tar')
+ a = tarfile.TarInfo('a')
+ a.type = tarfile.SYMTYPE
+ a.linkname = '.'
+ b = tarfile.TarInfo('a/b')
+ b.type = tarfile.SYMTYPE
+ b.linkname = '..'
+ f = tarfile.TarInfo('a/b/distlib_poc_marker.txt')
+ f.size = 4
+ self._make_tar(arc, [(a, None), (b, None), (f, b'PWND')])
+ dest = os.path.join(work, 'dest')
+ os.mkdir(dest)
+ self.assertRaises(ValueError, unarchive, arc, dest, check=True)
+ self.assertFalse(os.path.exists(os.path.join(work,
'distlib_poc_marker.txt')))
+ finally:
+ shutil.rmtree(work)
+
def test_string_sequence(self):
self.assertTrue(is_string_sequence(['a']))
self.assertTrue(is_string_sequence(['a', 'b']))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/distlib-0.4.3/tests/test_version.py
new/distlib-0.4.4/tests/test_version.py
--- old/distlib-0.4.3/tests/test_version.py 2024-03-19 11:40:26.000000000
+0100
+++ new/distlib-0.4.4/tests/test_version.py 2026-10-16 09:17:18.186694400
+0200
@@ -12,7 +12,7 @@
from distlib.version import (NormalizedVersion as NV, NormalizedMatcher as NM,
UnsupportedVersionError,
_suggest_normalized_version,
_suggest_semantic_version, LegacyVersion as LV, LegacyMatcher
as LM, SemanticVersion as SV, SemanticMatcher as
SM, is_semver, get_scheme,
- _normalized_key, _legacy_key, _semantic_key)
+ _normalized_key, _legacy_key, _semantic_key,
Matcher, VersionScheme)
class VersionTestCase(DistlibTestCase):
@@ -51,6 +51,120 @@
self.assertEqual(set([NV('1.0')]), set([NV('1.0'), NV('1.0')]))
+ def test_equivalent_versions(self):
+ cases = (
+ ('1.0alpha1', '1.0a1'),
+ ('1.0BETA2', '1.0b2'),
+ ('1.0c3', '1.0rc3'),
+ ('1.0PRE3', '1.0rc3'),
+ ('1.0preview3', '1.0rc3'),
+ ('1.0.r4', '1.0.post4'),
+ ('1.0.REV4', '1.0.post4'),
+ ('1.0.DEV5', '1.0.dev5'),
+ ('1.0+AbC', '1.0+abc'),
+ ('1.0+ABC_02-def.3', '1.0+abc.2.def.3'),
+ )
+ for alternative, canonical in cases:
+ first, second = NV(alternative), NV(canonical)
+ self.assertEqual(first, second, alternative)
+ self.assertEqual(hash(first), hash(second), alternative)
+
+ self.assertEqual(str(first), alternative)
+ self.assertTrue(NM('demo (==' + canonical + ')').match(first),
alternative)
+
+ def test_alias_numeric_order(self):
+ cases = (('1.0.r10', '1.0.post2'), ('1.0beta10', '1.0b2'))
+ for greater, smaller in cases:
+ self.assertGreater(NV(greater), NV(smaller), (greater, smaller))
+
+ def test_local_segment_order(self):
+ cases = (
+ ('1.0', '1.0+abc'),
+ ('1.0+abc', '1.0+abc.1'),
+ ('1.0+abc.def', '1.0+abc.1'),
+ ('1.0+abc.2', '1.0+ABC_10'),
+ ('1.0+abc.2.def.3', '1.0+abc-2-def-10'),
+ )
+ for smaller, greater in cases:
+ self.assertLess(NV(smaller), NV(greater), (smaller, greater))
+
+ def test_comparison_operators(self):
+ first, equal, greater = NV('1!1.0ALPHA1'), NV('1!1.0a1'), NV('1!1.0a2')
+ self.assertEqual(first, equal)
+ self.assertFalse(first != equal)
+ self.assertLessEqual(first, equal)
+ self.assertGreaterEqual(first, equal)
+ self.assertFalse(first < equal)
+ self.assertFalse(first > equal)
+
+ self.assertNotEqual(first, greater)
+ self.assertLess(first, greater)
+ self.assertLessEqual(first, greater)
+ self.assertGreater(greater, first)
+ self.assertGreaterEqual(greater, first)
+ self.assertFalse(first >= greater)
+ self.assertFalse(greater <= first)
+
+ def test_implicit_suffix_numbers(self):
+ cases = (
+ ('1.0ALPHA', '1.0a0'),
+ ('1.0.REV', '1.0.post0'),
+ ('1.0.DEV', '1.0.dev0'),
+ )
+ scheme = get_scheme('normalized')
+ for implicit, explicit in cases:
+ expected = NV(explicit)
+ self.assertEqual(NV(implicit), expected, implicit)
+ self.assertEqual(scheme.key(implicit), expected.parse(explicit),
implicit)
+
+ matcher = NM('demo (==' + explicit + ')')
+ self.assertTrue(matcher.match(implicit), implicit)
+
+ def test_scheme_key_rejects_invalid_versions(self):
+ scheme = get_scheme('normalized')
+ for version in ('', '1.0-', '1.0+a+'):
+ self.assertRaises(UnsupportedVersionError, scheme.key, version)
+
+ def test_epoch_prerelease_detection(self):
+ cases = (
+ ('1!1.0a1', True),
+ ('1!1.0rc1', True),
+ ('1!1.0.dev1', True),
+ ('1!1.0.post1.dev1', True),
+ ('1.0ALPHA1', True),
+ ('1!1.0', False),
+ ('1!1.0.post1', False),
+ ('1!1.0+dev', False),
+ )
+ for version, expected in cases:
+ self.assertEqual(NV(version).is_prerelease, expected, version)
+
+ def test_non_ascii_version_components(self):
+ versions = (
+ u'\u0661!1.0',
+ u'\u0661.0',
+ u'1.\u0661',
+ u'1.0a\u0661',
+ u'1.0.post\u0661',
+ u'1.0.dev\u0661',
+ u'1.0+\u0661',
+ u'1.0+abc.\u0661',
+ u'1.0+\u0130',
+ u'1.0+\u0131',
+ u'1.0+\u017f',
+ u'1.0+\u212a',
+ )
+ for version in versions:
+ self.assertRaises(UnsupportedVersionError, NV, version)
+
+ def test_release_clause_preserves_trailing_zeroes(self):
+ version = NV('1.2.0')
+ self.assertEqual(version.parse('1.2.0'), (0, (1, 2), ('z',), ('_',),
('final',), ()))
+
+ matcher = NM('demo (~=1.2.0)')
+ self.assertTrue(matcher.match('1.2.1'))
+ self.assertFalse(matcher.match('1.3'))
+
def test_unsupported_versions(self):
unsupported = (
# '1.2a', '1.2.3b', # see issue 200 - not unsupported
@@ -285,6 +399,103 @@
# numeric > lexicographic in local versions
self.assertTrue(NM('Foo (>2.6+a1.4)').match('2.6+1.4'))
+ def test_matcher_local_constraints(self):
+ cases = (
+ ('==1.0', '1.0+ABC_2-def.3', True),
+ ('!=1.0', '1.0+abc.2.def.3', False),
+ ('<=1.0', '1.0+abc.2.def.3', True),
+ ('>=1.0', '1.0+abc.2.def.3', True),
+ ('==1.0.*', '1.0+abc.2.def.3', True),
+ ('!=1.0.*', '1.0+abc.2.def.3', False),
+ ('==1.0+abc.2.def.3', '1.0+ABC_02-def.3', True),
+ ('!=1.0+abc.2.def.3', '1.0+ABC_02-def.3', False),
+ ('==1.0+abc.2.def.3', '1.0+abc.2.def.4', False),
+ ('!=1.0+abc.2.def.3', '1.0+abc.2.def.4', True),
+ ('==1.0+abc.*', '1.0+abc.2', True),
+ ('==1.0+abc.*', '1.0+abcd.2', False),
+ ('!=1.0+abc.*', '1.0+abc.2', False),
+ ('!=1.0+abc.*', '1.0+abcd.2', True),
+ )
+ for constraint, version, expected in cases:
+ matcher = NM('demo (' + constraint + ')')
+ self.assertEqual(matcher.match(version), expected, (constraint,
version))
+ self.assertEqual(matcher.match(NV(version)), expected,
(constraint, version))
+
+ def test_matcher_arbitrary_equality(self):
+ matcher = NM('demo (===1.0ALPHA1+ABC)')
+ self.assertTrue(matcher.match('1.0ALPHA1+ABC'))
+ self.assertFalse(matcher.match('1.0a1+abc'))
+ self.assertFalse(matcher.match('1.0ALPHA1'))
+
+ matcher = NM('demo (===1.0)')
+ self.assertTrue(matcher.match('1.0'))
+ self.assertFalse(matcher.match('1.0.0'))
+ self.assertFalse(matcher.match('1.0+abc'))
+
+ def test_matcher_equality_and_hash(self):
+ first = NM('demo (==1.0ALPHA1+ABC_02-def.3)')
+ equal = NM('demo (==1.0a1+abc.2.def.3)')
+ different = NM('demo (==1.0a2+abc.2.def.3)')
+ self.assertEqual(first, equal)
+ self.assertFalse(first != equal)
+ self.assertEqual(hash(first), hash(equal))
+ self.assertEqual(len(set([first, equal])), 1)
+ self.assertNotEqual(first, different)
+
+ self.assertEqual(str(first), 'demo (==1.0ALPHA1+ABC_02-def.3)')
+ self.assertEqual(repr(first), "NormalizedMatcher('demo
(==1.0ALPHA1+ABC_02-def.3)')")
+ self.assertEqual(first.exact_version, NV('1.0a1+abc.2.def.3'))
+ self.assertIsNone(NM('demo (>=1.0,<=1.0)').exact_version)
+
+ def test_matcher_single_component_compatible_release(self):
+ matcher = NM('demo (~=1)')
+ self.assertTrue(matcher.match('1'))
+ self.assertTrue(matcher.match('1.9'))
+ self.assertFalse(matcher.match('0.9'))
+ self.assertFalse(matcher.match('2'))
+
+ def test_matcher_requires_version_class_and_operator(self):
+ self.assertRaises(ValueError, Matcher, 'demo')
+
+ class EqualityMatcher(NM):
+ _operators = {'==': '_match_eq'}
+
+ self.assertTrue(EqualityMatcher('demo (==1.0ALPHA1)').match('1.0a1'))
+ matcher = EqualityMatcher('demo (>=1.0)')
+ self.assertRaises(NotImplementedError, matcher.match, '1.0')
+
+ def test_matcher_invalid_requirements(self):
+ for requirement in ('# comment', 'demo (>=)', 'demo (>=1.0,broken)',
+ 'demo (1.0 extra)', 'demo ( )', 'demo (>=1.0',
+ 'demo [extra', 'demo [@]', 'demo [first second]',
+ 'demo @', 'demo @relative/path', 'demo
unexpected'):
+ self.assertRaises((SyntaxError, ValueError), NM, requirement)
+
+ def test_scheme_validation(self):
+ scheme = get_scheme('normalized')
+ for version in ('1!1.0ALPHA1', '1.0+ABC_02-def.3', '1.0.REV',
'1.0.DEV'):
+ self.assertTrue(scheme.is_valid_version(version), version)
+ self.assertTrue(scheme.is_valid_matcher('demo (==' + version +
')'), version)
+ self.assertTrue(scheme.is_valid_constraint_list('==' + version),
version)
+ for version in ('1.0-', '1.0+a+', u'1.0+\u0661'):
+ self.assertFalse(scheme.is_valid_version(version), version)
+ self.assertFalse(scheme.is_valid_matcher('demo (==' + version +
')'), version)
+ self.assertFalse(scheme.is_valid_constraint_list('==' + version),
version)
+
+ self.assertTrue(scheme.is_valid_constraint_list('>=1.0,!=1.1,'))
+ self.assertRaises(SyntaxError, scheme.is_valid_constraint_list, '')
+ self.assertRaises(SyntaxError, scheme.is_valid_matcher, 'demo (>=)')
+
+ def test_scheme_suggestions(self):
+ scheme = get_scheme('normalized')
+ self.assertEqual(scheme.suggest('1.0ALPHA1+ABC_02-def.3'),
'1.0ALPHA1+ABC_02-def.3')
+ self.assertEqual(scheme.suggest('v1.0-alpha1'), '1.0a1')
+ self.assertEqual(scheme.suggest('1.0-dev-r3'), '1.0.dev3')
+ self.assertIsNone(scheme.suggest('unversioned'))
+
+ without_suggester = VersionScheme(scheme.key, scheme.matcher)
+ self.assertIsNone(without_suggester.suggest('1.0-alpha1'))
+
def test_schemes(self):
cases = (
('normalized', (_normalized_key, NV, NM)),
@@ -351,11 +562,11 @@
'1.1.dev1',
)
- n = len(versions)
- for i in range(n - 1):
- v1 = versions[i]
- v2 = versions[i + 1]
- self.assertLess(NV(v1), NV(v2))
+ for first, second in zip(versions, versions[1:]):
+ if (first, second) == ('1.0c1', '1.0rc1'):
+ self.assertEqual(NV(first), NV(second))
+ else:
+ self.assertLess(NV(first), NV(second), (first, second))
def test_440(self):
# compatible release matching
@@ -547,6 +758,22 @@
class SemanticVersionTestCase(DistlibTestCase):
+ def test_numeric_prerelease_identifiers(self):
+ cases = (
+ ('99999999', '100000000'),
+ ('999999999', '1000000000'),
+ ('99999999999999999999', '100000000000000000000'),
+ ('999999999', '1alpha'),
+ ('999999999', '9-alpha'),
+ )
+ for smaller, larger in cases:
+ first = SV('1.0.0-alpha.' + smaller)
+ second = SV('1.0.0-alpha.' + larger)
+ self.assertLess(first, second)
+ self.assertTrue(SM('demo (<' + str(second) + ')').match(first))
+ self.assertTrue(first.is_prerelease)
+ self.assertLess(second, SV('1.0.0'))
+
def test_basic(self):
bad = [
'a',