From f40e58cc50abcd03efae372df0586467a957f695 Mon Sep 17 00:00:00 2001 From: Fangchen Li Date: Wed, 5 May 2021 15:14:09 -0500 Subject: [PATCH] DEPS/CLN: remove distutils usage (#41207) --- LICENSES/PACKAGING_LICENSE | 202 ++++++ pandas/_libs/missing.pyx | 4 +- pandas/compat/_optional.py | 5 +- pandas/compat/numpy/__init__.py | 15 +- pandas/compat/numpy/function.py | 5 +- pandas/compat/pyarrow.py | 12 +- pandas/core/arrays/string_arrow.py | 6 +- pandas/core/computation/ops.py | 4 +- pandas/core/util/numba_.py | 5 +- pandas/io/clipboard/__init__.py | 4 +- pandas/io/excel/_base.py | 6 +- pandas/io/parquet.py | 4 +- pandas/io/sql.py | 4 +- pandas/plotting/_matplotlib/compat.py | 5 +- pandas/tests/arrays/interval/test_interval.py | 2 +- .../tests/arrays/masked/test_arrow_compat.py | 2 +- .../tests/arrays/period/test_arrow_compat.py | 2 +- pandas/tests/arrays/string_/test_string.py | 4 +- pandas/tests/computation/test_compat.py | 5 +- pandas/tests/computation/test_eval.py | 6 +- pandas/tests/generic/test_to_xarray.py | 9 +- pandas/tests/io/excel/__init__.py | 6 +- pandas/tests/io/excel/test_readers.py | 5 +- pandas/tests/io/excel/test_xlrd.py | 5 +- .../tests/io/generate_legacy_storage_files.py | 17 +- pandas/tests/io/pytables/test_select.py | 6 - pandas/tests/io/test_feather.py | 9 +- pandas/tests/io/test_parquet.py | 49 +- pandas/tests/test_common.py | 6 +- pandas/tests/util/test_show_versions.py | 1 - pandas/util/_test_decorators.py | 11 +- pandas/util/version/__init__.py | 580 ++++++++++++++++++ setup.py | 21 +- 33 files changed, 882 insertions(+), 145 deletions(-) create mode 100644 LICENSES/PACKAGING_LICENSE create mode 100644 pandas/util/version/__init__.py diff --git a/LICENSES/PACKAGING_LICENSE b/LICENSES/PACKAGING_LICENSE new file mode 100644 index 0000000000000..4216ea1ce2379 --- /dev/null +++ b/LICENSES/PACKAGING_LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +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/pandas/_libs/missing.pyx b/pandas/_libs/missing.pyx index bd749d6eca18e..cbe79d11fbfc9 100644 --- a/pandas/_libs/missing.pyx +++ b/pandas/_libs/missing.pyx @@ -1,5 +1,6 @@ from decimal import Decimal import numbers +from sys import maxsize import cython from cython import Py_ssize_t @@ -27,7 +28,6 @@ from pandas._libs.tslibs.np_datetime cimport ( ) from pandas._libs.ops_dispatch import maybe_dispatch_ufunc_to_dunder_op -from pandas.compat import IS64 cdef: float64_t INF = np.inf @@ -35,7 +35,7 @@ cdef: int64_t NPY_NAT = util.get_nat() - bint is_32bit = not IS64 + bint is_32bit = maxsize <= 2 ** 32 type cDecimal = Decimal # for faster isinstance checks diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index cf00618a7b2b9..0ef6da53191c5 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -1,11 +1,12 @@ from __future__ import annotations -import distutils.version import importlib import sys import types import warnings +from pandas.util.version import Version + # Update install.rst when updating versions! VERSIONS = { @@ -128,7 +129,7 @@ def import_optional_dependency( minimum_version = min_version if min_version is not None else VERSIONS.get(parent) if minimum_version: version = get_version(module_to_get) - if distutils.version.LooseVersion(version) < minimum_version: + if Version(version) < Version(minimum_version): msg = ( f"Pandas requires version '{minimum_version}' or newer of '{parent}' " f"(version '{version}' currently installed)." diff --git a/pandas/compat/numpy/__init__.py b/pandas/compat/numpy/__init__.py index 4812a0ecba919..619713f28ee2d 100644 --- a/pandas/compat/numpy/__init__.py +++ b/pandas/compat/numpy/__init__.py @@ -1,21 +1,22 @@ """ support numpy compatibility across versions """ -from distutils.version import LooseVersion import re import numpy as np +from pandas.util.version import Version + # numpy versioning _np_version = np.__version__ -_nlv = LooseVersion(_np_version) -np_version_under1p18 = _nlv < LooseVersion("1.18") -np_version_under1p19 = _nlv < LooseVersion("1.19") -np_version_under1p20 = _nlv < LooseVersion("1.20") -is_numpy_dev = ".dev" in str(_nlv) +_nlv = Version(_np_version) +np_version_under1p18 = _nlv < Version("1.18") +np_version_under1p19 = _nlv < Version("1.19") +np_version_under1p20 = _nlv < Version("1.20") +is_numpy_dev = _nlv.dev is not None _min_numpy_ver = "1.17.3" -if _nlv < _min_numpy_ver: +if _nlv < Version(_min_numpy_ver): raise ImportError( f"this version of pandas is incompatible with numpy < {_min_numpy_ver}\n" f"your numpy version is {_np_version}.\n" diff --git a/pandas/compat/numpy/function.py b/pandas/compat/numpy/function.py index 3f56ecd640774..63ea5554e32d7 100644 --- a/pandas/compat/numpy/function.py +++ b/pandas/compat/numpy/function.py @@ -15,7 +15,6 @@ methods that are spread throughout the codebase. This module will make it easier to adjust to future upstream changes in the analogous numpy signatures. """ -from distutils.version import LooseVersion from typing import ( Any, Dict, @@ -39,6 +38,8 @@ validate_kwargs, ) +from pandas.util.version import Version + class CompatValidator: def __init__( @@ -128,7 +129,7 @@ def validate_argmax_with_skipna(skipna, args, kwargs): ARGSORT_DEFAULTS["kind"] = "quicksort" ARGSORT_DEFAULTS["order"] = None -if LooseVersion(__version__) >= LooseVersion("1.17.0"): +if Version(__version__) >= Version("1.17.0"): # GH-26361. NumPy added radix sort and changed default to None. ARGSORT_DEFAULTS["kind"] = None diff --git a/pandas/compat/pyarrow.py b/pandas/compat/pyarrow.py index e9ca9b99d4380..cc5c7a2e51976 100644 --- a/pandas/compat/pyarrow.py +++ b/pandas/compat/pyarrow.py @@ -1,16 +1,16 @@ """ support pyarrow compatibility across versions """ -from distutils.version import LooseVersion +from pandas.util.version import Version try: import pyarrow as pa _pa_version = pa.__version__ - _palv = LooseVersion(_pa_version) - pa_version_under1p0 = _palv < LooseVersion("1.0.0") - pa_version_under2p0 = _palv < LooseVersion("2.0.0") - pa_version_under3p0 = _palv < LooseVersion("3.0.0") - pa_version_under4p0 = _palv < LooseVersion("4.0.0") + _palv = Version(_pa_version) + pa_version_under1p0 = _palv < Version("1.0.0") + pa_version_under2p0 = _palv < Version("2.0.0") + pa_version_under3p0 = _palv < Version("3.0.0") + pa_version_under4p0 = _palv < Version("4.0.0") except ImportError: pa_version_under1p0 = True pa_version_under2p0 = True diff --git a/pandas/core/arrays/string_arrow.py b/pandas/core/arrays/string_arrow.py index a1278a129c40f..87625015d83a2 100644 --- a/pandas/core/arrays/string_arrow.py +++ b/pandas/core/arrays/string_arrow.py @@ -1,6 +1,5 @@ from __future__ import annotations -from distutils.version import LooseVersion import re from typing import ( TYPE_CHECKING, @@ -53,6 +52,7 @@ validate_indices, ) from pandas.core.strings.object_array import ObjectStringArrayMixin +from pandas.util.version import Version try: import pyarrow as pa @@ -62,7 +62,7 @@ # PyArrow backed StringArrays are available starting at 1.0.0, but this # file is imported from even if pyarrow is < 1.0.0, before pyarrow.compute # and its compute functions existed. GH38801 - if LooseVersion(pa.__version__) >= "1.0.0": + if Version(pa.__version__) >= Version("1.0.0"): import pyarrow.compute as pc ARROW_CMP_FUNCS = { @@ -232,7 +232,7 @@ def __init__(self, values): def _chk_pyarrow_available(cls) -> None: # TODO: maybe update import_optional_dependency to allow a minimum # version to be specified rather than use the global minimum - if pa is None or LooseVersion(pa.__version__) < "1.0.0": + if pa is None or Version(pa.__version__) < Version("1.0.0"): msg = "pyarrow>=1.0.0 is required for PyArrow backed StringArray." raise ImportError(msg) diff --git a/pandas/core/computation/ops.py b/pandas/core/computation/ops.py index 223c4139f2b7c..231beb40e9630 100644 --- a/pandas/core/computation/ops.py +++ b/pandas/core/computation/ops.py @@ -5,7 +5,6 @@ from __future__ import annotations from datetime import datetime -from distutils.version import LooseVersion from functools import partial import operator from typing import ( @@ -28,6 +27,7 @@ result_type_many, ) from pandas.core.computation.scope import DEFAULT_GLOBALS +from pandas.util.version import Version from pandas.io.formats.printing import ( pprint_thing, @@ -623,7 +623,7 @@ def __init__(self, name: str): if name not in MATHOPS or ( NUMEXPR_INSTALLED - and NUMEXPR_VERSION < LooseVersion("2.6.9") + and Version(NUMEXPR_VERSION) < Version("2.6.9") and name in ("floor", "ceil") ): raise ValueError(f'"{name}" is not a supported function') diff --git a/pandas/core/util/numba_.py b/pandas/core/util/numba_.py index 3da6a5cbf7326..8a2e24b25268c 100644 --- a/pandas/core/util/numba_.py +++ b/pandas/core/util/numba_.py @@ -1,5 +1,4 @@ """Common utilities for Numba operations""" -from distutils.version import LooseVersion import types from typing import ( Callable, @@ -13,6 +12,8 @@ from pandas.compat._optional import import_optional_dependency from pandas.errors import NumbaUtilError +from pandas.util.version import Version + GLOBAL_USE_NUMBA: bool = False NUMBA_FUNC_CACHE: Dict[Tuple[Callable, str], Callable] = {} @@ -89,7 +90,7 @@ def jit_user_function( """ numba = import_optional_dependency("numba") - if LooseVersion(numba.__version__) >= LooseVersion("0.49.0"): + if Version(numba.__version__) >= Version("0.49.0"): is_jitted = numba.extending.is_jitted(func) else: is_jitted = isinstance(func, numba.targets.registry.CPUDispatcher) diff --git a/pandas/io/clipboard/__init__.py b/pandas/io/clipboard/__init__.py index e9c20ff42f51b..c1c9865e6721d 100644 --- a/pandas/io/clipboard/__init__.py +++ b/pandas/io/clipboard/__init__.py @@ -51,9 +51,9 @@ get_errno, sizeof, ) -import distutils.spawn import os import platform +from shutil import which import subprocess import time import warnings @@ -528,7 +528,7 @@ def determine_clipboard(): return init_windows_clipboard() if platform.system() == "Linux": - if distutils.spawn.find_executable("wslconfig.exe"): + if which("wslconfig.exe"): return init_wsl_clipboard() # Setup for the MAC OS X platform: diff --git a/pandas/io/excel/_base.py b/pandas/io/excel/_base.py index d26a991ba2820..4b81b69976c62 100644 --- a/pandas/io/excel/_base.py +++ b/pandas/io/excel/_base.py @@ -2,7 +2,6 @@ import abc import datetime -from distutils.version import LooseVersion import inspect from io import BytesIO import os @@ -44,6 +43,7 @@ from pandas.core.frame import DataFrame from pandas.core.shared_docs import _shared_docs +from pandas.util.version import Version from pandas.io.common import ( IOHandles, @@ -1163,7 +1163,7 @@ def __init__( else: import xlrd - xlrd_version = LooseVersion(get_version(xlrd)) + xlrd_version = Version(get_version(xlrd)) ext = None if engine is None: @@ -1190,7 +1190,7 @@ def __init__( path_or_buffer, storage_options=storage_options ) - if ext != "xls" and xlrd_version >= "2": + if ext != "xls" and xlrd_version >= Version("2"): raise ValueError( f"Your version of xlrd is {xlrd_version}. In xlrd >= 2.0, " f"only the xls format is supported. Install openpyxl instead." diff --git a/pandas/io/parquet.py b/pandas/io/parquet.py index 3801a29fec39e..5ad014a334c27 100644 --- a/pandas/io/parquet.py +++ b/pandas/io/parquet.py @@ -1,7 +1,6 @@ """ parquet compat """ from __future__ import annotations -from distutils.version import LooseVersion import io import os from typing import ( @@ -24,6 +23,7 @@ get_option, ) from pandas.core import generic +from pandas.util.version import Version from pandas.io.common import ( IOHandles, @@ -210,7 +210,7 @@ def read( to_pandas_kwargs = {} if use_nullable_dtypes: - if LooseVersion(self.api.__version__) >= "0.16": + if Version(self.api.__version__) >= Version("0.16"): import pandas as pd mapping = { diff --git a/pandas/io/sql.py b/pandas/io/sql.py index 04a7ccb538a67..a347e7a99be8b 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -11,7 +11,6 @@ datetime, time, ) -from distutils.version import LooseVersion from functools import partial import re from typing import ( @@ -45,6 +44,7 @@ ) from pandas.core.base import PandasObject from pandas.core.tools.datetimes import to_datetime +from pandas.util.version import Version class SQLAlchemyRequired(ImportError): @@ -86,7 +86,7 @@ def _gt14() -> bool: """ import sqlalchemy - return LooseVersion(sqlalchemy.__version__) >= LooseVersion("1.4.0") + return Version(sqlalchemy.__version__) >= Version("1.4.0") def _convert_params(sql, params): diff --git a/pandas/plotting/_matplotlib/compat.py b/pandas/plotting/_matplotlib/compat.py index 729d2bf1f019a..70ddd1ca09c7e 100644 --- a/pandas/plotting/_matplotlib/compat.py +++ b/pandas/plotting/_matplotlib/compat.py @@ -1,7 +1,8 @@ # being a bit too dynamic -from distutils.version import LooseVersion import operator +from pandas.util.version import Version + def _mpl_version(version, op): def inner(): @@ -10,7 +11,7 @@ def inner(): except ImportError: return False return ( - op(LooseVersion(mpl.__version__), LooseVersion(version)) + op(Version(mpl.__version__), Version(version)) and str(mpl.__version__)[0] != "0" ) diff --git a/pandas/tests/arrays/interval/test_interval.py b/pandas/tests/arrays/interval/test_interval.py index fde45a1e39bb2..6ae3f75069899 100644 --- a/pandas/tests/arrays/interval/test_interval.py +++ b/pandas/tests/arrays/interval/test_interval.py @@ -165,7 +165,7 @@ def test_repr(): # Arrow interaction -pyarrow_skip = td.skip_if_no("pyarrow", min_version="0.15.1.dev") +pyarrow_skip = td.skip_if_no("pyarrow", min_version="0.16.0") @pyarrow_skip diff --git a/pandas/tests/arrays/masked/test_arrow_compat.py b/pandas/tests/arrays/masked/test_arrow_compat.py index d64dd6fa24d2c..e06b8749fbf11 100644 --- a/pandas/tests/arrays/masked/test_arrow_compat.py +++ b/pandas/tests/arrays/masked/test_arrow_compat.py @@ -43,7 +43,7 @@ def test_arrow_roundtrip(data): tm.assert_frame_equal(result, df) -@td.skip_if_no("pyarrow", min_version="0.15.1.dev") +@td.skip_if_no("pyarrow", min_version="0.16.0") def test_arrow_load_from_zero_chunks(data): # GH-41040 diff --git a/pandas/tests/arrays/period/test_arrow_compat.py b/pandas/tests/arrays/period/test_arrow_compat.py index 398972a682504..d7b0704cdfb05 100644 --- a/pandas/tests/arrays/period/test_arrow_compat.py +++ b/pandas/tests/arrays/period/test_arrow_compat.py @@ -11,7 +11,7 @@ period_array, ) -pyarrow_skip = pyarrow_skip = td.skip_if_no("pyarrow", min_version="0.15.1.dev") +pyarrow_skip = pyarrow_skip = td.skip_if_no("pyarrow", min_version="0.16.0") @pyarrow_skip diff --git a/pandas/tests/arrays/string_/test_string.py b/pandas/tests/arrays/string_/test_string.py index 43ba5667d4d93..17d05ebeb0fc5 100644 --- a/pandas/tests/arrays/string_/test_string.py +++ b/pandas/tests/arrays/string_/test_string.py @@ -460,7 +460,7 @@ def test_arrow_array(dtype): assert arr.equals(expected) -@td.skip_if_no("pyarrow", min_version="0.15.1.dev") +@td.skip_if_no("pyarrow", min_version="0.16.0") def test_arrow_roundtrip(dtype, dtype_object): # roundtrip possible from arrow 1.0.0 import pyarrow as pa @@ -476,7 +476,7 @@ def test_arrow_roundtrip(dtype, dtype_object): assert result.loc[2, "a"] is pd.NA -@td.skip_if_no("pyarrow", min_version="0.15.1.dev") +@td.skip_if_no("pyarrow", min_version="0.16.0") def test_arrow_load_from_zero_chunks(dtype, dtype_object): # GH-41040 import pyarrow as pa diff --git a/pandas/tests/computation/test_compat.py b/pandas/tests/computation/test_compat.py index 8fa11ab75dd67..6d6aa08204c3f 100644 --- a/pandas/tests/computation/test_compat.py +++ b/pandas/tests/computation/test_compat.py @@ -1,5 +1,3 @@ -from distutils.version import LooseVersion - import pytest from pandas.compat._optional import VERSIONS @@ -7,6 +5,7 @@ import pandas as pd from pandas.core.computation.engines import ENGINES import pandas.core.computation.expr as expr +from pandas.util.version import Version def test_compat(): @@ -18,7 +17,7 @@ def test_compat(): import numexpr as ne ver = ne.__version__ - if LooseVersion(ver) < LooseVersion(VERSIONS["numexpr"]): + if Version(ver) < Version(VERSIONS["numexpr"]): assert not NUMEXPR_INSTALLED else: assert NUMEXPR_INSTALLED diff --git a/pandas/tests/computation/test_eval.py b/pandas/tests/computation/test_eval.py index eb2ed2c25d27c..9ee53a9d7c54d 100644 --- a/pandas/tests/computation/test_eval.py +++ b/pandas/tests/computation/test_eval.py @@ -1,4 +1,3 @@ -from distutils.version import LooseVersion from functools import reduce from itertools import product import operator @@ -52,6 +51,7 @@ _binary_ops_dict, _unary_math_ops, ) +from pandas.util.version import Version @pytest.fixture( @@ -78,14 +78,14 @@ def parser(request): @pytest.fixture def ne_lt_2_6_9(): - if NUMEXPR_INSTALLED and NUMEXPR_VERSION >= LooseVersion("2.6.9"): + if NUMEXPR_INSTALLED and Version(NUMEXPR_VERSION) >= Version("2.6.9"): pytest.skip("numexpr is >= 2.6.9") return "numexpr" def _get_unary_fns_for_ne(): if NUMEXPR_INSTALLED: - if NUMEXPR_VERSION >= LooseVersion("2.6.9"): + if Version(NUMEXPR_VERSION) >= Version("2.6.9"): return list(_unary_math_ops) else: return [x for x in _unary_math_ops if x not in ["floor", "ceil"]] diff --git a/pandas/tests/generic/test_to_xarray.py b/pandas/tests/generic/test_to_xarray.py index 8e33465efcbf7..556ae8baafd11 100644 --- a/pandas/tests/generic/test_to_xarray.py +++ b/pandas/tests/generic/test_to_xarray.py @@ -13,6 +13,7 @@ import pandas._testing as tm +@td.skip_if_no("xarray") class TestDataFrameToXArray: @pytest.fixture def df(self): @@ -29,7 +30,6 @@ def df(self): } ) - @td.skip_if_no("xarray", "0.10.0") def test_to_xarray_index_types(self, index, df): if isinstance(index, MultiIndex): pytest.skip("MultiIndex is tested separately") @@ -56,7 +56,6 @@ def test_to_xarray_index_types(self, index, df): expected.columns.name = None tm.assert_frame_equal(result.to_dataframe(), expected) - @td.skip_if_no("xarray", min_version="0.7.0") def test_to_xarray_empty(self, df): from xarray import Dataset @@ -65,11 +64,9 @@ def test_to_xarray_empty(self, df): assert result.dims["foo"] == 0 assert isinstance(result, Dataset) - @td.skip_if_no("xarray", min_version="0.7.0") def test_to_xarray_with_multiindex(self, df): from xarray import Dataset - # available in 0.7.1 # MultiIndex df.index = MultiIndex.from_product([["a"], range(3)], names=["one", "two"]) result = df.to_xarray() @@ -87,8 +84,8 @@ def test_to_xarray_with_multiindex(self, df): tm.assert_frame_equal(result, expected) +@td.skip_if_no("xarray") class TestSeriesToXArray: - @td.skip_if_no("xarray", "0.10.0") def test_to_xarray_index_types(self, index): if isinstance(index, MultiIndex): pytest.skip("MultiIndex is tested separately") @@ -107,7 +104,6 @@ def test_to_xarray_index_types(self, index): # idempotency tm.assert_series_equal(result.to_series(), ser) - @td.skip_if_no("xarray", min_version="0.7.0") def test_to_xarray_empty(self): from xarray import DataArray @@ -119,7 +115,6 @@ def test_to_xarray_empty(self): tm.assert_almost_equal(list(result.coords.keys()), ["foo"]) assert isinstance(result, DataArray) - @td.skip_if_no("xarray", min_version="0.7.0") def test_to_xarray_with_multiindex(self): from xarray import DataArray diff --git a/pandas/tests/io/excel/__init__.py b/pandas/tests/io/excel/__init__.py index e1de03e1f306c..c4343497ded48 100644 --- a/pandas/tests/io/excel/__init__.py +++ b/pandas/tests/io/excel/__init__.py @@ -1,5 +1,3 @@ -from distutils.version import LooseVersion - import pytest from pandas.compat._optional import ( @@ -7,6 +5,8 @@ import_optional_dependency, ) +from pandas.util.version import Version + pytestmark = [ pytest.mark.filterwarnings( # Looks like tree.getiterator is deprecated in favor of tree.iter @@ -32,4 +32,4 @@ else: import xlrd - xlrd_version = LooseVersion(get_version(xlrd)) + xlrd_version = Version(get_version(xlrd)) diff --git a/pandas/tests/io/excel/test_readers.py b/pandas/tests/io/excel/test_readers.py index c4b3221e1d3a7..8114ac049bdb9 100644 --- a/pandas/tests/io/excel/test_readers.py +++ b/pandas/tests/io/excel/test_readers.py @@ -21,6 +21,7 @@ ) import pandas._testing as tm from pandas.tests.io.excel import xlrd_version +from pandas.util.version import Version read_ext_params = [".xls", ".xlsx", ".xlsm", ".xlsb", ".ods"] engine_params = [ @@ -70,7 +71,7 @@ def _is_valid_engine_ext_pair(engine, read_ext: str) -> bool: if ( engine == "xlrd" and xlrd_version is not None - and xlrd_version >= "2" + and xlrd_version >= Version("2") and read_ext != ".xls" ): return False @@ -1404,7 +1405,7 @@ def test_excel_read_binary_via_read_excel(self, read_ext, engine): tm.assert_frame_equal(result, expected) @pytest.mark.skipif( - xlrd_version is not None and xlrd_version >= "2", + xlrd_version is not None and xlrd_version >= Version("2"), reason="xlrd no longer supports xlsx", ) def test_excel_high_surrogate(self, engine): diff --git a/pandas/tests/io/excel/test_xlrd.py b/pandas/tests/io/excel/test_xlrd.py index c0d8acf8ab562..bf0a0de442ae1 100644 --- a/pandas/tests/io/excel/test_xlrd.py +++ b/pandas/tests/io/excel/test_xlrd.py @@ -5,6 +5,7 @@ import pandas as pd import pandas._testing as tm from pandas.tests.io.excel import xlrd_version +from pandas.util.version import Version from pandas.io.excel import ExcelFile @@ -18,7 +19,7 @@ def skip_ods_and_xlsb_files(read_ext): pytest.skip("Not valid for xlrd") if read_ext == ".xlsb": pytest.skip("Not valid for xlrd") - if read_ext in (".xlsx", ".xlsm") and xlrd_version >= "2": + if read_ext in (".xlsx", ".xlsm") and xlrd_version >= Version("2"): pytest.skip("Not valid for xlrd >= 2.0") @@ -61,7 +62,7 @@ def test_read_excel_warning_with_xlsx_file(datapath): path = datapath("io", "data", "excel", "test1.xlsx") has_openpyxl = import_optional_dependency("openpyxl", errors="ignore") is not None if not has_openpyxl: - if xlrd_version >= "2": + if xlrd_version >= Version("2"): with pytest.raises( ValueError, match="Your version of xlrd is ", diff --git a/pandas/tests/io/generate_legacy_storage_files.py b/pandas/tests/io/generate_legacy_storage_files.py index f33299a1b14de..601b50fb469cb 100644 --- a/pandas/tests/io/generate_legacy_storage_files.py +++ b/pandas/tests/io/generate_legacy_storage_files.py @@ -33,7 +33,6 @@ """ from datetime import timedelta -from distutils.version import LooseVersion import os import pickle import platform as pl @@ -54,9 +53,11 @@ Timestamp, bdate_range, date_range, + interval_range, period_range, timedelta_range, ) +from pandas.arrays import SparseArray from pandas.tseries.offsets import ( FY5253, @@ -81,15 +82,6 @@ YearEnd, ) -try: - # TODO: remove try/except when 0.24.0 is the legacy version. - from pandas.arrays import SparseArray -except ImportError: - from pandas.core.sparse.api import SparseArray - - -_loose_version = LooseVersion(pandas.__version__) - def _create_sp_series(): nan = np.nan @@ -155,10 +147,7 @@ def create_data(): index["range"] = RangeIndex(10) - if _loose_version >= LooseVersion("0.21"): - from pandas import interval_range - - index["interval"] = interval_range(0, periods=10) + index["interval"] = interval_range(0, periods=10) mi = { "reg2": MultiIndex.from_tuples( diff --git a/pandas/tests/io/pytables/test_select.py b/pandas/tests/io/pytables/test_select.py index 3ee81a176ab9d..fc19a3bd63c74 100644 --- a/pandas/tests/io/pytables/test_select.py +++ b/pandas/tests/io/pytables/test_select.py @@ -1,4 +1,3 @@ -from distutils.version import LooseVersion from warnings import catch_warnings import numpy as np @@ -25,7 +24,6 @@ _maybe_remove, ensure_clean_path, ensure_clean_store, - tables, ) from pandas.io.pytables import Term @@ -861,10 +859,6 @@ def test_select_as_multiple(setup_path): ) -@pytest.mark.skipif( - LooseVersion(tables.__version__) < LooseVersion("3.1.0"), - reason=("tables version does not support fix for nan selection bug: GH 4858"), -) def test_nan_selection_bug_4858(setup_path): with ensure_clean_store(setup_path) as store: diff --git a/pandas/tests/io/test_feather.py b/pandas/tests/io/test_feather.py index 81af799640135..a5254f5ff7988 100644 --- a/pandas/tests/io/test_feather.py +++ b/pandas/tests/io/test_feather.py @@ -1,6 +1,4 @@ """ test feather-format compat """ -from distutils.version import LooseVersion - import numpy as np import pytest @@ -8,13 +6,14 @@ import pandas as pd import pandas._testing as tm +from pandas.util.version import Version from pandas.io.feather_format import read_feather, to_feather # isort:skip pyarrow = pytest.importorskip("pyarrow") -pyarrow_version = LooseVersion(pyarrow.__version__) +pyarrow_version = Version(pyarrow.__version__) filter_sparse = pytest.mark.filterwarnings("ignore:The Sparse") @@ -90,7 +89,7 @@ def test_basic(self): ), } ) - if pyarrow_version >= LooseVersion("0.16.1.dev"): + if pyarrow_version >= Version("0.17.0"): df["periods"] = pd.period_range("2013", freq="M", periods=3) df["timedeltas"] = pd.timedelta_range("1 day", periods=3) # TODO temporary disable due to regression in pyarrow 0.17.1 @@ -186,7 +185,7 @@ def test_path_localpath(self): result = tm.round_trip_localpath(df.to_feather, read_feather) tm.assert_frame_equal(df, result) - @td.skip_if_no("pyarrow", min_version="0.16.1.dev") + @td.skip_if_no("pyarrow", min_version="0.17.0") def test_passthrough_keywords(self): df = tm.makeDataFrame().reset_index() self.check_round_trip(df, write_kwargs={"version": 1}) diff --git a/pandas/tests/io/test_parquet.py b/pandas/tests/io/test_parquet.py index 7cc7acd9007fa..30666a716859a 100644 --- a/pandas/tests/io/test_parquet.py +++ b/pandas/tests/io/test_parquet.py @@ -1,6 +1,5 @@ """ test parquet compat """ import datetime -from distutils.version import LooseVersion from io import BytesIO import os import pathlib @@ -19,6 +18,7 @@ import pandas as pd import pandas._testing as tm +from pandas.util.version import Version from pandas.io.parquet import ( FastParquetImpl, @@ -273,12 +273,12 @@ def test_get_engine_auto_error_message(): have_pa_bad_version = ( False if not _HAVE_PYARROW - else LooseVersion(pyarrow.__version__) < LooseVersion(pa_min_ver) + else Version(pyarrow.__version__) < Version(pa_min_ver) ) have_fp_bad_version = ( False if not _HAVE_FASTPARQUET - else LooseVersion(fastparquet.__version__) < LooseVersion(fp_min_ver) + else Version(fastparquet.__version__) < Version(fp_min_ver) ) # Do we have usable engines installed? have_usable_pa = _HAVE_PYARROW and not have_pa_bad_version @@ -321,18 +321,6 @@ def test_cross_engine_pa_fp(df_cross_compat, pa, fp): def test_cross_engine_fp_pa(request, df_cross_compat, pa, fp): # cross-compat with differing reading/writing engines - - if ( - LooseVersion(pyarrow.__version__) < "0.15" - and LooseVersion(pyarrow.__version__) >= "0.13" - ): - request.node.add_marker( - pytest.mark.xfail( - "Reading fastparquet with pyarrow in 0.14 fails: " - "https://issues.apache.org/jira/browse/ARROW-6492" - ) - ) - df = df_cross_compat with tm.ensure_clean() as path: df.to_parquet(path, engine=fp, compression=None) @@ -623,13 +611,6 @@ def test_duplicate_columns(self, pa): self.check_error_on_write(df, pa, ValueError, "Duplicate column names found") def test_unsupported(self, pa): - if LooseVersion(pyarrow.__version__) < LooseVersion("0.15.1.dev"): - # period - will be supported using an extension type with pyarrow 1.0 - df = pd.DataFrame({"a": pd.period_range("2013", freq="M", periods=3)}) - # pyarrow 0.11 raises ArrowTypeError - # older pyarrows raise ArrowInvalid - self.check_external_error_on_write(df, pa, pyarrow.ArrowException) - # timedelta df = pd.DataFrame({"a": pd.timedelta_range("1 day", periods=3)}) self.check_external_error_on_write(df, pa, NotImplementedError) @@ -657,12 +638,7 @@ def test_categorical(self, pa): ["a", "b", "c", "a", "c", "b"], categories=["b", "c", "d"], ordered=True ) - if LooseVersion(pyarrow.__version__) >= LooseVersion("0.15.0"): - check_round_trip(df, pa) - else: - # de-serialized as object for pyarrow < 0.15 - expected = df.astype(object) - check_round_trip(df, pa, expected=expected) + check_round_trip(df, pa) @pytest.mark.xfail( is_platform_windows() and PY38, @@ -671,7 +647,7 @@ def test_categorical(self, pa): ) def test_s3_roundtrip_explicit_fs(self, df_compat, s3_resource, pa, s3so): s3fs = pytest.importorskip("s3fs") - if LooseVersion(pyarrow.__version__) <= LooseVersion("0.17.0"): + if Version(pyarrow.__version__) <= Version("0.17.0"): pytest.skip() s3 = s3fs.S3FileSystem(**s3so) kw = {"filesystem": s3} @@ -684,7 +660,7 @@ def test_s3_roundtrip_explicit_fs(self, df_compat, s3_resource, pa, s3so): ) def test_s3_roundtrip(self, df_compat, s3_resource, pa, s3so): - if LooseVersion(pyarrow.__version__) <= LooseVersion("0.17.0"): + if Version(pyarrow.__version__) <= Version("0.17.0"): pytest.skip() # GH #19134 s3so = {"storage_options": s3so} @@ -716,8 +692,8 @@ def test_s3_roundtrip_for_dir( # These are added to back of dataframe on read. In new API category dtype is # only used if partition field is string, but this changed again to use # category dtype for all types (not only strings) in pyarrow 2.0.0 - pa10 = (LooseVersion(pyarrow.__version__) >= LooseVersion("1.0.0")) and ( - LooseVersion(pyarrow.__version__) < LooseVersion("2.0.0") + pa10 = (Version(pyarrow.__version__) >= Version("1.0.0")) and ( + Version(pyarrow.__version__) < Version("2.0.0") ) if partition_col: if pa10: @@ -824,7 +800,7 @@ def test_additional_extension_arrays(self, pa): "c": pd.Series(["a", None, "c"], dtype="string"), } ) - if LooseVersion(pyarrow.__version__) >= LooseVersion("0.16.0"): + if Version(pyarrow.__version__) >= Version("0.16.0"): expected = df else: # de-serialized as plain int / object @@ -834,7 +810,7 @@ def test_additional_extension_arrays(self, pa): check_round_trip(df, pa, expected=expected) df = pd.DataFrame({"a": pd.Series([1, 2, 3, None], dtype="Int64")}) - if LooseVersion(pyarrow.__version__) >= LooseVersion("0.16.0"): + if Version(pyarrow.__version__) >= Version("0.16.0"): expected = df else: # if missing values in integer, currently de-serialized as float @@ -862,7 +838,7 @@ def test_additional_extension_types(self, pa): ) check_round_trip(df, pa) - @td.skip_if_no("pyarrow", min_version="0.16") + @td.skip_if_no("pyarrow", min_version="0.16.0") def test_use_nullable_dtypes(self, pa): import pyarrow.parquet as pq @@ -891,7 +867,6 @@ def test_use_nullable_dtypes(self, pa): ) tm.assert_frame_equal(result2, expected) - @td.skip_if_no("pyarrow", min_version="0.14") def test_timestamp_nanoseconds(self, pa): # with version 2.0, pyarrow defaults to writing the nanoseconds, so # this should work without error @@ -899,7 +874,7 @@ def test_timestamp_nanoseconds(self, pa): check_round_trip(df, pa, write_kwargs={"version": "2.0"}) def test_timezone_aware_index(self, pa, timezone_aware_date_list): - if LooseVersion(pyarrow.__version__) >= LooseVersion("2.0.0"): + if Version(pyarrow.__version__) >= Version("2.0.0"): # temporary skip this test until it is properly resolved # https://github.com/pandas-dev/pandas/issues/37286 pytest.skip() diff --git a/pandas/tests/test_common.py b/pandas/tests/test_common.py index 229399476773f..0b2a4cfb94d18 100644 --- a/pandas/tests/test_common.py +++ b/pandas/tests/test_common.py @@ -1,5 +1,4 @@ import collections -from distutils.version import LooseVersion from functools import partial import string @@ -13,6 +12,7 @@ import pandas._testing as tm from pandas.core import ops import pandas.core.common as com +from pandas.util.version import Version def test_get_callable_name(): @@ -142,9 +142,9 @@ def test_git_version(): def test_version_tag(): - version = pd.__version__ + version = Version(pd.__version__) try: - version > LooseVersion("0.0.1") + version > Version("0.0.1") except TypeError: raise ValueError( "No git tags exist, please sync tags between upstream and your repo" diff --git a/pandas/tests/util/test_show_versions.py b/pandas/tests/util/test_show_versions.py index 57cd2e1a144b6..7a1363099e7a0 100644 --- a/pandas/tests/util/test_show_versions.py +++ b/pandas/tests/util/test_show_versions.py @@ -32,7 +32,6 @@ # https://github.com/pandas-dev/pandas/issues/35252 "ignore:Distutils:UserWarning" ) -@pytest.mark.filterwarnings("ignore:Setuptools is replacing distutils:UserWarning") def test_show_versions(tmpdir): # GH39701 as_json = os.path.join(tmpdir, "test_output.json") diff --git a/pandas/util/_test_decorators.py b/pandas/util/_test_decorators.py index dd22b5ef5e4ac..62e31c0e46715 100644 --- a/pandas/util/_test_decorators.py +++ b/pandas/util/_test_decorators.py @@ -26,7 +26,6 @@ def test_foo(): from __future__ import annotations from contextlib import contextmanager -from distutils.version import LooseVersion import locale from typing import Callable import warnings @@ -46,6 +45,7 @@ def test_foo(): NUMEXPR_INSTALLED, USE_NUMEXPR, ) +from pandas.util.version import Version def safe_import(mod_name: str, min_version: str | None = None): @@ -87,11 +87,8 @@ def safe_import(mod_name: str, min_version: str | None = None): except AttributeError: # xlrd uses a capitalized attribute name version = getattr(sys.modules[mod_name], "__VERSION__") - if version: - from distutils.version import LooseVersion - - if LooseVersion(version) >= LooseVersion(min_version): - return mod + if version and Version(version) >= Version(min_version): + return mod return False @@ -211,7 +208,7 @@ def skip_if_np_lt(ver_str: str, *args, reason: str | None = None): if reason is None: reason = f"NumPy {ver_str} or greater required" return pytest.mark.skipif( - np.__version__ < LooseVersion(ver_str), + Version(np.__version__) < Version(ver_str), *args, reason=reason, ) diff --git a/pandas/util/version/__init__.py b/pandas/util/version/__init__.py new file mode 100644 index 0000000000000..5ca3abb916ce0 --- /dev/null +++ b/pandas/util/version/__init__.py @@ -0,0 +1,580 @@ +# Vendored from https://github.com/pypa/packaging/blob/main/packaging/_structures.py +# and https://github.com/pypa/packaging/blob/main/packaging/_structures.py +# changeset ae891fd74d6dd4c6063bb04f2faeadaac6fc6313 +# 04/30/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, + Iterator, + List, + Optional, + SupportsInt, + Tuple, + Union, +) +import warnings + +__all__ = ["parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN"] + + +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, type(self)) + + def __ne__(self, other: object) -> bool: + return not isinstance(other, type(self)) + + 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, type(self)) + + def __ne__(self, other: object) -> bool: + return not isinstance(other, type(self)) + + 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() + + +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"] +) + + +def parse(version: str) -> Union["LegacyVersion", "Version"]: + """ + Parse the given version string and return either a :class:`Version` object + or a :class:`LegacyVersion` object depending on if the given version is + a valid PEP 440 version or a legacy version. + """ + try: + return Version(version) + except InvalidVersion: + return LegacyVersion(version) + + +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 + + +class LegacyVersion(_BaseVersion): + def __init__(self, version: str) -> None: + self._version = str(version) + self._key = _legacy_cmpkey(self._version) + + warnings.warn( + "Creating a LegacyVersion has been deprecated and will be " + "removed in the next major release", + DeprecationWarning, + ) + + def __str__(self) -> str: + return self._version + + def __repr__(self) -> str: + return f"" + + @property + def public(self) -> str: + return self._version + + @property + def base_version(self) -> str: + return self._version + + @property + def epoch(self) -> int: + return -1 + + @property + def release(self) -> None: + return None + + @property + def pre(self) -> None: + return None + + @property + def post(self) -> None: + return None + + @property + def dev(self) -> None: + return None + + @property + def local(self) -> None: + return None + + @property + def is_prerelease(self) -> bool: + return False + + @property + def is_postrelease(self) -> bool: + return False + + @property + def is_devrelease(self) -> bool: + return False + + +_legacy_version_component_re = re.compile(r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE) + +_legacy_version_replacement_map = { + "pre": "c", + "preview": "c", + "-": "final-", + "rc": "c", + "dev": "@", +} + + +def _parse_version_parts(s: str) -> Iterator[str]: + for part in _legacy_version_component_re.split(s): + part = _legacy_version_replacement_map.get(part, part) + + if not part or part == ".": + continue + + if part[:1] in "0123456789": + # pad for numeric comparison + yield part.zfill(8) + else: + yield "*" + part + + # ensure that alpha/beta/candidate are before final + yield "*final" + + +def _legacy_cmpkey(version: str) -> LegacyCmpKey: + + # We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch + # greater than or equal to 0. This will effectively put the LegacyVersion, + # which uses the defacto standard originally implemented by setuptools, + # as before all PEP 440 versions. + epoch = -1 + + # This scheme is taken from pkg_resources.parse_version setuptools prior to + # it's adoption of the packaging library. + parts: List[str] = [] + for part in _parse_version_parts(version.lower()): + if part.startswith("*"): + # remove "-" before a prerelease tag + if part < "*final": + while parts and parts[-1] == "*final-": + parts.pop() + + # remove trailing zeros from each series of numeric parts + while parts and parts[-1] == "00000000": + parts.pop() + + parts.append(part) + + return epoch, tuple(parts) + + +# 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/setup.py b/setup.py
index b410c5c154648..386074519ca4f 100755
--- a/setup.py
+++ b/setup.py
@@ -7,17 +7,16 @@
 """
 
 import argparse
-from distutils.command.build import build
-from distutils.sysconfig import get_config_vars
-from distutils.version import LooseVersion
 import multiprocessing
 import os
 from os.path import join as pjoin
 import platform
 import shutil
 import sys
+from sysconfig import get_config_vars
 
 import numpy
+from pkg_resources import parse_version
 from setuptools import (
     Command,
     Extension,
@@ -47,7 +46,7 @@ def is_platform_mac():
     )
     from Cython.Build import cythonize
 
-    _CYTHON_INSTALLED = _CYTHON_VERSION >= LooseVersion(min_cython_ver)
+    _CYTHON_INSTALLED = parse_version(_CYTHON_VERSION) >= parse_version(min_cython_ver)
 except ImportError:
     _CYTHON_VERSION = None
     _CYTHON_INSTALLED = False
@@ -106,7 +105,7 @@ def build_extensions(self):
 
 
 class CleanCommand(Command):
-    """Custom distutils command to clean the .so and .pyc files."""
+    """Custom command to clean the .so and .pyc files."""
 
     user_options = [("all", "a", "")]
 
@@ -278,7 +277,7 @@ def build_extensions(self):
 
 class CythonCommand(build_ext):
     """
-    Custom distutils command subclassed from Cython.Distutils.build_ext
+    Custom command subclassed from Cython.Distutils.build_ext
     to compile pyx->c, and stop there. All this does is override the
     C-compile method build_extension() with a no-op.
     """
@@ -302,7 +301,7 @@ def run(self):
         pass
 
 
-cmdclass.update({"clean": CleanCommand, "build": build})
+cmdclass["clean"] = CleanCommand
 cmdclass["build_ext"] = CheckingBuildExt
 
 if _CYTHON_INSTALLED:
@@ -351,11 +350,13 @@ def run(self):
         python_target = get_config_vars().get(
             "MACOSX_DEPLOYMENT_TARGET", current_system
         )
+        target_macos_version = "10.9"
+        parsed_macos_version = parse_version(target_macos_version)
         if (
-            LooseVersion(str(python_target)) < "10.9"
-            and LooseVersion(current_system) >= "10.9"
+            parse_version(str(python_target)) < parsed_macos_version
+            and parse_version(current_system) >= parsed_macos_version
         ):
-            os.environ["MACOSX_DEPLOYMENT_TARGET"] = "10.9"
+            os.environ["MACOSX_DEPLOYMENT_TARGET"] = target_macos_version
 
     if sys.version_info[:2] == (3, 8):  # GH 33239
         extra_compile_args.append("-Wno-error=deprecated-declarations")