diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 27d4eda354..01c8d15073 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,7 +4,7 @@ on: push: branches: [master, v0.11] pull_request: - branches: master + branches: [master, v0.11] env: NB_KERNEL: python diff --git a/doc/requirements.txt b/doc/requirements.txt index c6157fa9c4..9f908d0989 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,5 +1,5 @@ sphinx==3.3.1 -sphinx_bootstrap_theme==0.7.1 +sphinx_bootstrap_theme==0.8.1 numpydoc nbconvert ipykernel diff --git a/licences/PACKAGING_LICENSE b/licences/PACKAGING_LICENSE new file mode 100644 index 0000000000..42ce7b75c9 --- /dev/null +++ b/licences/PACKAGING_LICENSE @@ -0,0 +1,23 @@ +Copyright (c) Donald Stufft and individual contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/seaborn/_core.py b/seaborn/_core.py index 34f80fc965..99f8a95892 100644 --- a/seaborn/_core.py +++ b/seaborn/_core.py @@ -5,7 +5,6 @@ from collections.abc import Iterable, Sequence, Mapping from numbers import Number from datetime import datetime -from distutils.version import LooseVersion import numpy as np import pandas as pd @@ -14,6 +13,7 @@ from ._decorators import ( share_init_params_with_map, ) +from .external.version import Version from .palettes import ( QUAL_PALETTES, color_palette, @@ -1162,7 +1162,7 @@ def _attach(self, obj, allowed_types=None, log_scale=None): if scale is True: set_scale("log") else: - if LooseVersion(mpl.__version__) >= "3.3": + if Version(mpl.__version__) >= Version("3.3"): set_scale("log", base=scale) else: set_scale("log", **{f"base{axis}": scale}) diff --git a/seaborn/_statistics.py b/seaborn/_statistics.py index a0acd36f3a..4f40e1b40d 100644 --- a/seaborn/_statistics.py +++ b/seaborn/_statistics.py @@ -24,12 +24,12 @@ class instantiation. """ -from distutils.version import LooseVersion from numbers import Number import numpy as np import scipy as sp from scipy import stats +from .external.version import Version from .utils import _check_argument @@ -129,7 +129,7 @@ def _fit(self, fit_data, weights=None): """Fit the scipy kde while adding bw_adjust logic and version check.""" fit_kws = {"bw_method": self.bw_method} if weights is not None: - if LooseVersion(sp.__version__) < "1.2.0": + if Version(sp.__version__) < Version("1.2.0"): msg = "Weighted KDE requires scipy >= 1.2.0" raise RuntimeError(msg) fit_kws["weights"] = weights diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py index cc9e92945f..d3896e4a6b 100644 --- a/seaborn/axisgrid.py +++ b/seaborn/axisgrid.py @@ -2,7 +2,6 @@ from inspect import signature import warnings from textwrap import dedent -from distutils.version import LooseVersion import numpy as np import pandas as pd @@ -11,6 +10,7 @@ from ._core import VectorPlotter, variable_type, categorical_order from . import utils +from .external.version import Version from .utils import _check_argument, adjust_legend_subtitles, _draw_figure from .palettes import color_palette, blend_palette from ._decorators import _deprecate_positional_args @@ -127,7 +127,7 @@ def add_legend(self, legend_data=None, title=None, label_order=None, blank_handle = mpl.patches.Patch(alpha=0, linewidth=0) handles = [legend_data.get(l, blank_handle) for l in label_order] title = self._hue_var if title is None else title - if LooseVersion(mpl.__version__) < LooseVersion("3.0"): + if Version(mpl.__version__) < Version("3.0"): try: title_size = mpl.rcParams["axes.labelsize"] * .85 except TypeError: # labelsize is something like "large" diff --git a/seaborn/categorical.py b/seaborn/categorical.py index 14db0864f3..ca8b871917 100644 --- a/seaborn/categorical.py +++ b/seaborn/categorical.py @@ -9,10 +9,10 @@ import matplotlib.patches as Patches import matplotlib.pyplot as plt import warnings -from distutils.version import LooseVersion from ._core import variable_type, infer_orient, categorical_order from . import utils +from .external.version import Version from .utils import remove_na from .algorithms import bootstrap from .palettes import color_palette, husl_palette, light_palette, dark_palette @@ -378,7 +378,7 @@ def annotate_axes(self, ax): if self.hue_names is not None: leg = ax.legend(loc="best", title=self.hue_title) if self.hue_title is not None: - if LooseVersion(mpl.__version__) < "3.0": + if Version(mpl.__version__) < Version("3.0"): # Old Matplotlib has no legend title size rcparam try: title_size = mpl.rcParams["axes.labelsize"] * .85 diff --git a/seaborn/external/version.py b/seaborn/external/version.py new file mode 100644 index 0000000000..7eb57d32ce --- /dev/null +++ b/seaborn/external/version.py @@ -0,0 +1,461 @@ +"""Extract reference documentation from the pypa/packaging source tree. + +In the process of copying, some unused methods / classes were removed. +These include: + +- parse() +- anything involving LegacyVersion + +This software is made available under the terms of *either* of the licenses +found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made +under the terms of *both* these licenses. + +Vendored from: +- https://github.com/pypa/packaging/ +- commit ba07d8287b4554754ac7178d177033ea3f75d489 (09/09/2021) +""" + + +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + + +import collections +import itertools +import re +from typing import Callable, Optional, SupportsInt, Tuple, Union + +__all__ = ["Version", "InvalidVersion", "VERSION_PATTERN"] + + +# Vendored from https://github.com/pypa/packaging/blob/main/packaging/_structures.py + +class InfinityType: + def __repr__(self) -> str: + return "Infinity" + + def __hash__(self) -> int: + return hash(repr(self)) + + def __lt__(self, other: object) -> bool: + return False + + def __le__(self, other: object) -> bool: + return False + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) + + def __ne__(self, other: object) -> bool: + return not isinstance(other, self.__class__) + + def __gt__(self, other: object) -> bool: + return True + + def __ge__(self, other: object) -> bool: + return True + + def __neg__(self: object) -> "NegativeInfinityType": + return NegativeInfinity + + +Infinity = InfinityType() + + +class NegativeInfinityType: + def __repr__(self) -> str: + return "-Infinity" + + def __hash__(self) -> int: + return hash(repr(self)) + + def __lt__(self, other: object) -> bool: + return True + + def __le__(self, other: object) -> bool: + return True + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) + + def __ne__(self, other: object) -> bool: + return not isinstance(other, self.__class__) + + def __gt__(self, other: object) -> bool: + return False + + def __ge__(self, other: object) -> bool: + return False + + def __neg__(self: object) -> InfinityType: + return Infinity + + +NegativeInfinity = NegativeInfinityType() + + +# Vendored from https://github.com/pypa/packaging/blob/main/packaging/version.py + +InfiniteTypes = Union[InfinityType, NegativeInfinityType] +PrePostDevType = Union[InfiniteTypes, Tuple[str, int]] +SubLocalType = Union[InfiniteTypes, int, str] +LocalType = Union[ + NegativeInfinityType, + Tuple[ + Union[ + SubLocalType, + Tuple[SubLocalType, str], + Tuple[NegativeInfinityType, SubLocalType], + ], + ..., + ], +] +CmpKey = Tuple[ + int, Tuple[int, ...], PrePostDevType, PrePostDevType, PrePostDevType, LocalType +] +LegacyCmpKey = Tuple[int, Tuple[str, ...]] +VersionComparisonMethod = Callable[ + [Union[CmpKey, LegacyCmpKey], Union[CmpKey, LegacyCmpKey]], bool +] + +_Version = collections.namedtuple( + "_Version", ["epoch", "release", "dev", "pre", "post", "local"] +) + + + +class InvalidVersion(ValueError): + """ + An invalid version was found, users should refer to PEP 440. + """ + + +class _BaseVersion: + _key: Union[CmpKey, LegacyCmpKey] + + def __hash__(self) -> int: + return hash(self._key) + + # Please keep the duplicated `isinstance` check + # in the six comparisons hereunder + # unless you find a way to avoid adding overhead function calls. + def __lt__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key < other._key + + def __le__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key <= other._key + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key == other._key + + def __ge__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key >= other._key + + def __gt__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key > other._key + + def __ne__(self, other: object) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key != other._key + + +# Deliberately not anchored to the start and end of the string, to make it +# easier for 3rd party code to reuse +VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+
+class Version(_BaseVersion):
+
+    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
+
+    def __init__(self, version: str) -> None:
+
+        # Validate the version and parse it into pieces
+        match = self._regex.search(version)
+        if not match:
+            raise InvalidVersion(f"Invalid version: '{version}'")
+
+        # Store the parsed out pieces of the version
+        self._version = _Version(
+            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
+            release=tuple(int(i) for i in match.group("release").split(".")),
+            pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
+            post=_parse_letter_version(
+                match.group("post_l"), match.group("post_n1") or match.group("post_n2")
+            ),
+            dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
+            local=_parse_local_version(match.group("local")),
+        )
+
+        # Generate a key which will be used for sorting
+        self._key = _cmpkey(
+            self._version.epoch,
+            self._version.release,
+            self._version.pre,
+            self._version.post,
+            self._version.dev,
+            self._version.local,
+        )
+
+    def __repr__(self) -> str:
+        return f""
+
+    def __str__(self) -> str:
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        # Pre-release
+        if self.pre is not None:
+            parts.append("".join(str(x) for x in self.pre))
+
+        # Post-release
+        if self.post is not None:
+            parts.append(f".post{self.post}")
+
+        # Development release
+        if self.dev is not None:
+            parts.append(f".dev{self.dev}")
+
+        # Local version segment
+        if self.local is not None:
+            parts.append(f"+{self.local}")
+
+        return "".join(parts)
+
+    @property
+    def epoch(self) -> int:
+        _epoch: int = self._version.epoch
+        return _epoch
+
+    @property
+    def release(self) -> Tuple[int, ...]:
+        _release: Tuple[int, ...] = self._version.release
+        return _release
+
+    @property
+    def pre(self) -> Optional[Tuple[str, int]]:
+        _pre: Optional[Tuple[str, int]] = self._version.pre
+        return _pre
+
+    @property
+    def post(self) -> Optional[int]:
+        return self._version.post[1] if self._version.post else None
+
+    @property
+    def dev(self) -> Optional[int]:
+        return self._version.dev[1] if self._version.dev else None
+
+    @property
+    def local(self) -> Optional[str]:
+        if self._version.local:
+            return ".".join(str(x) for x in self._version.local)
+        else:
+            return None
+
+    @property
+    def public(self) -> str:
+        return str(self).split("+", 1)[0]
+
+    @property
+    def base_version(self) -> str:
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        return "".join(parts)
+
+    @property
+    def is_prerelease(self) -> bool:
+        return self.dev is not None or self.pre is not None
+
+    @property
+    def is_postrelease(self) -> bool:
+        return self.post is not None
+
+    @property
+    def is_devrelease(self) -> bool:
+        return self.dev is not None
+
+    @property
+    def major(self) -> int:
+        return self.release[0] if len(self.release) >= 1 else 0
+
+    @property
+    def minor(self) -> int:
+        return self.release[1] if len(self.release) >= 2 else 0
+
+    @property
+    def micro(self) -> int:
+        return self.release[2] if len(self.release) >= 3 else 0
+
+
+def _parse_letter_version(
+    letter: str, number: Union[str, bytes, SupportsInt]
+) -> Optional[Tuple[str, int]]:
+
+    if letter:
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        if number is None:
+            number = 0
+
+        # We normalize any letters to their lower case form
+        letter = letter.lower()
+
+        # We consider some words to be alternate spellings of other words and
+        # in those cases we want to normalize the spellings to our preferred
+        # spelling.
+        if letter == "alpha":
+            letter = "a"
+        elif letter == "beta":
+            letter = "b"
+        elif letter in ["c", "pre", "preview"]:
+            letter = "rc"
+        elif letter in ["rev", "r"]:
+            letter = "post"
+
+        return letter, int(number)
+    if not letter and number:
+        # We assume if we are given a number, but we are not given a letter
+        # then this is using the implicit post release syntax (e.g. 1.0-1)
+        letter = "post"
+
+        return letter, int(number)
+
+    return None
+
+
+_local_version_separators = re.compile(r"[\._-]")
+
+
+def _parse_local_version(local: str) -> Optional[LocalType]:
+    """
+    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
+    """
+    if local is not None:
+        return tuple(
+            part.lower() if not part.isdigit() else int(part)
+            for part in _local_version_separators.split(local)
+        )
+    return None
+
+
+def _cmpkey(
+    epoch: int,
+    release: Tuple[int, ...],
+    pre: Optional[Tuple[str, int]],
+    post: Optional[Tuple[str, int]],
+    dev: Optional[Tuple[str, int]],
+    local: Optional[Tuple[SubLocalType]],
+) -> CmpKey:
+
+    # When we compare a release version, we want to compare it with all of the
+    # trailing zeros removed. So we'll use a reverse the list, drop all the now
+    # leading zeros until we come to something non zero, then take the rest
+    # re-reverse it back into the correct order and make it a tuple and use
+    # that for our sorting key.
+    _release = tuple(
+        reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
+    )
+
+    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
+    # We'll do this by abusing the pre segment, but we _only_ want to do this
+    # if there is not a pre or a post segment. If we have one of those then
+    # the normal sorting rules will handle this case correctly.
+    if pre is None and post is None and dev is not None:
+        _pre: PrePostDevType = NegativeInfinity
+    # Versions without a pre-release (except as noted above) should sort after
+    # those with one.
+    elif pre is None:
+        _pre = Infinity
+    else:
+        _pre = pre
+
+    # Versions without a post segment should sort before those with one.
+    if post is None:
+        _post: PrePostDevType = NegativeInfinity
+
+    else:
+        _post = post
+
+    # Versions without a development segment should sort after those with one.
+    if dev is None:
+        _dev: PrePostDevType = Infinity
+
+    else:
+        _dev = dev
+
+    if local is None:
+        # Versions without a local segment should sort before those with one.
+        _local: LocalType = NegativeInfinity
+    else:
+        # Versions with a local segment need that segment parsed to implement
+        # the sorting rules in PEP440.
+        # - Alpha numeric segments sort before numeric segments
+        # - Alpha numeric segments sort lexicographically
+        # - Numeric segments sort numerically
+        # - Shorter versions sort before longer versions when the prefixes
+        #   match exactly
+        _local = tuple(
+            (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
+        )
+
+    return epoch, _release, _pre, _post, _dev, _local
diff --git a/seaborn/rcmod.py b/seaborn/rcmod.py
index 395c376b25..85968654ac 100644
--- a/seaborn/rcmod.py
+++ b/seaborn/rcmod.py
@@ -1,10 +1,10 @@
 """Control plot style and scaling using the matplotlib rcParams interface."""
 import warnings
 import functools
-from distutils.version import LooseVersion
 import matplotlib as mpl
 from cycler import cycler
 from . import palettes
+from .external.version import Version
 
 
 __all__ = ["set_theme", "set", "reset_defaults", "reset_orig",
@@ -79,7 +79,7 @@
 
 ]
 
-if LooseVersion(mpl.__version__) >= "3.0":
+if Version(mpl.__version__) >= Version("3.0"):
     _context_keys.append("legend.title_fontsize")
 
 
@@ -397,7 +397,7 @@ def plotting_context(context=None, font_scale=1, rc=None):
 
         }
 
-        if LooseVersion(mpl.__version__) >= "3.0":
+        if Version(mpl.__version__) >= Version("3.0"):
             texts_base_context["legend.title_fontsize"] = 12
 
         base_context = {
diff --git a/seaborn/tests/test_algorithms.py b/seaborn/tests/test_algorithms.py
index f4fe38ca1a..83e215d32d 100644
--- a/seaborn/tests/test_algorithms.py
+++ b/seaborn/tests/test_algorithms.py
@@ -3,9 +3,9 @@
 
 import pytest
 from numpy.testing import assert_array_equal
-from distutils.version import LooseVersion
 
 from .. import algorithms as algo
+from ..external.version import Version
 
 
 @pytest.fixture
@@ -151,7 +151,7 @@ def test_bootstrap_reproducibility(random):
         assert_array_equal(boots1, boots2)
 
 
-@pytest.mark.skipif(LooseVersion(np.__version__) < "1.17",
+@pytest.mark.skipif(Version(np.__version__) < Version("1.17"),
                     reason="Tests new numpy random functionality")
 def test_seed_new():
 
@@ -177,7 +177,7 @@ def test_seed_new():
         assert (rng1.uniform() == rng2.uniform()) == match
 
 
-@pytest.mark.skipif(LooseVersion(np.__version__) >= "1.17",
+@pytest.mark.skipif(Version(np.__version__) >= Version("1.17"),
                     reason="Tests old numpy random functionality")
 @pytest.mark.parametrize("seed1, seed2, match", [
     (None, None, False),
@@ -194,7 +194,7 @@ def test_seed_old(seed1, seed2, match):
     assert (rng1.uniform() == rng2.uniform()) == match
 
 
-@pytest.mark.skipif(LooseVersion(np.__version__) >= "1.17",
+@pytest.mark.skipif(Version(np.__version__) >= Version("1.17"),
                     reason="Tests old numpy random functionality")
 def test_bad_seed_old():
 
diff --git a/seaborn/tests/test_categorical.py b/seaborn/tests/test_categorical.py
index a0b0393c33..1b6e49363f 100644
--- a/seaborn/tests/test_categorical.py
+++ b/seaborn/tests/test_categorical.py
@@ -8,10 +8,10 @@
 import pytest
 from pytest import approx
 import numpy.testing as npt
-from distutils.version import LooseVersion
 
 from .. import categorical as cat
 from .. import palettes
+from ..external.version import Version
 
 
 class CategoricalFixture:
@@ -28,6 +28,14 @@ class CategoricalFixture:
     df = pd.DataFrame(dict(y=y, g=g, h=h, u=u))
     x_df["W"] = g
 
+    def get_box_artists(self, ax):
+
+        if Version(mpl.__version__) < Version("3.5.0b0"):
+            return ax.artists
+        else:
+            # Exclude labeled patches, which are for the legend
+            return [p for p in ax.patches if not p.get_label()]
+
 
 class TestCategoricalPlotter(CategoricalFixture):
 
@@ -772,12 +780,12 @@ def test_hue_offsets(self):
     def test_axes_data(self):
 
         ax = cat.boxplot(x="g", y="y", data=self.df)
-        assert len(ax.artists) == 3
+        assert len(self.get_box_artists(ax)) == 3
 
         plt.close("all")
 
         ax = cat.boxplot(x="g", y="y", hue="h", data=self.df)
-        assert len(ax.artists) == 6
+        assert len(self.get_box_artists(ax)) == 6
 
         plt.close("all")
 
@@ -785,14 +793,14 @@ def test_box_colors(self):
 
         ax = cat.boxplot(x="g", y="y", data=self.df, saturation=1)
         pal = palettes.color_palette(n_colors=3)
-        for patch, color in zip(ax.artists, pal):
+        for patch, color in zip(self.get_box_artists(ax), pal):
             assert patch.get_facecolor()[:3] == color
 
         plt.close("all")
 
         ax = cat.boxplot(x="g", y="y", hue="h", data=self.df, saturation=1)
         pal = palettes.color_palette(n_colors=2)
-        for patch, color in zip(ax.artists, pal * 2):
+        for patch, color in zip(self.get_box_artists(ax), pal * 2):
             assert patch.get_facecolor()[:3] == color
 
         plt.close("all")
@@ -801,7 +809,7 @@ def test_draw_missing_boxes(self):
 
         ax = cat.boxplot(x="g", y="y", data=self.df,
                          order=["a", "b", "c", "d"])
-        assert len(ax.artists) == 3
+        assert len(self.get_box_artists(ax)) == 3
 
     def test_missing_data(self):
 
@@ -811,13 +819,13 @@ def test_missing_data(self):
         y[-2:] = np.nan
 
         ax = cat.boxplot(x=x, y=y)
-        assert len(ax.artists) == 3
+        assert len(self.get_box_artists(ax)) == 3
 
         plt.close("all")
 
         y[-1] = 0
         ax = cat.boxplot(x=x, y=y, hue=h)
-        assert len(ax.artists) == 7
+        assert len(self.get_box_artists(ax)) == 7
 
         plt.close("all")
 
@@ -2504,11 +2512,11 @@ def test_plot_elements(self):
 
         g = cat.catplot(x="g", y="y", data=self.df, kind="box")
         want_artists = self.g.unique().size
-        assert len(g.ax.artists) == want_artists
+        assert len(self.get_box_artists(g.ax)) == want_artists
 
         g = cat.catplot(x="g", y="y", hue="h", data=self.df, kind="box")
         want_artists = self.g.unique().size * self.h.unique().size
-        assert len(g.ax.artists) == want_artists
+        assert len(self.get_box_artists(g.ax)) == want_artists
 
         g = cat.catplot(x="g", y="y", data=self.df,
                         kind="violin", inner=None)
@@ -2858,14 +2866,14 @@ def test_box_colors(self):
 
         ax = cat.boxenplot(x="g", y="y", data=self.df, saturation=1)
         pal = palettes.color_palette(n_colors=3)
-        for patch, color in zip(ax.artists, pal):
+        for patch, color in zip(self.get_box_artists(ax), pal):
             assert patch.get_facecolor()[:3] == color
 
         plt.close("all")
 
         ax = cat.boxenplot(x="g", y="y", hue="h", data=self.df, saturation=1)
         pal = palettes.color_palette(n_colors=2)
-        for patch, color in zip(ax.artists, pal * 2):
+        for patch, color in zip(self.get_box_artists(ax), pal * 2):
             assert patch.get_facecolor()[:3] == color
 
         plt.close("all")
@@ -2996,7 +3004,7 @@ def test_axes_annotation(self):
     @pytest.mark.parametrize("size", ["large", "medium", "small", 22, 12])
     def test_legend_titlesize(self, size):
 
-        if LooseVersion(mpl.__version__) >= LooseVersion("3.0"):
+        if Version(mpl.__version__) >= Version("3.0"):
             rc_ctx = {"legend.title_fontsize": size}
         else:  # Old matplotlib doesn't have legend.title_fontsize rcparam
             rc_ctx = {"axes.labelsize": size}
@@ -3012,7 +3020,7 @@ def test_legend_titlesize(self, size):
         plt.close("all")
 
     @pytest.mark.skipif(
-        LooseVersion(pd.__version__) < "1.2",
+        Version(pd.__version__) < Version("1.2"),
         reason="Test requires pandas>=1.2")
     def test_Float64_input(self):
         data = pd.DataFrame(
diff --git a/seaborn/tests/test_distributions.py b/seaborn/tests/test_distributions.py
index 737d6ccdad..bf20616d5d 100644
--- a/seaborn/tests/test_distributions.py
+++ b/seaborn/tests/test_distributions.py
@@ -1,5 +1,4 @@
 import itertools
-from distutils.version import LooseVersion
 
 import numpy as np
 import matplotlib as mpl
@@ -32,13 +31,36 @@
     kdeplot,
     rugplot,
 )
+from ..external.version import Version
 from ..axisgrid import FacetGrid
 from .._testing import (
     assert_plots_equal,
     assert_legends_equal,
+    assert_colors_equal,
 )
 
 
+def get_contour_coords(c):
+    """Provide compatability for change in contour artist type in mpl3.5."""
+    # See https://github.com/matplotlib/matplotlib/issues/20906
+    if isinstance(c, mpl.collections.LineCollection):
+        return c.get_segments()
+    elif isinstance(c, mpl.collections.PathCollection):
+        return [p.vertices[:np.argmax(p.codes) + 1] for p in c.get_paths()]
+
+
+def get_contour_color(c):
+    """Provide compatability for change in contour artist type in mpl3.5."""
+    # See https://github.com/matplotlib/matplotlib/issues/20906
+    if isinstance(c, mpl.collections.LineCollection):
+        return c.get_color()
+    elif isinstance(c, mpl.collections.PathCollection):
+        if c.get_facecolor().size:
+            return c.get_facecolor()
+        else:
+            return c.get_edgecolor()
+
+
 class TestDistPlot(object):
 
     rs = np.random.RandomState(0)
@@ -532,7 +554,7 @@ def test_color(self, long_df, fill):
         assert to_rgba(artist_color) == to_rgba(color, alpha)
 
     @pytest.mark.skipif(
-        LooseVersion(np.__version__) < "1.17",
+        Version(np.__version__) < Version("1.17"),
         reason="Histogram over datetime64 requires numpy >= 1.17",
     )
     def test_datetime_scale(self, long_df):
@@ -736,7 +758,7 @@ def test_log_scale_normalization(self, rng):
         assert integral == pytest.approx(1)
 
     @pytest.mark.skipif(
-        LooseVersion(scipy.__version__) < "1.2.0",
+        Version(scipy.__version__) < Version("1.2.0"),
         reason="Weights require scipy >= 1.2.0"
     )
     def test_weights(self):
@@ -803,7 +825,10 @@ def test_legend(self, long_df):
         for label, level in zip(legend_labels, order):
             assert label.get_text() == level
 
-        legend_artists = ax.legend_.findobj(mpl.lines.Line2D)[::2]
+        legend_artists = ax.legend_.findobj(mpl.lines.Line2D)
+        if Version(mpl.__version__) < Version("3.5.0b0"):
+            # https://github.com/matplotlib/matplotlib/pull/20699
+            legend_artists = legend_artists[::2]
         palette = color_palette()
         for artist, color in zip(legend_artists, palette):
             assert to_rgb(artist.get_color()) == to_rgb(color)
@@ -854,7 +879,7 @@ def test_fill_artists(self, long_df):
             f, ax = plt.subplots()
             kdeplot(data=long_df, x="x", y="y", hue="c", fill=fill)
             for c in ax.collections:
-                if fill:
+                if fill or Version(mpl.__version__) >= Version("3.5.0b0"):
                     assert isinstance(c, mpl.collections.PathCollection)
                 else:
                     assert isinstance(c, mpl.collections.LineCollection)
@@ -870,8 +895,8 @@ def test_common_norm(self, rng):
         kdeplot(x=x, y=y, hue=hue, common_norm=True, ax=ax1)
         kdeplot(x=x, y=y, hue=hue, common_norm=False, ax=ax2)
 
-        n_seg_1 = sum([len(c.get_segments()) > 0 for c in ax1.collections])
-        n_seg_2 = sum([len(c.get_segments()) > 0 for c in ax2.collections])
+        n_seg_1 = sum([len(get_contour_coords(c)) > 0 for c in ax1.collections])
+        n_seg_2 = sum([len(get_contour_coords(c)) > 0 for c in ax2.collections])
         assert n_seg_2 > n_seg_1
 
     def test_log_scale(self, rng):
@@ -898,7 +923,7 @@ def test_log_scale(self, rng):
         ax2.contour(10 ** xx, yy, density, levels=levels)
 
         for c1, c2 in zip(ax1.collections, ax2.collections):
-            assert_array_equal(c1.get_segments(), c2.get_segments())
+            assert_array_equal(get_contour_coords(c1), get_contour_coords(c2))
 
     def test_bandwidth(self, rng):
 
@@ -911,14 +936,14 @@ def test_bandwidth(self, rng):
         kdeplot(x=x, y=y, bw_adjust=2, ax=ax2)
 
         for c1, c2 in zip(ax1.collections, ax2.collections):
-            seg1, seg2 = c1.get_segments(), c2.get_segments()
+            seg1, seg2 = get_contour_coords(c1), get_contour_coords(c2)
             if seg1 + seg2:
                 x1 = seg1[0][:, 0]
                 x2 = seg2[0][:, 0]
                 assert np.abs(x2).max() > np.abs(x1).max()
 
     @pytest.mark.skipif(
-        LooseVersion(scipy.__version__) < "1.2.0",
+        Version(scipy.__version__) < Version("1.2.0"),
         reason="Weights require scipy >= 1.2.0"
     )
     def test_weights(self, rng):
@@ -936,9 +961,9 @@ def test_weights(self, rng):
         kdeplot(x=x, y=y, hue=hue, weights=weights, ax=ax2)
 
         for c1, c2 in zip(ax1.collections, ax2.collections):
-            if c1.get_segments() and c2.get_segments():
-                seg1 = np.concatenate(c1.get_segments(), axis=0)
-                seg2 = np.concatenate(c2.get_segments(), axis=0)
+            if get_contour_coords(c1) and get_contour_coords(c2):
+                seg1 = np.concatenate(get_contour_coords(c1), axis=0)
+                seg2 = np.concatenate(get_contour_coords(c2), axis=0)
                 assert not np.array_equal(seg1, seg2)
 
     def test_hue_ignores_cmap(self, long_df):
@@ -946,8 +971,7 @@ def test_hue_ignores_cmap(self, long_df):
         with pytest.warns(UserWarning, match="cmap parameter ignored"):
             ax = kdeplot(data=long_df, x="x", y="y", hue="c", cmap="viridis")
 
-        color = tuple(ax.collections[0].get_color().squeeze())
-        assert color == mpl.colors.colorConverter.to_rgba("C0")
+        assert_colors_equal(get_contour_color(ax.collections[0]), "C0")
 
     def test_contour_line_colors(self, long_df):
 
@@ -955,7 +979,7 @@ def test_contour_line_colors(self, long_df):
         ax = kdeplot(data=long_df, x="x", y="y", color=color)
 
         for c in ax.collections:
-            assert tuple(c.get_color().squeeze()) == color
+            assert_colors_equal(get_contour_color(c), color)
 
     def test_contour_fill_colors(self, long_df):
 
@@ -987,7 +1011,7 @@ def test_levels_and_thresh(self, long_df):
         kdeplot(**plot_kws, levels=np.linspace(thresh, 1, n), ax=ax2)
 
         for c1, c2 in zip(ax1.collections, ax2.collections):
-            assert_array_equal(c1.get_segments(), c2.get_segments())
+            assert_array_equal(get_contour_coords(c1), get_contour_coords(c2))
 
         with pytest.raises(ValueError):
             kdeplot(**plot_kws, levels=[0, 1, 2])
@@ -999,7 +1023,7 @@ def test_levels_and_thresh(self, long_df):
         kdeplot(**plot_kws, levels=n, thresh=0, ax=ax2)
 
         for c1, c2 in zip(ax1.collections, ax2.collections):
-            assert_array_equal(c1.get_segments(), c2.get_segments())
+            assert_array_equal(get_contour_coords(c1), get_contour_coords(c2))
         for c1, c2 in zip(ax1.collections, ax2.collections):
             assert_array_equal(c1.get_facecolors(), c2.get_facecolors())
 
@@ -1332,7 +1356,7 @@ def test_discrete_requires_bars(self, long_df):
             histplot(long_df, x="s", discrete=True, element="poly")
 
     @pytest.mark.skipif(
-        LooseVersion(np.__version__) < "1.17",
+        Version(np.__version__) < Version("1.17"),
         reason="Histogram over datetime64 requires numpy >= 1.17",
     )
     def test_datetime_scale(self, long_df):
@@ -2109,7 +2133,7 @@ def test_versus_single_histplot(self, long_df, kwargs):
     )
     def test_versus_single_kdeplot(self, long_df, kwargs):
 
-        if "weights" in kwargs and LooseVersion(scipy.__version__) < "1.2":
+        if "weights" in kwargs and Version(scipy.__version__) < Version("1.2"):
             pytest.skip("Weights require scipy >= 1.2")
 
         ax = kdeplot(data=long_df, **kwargs)
@@ -2161,15 +2185,16 @@ def test_versus_single_ecdfplot(self, long_df, kwargs):
     )
     def test_with_rug(self, long_df, kwargs):
 
-        ax = rugplot(data=long_df, **kwargs)
+        ax = plt.figure().subplots()
+        histplot(data=long_df, **kwargs, ax=ax)
+        rugplot(data=long_df, **kwargs, ax=ax)
+
         g = displot(long_df, rug=True, **kwargs)
-        g.ax.patches = []
 
         assert_plots_equal(ax, g.ax, labels=False)
 
         long_df["_"] = "_"
         g2 = displot(long_df, col="_", rug=True, **kwargs)
-        g2.ax.patches = []
 
         assert_plots_equal(ax, g2.ax, labels=False)
 
@@ -2246,13 +2271,13 @@ def test_bivariate_kde_norm(self, rng):
         z = [0] * 80 + [1] * 20
 
         g = displot(x=x, y=y, col=z, kind="kde", levels=10)
-        l1 = sum(bool(c.get_segments()) for c in g.axes.flat[0].collections)
-        l2 = sum(bool(c.get_segments()) for c in g.axes.flat[1].collections)
+        l1 = sum(bool(get_contour_coords(c)) for c in g.axes.flat[0].collections)
+        l2 = sum(bool(get_contour_coords(c)) for c in g.axes.flat[1].collections)
         assert l1 > l2
 
         g = displot(x=x, y=y, col=z, kind="kde", levels=10, common_norm=False)
-        l1 = sum(bool(c.get_segments()) for c in g.axes.flat[0].collections)
-        l2 = sum(bool(c.get_segments()) for c in g.axes.flat[1].collections)
+        l1 = sum(bool(get_contour_coords(c)) for c in g.axes.flat[0].collections)
+        l2 = sum(bool(get_contour_coords(c)) for c in g.axes.flat[1].collections)
         assert l1 == l2
 
     def test_bivariate_hist_norm(self, rng):
diff --git a/seaborn/tests/test_rcmod.py b/seaborn/tests/test_rcmod.py
index b1cbec571f..1c886c8f5b 100644
--- a/seaborn/tests/test_rcmod.py
+++ b/seaborn/tests/test_rcmod.py
@@ -1,5 +1,3 @@
-from distutils.version import LooseVersion
-
 import pytest
 import numpy as np
 import matplotlib as mpl
@@ -8,6 +6,7 @@
 
 from .. import rcmod, palettes, utils
 from ..conftest import has_verdana
+from ..external.version import Version
 
 
 class RCParamTester:
@@ -184,7 +183,7 @@ def test_font_scale(self):
         font_keys = ["axes.labelsize", "axes.titlesize", "legend.fontsize",
                      "xtick.labelsize", "ytick.labelsize", "font.size"]
 
-        if LooseVersion(mpl.__version__) >= "3.0":
+        if Version(mpl.__version__) >= Version("3.0"):
             font_keys.append("legend.title_fontsize")
 
         for k in font_keys:
diff --git a/seaborn/tests/test_regression.py b/seaborn/tests/test_regression.py
index 63768a521c..d7f7b982ab 100644
--- a/seaborn/tests/test_regression.py
+++ b/seaborn/tests/test_regression.py
@@ -1,4 +1,3 @@
-from distutils.version import LooseVersion
 import numpy as np
 import matplotlib as mpl
 import matplotlib.pyplot as plt
@@ -18,6 +17,7 @@
     _no_statsmodels = True
 
 from .. import regression as lm
+from ..external.version import Version
 from ..palettes import color_palette
 
 rs = np.random.RandomState(0)
@@ -597,7 +597,7 @@ def test_lmplot_scatter_kws(self):
         npt.assert_array_equal(red, red_scatter.get_facecolors()[0, :3])
         npt.assert_array_equal(blue, blue_scatter.get_facecolors()[0, :3])
 
-    @pytest.mark.skipif(LooseVersion(mpl.__version__) < "3.4",
+    @pytest.mark.skipif(Version(mpl.__version__) < Version("3.4"),
                         reason="MPL bug #15967")
     @pytest.mark.parametrize("sharex", [True, False])
     def test_lmplot_facet_truncate(self, sharex):
diff --git a/seaborn/tests/test_utils.py b/seaborn/tests/test_utils.py
index 68831b4544..ad5b60cf47 100644
--- a/seaborn/tests/test_utils.py
+++ b/seaborn/tests/test_utils.py
@@ -18,9 +18,8 @@
     assert_frame_equal,
 )
 
-from distutils.version import LooseVersion
-
 from .. import utils, rcmod
+from ..external.version import Version
 from ..utils import (
     get_dataset_names,
     get_color_cycle,
@@ -373,14 +372,14 @@ def test_locator_to_legend_entries():
     locator = mpl.ticker.LogLocator(numticks=5)
     limits = (5, 1425)
     levels, str_levels = utils.locator_to_legend_entries(locator, limits, int)
-    if LooseVersion(mpl.__version__) >= "3.1":
+    if Version(mpl.__version__) >= Version("3.1"):
         assert str_levels == ['10', '100', '1000']
 
     limits = (0.00003, 0.02)
     levels, str_levels = utils.locator_to_legend_entries(
         locator, limits, float
     )
-    if LooseVersion(mpl.__version__) >= "3.1":
+    if Version(mpl.__version__) >= Version("3.1"):
         assert str_levels == ['1e-04', '1e-03', '1e-02']