Repository: Pyomo/pyomo

Issue:
Update Wheel Building Action

## Summary

The existing action we use to build wheels for release has been archived. It has a recommendation for a better maintained action to use instead.

### Rationale

We don't want to rely on archived/not maintained actions.

### Description

- The original wheel builder: https://github.com/RalfG/python-wheels-manylinux-build
- The new wheel builder: https://github.com/pypa/cibuildwheel


### Additional information
There are more options for architectures in the new wheel builder. We may want to explore those options.



<!-- If you want to ask a question (how to use Pyomo, what it can do, etc.), please refer to our [online documentation](https://github.com/Pyomo/pyomo#getting-help) on ways to get help. -->



Discussion:

Resolving patch:
diff --git a/pyomo/core/base/indexed_component.py b/pyomo/core/base/indexed_component.py
--- a/pyomo/core/base/indexed_component.py
+++ b/pyomo/core/base/indexed_component.py
@@ -20,6 +20,7 @@
 from copy import deepcopy
 
 import pyomo.core.expr as EXPR
+import pyomo.core.base as BASE
 from pyomo.core.expr.numeric_expr import NumericNDArray
 from pyomo.core.expr.numvalue import native_types
 from pyomo.core.base.indexed_component_slice import IndexedComponent_slice
@@ -42,6 +43,7 @@
 logger = logging.getLogger('pyomo.core')
 
 sequence_types = {tuple, list}
+slicer_types = {slice, Ellipsis.__class__, IndexedComponent_slice}
 
 
 def normalize_index(x):
@@ -296,8 +298,6 @@ class Skip(object):
     _DEFAULT_INDEX_CHECKING_ENABLED = True
 
     def __init__(self, *args, **kwds):
-        from pyomo.core.base.set import process_setarg
-
         #
         kwds.pop('noruleinit', None)
         Component.__init__(self, **kwds)
@@ -315,7 +315,7 @@ def __init__(self, *args, **kwds):
             # If a single indexing set is provided, just process it.
             #
             self._implicit_subsets = None
-            self._index_set = process_setarg(args[0])
+            self._index_set = BASE.set.process_setarg(args[0])
         else:
             #
             # If multiple indexing sets are provided, process them all,
@@ -332,7 +332,7 @@ def __init__(self, *args, **kwds):
             # is assigned to a model (where the implicit subsets can be
             # "transferred" to the model).
             #
-            tmp = [process_setarg(x) for x in args]
+            tmp = [BASE.set.process_setarg(x) for x in args]
             self._implicit_subsets = tmp
             self._index_set = tmp[0].cross(*tmp[1:])
 
@@ -833,16 +833,23 @@ def _validate_index(self, idx):
             return idx
 
         # This is only called through __{get,set,del}item__, which has
-        # already trapped unhashable objects.
-        validated_idx = self._index_set.get(idx, _NotFound)
-        if validated_idx is not _NotFound:
-            # If the index is in the underlying index set, then return it
-            #  Note: This check is potentially expensive (e.g., when the
-            # indexing set is a complex set operation)!
-            return validated_idx
-
-        if idx.__class__ is IndexedComponent_slice:
-            return idx
+        # already trapped unhashable objects.  Unfortunately, Python
+        # 3.12 made slices hashable.  This means that slices will get
+        # here and potentially be looked up in the index_set.  This will
+        # cause problems with Any, where Any will happily return the
+        # index as a valid set.  We will only validate the index for
+        # non-Any sets.  Any will pass through so that normalize_index
+        # can be called (which can generate the TypeError for slices)
+        _any = isinstance(self._index_set, BASE.set._AnySet)
+        if _any:
+            validated_idx = _NotFound
+        else:
+            validated_idx = self._index_set.get(idx, _NotFound)
+            if validated_idx is not _NotFound:
+                # If the index is in the underlying index set, then return it
+                #  Note: This check is potentially expensive (e.g., when the
+                # indexing set is a complex set operation)!
+                return validated_idx
 
         if normalize_index.flatten:
             # Now we normalize the index and check again.  Usually,
@@ -850,16 +857,24 @@ def _validate_index(self, idx):
             # "automatic" call to normalize_index until now for the
             # sake of efficiency.
             normalized_idx = normalize_index(idx)
-            if normalized_idx is not idx:
-                idx = normalized_idx
-                if idx in self._data:
-                    return idx
-                if idx in self._index_set:
-                    return idx
+            if normalized_idx is not idx and not _any:
+                if normalized_idx in self._data:
+                    return normalized_idx
+                if normalized_idx in self._index_set:
+                    return normalized_idx
+        else:
+            normalized_idx = idx
+
         # There is the chance that the index contains an Ellipsis,
         # so we should generate a slicer
-        if idx is Ellipsis or idx.__class__ is tuple and Ellipsis in idx:
-            return self._processUnhashableIndex(idx)
+        if (
+            normalized_idx.__class__ in slicer_types
+            or normalized_idx.__class__ is tuple
+            and any(_.__class__ in slicer_types for _ in normalized_idx)
+        ):
+            return self._processUnhashableIndex(normalized_idx)
+        if _any:
+            return idx
         #
         # Generate different errors, depending on the state of the index.
         #
@@ -872,7 +887,8 @@ def _validate_index(self, idx):
         # Raise an exception
         #
         raise KeyError(
-            "Index '%s' is not valid for indexed component '%s'" % (idx, self.name)
+            "Index '%s' is not valid for indexed component '%s'"
+            % (normalized_idx, self.name)
         )
 
     def _processUnhashableIndex(self, idx):
@@ -881,7 +897,7 @@ def _processUnhashableIndex(self, idx):
         There are three basic ways to get here:
           1) the index contains one or more slices or ellipsis
           2) the index contains an unhashable type (e.g., a Pyomo
-             (Scalar)Component
+             (Scalar)Component)
           3) the index contains an IndexTemplate
         """
         #
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -232,6 +232,7 @@ def __ne__(self, other):
         'Programming Language :: Python :: 3.9',
         'Programming Language :: Python :: 3.10',
         'Programming Language :: Python :: 3.11',
+        'Programming Language :: Python :: 3.12',
         'Programming Language :: Python :: Implementation :: CPython',
         'Programming Language :: Python :: Implementation :: PyPy',
         'Topic :: Scientific/Engineering :: Mathematics',


Test patch:
diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml
--- a/.github/workflows/test_branches.yml
+++ b/.github/workflows/test_branches.yml
@@ -33,7 +33,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
     - name: Set up Python
       uses: actions/setup-python@v4
       with:
@@ -56,13 +56,13 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest]
-        python: ['3.11']
+        python: ['3.12']
         other: [""]
         category: [""]
 
         include:
         - os: ubuntu-latest
-          python: '3.11'
+          python: '3.12'
           TARGET: linux
           PYENV: pip
 
@@ -78,7 +78,7 @@ jobs:
           PACKAGES: glpk
 
         - os: ubuntu-latest
-          python: 3.9
+          python: '3.11'
           other: /conda
           skip_doctest: 1
           TARGET: linux
@@ -86,7 +86,7 @@ jobs:
           PACKAGES:
 
         - os: ubuntu-latest
-          python: 3.8
+          python: 3.9
           other: /mpi
           mpi: 3
           skip_doctest: 1
@@ -112,7 +112,7 @@ jobs:
 
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Configure job parameters
       run: |
@@ -235,6 +235,7 @@ jobs:
       run: |
         python -c 'import sys;print(sys.executable)'
         python -m pip install --cache-dir cache/pip --upgrade pip
+        python -m pip install --cache-dir cache/pip setuptools
         PYOMO_DEPENDENCIES=`python setup.py dependencies \
             --extras "$EXTRAS" | tail -1`
         PACKAGES="${PYTHON_CORE_PKGS} ${PYTHON_PACKAGES} ${PYOMO_DEPENDENCIES} "
@@ -660,7 +661,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Set up Python 3.8
       uses: actions/setup-python@v4
@@ -715,7 +716,7 @@ jobs:
 
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
       # We need the source for .codecov.yml and running "coverage xml"
 
     #- name: Pip package cache
diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml
--- a/.github/workflows/test_pr_and_main.yml
+++ b/.github/workflows/test_pr_and_main.yml
@@ -36,7 +36,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
     - name: Set up Python
       uses: actions/setup-python@v4
       with:
@@ -60,7 +60,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python: [ 3.8, 3.9, '3.10', '3.11' ]
+        python: [ 3.8, 3.9, '3.10', '3.11', '3.12' ]
         other: [""]
         category: [""]
 
@@ -79,7 +79,7 @@ jobs:
           PACKAGES: glpk
 
         - os: ubuntu-latest
-          python: 3.9
+          python: '3.11'
           other: /conda
           skip_doctest: 1
           TARGET: linux
@@ -96,7 +96,7 @@ jobs:
           PACKAGES: mpi4py
 
         - os: ubuntu-latest
-          python: 3.11
+          python: '3.11'
           other: /singletest
           category: "-m 'neos or importtest'"
           skip_doctest: 1
@@ -142,7 +142,7 @@ jobs:
 
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Configure job parameters
       run: |
@@ -265,6 +265,7 @@ jobs:
       run: |
         python -c 'import sys;print(sys.executable)'
         python -m pip install --cache-dir cache/pip --upgrade pip
+        python -m pip install --cache-dir cache/pip setuptools
         PYOMO_DEPENDENCIES=`python setup.py dependencies \
             --extras "$EXTRAS" | tail -1`
         PACKAGES="${PYTHON_CORE_PKGS} ${PYTHON_PACKAGES} ${PYOMO_DEPENDENCIES} "
@@ -691,7 +692,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Set up Python 3.8
       uses: actions/setup-python@v4
@@ -746,7 +747,7 @@ jobs:
 
     steps:
     - name: Checkout Pyomo source
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
       # We need the source for .codecov.yml and running "coverage xml"
 
     #- name: Pip package cache
diff --git a/pyomo/contrib/trustregion/tests/test_interface.py b/pyomo/contrib/trustregion/tests/test_interface.py
--- a/pyomo/contrib/trustregion/tests/test_interface.py
+++ b/pyomo/contrib/trustregion/tests/test_interface.py
@@ -107,7 +107,7 @@ def test_replaceRF(self):
         expr = self.interface.model.c1.expr
         new_expr = self.interface.replaceEF(expr)
         self.assertIsNot(expr, new_expr)
-        self.assertEquals(
+        self.assertEqual(
             str(new_expr),
             'x[0]*z[0]**2 + trf_data.ef_outputs[1]  ==  2.8284271247461903',
         )
@@ -382,17 +382,17 @@ def test_solveModel(self):
         self.interface.data.value_of_ef_inputs[...] = 0
         # Run the solve
         objective, step_norm, feasibility = self.interface.solveModel()
-        self.assertEqual(objective, 5.150744273013601)
-        self.assertEqual(step_norm, 3.393437471478297)
-        self.assertEqual(feasibility, 0.09569982275514467)
+        self.assertAlmostEqual(objective, 5.150744273013601)
+        self.assertAlmostEqual(step_norm, 3.393437471478297)
+        self.assertAlmostEqual(feasibility, 0.09569982275514467)
         self.interface.data.basis_constraint.deactivate()
         # Change the constraint and update the surrogate model
         self.interface.updateSurrogateModel()
         self.interface.data.sm_constraint_basis.activate()
         objective, step_norm, feasibility = self.interface.solveModel()
-        self.assertEqual(objective, 5.15065981284333)
-        self.assertEqual(step_norm, 0.0017225116628372117)
-        self.assertEqual(feasibility, 0.00014665023773349772)
+        self.assertAlmostEqual(objective, 5.15065981284333)
+        self.assertAlmostEqual(step_norm, 0.0017225116628372117)
+        self.assertAlmostEqual(feasibility, 0.00014665023773349772)
 
     @unittest.skipIf(
         not SolverFactory('ipopt').available(False), "The IPOPT solver is not available"
@@ -407,8 +407,8 @@ def test_initializeProblem(self):
             self.assertEqual(
                 self.interface.initial_decision_bounds[var.name], [var.lb, var.ub]
             )
-        self.assertEqual(objective, 5.150744273013601)
-        self.assertEqual(feasibility, 0.09569982275514467)
+        self.assertAlmostEqual(objective, 5.150744273013601)
+        self.assertAlmostEqual(feasibility, 0.09569982275514467)
         self.assertTrue(self.interface.data.sm_constraint_basis.active)
         self.assertFalse(self.interface.data.basis_constraint.active)
 
diff --git a/pyomo/core/tests/unit/test_visitor.py b/pyomo/core/tests/unit/test_visitor.py
--- a/pyomo/core/tests/unit/test_visitor.py
+++ b/pyomo/core/tests/unit/test_visitor.py
@@ -1822,8 +1822,9 @@ def run_walker(self, walker):
             cases = []
         else:
             # 3 sufficed through Python 3.10, but appeared to need to be
-            # raised to 5 for recent 3.11 builds (3.11.2)
-            cases = [(0, ""), (5, warn_msg)]
+            # raised to 5 for Python 3.11 builds (3.11.2), and again to
+            # 10 for Python 3.12 builds (3.12.0)
+            cases = [(0, ""), (10, warn_msg)]
 
         head_room = sys.getrecursionlimit() - get_stack_depth()
         for n, msg in cases:
diff --git a/pyomo/repn/tests/test_util.py b/pyomo/repn/tests/test_util.py
--- a/pyomo/repn/tests/test_util.py
+++ b/pyomo/repn/tests/test_util.py
@@ -100,7 +100,7 @@ def test_ftoa_precision(self):
         # Depending on the platform, np.longdouble may or may not have
         # higher precision than float:
         if f == float(f):
-            test = self.assertNotRegexpMatches
+            test = self.assertNotRegex
         else:
             test = self.assertRegex
         test(