Repository: pypa/setuptools
Pull request #4577 (2024-08-19 23:00:37)

Override distribution attribute type in all distutils-based commands
<!-- First time contributors: Take a moment to review https://setuptools.pypa.io/en/latest/development/developer-guide.html! -->
<!-- Remove sections if not applicable -->

## Summary of changes

<!-- Summary goes here -->

This was already done here and there for various commands as needed as they came up from type-checking more code. Figured I may as well go ahead and make it complete.

### Pull Request Checklist
- [x] Changes have tests (existing type-checking tests)
- [x] News fragment added in [`newsfragments/`]. (not user facing yet)
  _(See [documentation][PR docs] for details)_


[`newsfragments/`]: https://github.com/pypa/setuptools/tree/master/newsfragments
[PR docs]:
https://setuptools.pypa.io/en/latest/development/developer-guide.html#making-a-pull-request



Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 8cd611b4a..721548788 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 73.0.0
+current_version = 72.2.0
 commit = True
 tag = True
 
diff --git a/NEWS.rst b/NEWS.rst
index cfb8a2379..a2d5eeba3 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,37 +1,3 @@
-v73.0.0
-=======
-
-Features
---------
-
-- Mark abstract base classes and methods with `abc.ABC` and `abc.abstractmethod` -- by :user:`Avasam` (#4503)
-- Changed the order of type checks in ``setuptools.command.easy_install.CommandSpec.from_param`` to support any `collections.abc.Iterable` of `str` param -- by :user:`Avasam` (#4505)
-
-
-Bugfixes
---------
-
-- Prevent an error in ``bdist_wheel`` if ``compression`` is set to a `str` (even if valid) after finalizing options but before running the command. -- by :user:`Avasam` (#4383)
-- Raises an exception when ``py_limited_api`` is used in a build with
-  ``Py_GIL_DISABLED``. This is currently not supported (python/cpython#111506). (#4420)
-- Synced with pypa/distutils@30b7331 including fix for modified check on empty sources (pypa/distutils#284).
-
-
-Deprecations and Removals
--------------------------
-
-- ``setuptools`` is replacing the usages of :pypi:`ordered_set` with simple
-  instances of ``dict[Hashable, None]``. This is done to remove the extra
-  dependency and it is possible because since Python 3.7, ``dict`` maintain
-  insertion order. (#4574)
-
-
-Misc
-----
-
-- #4534, #4546, #4554, #4559, #4565
-
-
 v72.2.0
 =======
 
diff --git a/newsfragments/4383.bugfix.rst b/newsfragments/4383.bugfix.rst
new file mode 100644
index 000000000..e5fd603ab
--- /dev/null
+++ b/newsfragments/4383.bugfix.rst
@@ -0,0 +1,1 @@
+Prevent an error in ``bdist_wheel`` if ``compression`` is set to a `str` (even if valid) after finalizing options but before running the command. -- by :user:`Avasam`
diff --git a/newsfragments/4420.bugfix.rst b/newsfragments/4420.bugfix.rst
new file mode 100644
index 000000000..c5f75fcdd
--- /dev/null
+++ b/newsfragments/4420.bugfix.rst
@@ -0,0 +1,2 @@
+Raises an exception when ``py_limited_api`` is used in a build with
+``Py_GIL_DISABLED``. This is currently not supported (python/cpython#111506).
diff --git a/newsfragments/4503.feature.rst b/newsfragments/4503.feature.rst
new file mode 100644
index 000000000..9c2e43324
--- /dev/null
+++ b/newsfragments/4503.feature.rst
@@ -0,0 +1,1 @@
+Mark abstract base classes and methods with `abc.ABC` and `abc.abstractmethod` -- by :user:`Avasam`
diff --git a/newsfragments/4505.feature.rst b/newsfragments/4505.feature.rst
new file mode 100644
index 000000000..e032dd997
--- /dev/null
+++ b/newsfragments/4505.feature.rst
@@ -0,0 +1,1 @@
+Changed the order of type checks in ``setuptools.command.easy_install.CommandSpec.from_param`` to support any `collections.abc.Iterable` of `str` param -- by :user:`Avasam`
diff --git a/newsfragments/4534.misc.rst b/newsfragments/4534.misc.rst
new file mode 100644
index 000000000..f7c1a1d31
--- /dev/null
+++ b/newsfragments/4534.misc.rst
@@ -0,0 +1,1 @@
+Changed the import of ``ctypes.wintypes`` from ``__import__`` to a regular ``import`` statement -- by :user:`Avasam`
diff --git a/newsfragments/4546.misc.rst b/newsfragments/4546.misc.rst
new file mode 100644
index 000000000..f056a2b37
--- /dev/null
+++ b/newsfragments/4546.misc.rst
@@ -0,0 +1,2 @@
+Added lower bound to test dependency on ``wheel`` (0.44.0) to avoid
+small inconsistencies in ``Requires-Dist`` normalisation for ``METADATA``.
diff --git a/newsfragments/4554.misc.rst b/newsfragments/4554.misc.rst
new file mode 100644
index 000000000..9992f9344
--- /dev/null
+++ b/newsfragments/4554.misc.rst
@@ -0,0 +1,1 @@
+Removed ``setputools.sandbox``'s Python 2 ``builtins.file`` support -- by :user:`Avasam`
diff --git a/newsfragments/4559.misc.rst b/newsfragments/4559.misc.rst
new file mode 100644
index 000000000..34b5a3066
--- /dev/null
+++ b/newsfragments/4559.misc.rst
@@ -0,0 +1,2 @@
+Prevent deprecation warning from ``pypa/wheel#631`` to accidentally
+trigger when validating ``pyproject.toml``.
diff --git a/newsfragments/4565.misc.rst b/newsfragments/4565.misc.rst
new file mode 100644
index 000000000..031f8d66c
--- /dev/null
+++ b/newsfragments/4565.misc.rst
@@ -0,0 +1,3 @@
+Replace ``pip install -I`` with ``pip install --force-reinstall`` in
+integration tests. Additionally, remove ``wheel`` from virtual environment as
+it is no longer a build dependency.
diff --git a/pyproject.toml b/pyproject.toml
index c9f8e61bb..1ce17e63a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ backend-path = ["."]
 
 [project]
 name = "setuptools"
-version = "73.0.0"
+version = "72.2.0"
 authors = [
 	{ name = "Python Packaging Authority", email = "distutils-sig@python.org" },
 ]
@@ -110,6 +110,7 @@ ssl = []
 certs = []
 core = [
 	"packaging>=24",
+	"ordered-set>=3.1.1",
 	"more_itertools>=8.8",
 	"jaraco.text>=3.7",
 	"importlib_resources>=5.10.2; python_version < '3.9'",
diff --git a/setuptools/_distutils/_modified.py b/setuptools/_distutils/_modified.py
index b7bdaa294..6532aa107 100644
--- a/setuptools/_distutils/_modified.py
+++ b/setuptools/_distutils/_modified.py
@@ -63,7 +63,7 @@ def newer_group(sources, target, missing='error'):
         return missing == 'newer' and not os.path.exists(source)
 
     ignored = os.path.exists if missing == 'ignore' else None
-    return not os.path.exists(target) or any(
+    return any(
         missing_as_newer(source) or _newer(source, target)
         for source in filter(ignored, sources)
     )
diff --git a/setuptools/_distutils/dist.py b/setuptools/_distutils/dist.py
index 115302b3e..0a57d60be 100644
--- a/setuptools/_distutils/dist.py
+++ b/setuptools/_distutils/dist.py
@@ -658,7 +658,7 @@ Common commands: (see '--help-commands' for more)
             )
             print()
 
-        for command in commands:
+        for command in self.commands:
             if isinstance(command, type) and issubclass(command, Command):
                 klass = command
             else:
diff --git a/setuptools/_vendor/ordered_set-4.1.0.dist-info/INSTALLER b/setuptools/_vendor/ordered_set-4.1.0.dist-info/INSTALLER
new file mode 100644
index 000000000..a1b589e38
--- /dev/null
+++ b/setuptools/_vendor/ordered_set-4.1.0.dist-info/INSTALLER
@@ -0,0 +1,1 @@
+pip
diff --git a/setuptools/_vendor/ordered_set-4.1.0.dist-info/METADATA b/setuptools/_vendor/ordered_set-4.1.0.dist-info/METADATA
new file mode 100644
index 000000000..7aea13681
--- /dev/null
+++ b/setuptools/_vendor/ordered_set-4.1.0.dist-info/METADATA
@@ -0,0 +1,158 @@
+Metadata-Version: 2.1
+Name: ordered-set
+Version: 4.1.0
+Summary: An OrderedSet is a custom MutableSet that remembers its order, so that every
+Author-email: Elia Robyn Lake <gh@arborelia.net>
+Requires-Python: >=3.7
+Description-Content-Type: text/markdown
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: MIT License
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+Requires-Dist: pytest ; extra == "dev"
+Requires-Dist: black ; extra == "dev"
+Requires-Dist: mypy ; extra == "dev"
+Project-URL: Home, https://github.com/rspeer/ordered-set
+Provides-Extra: dev
+
+[![Pypi](https://img.shields.io/pypi/v/ordered-set.svg)](https://pypi.python.org/pypi/ordered-set)
+
+An OrderedSet is a mutable data structure that is a hybrid of a list and a set.
+It remembers the order of its entries, and every entry has an index number that
+can be looked up.
+
+## Installation
+
+`ordered_set` is available on PyPI and packaged as a wheel. You can list it
+as a dependency of your project, in whatever form that takes.
+
+To install it into your current Python environment:
+
+    pip install ordered-set
+
+To install the code for development, after checking out the repository:
+
+    pip install flit
+    flit install
+
+## Usage examples
+
+An OrderedSet is created and used like a set:
+
+    >>> from ordered_set import OrderedSet
+
+    >>> letters = OrderedSet('abracadabra')
+
+    >>> letters
+    OrderedSet(['a', 'b', 'r', 'c', 'd'])
+
+    >>> 'r' in letters
+    True
+
+It is efficient to find the index of an entry in an OrderedSet, or find an
+entry by its index. To help with this use case, the `.add()` method returns
+the index of the added item, whether it was already in the set or not.
+
+    >>> letters.index('r')
+    2
+
+    >>> letters[2]
+    'r'
+
+    >>> letters.add('r')
+    2
+
+    >>> letters.add('x')
+    5
+
+OrderedSets implement the union (`|`), intersection (`&`), and difference (`-`)
+operators like sets do.
+
+    >>> letters |= OrderedSet('shazam')
+
+    >>> letters
+    OrderedSet(['a', 'b', 'r', 'c', 'd', 'x', 's', 'h', 'z', 'm'])
+
+    >>> letters & set('aeiou')
+    OrderedSet(['a'])
+
+    >>> letters -= 'abcd'
+
+    >>> letters
+    OrderedSet(['r', 'x', 's', 'h', 'z', 'm'])
+
+The `__getitem__()` and `index()` methods have been extended to accept any
+iterable except a string, returning a list, to perform NumPy-like "fancy
+indexing".
+
+    >>> letters = OrderedSet('abracadabra')
+
+    >>> letters[[0, 2, 3]]
+    ['a', 'r', 'c']
+
+    >>> letters.index(['a', 'r', 'c'])
+    [0, 2, 3]
+
+OrderedSet implements `__getstate__` and `__setstate__` so it can be pickled,
+and implements the abstract base classes `collections.MutableSet` and
+`collections.Sequence`.
+
+OrderedSet can be used as a generic collection type, similar to the collections
+in the `typing` module like List, Dict, and Set. For example, you can annotate
+a variable as having the type `OrderedSet[str]` or `OrderedSet[Tuple[int,
+str]]`.
+
+
+## OrderedSet in data science applications
+
+An OrderedSet can be used as a bi-directional mapping between a sparse
+vocabulary and dense index numbers. As of version 3.1, it accepts NumPy arrays
+of index numbers as well as lists.
+
+This combination of features makes OrderedSet a simple implementation of many
+of the things that `pandas.Index` is used for, and many of its operations are
+faster than the equivalent pandas operations.
+
+For further compatibility with pandas.Index, `get_loc` (the pandas method for
+looking up a single index) and `get_indexer` (the pandas method for fancy
+indexing in reverse) are both aliases for `index` (which handles both cases
+in OrderedSet).
+
+
+## Authors
+
+OrderedSet was implemented by Elia Robyn Lake (maiden name: Robyn Speer).
+Jon Crall contributed changes and tests to make it fit the Python set API.
+Roman Inflianskas added the original type annotations.
+
+
+## Comparisons
+
+The original implementation of OrderedSet was a [recipe posted to ActiveState
+Recipes][recipe] by Raymond Hettiger, released under the MIT license.
+
+[recipe]: https://code.activestate.com/recipes/576694-orderedset/
+
+Hettiger's implementation kept its content in a doubly-linked list referenced by a
+dict. As a result, looking up an item by its index was an O(N) operation, while
+deletion was O(1).
+
+This version makes different trade-offs for the sake of efficient lookups. Its
+content is a standard Python list instead of a doubly-linked list. This
+provides O(1) lookups by index at the expense of O(N) deletion, as well as
+slightly faster iteration.
+
+In Python 3.6 and later, the built-in `dict` type is inherently ordered. If you
+ignore the dictionary values, that also gives you a simple ordered set, with
+fast O(1) insertion, deletion, iteration and membership testing. However, `dict`
+does not provide the list-like random access features of OrderedSet. You
+would have to convert it to a list in O(N) to look up the index of an entry or
+look up an entry by its index.
+
diff --git a/setuptools/_vendor/ordered_set-4.1.0.dist-info/RECORD b/setuptools/_vendor/ordered_set-4.1.0.dist-info/RECORD
new file mode 100644
index 000000000..a9875cde4
--- /dev/null
+++ b/setuptools/_vendor/ordered_set-4.1.0.dist-info/RECORD
@@ -0,0 +1,8 @@
+ordered_set-4.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+ordered_set-4.1.0.dist-info/METADATA,sha256=FqVN_VUTJTCDQ-vtnmXrbgapDjciET-54gSNJ47sro8,5340
+ordered_set-4.1.0.dist-info/RECORD,,
+ordered_set-4.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+ordered_set-4.1.0.dist-info/WHEEL,sha256=jPMR_Dzkc4X4icQtmz81lnNY_kAsfog7ry7qoRvYLXw,81
+ordered_set/__init__.py,sha256=ytazgKsyBKi9uFtBt938yXxQtdat1VCC681s9s0CMqg,17146
+ordered_set/__pycache__/__init__.cpython-312.pyc,,
+ordered_set/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
diff --git a/setuptools/_vendor/ordered_set-4.1.0.dist-info/REQUESTED b/setuptools/_vendor/ordered_set-4.1.0.dist-info/REQUESTED
new file mode 100644
index 000000000..e69de29bb
diff --git a/setuptools/_vendor/ordered_set-4.1.0.dist-info/WHEEL b/setuptools/_vendor/ordered_set-4.1.0.dist-info/WHEEL
new file mode 100644
index 000000000..c727d1482
--- /dev/null
+++ b/setuptools/_vendor/ordered_set-4.1.0.dist-info/WHEEL
@@ -0,0 +1,4 @@
+Wheel-Version: 1.0
+Generator: flit 3.6.0
+Root-Is-Purelib: true
+Tag: py3-none-any
diff --git a/setuptools/_vendor/ordered_set/__init__.py b/setuptools/_vendor/ordered_set/__init__.py
new file mode 100644
index 000000000..e86c70ed8
--- /dev/null
+++ b/setuptools/_vendor/ordered_set/__init__.py
@@ -0,0 +1,536 @@
+"""
+An OrderedSet is a custom MutableSet that remembers its order, so that every
+entry has an index that can be looked up. It can also act like a Sequence.
+
+Based on a recipe originally posted to ActiveState Recipes by Raymond Hettiger,
+and released under the MIT license.
+"""
+import itertools as it
+from typing import (
+    Any,
+    Dict,
+    Iterable,
+    Iterator,
+    List,
+    MutableSet,
+    AbstractSet,
+    Sequence,
+    Set,
+    TypeVar,
+    Union,
+    overload,
+)
+
+SLICE_ALL = slice(None)
+__version__ = "4.1.0"
+
+
+T = TypeVar("T")
+
+# SetLike[T] is either a set of elements of type T, or a sequence, which
+# we will convert to an OrderedSet by adding its elements in order.
+SetLike = Union[AbstractSet[T], Sequence[T]]
+OrderedSetInitializer = Union[AbstractSet[T], Sequence[T], Iterable[T]]
+
+
+def _is_atomic(obj: Any) -> bool:
+    """
+    Returns True for objects which are iterable but should not be iterated in
+    the context of indexing an OrderedSet.
+
+    When we index by an iterable, usually that means we're being asked to look
+    up a list of things.
+
+    However, in the case of the .index() method, we shouldn't handle strings
+    and tuples like other iterables. They're not sequences of things to look
+    up, they're the single, atomic thing we're trying to find.
+
+    As an example, oset.index('hello') should give the index of 'hello' in an
+    OrderedSet of strings. It shouldn't give the indexes of each individual
+    character.
+    """
+    return isinstance(obj, str) or isinstance(obj, tuple)
+
+
+class OrderedSet(MutableSet[T], Sequence[T]):
+    """
+    An OrderedSet is a custom MutableSet that remembers its order, so that
+    every entry has an index that can be looked up.
+
+    Example:
+        >>> OrderedSet([1, 1, 2, 3, 2])
+        OrderedSet([1, 2, 3])
+    """
+
+    def __init__(self, initial: OrderedSetInitializer[T] = None):
+        self.items: List[T] = []
+        self.map: Dict[T, int] = {}
+        if initial is not None:
+            # In terms of duck-typing, the default __ior__ is compatible with
+            # the types we use, but it doesn't expect all the types we
+            # support as values for `initial`.
+            self |= initial  # type: ignore
+
+    def __len__(self):
+        """
+        Returns the number of unique elements in the ordered set
+
+        Example:
+            >>> len(OrderedSet([]))
+            0
+            >>> len(OrderedSet([1, 2]))
+            2
+        """
+        return len(self.items)
+
+    @overload
+    def __getitem__(self, index: slice) -> "OrderedSet[T]":
+        ...
+
+    @overload
+    def __getitem__(self, index: Sequence[int]) -> List[T]:
+        ...
+
+    @overload
+    def __getitem__(self, index: int) -> T:
+        ...
+
+    # concrete implementation
+    def __getitem__(self, index):
+        """
+        Get the item at a given index.
+
+        If `index` is a slice, you will get back that slice of items, as a
+        new OrderedSet.
+
+        If `index` is a list or a similar iterable, you'll get a list of
+        items corresponding to those indices. This is similar to NumPy's
+        "fancy indexing". The result is not an OrderedSet because you may ask
+        for duplicate indices, and the number of elements returned should be
+        the number of elements asked for.
+
+        Example:
+            >>> oset = OrderedSet([1, 2, 3])
+            >>> oset[1]
+            2
+        """
+        if isinstance(index, slice) and index == SLICE_ALL:
+            return self.copy()
+        elif isinstance(index, Iterable):
+            return [self.items[i] for i in index]
+        elif isinstance(index, slice) or hasattr(index, "__index__"):
+            result = self.items[index]
+            if isinstance(result, list):
+                return self.__class__(result)
+            else:
+                return result
+        else:
+            raise TypeError("Don't know how to index an OrderedSet by %r" % index)
+
+    def copy(self) -> "OrderedSet[T]":
+        """
+        Return a shallow copy of this object.
+
+        Example:
+            >>> this = OrderedSet([1, 2, 3])
+            >>> other = this.copy()
+            >>> this == other
+            True
+            >>> this is other
+            False
+        """
+        return self.__class__(self)
+
+    # Define the gritty details of how an OrderedSet is serialized as a pickle.
+    # We leave off type annotations, because the only code that should interact
+    # with these is a generalized tool such as pickle.
+    def __getstate__(self):
+        if len(self) == 0:
+            # In pickle, the state can't be an empty list.
+            # We need to return a truthy value, or else __setstate__ won't be run.
+            #
+            # This could have been done more gracefully by always putting the state
+            # in a tuple, but this way is backwards- and forwards- compatible with
+            # previous versions of OrderedSet.
+            return (None,)
+        else:
+            return list(self)
+
+    def __setstate__(self, state):
+        if state == (None,):
+            self.__init__([])
+        else:
+            self.__init__(state)
+
+    def __contains__(self, key: Any) -> bool:
+        """
+        Test if the item is in this ordered set.
+
+        Example:
+            >>> 1 in OrderedSet([1, 3, 2])
+            True
+            >>> 5 in OrderedSet([1, 3, 2])
+            False
+        """
+        return key in self.map
+
+    # Technically type-incompatible with MutableSet, because we return an
+    # int instead of nothing. This is also one of the things that makes
+    # OrderedSet convenient to use.
+    def add(self, key: T) -> int:
+        """
+        Add `key` as an item to this OrderedSet, then return its index.
+
+        If `key` is already in the OrderedSet, return the index it already
+        had.
+
+        Example:
+            >>> oset = OrderedSet()
+            >>> oset.append(3)
+            0
+            >>> print(oset)
+            OrderedSet([3])
+        """
+        if key not in self.map:
+            self.map[key] = len(self.items)
+            self.items.append(key)
+        return self.map[key]
+
+    append = add
+
+    def update(self, sequence: SetLike[T]) -> int:
+        """
+        Update the set with the given iterable sequence, then return the index
+        of the last element inserted.
+
+        Example:
+            >>> oset = OrderedSet([1, 2, 3])
+            >>> oset.update([3, 1, 5, 1, 4])
+            4
+            >>> print(oset)
+            OrderedSet([1, 2, 3, 5, 4])
+        """
+        item_index = 0
+        try:
+            for item in sequence:
+                item_index = self.add(item)
+        except TypeError:
+            raise ValueError(
+                "Argument needs to be an iterable, got %s" % type(sequence)
+            )
+        return item_index
+
+    @overload
+    def index(self, key: Sequence[T]) -> List[int]:
+        ...
+
+    @overload
+    def index(self, key: T) -> int:
+        ...
+
+    # concrete implementation
+    def index(self, key):
+        """
+        Get the index of a given entry, raising an IndexError if it's not
+        present.
+
+        `key` can be an iterable of entries that is not a string, in which case
+        this returns a list of indices.
+
+        Example:
+            >>> oset = OrderedSet([1, 2, 3])
+            >>> oset.index(2)
+            1
+        """
+        if isinstance(key, Iterable) and not _is_atomic(key):
+            return [self.index(subkey) for subkey in key]
+        return self.map[key]
+
+    # Provide some compatibility with pd.Index
+    get_loc = index
+    get_indexer = index
+
+    def pop(self, index=-1) -> T:
+        """
+        Remove and return item at index (default last).
+
+        Raises KeyError if the set is empty.
+        Raises IndexError if index is out of range.
+
+        Example:
+            >>> oset = OrderedSet([1, 2, 3])
+            >>> oset.pop()
+            3
+        """
+        if not self.items:
+            raise KeyError("Set is empty")
+
+        elem = self.items[index]
+        del self.items[index]
+        del self.map[elem]
+        return elem
+
+    def discard(self, key: T) -> None:
+        """
+        Remove an element.  Do not raise an exception if absent.
+
+        The MutableSet mixin uses this to implement the .remove() method, which
+        *does* raise an error when asked to remove a non-existent item.
+
+        Example:
+            >>> oset = OrderedSet([1, 2, 3])
+            >>> oset.discard(2)
+            >>> print(oset)
+            OrderedSet([1, 3])
+            >>> oset.discard(2)
+            >>> print(oset)
+            OrderedSet([1, 3])
+        """
+        if key in self:
+            i = self.map[key]
+            del self.items[i]
+            del self.map[key]
+            for k, v in self.map.items():
+                if v >= i:
+                    self.map[k] = v - 1
+
+    def clear(self) -> None:
+        """
+        Remove all items from this OrderedSet.
+        """
+        del self.items[:]
+        self.map.clear()
+
+    def __iter__(self) -> Iterator[T]:
+        """
+        Example:
+            >>> list(iter(OrderedSet([1, 2, 3])))
+            [1, 2, 3]
+        """
+        return iter(self.items)
+
+    def __reversed__(self) -> Iterator[T]:
+        """
+        Example:
+            >>> list(reversed(OrderedSet([1, 2, 3])))
+            [3, 2, 1]
+        """
+        return reversed(self.items)
+
+    def __repr__(self) -> str:
+        if not self:
+            return "%s()" % (self.__class__.__name__,)
+        return "%s(%r)" % (self.__class__.__name__, list(self))
+
+    def __eq__(self, other: Any) -> bool:
+        """
+        Returns true if the containers have the same items. If `other` is a
+        Sequence, then order is checked, otherwise it is ignored.
+
+        Example:
+            >>> oset = OrderedSet([1, 3, 2])
+            >>> oset == [1, 3, 2]
+            True
+            >>> oset == [1, 2, 3]
+            False
+            >>> oset == [2, 3]
+            False
+            >>> oset == OrderedSet([3, 2, 1])
+            False
+        """
+        if isinstance(other, Sequence):
+            # Check that this OrderedSet contains the same elements, in the
+            # same order, as the other object.
+            return list(self) == list(other)
+        try:
+            other_as_set = set(other)
+        except TypeError:
+            # If `other` can't be converted into a set, it's not equal.
+            return False
+        else:
+            return set(self) == other_as_set
+
+    def union(self, *sets: SetLike[T]) -> "OrderedSet[T]":
+        """
+        Combines all unique items.
+        Each items order is defined by its first appearance.
+
+        Example:
+            >>> oset = OrderedSet.union(OrderedSet([3, 1, 4, 1, 5]), [1, 3], [2, 0])
+            >>> print(oset)
+            OrderedSet([3, 1, 4, 5, 2, 0])
+            >>> oset.union([8, 9])
+            OrderedSet([3, 1, 4, 5, 2, 0, 8, 9])
+            >>> oset | {10}
+            OrderedSet([3, 1, 4, 5, 2, 0, 10])
+        """
+        cls: type = OrderedSet
+        if isinstance(self, OrderedSet):
+            cls = self.__class__
+        containers = map(list, it.chain([self], sets))
+        items = it.chain.from_iterable(containers)
+        return cls(items)
+
+    def __and__(self, other: SetLike[T]) -> "OrderedSet[T]":
+        # the parent implementation of this is backwards
+        return self.intersection(other)
+
+    def intersection(self, *sets: SetLike[T]) -> "OrderedSet[T]":
+        """
+        Returns elements in common between all sets. Order is defined only
+        by the first set.
+
+        Example:
+            >>> oset = OrderedSet.intersection(OrderedSet([0, 1, 2, 3]), [1, 2, 3])
+            >>> print(oset)
+            OrderedSet([1, 2, 3])
+            >>> oset.intersection([2, 4, 5], [1, 2, 3, 4])
+            OrderedSet([2])
+            >>> oset.intersection()
+            OrderedSet([1, 2, 3])
+        """
+        cls: type = OrderedSet
+        items: OrderedSetInitializer[T] = self
+        if isinstance(self, OrderedSet):
+            cls = self.__class__
+        if sets:
+            common = set.intersection(*map(set, sets))
+            items = (item for item in self if item in common)
+        return cls(items)
+
+    def difference(self, *sets: SetLike[T]) -> "OrderedSet[T]":
+        """
+        Returns all elements that are in this set but not the others.
+
+        Example:
+            >>> OrderedSet([1, 2, 3]).difference(OrderedSet([2]))
+            OrderedSet([1, 3])
+            >>> OrderedSet([1, 2, 3]).difference(OrderedSet([2]), OrderedSet([3]))
+            OrderedSet([1])
+            >>> OrderedSet([1, 2, 3]) - OrderedSet([2])
+            OrderedSet([1, 3])
+            >>> OrderedSet([1, 2, 3]).difference()
+            OrderedSet([1, 2, 3])
+        """
+        cls = self.__class__
+        items: OrderedSetInitializer[T] = self
+        if sets:
+            other = set.union(*map(set, sets))
+            items = (item for item in self if item not in other)
+        return cls(items)
+
+    def issubset(self, other: SetLike[T]) -> bool:
+        """
+        Report whether another set contains this set.
+
+        Example:
+            >>> OrderedSet([1, 2, 3]).issubset({1, 2})
+            False
+            >>> OrderedSet([1, 2, 3]).issubset({1, 2, 3, 4})
+            True
+            >>> OrderedSet([1, 2, 3]).issubset({1, 4, 3, 5})
+            False
+        """
+        if len(self) > len(other):  # Fast check for obvious cases
+            return False
+        return all(item in other for item in self)
+
+    def issuperset(self, other: SetLike[T]) -> bool:
+        """
+        Report whether this set contains another set.
+
+        Example:
+            >>> OrderedSet([1, 2]).issuperset([1, 2, 3])
+            False
+            >>> OrderedSet([1, 2, 3, 4]).issuperset({1, 2, 3})
+            True
+            >>> OrderedSet([1, 4, 3, 5]).issuperset({1, 2, 3})
+            False
+        """
+        if len(self) < len(other):  # Fast check for obvious cases
+            return False
+        return all(item in self for item in other)
+
+    def symmetric_difference(self, other: SetLike[T]) -> "OrderedSet[T]":
+        """
+        Return the symmetric difference of two OrderedSets as a new set.
+        That is, the new set will contain all elements that are in exactly
+        one of the sets.
+
+        Their order will be preserved, with elements from `self` preceding
+        elements from `other`.
+
+        Example:
+            >>> this = OrderedSet([1, 4, 3, 5, 7])
+            >>> other = OrderedSet([9, 7, 1, 3, 2])
+            >>> this.symmetric_difference(other)
+            OrderedSet([4, 5, 9, 2])
+        """
+        cls: type = OrderedSet
+        if isinstance(self, OrderedSet):
+            cls = self.__class__
+        diff1 = cls(self).difference(other)
+        diff2 = cls(other).difference(self)
+        return diff1.union(diff2)
+
+    def _update_items(self, items: list) -> None:
+        """
+        Replace the 'items' list of this OrderedSet with a new one, updating
+        self.map accordingly.
+        """
+        self.items = items
+        self.map = {item: idx for (idx, item) in enumerate(items)}
+
+    def difference_update(self, *sets: SetLike[T]) -> None:
+        """
+        Update this OrderedSet to remove items from one or more other sets.
+
+        Example:
+            >>> this = OrderedSet([1, 2, 3])
+            >>> this.difference_update(OrderedSet([2, 4]))
+            >>> print(this)
+            OrderedSet([1, 3])
+
+            >>> this = OrderedSet([1, 2, 3, 4, 5])
+            >>> this.difference_update(OrderedSet([2, 4]), OrderedSet([1, 4, 6]))
+            >>> print(this)
+            OrderedSet([3, 5])
+        """
+        items_to_remove = set()  # type: Set[T]
+        for other in sets:
+            items_as_set = set(other)  # type: Set[T]
+            items_to_remove |= items_as_set
+        self._update_items([item for item in self.items if item not in items_to_remove])
+
+    def intersection_update(self, other: SetLike[T]) -> None:
+        """
+        Update this OrderedSet to keep only items in another set, preserving
+        their order in this set.
+
+        Example:
+            >>> this = OrderedSet([1, 4, 3, 5, 7])
+            >>> other = OrderedSet([9, 7, 1, 3, 2])
+            >>> this.intersection_update(other)
+            >>> print(this)
+            OrderedSet([1, 3, 7])
+        """
+        other = set(other)
+        self._update_items([item for item in self.items if item in other])
+
+    def symmetric_difference_update(self, other: SetLike[T]) -> None:
+        """
+        Update this OrderedSet to remove items from another set, then
+        add items from the other set that were not present in this set.
+
+        Example:
+            >>> this = OrderedSet([1, 4, 3, 5, 7])
+            >>> other = OrderedSet([9, 7, 1, 3, 2])
+            >>> this.symmetric_difference_update(other)
+            >>> print(this)
+            OrderedSet([4, 5, 9, 2])
+        """
+        items_to_add = [item for item in other if item not in self]
+        items_to_remove = set(other)
+        self._update_items(
+            [item for item in self.items if item not in items_to_remove] + items_to_add
+        )
diff --git a/setuptools/_vendor/ordered_set/py.typed b/setuptools/_vendor/ordered_set/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/setuptools/command/bdist_rpm.py b/setuptools/command/bdist_rpm.py
index abf2b88bf..e0d4caf2e 100644
--- a/setuptools/command/bdist_rpm.py
+++ b/setuptools/command/bdist_rpm.py
@@ -1,3 +1,4 @@
+from ..dist import Distribution
 from ..warnings import SetuptoolsDeprecationWarning
 
 import distutils.command.bdist_rpm as orig
@@ -12,6 +13,8 @@ class bdist_rpm(orig.bdist_rpm):
        disable eggs in RPM distributions.
     """
 
+    distribution: Distribution  # override distutils.dist.Distribution with setuptools.dist.Distribution
+
     def run(self):
         SetuptoolsDeprecationWarning.emit(
             "Deprecated command",
diff --git a/setuptools/command/build.py b/setuptools/command/build.py
index fd53fae8c..0c5e54480 100644
--- a/setuptools/command/build.py
+++ b/setuptools/command/build.py
@@ -2,12 +2,16 @@ from __future__ import annotations
 
 from typing import Protocol
 
+from ..dist import Distribution
+
 from distutils.command.build import build as _build
 
 _ORIGINAL_SUBCOMMANDS = {"build_py", "build_clib", "build_ext", "build_scripts"}
 
 
 class build(_build):
+    distribution: Distribution  # override distutils.dist.Distribution with setuptools.dist.Distribution
+
     # copy to avoid sharing the object with parent class
     sub_commands = _build.sub_commands[:]
 
diff --git a/setuptools/command/build_clib.py b/setuptools/command/build_clib.py
index 5366b0c5c..9db57ac8a 100644
--- a/setuptools/command/build_clib.py
+++ b/setuptools/command/build_clib.py
@@ -1,3 +1,5 @@
+from ..dist import Distribution
+
 import distutils.command.build_clib as orig
 from distutils import log
 from distutils.errors import DistutilsSetupError
@@ -25,6 +27,8 @@ class build_clib(orig.build_clib):
                      the compiler.
     """
 
+    distribution: Distribution  # override distutils.dist.Distribution with setuptools.dist.Distribution
+
     def build_libraries(self, libraries):
         for lib_name, build_info in libraries:
             sources = build_info.get('sources')
diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py
index 794ecd3dc..09255a324 100644
--- a/setuptools/command/egg_info.py
+++ b/setuptools/command/egg_info.py
@@ -18,7 +18,6 @@ from setuptools import Command
 from setuptools.command import bdist_egg
 from setuptools.command.sdist import sdist, walk_revctrl
 from setuptools.command.setopt import edit_config
-from setuptools.dist import Distribution
 from setuptools.glob import glob
 
 from .. import _entry_points, _normalization
@@ -522,7 +521,6 @@ class FileList(_FileList):
 
 
 class manifest_maker(sdist):
-    distribution: Distribution  # override distutils.dist.Distribution with setuptools.dist.Distribution
     template = "MANIFEST.in"
 
     def initialize_options(self):
diff --git a/setuptools/command/register.py b/setuptools/command/register.py
index 575790e5f..93ef04aa0 100644
--- a/setuptools/command/register.py
+++ b/setuptools/command/register.py
@@ -1,5 +1,7 @@
 from setuptools.errors import RemovedCommandError
 
+from ..dist import Distribution
+
 import distutils.command.register as orig
 from distutils import log
 
@@ -7,6 +9,8 @@ from distutils import log
 class register(orig.register):
     """Formerly used to register packages on PyPI."""
 
+    distribution: Distribution  # override distutils.dist.Distribution with setuptools.dist.Distribution
+
     def run(self):
         msg = (
             "The register command has been removed, use twine to upload "
diff --git a/setuptools/dist.py b/setuptools/dist.py
index 715e8fbb7..b2250e347 100644
--- a/setuptools/dist.py
+++ b/setuptools/dist.py
@@ -11,6 +11,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING, MutableMapping
 
 from more_itertools import partition, unique_everseen
+from ordered_set import OrderedSet
 from packaging.markers import InvalidMarker, Marker
 from packaging.specifiers import InvalidSpecifier, SpecifierSet
 from packaging.version import Version
@@ -250,7 +251,7 @@ class Distribution(_Distribution):
     _DISTUTILS_UNSUPPORTED_METADATA = {
         'long_description_content_type': lambda: None,
         'project_urls': dict,
-        'provides_extras': dict,  # behaves like an ordered set
+        'provides_extras': OrderedSet,
         'license_file': lambda: None,
         'license_files': lambda: None,
         'install_requires': list,
@@ -348,7 +349,7 @@ class Distribution(_Distribution):
                 # Setuptools allows a weird "<name>:<env markers> syntax for extras
                 extra = extra.split(':')[0]
                 if extra:
-                    self.metadata.provides_extras.setdefault(extra)
+                    self.metadata.provides_extras.add(extra)
 
     def _normalize_requires(self):
         """Make sure requirement-related attributes exist and are normalized"""


Test patch:
diff --git a/setuptools/_distutils/tests/test_modified.py b/setuptools/_distutils/tests/test_modified.py
index e35cec2d6..2bd82346c 100644
--- a/setuptools/_distutils/tests/test_modified.py
+++ b/setuptools/_distutils/tests/test_modified.py
@@ -117,10 +117,3 @@ def test_newer_pairwise_group(groups_target):
     newer = newer_pairwise_group([groups_target.newer], [groups_target.target])
     assert older == ([], [])
     assert newer == ([groups_target.newer], [groups_target.target])
-
-
-def test_newer_group_no_sources_no_target(tmp_path):
-    """
-    Consider no sources and no target "newer".
-    """
-    assert newer_group([], str(tmp_path / 'does-not-exist'))
diff --git a/setuptools/tests/config/test_setupcfg.py b/setuptools/tests/config/test_setupcfg.py
index 4f0a7349f..2c50ee2db 100644
--- a/setuptools/tests/config/test_setupcfg.py
+++ b/setuptools/tests/config/test_setupcfg.py
@@ -675,7 +675,7 @@ class TestOptions:
                 'pdf': ['ReportLab>=1.2', 'RXP'],
                 'rest': ['docutils>=0.3', 'pack==1.1,==1.3'],
             }
-            assert set(dist.metadata.provides_extras) == {'pdf', 'rest'}
+            assert dist.metadata.provides_extras == set(['pdf', 'rest'])
 
     @pytest.mark.parametrize(
         "config",
diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py
index 34828ac75..3e2ce65ea 100644
--- a/setuptools/tests/test_core_metadata.py
+++ b/setuptools/tests/test_core_metadata.py
@@ -188,7 +188,7 @@ def test_read_metadata(name, attrs):
         ('requires', dist_class.get_requires),
         ('classifiers', dist_class.get_classifiers),
         ('project_urls', lambda s: getattr(s, 'project_urls', {})),
-        ('provides_extras', lambda s: getattr(s, 'provides_extras', {})),
+        ('provides_extras', lambda s: getattr(s, 'provides_extras', set())),
     ]
 
     for attr, getter in tested_attrs:
diff --git a/setuptools/tests/test_dist.py b/setuptools/tests/test_dist.py
index 597785b51..a5a5e7606 100644
--- a/setuptools/tests/test_dist.py
+++ b/setuptools/tests/test_dist.py
@@ -77,12 +77,12 @@ def test_provides_extras_deterministic_order():
     extras['b'] = ['bar']
     attrs = dict(extras_require=extras)
     dist = Distribution(attrs)
-    assert list(dist.metadata.provides_extras) == ['a', 'b']
+    assert dist.metadata.provides_extras == ['a', 'b']
     attrs['extras_require'] = collections.OrderedDict(
         reversed(list(attrs['extras_require'].items()))
     )
     dist = Distribution(attrs)
-    assert list(dist.metadata.provides_extras) == ['b', 'a']
+    assert dist.metadata.provides_extras == ['b', 'a']
 
 
 CHECK_PACKAGE_DATA_TESTS = (
diff --git a/setuptools/tests/test_extern.py b/setuptools/tests/test_extern.py
index d7eb3c62c..105279d08 100644
--- a/setuptools/tests/test_extern.py
+++ b/setuptools/tests/test_extern.py
@@ -1,14 +1,20 @@
 import importlib
 import pickle
 
-import packaging
+import ordered_set
 
 from setuptools import Distribution
 
 
 def test_reimport_extern():
-    packaging2 = importlib.import_module(packaging.__name__)
-    assert packaging is packaging2
+    ordered_set2 = importlib.import_module(ordered_set.__name__)
+    assert ordered_set is ordered_set2
+
+
+def test_orderedset_pickle_roundtrip():
+    o1 = ordered_set.OrderedSet([1, 2, 5])
+    o2 = pickle.loads(pickle.dumps(o1))
+    assert o1 == o2
 
 
 def test_distribution_picklable():