Repository: Pyomo/pyomo

Issue:
sorted_robust fails on tuples with non identical types when given key
## Summary

`sorted_robust` fails when trying to sort tuples of different types with a custom key.

### Steps to reproduce the issue

Run the following script

```python
from operator import itemgetter

from pyomo.core.base.misc import sorted_robust

val = [
    (("10_2", 2), None),
    ((10, 2), None)
]
sorted_robust(
    val,
    key=itemgetter(0)
)
```

This will throw an error. The reason is because `_robust_sort_keyfcn(val)` will call itself when `val` is a tuple. However in the recursive call it still uses the `key` which will try to access the first element of a pair.

### Consequences

The following code throws the same error.

```python
from pyomo.environ import *

model = ConcreteModel()
model.s = Set(
    dimen=2,
    initialize=lambda m: [
        ("10_1", 2),
        (10, 2)
    ]
)
model.v = Var(model.s)
opt = SolverFactory('gurobi')
opt.solve(model)
```

### Error Message
The error message that is given is the following

<!-- If Pyomo reported an error, provide the error message. PLEASE INCLUDE THE ENTIRE STACK TRACE. -->

<!-- If it did not report an error but the output appears incorrect, provide the incorrect output. If there was no error message and no output but the result is incorrect, describe how it does not match what you expect. -->
```console
Traceback (most recent call last):
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\misc.py", line 184, in sorted_robust
    return sorted(arg, key=key, reverse=reverse)
TypeError: '<' not supported between instances of 'int' and 'str'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\machs\AppData\Roaming\JetBrains\PyCharm2021.1\scratches\scratch.py", line 30, in <module>
    opt.solve(model)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\opt\base\solvers.py", line 569, in solve
    self._presolve(*args, **kwds)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\solvers\plugins\solvers\GUROBI.py", line 228, in _presolve
    ILMLicensedSystemCallSolver._presolve(self, *args, **kwds)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\opt\solver\shellcmd.py", line 200, in _presolve
    OptSolver._presolve(self, *args, **kwds)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\opt\base\solvers.py", line 666, in _presolve
    self._convert_problem(args,
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\opt\base\solvers.py", line 717, in _convert_problem
    return convert_problem(args,
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\opt\base\convert.py", line 100, in convert_problem
    problem_files, symbol_map = converter.apply(*tmp, **tmpkw)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\solvers\plugins\converter\model.py", line 77, in apply
    instance.write(
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\block.py", line 1809, in write
    (filename, smap) = problem_writer(self,
  File "pyomo\repn\plugins\cpxlp.pyx", line 157, in pyomo.repn.plugins.cpxlp.ProblemWriter_cpxlp.__call__
  File "pyomo\repn\plugins\cpxlp.pyx", line 158, in pyomo.repn.plugins.cpxlp.ProblemWriter_cpxlp.__call__
  File "pyomo\repn\plugins\cpxlp.pyx", line 159, in pyomo.repn.plugins.cpxlp.ProblemWriter_cpxlp.__call__
  File "pyomo\repn\plugins\cpxlp.pyx", line 445, in pyomo.repn.plugins.cpxlp.ProblemWriter_cpxlp._print_model_LP
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\block.py", line 1433, in component_data_objects
    for x in _block._component_data_iter(ctype=ctype,
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\block.py", line 1361, in _component_data_iter
    _items = sorted_robust(_items, key=itemgetter(0))
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\misc.py", line 186, in sorted_robust
    return sorted(arg, key=_robust_sort_keyfcn(key), reverse=reverse)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\misc.py", line 159, in __call__
    return _typename, tuple(self(v) for v in val)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\misc.py", line 159, in <genexpr>
    return _typename, tuple(self(v) for v in val)
  File "C:\Users\machs\miniconda3\envs\switch-1\lib\site-packages\pyomo\core\base\misc.py", line 131, in __call__
    val = self._key(val)
TypeError: 'int' object is not subscriptable
```

### Information on your system

Pyomo version: 6.0.1
Python version: CPython 3.9.5 on Windows 10
Operating system: Windows
How Pyomo was installed (PyPI, conda, source): PyPi
Solver (if applicable): gurobi


Discussion:

Resolving patch:
diff --git a/pyomo/core/base/misc.py b/pyomo/core/base/misc.py
--- a/pyomo/core/base/misc.py
+++ b/pyomo/core/base/misc.py
@@ -115,7 +115,7 @@ class _robust_sort_keyfcn(object):
     def __init__(self, key=None):
         # sort all native numeric types as if they were floats
         self._typemap = {t: (1, float.__name__) for t in native_numeric_types}
-        self._typemap[tuple] =(3, tuple.__name__)
+        self._typemap[tuple] =(4, tuple.__name__)
         self._key = key
 
     def __call__(self, val):
@@ -130,6 +130,9 @@ def __call__(self, val):
         if self._key is not None:
             val = self._key(val)
 
+        return self._generate_sort_key(val)
+
+    def _generate_sort_key(self, val):
         try:
             i, _typename = self._typemap[val.__class__]
         except KeyError:
@@ -154,12 +157,16 @@ def __call__(self, val):
                     i = 3
             self._typemap[_type] = i, _typename
         if i == 1:
+            # value type is directly comparable
             return _typename, val
-        elif i == 3:
-            return _typename, tuple(self(v) for v in val)
+        elif i == 4:
+            # nested tuple: recurse into it (so that the tuple is comparable)
+            return _typename, tuple(self._generate_sort_key(v) for v in val)
         elif i == 2:
+            # value type is convertible to string
             return _typename, str(val)
         else:
+            # everything else (incuding i==3), fall back on id()
             return _typename, id(val)
 
 


Test patch:
diff --git a/pyomo/core/tests/unit/test_base_misc.py b/pyomo/core/tests/unit/test_base_misc.py
--- a/pyomo/core/tests/unit/test_base_misc.py
+++ b/pyomo/core/tests/unit/test_base_misc.py
@@ -54,6 +54,16 @@ def test_sorted_robust(self):
         a = sorted_robust([('str1', 'str1'), ((1,), 'str2')])
         self.assertEqual(a, [('str1', 'str1'), ((1,), 'str2')])
 
+        # ensure it doesn't throw an error
+        # Test for issue https://github.com/Pyomo/pyomo/issues/2019
+        sorted_robust(
+            [
+                (("10_1", 2), None),
+                ((10, 2), None)
+            ],
+            key=lambda x: x[0]
+        )
+
 
 if __name__ == "__main__":
     unittest.main()