Repository: pypa/pip

Issue:
Impossible platform tags generated on OS X
Different versions of OS X support different architectures. Specifically:
- 10.X supports `ppc` where X is between 0-5
- 10.X supports `ppc64` where X is between 2-5
- 10.X supports `i386` where X is between 4-6
- 10.X supports `x86_64` where X is _not_ between 0-3

As a result, these platform tags are impossible:
- `macosx_10_X_ppc64` where `X` is _not_ between 2-5
- `macosx_10_X_i386` where `X` is _not_ between 4-6
- `macosx_10_X_x86_64` where `X` is between 0-3

Additionally, these group architecture tags are also impossible:
- `macosx_10_X_fat` where `X` is _not_ between 0-6
- `macosx_10_X_intel` where `X` is between 0-3
- `macosx_10_X_fat64` where `X` is between 0-1

However, these tags can be generated. (There exist impossible tags of the form `macosx_10_X_ppc`, but they cannot be generated).

Verify whether a given OS X version supports a given arch (fixes #3403)
<!-- Reviewable:start -->

[<img src="https://reviewable.io/review_button.png" height=40 alt="Review on Reviewable"/>](https://reviewable.io/reviews/pypa/pip/3411)

<!-- Reviewable:end -->

Impossible platform tags generated on OS X
Different versions of OS X support different architectures. Specifically:
- 10.X supports `ppc` where X is between 0-5
- 10.X supports `ppc64` where X is between 2-5
- 10.X supports `i386` where X is between 4-6
- 10.X supports `x86_64` where X is _not_ between 0-3

As a result, these platform tags are impossible:
- `macosx_10_X_ppc64` where `X` is _not_ between 2-5
- `macosx_10_X_i386` where `X` is _not_ between 4-6
- `macosx_10_X_x86_64` where `X` is between 0-3

Additionally, these group architecture tags are also impossible:
- `macosx_10_X_fat` where `X` is _not_ between 0-6
- `macosx_10_X_intel` where `X` is between 0-3
- `macosx_10_X_fat64` where `X` is between 0-1

However, these tags can be generated. (There exist impossible tags of the form `macosx_10_X_ppc`, but they cannot be generated).



Discussion:
I don't think it's actually a problem to generate a tag that is impossible right? It's just sort of unsightly?

I don't see how this would lead to undesired behavior in practice, but I think (especially if #3357 is considered) it would be best to avoid creating impossible tags whenever possible.

Ok. I agree it'd be best, just wanted to clarify if it was a big deal for immediate fixing or not :)


I don't think it's actually a problem to generate a tag that is impossible right? It's just sort of unsightly?

I don't see how this would lead to undesired behavior in practice, but I think (especially if #3357 is considered) it would be best to avoid creating impossible tags whenever possible.

Ok. I agree it'd be best, just wanted to clarify if it was a big deal for immediate fixing or not :)


Resolving patch:
diff --git a/pip/compat/__init__.py b/pip/compat/__init__.py
--- a/pip/compat/__init__.py
+++ b/pip/compat/__init__.py
@@ -12,6 +12,11 @@
 except ImportError:
     from pip.compat.dictconfig import dictConfig as logging_dictConfig
 
+try:
+    from collections import OrderedDict
+except ImportError:
+    from pip.compat.ordereddict import OrderedDict
+
 try:
     import ipaddress
 except ImportError:
@@ -45,7 +50,8 @@ def get_stdlib():
 
 __all__ = [
     "logging_dictConfig", "ipaddress", "uses_pycache", "console_to_str",
-    "native_str", "get_path_uid", "stdlib_pkgs", "WINDOWS", "samefile"
+    "native_str", "get_path_uid", "stdlib_pkgs", "WINDOWS", "samefile",
+    "OrderedDict",
 ]
 
 
diff --git a/pip/compat/ordereddict.py b/pip/compat/ordereddict.py
new file mode 100644
--- /dev/null
+++ b/pip/compat/ordereddict.py
@@ -0,0 +1,129 @@
+# Copyright (c) 2009 Raymond Hettinger
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+#     The above copyright notice and this permission notice shall be
+#     included in all copies or substantial portions of the Software.
+#
+#     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+#     EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+#     OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+#     NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+#     HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+#     WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#     FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+#     OTHER DEALINGS IN THE SOFTWARE.
+
+# flake8: noqa
+
+from UserDict import DictMixin
+
+class OrderedDict(dict, DictMixin):
+
+    def __init__(self, *args, **kwds):
+        if len(args) > 1:
+            raise TypeError('expected at most 1 arguments, got %d' % len(args))
+        try:
+            self.__end
+        except AttributeError:
+            self.clear()
+        self.update(*args, **kwds)
+
+    def clear(self):
+        self.__end = end = []
+        end += [None, end, end]         # sentinel node for doubly linked list
+        self.__map = {}                 # key --> [key, prev, next]
+        dict.clear(self)
+
+    def __setitem__(self, key, value):
+        if key not in self:
+            end = self.__end
+            curr = end[1]
+            curr[2] = end[1] = self.__map[key] = [key, curr, end]
+        dict.__setitem__(self, key, value)
+
+    def __delitem__(self, key):
+        dict.__delitem__(self, key)
+        key, prev, next = self.__map.pop(key)
+        prev[2] = next
+        next[1] = prev
+
+    def __iter__(self):
+        end = self.__end
+        curr = end[2]
+        while curr is not end:
+            yield curr[0]
+            curr = curr[2]
+
+    def __reversed__(self):
+        end = self.__end
+        curr = end[1]
+        while curr is not end:
+            yield curr[0]
+            curr = curr[1]
+
+    def popitem(self, last=True):
+        if not self:
+            raise KeyError('dictionary is empty')
+        if last:
+            key = reversed(self).next()
+        else:
+            key = iter(self).next()
+        value = self.pop(key)
+        return key, value
+
+    def __reduce__(self):
+        items = [[k, self[k]] for k in self]
+        tmp = self.__map, self.__end
+        del self.__map, self.__end
+        inst_dict = vars(self).copy()
+        self.__map, self.__end = tmp
+        if inst_dict:
+            return (self.__class__, (items,), inst_dict)
+        return self.__class__, (items,)
+
+    def keys(self):
+        return list(self)
+
+    setdefault = DictMixin.setdefault
+    update = DictMixin.update
+    pop = DictMixin.pop
+    values = DictMixin.values
+    items = DictMixin.items
+    iterkeys = DictMixin.iterkeys
+    itervalues = DictMixin.itervalues
+    iteritems = DictMixin.iteritems
+
+    def __repr__(self):
+        if not self:
+            return '%s()' % (self.__class__.__name__,)
+        return '%s(%r)' % (self.__class__.__name__, self.items())
+
+    def copy(self):
+        return self.__class__(self)
+
+    @classmethod
+    def fromkeys(cls, iterable, value=None):
+        d = cls()
+        for key in iterable:
+            d[key] = value
+        return d
+
+    def __eq__(self, other):
+        if isinstance(other, OrderedDict):
+            if len(self) != len(other):
+                return False
+            for p, q in  zip(self.items(), other.items()):
+                if p != q:
+                    return False
+            return True
+        return dict.__eq__(self, other)
+
+    def __ne__(self, other):
+        return not self == other
diff --git a/pip/pep425tags.py b/pip/pep425tags.py
--- a/pip/pep425tags.py
+++ b/pip/pep425tags.py
@@ -15,9 +15,10 @@
     import distutils.sysconfig as sysconfig
 import distutils.util
 
+from pip.compat import OrderedDict
 
-logger = logging.getLogger(__name__)
 
+logger = logging.getLogger(__name__)
 
 _osx_arch_pat = re.compile(r'(.+)_(\d+)_(\d+)_(.+)')
 
@@ -115,6 +116,10 @@ def get_abi_tag():
     return abi
 
 
+def _is_running_32bit():
+    return sys.maxsize == 2147483647
+
+
 def get_platform():
     """Return our platform name 'win32', 'linux_x86_64'"""
     if sys.platform == 'darwin':
@@ -123,13 +128,21 @@ def get_platform():
         # be signficantly older than the user's current machine.
         release, _, machine = platform.mac_ver()
         split_ver = release.split('.')
+
+        if machine == "x86_64" and _is_running_32bit():
+            machine = "i386"
+        elif machine == "ppc64" and _is_running_32bit():
+            machine = "ppc"
+
         return 'macosx_{0}_{1}_{2}'.format(split_ver[0], split_ver[1], machine)
+
     # XXX remove distutils dependency
     result = distutils.util.get_platform().replace('.', '_').replace('-', '_')
-    if result == "linux_x86_64" and sys.maxsize == 2147483647:
+    if result == "linux_x86_64" and _is_running_32bit():
         # 32 bit Python program (running on a 64 bit Linux): pip should only
         # install and run 32 bit compiled extensions in that case.
         result = "linux_i686"
+
     return result
 
 
@@ -179,6 +192,67 @@ def have_compatible_glibc(major, minimum_minor):
     return version[0] == major and version[1] >= minimum_minor
 
 
+def get_darwin_arches(major, minor, machine):
+    """Return a list of supported arches (including group arches) for
+    the given major, minor and machine architecture of an OS X machine.
+    """
+    arches = []
+
+    def _supports_arch(major, minor, arch):
+        # Looking at the application support for OS X versions in the chart
+        # provided by https://en.wikipedia.org/wiki/OS_X#Versions it appears
+        # our timeline looks roughly like:
+        #
+        # 10.0 - Introduces ppc support.
+        # 10.4 - Introduces ppc64, i386, and x86_64 support, however the ppc64
+        #        and x86_64 support is CLI only, and cannot be used for GUI
+        #        applications.
+        # 10.5 - Extends ppc64 and x86_64 support to cover GUI applications.
+        # 10.6 - Drops support for ppc64
+        # 10.7 - Drops support for ppc
+        #
+        # Given that we do not know if we're installing a CLI or a GUI
+        # application, we must be conservative and assume it might be a GUI
+        # application and behave as if ppc64 and x86_64 support did not occur
+        # until 10.5.
+        #
+        # Note: The above information is taken from the "Application support"
+        #       column in the chart not the "Processor support" since I believe
+        #       that we care about what instruction sets an application can use
+        #       not which processors the OS supports.
+        if arch == 'ppc':
+            return (major, minor) <= (10, 5)
+        if arch == 'ppc64':
+            return (major, minor) == (10, 5)
+        if arch == 'i386':
+            return (major, minor) >= (10, 4)
+        if arch == 'x86_64':
+            return (major, minor) >= (10, 5)
+        if arch in groups:
+            for garch in groups[arch]:
+                if _supports_arch(major, minor, garch):
+                    return True
+        return False
+
+    groups = OrderedDict([
+        ("fat", ("i386", "ppc")),
+        ("intel", ("x86_64", "i386")),
+        ("fat64", ("x86_64", "ppc64")),
+        ("fat32", ("x86_64", "i386", "ppc")),
+    ])
+
+    if _supports_arch(major, minor, machine):
+        arches.append(machine)
+
+    for garch in groups:
+        if machine in groups[garch] and _supports_arch(major, minor, garch):
+            arches.append(garch)
+
+    arches.append('universal')
+
+    return arches
+
+
 def get_supported(versions=None, noarch=False):
     """Return a list of supported tags for each version specified in
     `versions`.
@@ -222,21 +296,10 @@ def get_supported(versions=None, noarch=False):
             match = _osx_arch_pat.match(arch)
             if match:
                 name, major, minor, actual_arch = match.groups()
-                actual_arches = [actual_arch]
-                if actual_arch in ('i386', 'ppc'):
-                    actual_arches.append('fat')
-                if actual_arch in ('i386', 'x86_64'):
-                    actual_arches.append('intel')
-                if actual_arch in ('ppc64', 'x86_64'):
-                    actual_arches.append('fat64')
-                if actual_arch in ('i386', 'ppc', 'x86_64'):
-                    actual_arches.append('fat32')
-                if actual_arch in ('i386', 'x86_64', 'intel', 'ppc', 'ppc64'):
-                    actual_arches.append('universal')
                 tpl = '{0}_{1}_%i_%s'.format(name, major)
                 arches = []
                 for m in reversed(range(int(minor) + 1)):
-                    for a in actual_arches:
+                    for a in get_darwin_arches(int(major), m, actual_arch):
                         arches.append(tpl % (m, a))
             else:
                 # arch pattern didn't match (?!)


Test patch: