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',

Reply via email to