Repository: dirty-cat/dirty_cat

Issue:
DeprecationWarning: distutils Version classes are deprecated. Use packaging.version instead.
<!--Provide a brief description of the bug.-->

Since december 2021, `distutils.version.Version` (and thus `LooseVersion`) [is deprecated](https://github.com/pypa/setuptools/commit/1701579e0827317d8888c2254a17b5786b6b5246).
We use it in [several components](https://github.com/dirty-cat/dirty_cat/search?q=LooseVersion), and therefore we have a number of warnings in the CI logs ([for example](https://github.com/dirty-cat/dirty_cat/runs/6667648188?check_suite_focus=true), under "Run tests")

Sample:
```
dirty_cat/test/test_similarity_encoder.py: 118 warnings
  /Users/runner/work/dirty_cat/dirty_cat/dirty_cat/similarity_encoder.py:328: DeprecationWarning: distutils Version classes are deprecated. Use packaging.version instead.
    if LooseVersion(sklearn.__version__) > LooseVersion('0.21'):
```

as suggested by the warning, we could use `packaging.version.Version` instead.

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 3b92fa9..027a15b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -30,6 +30,7 @@ Release 0.2.1
 
 Major changes
 -------------
+
 * Improvements to the :class:`SuperVectorizer`
 
     - Type detection works better: handles dates, numerics columns encoded as strings,
@@ -51,6 +52,8 @@ Bug-fixes
 
 * GapEncoder's `get_feature_names_out` now accepts all iterators, not just lists.  
 
+* Fixed `DeprecationWarning` raised by the usage of `distutils.version.LooseVersion`
+
 Notes
 -----
 
diff --git a/build_tools/circle/list_versions.py b/build_tools/circle/list_versions.py
index 19fa8aa..0322cda 100755
--- a/build_tools/circle/list_versions.py
+++ b/build_tools/circle/list_versions.py
@@ -5,9 +5,10 @@ import json
 import re
 import sys
 
-from distutils.version import LooseVersion
+from dirty_cat.utils import Version
 from urllib.request import urlopen
 
+
 def json_urlread(url):
     try:
         return json.loads(urlopen(url).read().decode('utf8'))
@@ -80,7 +81,7 @@ for src, dst in symlinks.items():
 seen = set()
 for name in (NAMED_DIRS +
              sorted((k for k in dirs if k[:1].isdigit()),
-                    key=LooseVersion, reverse=True)):
+                    key=Version, reverse=True)):
     version_num, pdf_size = dirs[name]
     if version_num in seen:
         # symlink came first
diff --git a/dirty_cat/datasets/fetching.py b/dirty_cat/datasets/fetching.py
index f42dd59..8b28c93 100644
--- a/dirty_cat/datasets/fetching.py
+++ b/dirty_cat/datasets/fetching.py
@@ -24,8 +24,8 @@ import pandas as pd
 from pathlib import Path
 from collections import namedtuple
 from typing import Union, Dict, Any
-from distutils.version import LooseVersion
 
+from dirty_cat.utils import Version
 from dirty_cat.datasets.utils import get_data_dir
 
 
@@ -165,7 +165,7 @@ def _download_and_write_openml_dataset(dataset_id: int,
     from sklearn.datasets import fetch_openml
 
     fetch_kwargs = {}
-    if LooseVersion(sklearn.__version__) >= LooseVersion('0.22'):
+    if Version(sklearn.__version__) >= Version('0.22'):
         fetch_kwargs.update({'as_frame': True})
 
     # The ``fetch_openml()`` function returns a Scikit-Learn ``Bunch`` object,
diff --git a/dirty_cat/gap_encoder.py b/dirty_cat/gap_encoder.py
index dea1cdc..772c6d2 100644
--- a/dirty_cat/gap_encoder.py
+++ b/dirty_cat/gap_encoder.py
@@ -16,7 +16,6 @@ The principle is as follows:
 """
 import warnings
 import numpy as np
-from distutils.version import LooseVersion
 from scipy import sparse
 from sklearn import __version__ as sklearn_version
 from sklearn.utils import check_random_state, gen_batches
@@ -28,15 +27,16 @@ from sklearn.neighbors import NearestNeighbors
 from sklearn.utils.fixes import _object_dtype_isnan
 import pandas as pd
 from .utils import check_input
+from dirty_cat.utils import Version
 
-if LooseVersion(sklearn_version) <= LooseVersion('0.22'):
+if Version(sklearn_version) <= Version('0.22'):
     from sklearn.cluster.k_means_ import _k_init
-elif LooseVersion(sklearn_version) < LooseVersion('0.24'):
+elif Version(sklearn_version) < Version('0.24'):
     from sklearn.cluster._kmeans import _k_init
 else:
     from sklearn.cluster import kmeans_plusplus
 
-if LooseVersion(sklearn_version) <= LooseVersion('0.22'):
+if Version(sklearn_version) <= Version('0.22'):
     from sklearn.decomposition.nmf import _beta_divergence
 else:
     from sklearn.decomposition._nmf import _beta_divergence
@@ -106,12 +106,12 @@ class GapEncoderColumn(BaseEstimator, TransformerMixin):
             unq_V = sparse.hstack((unq_V, unq_V2), format='csr')
 
         if not self.hashing: # Build n-grams/word vocabulary
-            if LooseVersion(sklearn_version) < LooseVersion('1.0'):
+            if Version(sklearn_version) < Version('1.0'):
                 self.vocabulary = self.ngrams_count_.get_feature_names()
             else:
                 self.vocabulary = self.ngrams_count_.get_feature_names_out()
             if self.add_words:
-                if LooseVersion(sklearn_version) < LooseVersion('1.0'):
+                if Version(sklearn_version) < Version('1.0'):
                     self.vocabulary = np.concatenate((
                         self.vocabulary,
                         self.word_count_.get_feature_names()
@@ -153,7 +153,7 @@ class GapEncoderColumn(BaseEstimator, TransformerMixin):
         n-grams counts.
         """
         if self.init == 'k-means++':
-            if LooseVersion(sklearn_version) < LooseVersion('0.24'):
+            if Version(sklearn_version) < Version('0.24'):
                 W = _k_init(
                     V, self.n_components,
                     x_squared_norms=row_norms(V, squared=True),
@@ -179,7 +179,7 @@ class GapEncoderColumn(BaseEstimator, TransformerMixin):
                 W = np.hstack((W, W2))
             # if k-means doesn't find the exact number of prototypes
             if W.shape[0] < self.n_components:
-                if LooseVersion(sklearn_version) < LooseVersion('0.24'):
+                if Version(sklearn_version) < Version('0.24'):
                     W2 = _k_init(
                         V, self.n_components - W.shape[0],
                         x_squared_norms=row_norms(V, squared=True),
@@ -284,7 +284,7 @@ class GapEncoderColumn(BaseEstimator, TransformerMixin):
         """
         vectorizer = CountVectorizer()
         vectorizer.fit(list(self.H_dict_.keys()))
-        if LooseVersion(sklearn_version) < LooseVersion('1.0'):
+        if Version(sklearn_version) < Version('1.0'):
             vocabulary = np.array(vectorizer.get_feature_names())
         else:
             vocabulary = np.array(vectorizer.get_feature_names_out())
diff --git a/dirty_cat/similarity_encoder.py b/dirty_cat/similarity_encoder.py
index c0cfe8b..18b9f4e 100644
--- a/dirty_cat/similarity_encoder.py
+++ b/dirty_cat/similarity_encoder.py
@@ -15,7 +15,6 @@ The principle is as follows:
 import warnings
 
 import numpy as np
-from distutils.version import LooseVersion
 from joblib import Parallel, delayed
 from scipy import sparse
 import sklearn
@@ -26,6 +25,7 @@ from sklearn.preprocessing import OneHotEncoder
 from sklearn.utils import check_random_state
 from sklearn.utils.fixes import _object_dtype_isnan
 
+from dirty_cat.utils import Version
 from . import string_distances
 from .string_distances import get_ngram_count, preprocess
 
@@ -400,7 +400,7 @@ class SimilarityEncoder(OneHotEncoder):
                 self.vocabulary_ngram_counts_.append(vocabulary_ngram_count)
 
         self.drop_idx_ = self._compute_drop_idx()
-        if LooseVersion(sklearn.__version__) >= LooseVersion('1.1.0'):
+        if Version(sklearn.__version__) >= Version('1.1.0'):
             self._infrequent_enabled = False
 
         return self
diff --git a/dirty_cat/super_vectorizer.py b/dirty_cat/super_vectorizer.py
index e31e00a..219d117 100644
--- a/dirty_cat/super_vectorizer.py
+++ b/dirty_cat/super_vectorizer.py
@@ -15,15 +15,15 @@ import pandas as pd
 
 from warnings import warn
 from typing import Union, Optional, List
-from distutils.version import LooseVersion
 
 from sklearn.base import BaseEstimator
 from sklearn.compose import ColumnTransformer
 from sklearn.preprocessing import OneHotEncoder
 
 from dirty_cat import GapEncoder, DatetimeEncoder
+from dirty_cat.utils import Version
 
-_sklearn_loose_version = LooseVersion(sklearn.__version__)
+_sklearn_loose_version = Version(sklearn.__version__)
 
 
 def _has_missing_values(df: Union[pd.DataFrame, pd.Series]) -> bool:
@@ -402,7 +402,7 @@ class SuperVectorizer(ColumnTransformer):
                     impute: bool = False
 
                     if isinstance(trans, OneHotEncoder) \
-                            and _sklearn_loose_version < LooseVersion('0.24'):
+                            and _sklearn_loose_version < Version('0.24'):
                         impute = True
 
                     if impute:
@@ -446,9 +446,9 @@ class SuperVectorizer(ColumnTransformer):
         e.g. "job_title_Police officer",
         or "<column_name>" if not encoded.
         """
-        if _sklearn_loose_version < LooseVersion('0.23'):
+        if _sklearn_loose_version < Version('0.23'):
             try:
-                if _sklearn_loose_version < LooseVersion('1.0'):
+                if _sklearn_loose_version < Version('1.0'):
                     ct_feature_names = super().get_feature_names()
                 else:
                     ct_feature_names = super().get_feature_names_out()
@@ -460,7 +460,7 @@ class SuperVectorizer(ColumnTransformer):
                     'transformers, or update your copy of scikit-learn.'
                 )
         else:
-            if _sklearn_loose_version < LooseVersion('1.0'):
+            if _sklearn_loose_version < Version('1.0'):
                 ct_feature_names = super().get_feature_names()
             else:
                 ct_feature_names = super().get_feature_names_out()
@@ -478,7 +478,7 @@ class SuperVectorizer(ColumnTransformer):
             if not hasattr(trans, 'get_feature_names'):
                 all_trans_feature_names.extend(cols)
             else:
-                if _sklearn_loose_version < LooseVersion('1.0'):
+                if _sklearn_loose_version < Version('1.0'):
                     trans_feature_names = trans.get_feature_names(cols)
                 else:
                     trans_feature_names = trans.get_feature_names_out(cols)
diff --git a/dirty_cat/utils.py b/dirty_cat/utils.py
index 4891f7a..920e0ad 100644
--- a/dirty_cat/utils.py
+++ b/dirty_cat/utils.py
@@ -2,6 +2,8 @@ import collections
 
 import numpy as np
 
+from typing import Tuple, Union
+
 
 class LRUDict:
     """ dict with limited capacity
@@ -44,3 +46,81 @@ def check_input(X):
             'array.reshape(1, -1) if it contains a single sample.'
         )
     return X
+
+
+class Version:
+    """
+    Replacement for `distutil.version.LooseVersion` and `packaging.version.Version`.
+    Implemented to avoid `DeprecationWarning`s raised by the former,
+    and avoid adding a dependency for the latter.
+
+    It is therefore very bare-bones, so its code shouldn't be too
+    hard to understand.
+    It currently only supports major and minor versions.
+
+    Inspired from https://stackoverflow.com/a/11887825/9084059
+    Should eventually dissapear.
+
+    Examples:
+    >>> # Standard usage
+    >>> Version(sklearn.__version__) > Version('0.22')
+    >>> Version(sklearn.__version__) > '0.22'
+    >>> # In general, pass the version as numbers separated by dots.
+    >>> Version('1.5') <= Version('1.6.5')
+    >>> Version('1.5') <= '1.6.5'
+    >>> # You can also pass the separator for specific cases
+    >>> Version('1-5', separator='-') == Version('1-6-5', separator='-')
+    >>> Version('1-5', separator='-') == '1-6-5'
+    >>> Version('1-5', separator='-') == '1.6.5'  # Won't work !
+    """
+
+    def __init__(self, value: str, separator: str = '.'):
+        self.separator = separator
+        self.major, self.minor = self._parse_version(value)
+
+    def __repr__(self):
+        return f'Version({self.major}.{self.minor})'
+
+    def _parse_version(self, value: str) -> Tuple[int, int]:
+        raw_parts = value.split(self.separator)
+        if len(raw_parts) == 0:
+            raise ValueError(f'Could not extract version from {value!r} '
+                             f'(separator: {self.separator!r})')
+        elif len(raw_parts) == 1:
+            major = int(raw_parts[0])
+            minor = 0
+        else:
+            major = int(raw_parts[0])
+            minor = int(raw_parts[1])
+            # Ditch the rest
+        return major, minor
+
+    def _cast_to_version(self, other: Union["Version", str]) -> "Version":
+        if isinstance(other, str):
+            # We pass our separator, as we expect they are the same
+            other = Version(other, self.separator)
+        return other
+
+    def __eq__(self, other: Union["Version", str]):
+        other = self._cast_to_version(other)
+        return (self.major, self.minor) == (other.major, other.minor)
+
+    def __ne__(self, other: Union["Version", str]):
+        other = self._cast_to_version(other)
+        return (self.major, self.minor) != (other.major, other.minor)
+
+    def __lt__(self, other: Union["Version", str]):
+        other = self._cast_to_version(other)
+        return (self.major, self.minor) < (other.major, other.minor)
+
+    def __le__(self, other: Union["Version", str]):
+        other = self._cast_to_version(other)
+        return (self.major, self.minor) <= (other.major, other.minor)
+
+    def __gt__(self, other: Union["Version", str]):
+        other = self._cast_to_version(other)
+        return (self.major, self.minor) > (other.major, other.minor)
+
+    def __ge__(self, other: Union["Version", str]):
+        other = self._cast_to_version(other)
+        return (self.major, self.minor) >= (other.major, other.minor)


Test patch:
diff --git a/dirty_cat/datasets/tests/test_fetching.py b/dirty_cat/datasets/tests/test_fetching.py
index b0056c4..a85f425 100644
--- a/dirty_cat/datasets/tests/test_fetching.py
+++ b/dirty_cat/datasets/tests/test_fetching.py
@@ -15,7 +15,7 @@ import warnings
 import pandas as pd
 
 from pathlib import Path
-from distutils.version import LooseVersion
+from dirty_cat.utils import Version
 
 from unittest import mock
 from unittest.mock import mock_open
@@ -152,7 +152,7 @@ def test__download_and_write_openml_dataset(mock_fetch_openml):
     test_data_dir = get_test_data_dir()
     _download_and_write_openml_dataset(1, test_data_dir)
 
-    if LooseVersion(sklearn.__version__) >= LooseVersion('0.22'):
+    if Version(sklearn.__version__) >= Version('0.22'):
         mock_fetch_openml.assert_called_once_with(data_id=1,
                                                   data_home=str(test_data_dir),
                                                   as_frame=True)
diff --git a/dirty_cat/test/test_super_vectorizer.py b/dirty_cat/test/test_super_vectorizer.py
index d299f19..a836dcc 100644
--- a/dirty_cat/test/test_super_vectorizer.py
+++ b/dirty_cat/test/test_super_vectorizer.py
@@ -7,10 +7,10 @@ from sklearn.preprocessing import StandardScaler
 from sklearn.utils.validation import check_is_fitted
 from sklearn.exceptions import NotFittedError
 
-from distutils.version import LooseVersion
 
 from dirty_cat import SuperVectorizer
 from dirty_cat import GapEncoder
+from dirty_cat.utils import Version
 
 
 def check_same_transformers(expected_transformers: dict, actual_transformers: list):
@@ -336,7 +336,7 @@ def test_get_feature_names_out():
     vectorizer_w_pass = SuperVectorizer(remainder='passthrough')
     vectorizer_w_pass.fit(X)
 
-    if LooseVersion(sklearn.__version__) < LooseVersion('0.23'):
+    if Version(sklearn.__version__) < Version('0.23'):
         with pytest.raises(NotImplementedError):
             # Prior to sklearn 0.23, ColumnTransformer.get_feature_names
             # with "passthrough" transformer(s) raises a NotImplementedError
@@ -370,7 +370,7 @@ def test_fit():
     # See GH#193
     sup_vec = SuperVectorizer()
     with pytest.raises(NotFittedError):
-        if LooseVersion(sklearn.__version__) >= LooseVersion('0.22'):
+        if Version(sklearn.__version__) >= Version('0.22'):
             assert check_is_fitted(sup_vec)
         else:
             assert check_is_fitted(sup_vec, attributes=dir(sup_vec))
diff --git a/dirty_cat/test/test_utils.py b/dirty_cat/test/test_utils.py
index 70caf4a..a339c34 100644
--- a/dirty_cat/test/test_utils.py
+++ b/dirty_cat/test/test_utils.py
@@ -1,4 +1,6 @@
-from dirty_cat.utils import LRUDict
+import pytest
+
+from dirty_cat.utils import LRUDict, Version
 
 
 def test_lrudict():
@@ -13,3 +15,28 @@ def test_lrudict():
 
     for x in range(5):
         assert (x not in dict_)
+
+
+def test_version():
+    # Test those specified in its docstring
+    assert Version('1.5') <= Version('1.6.5')
+    assert Version('1.5') <= '1.6.5'
+
+    assert (Version('1-5', separator='-') == Version('1-6-5', separator='-')) is False
+    assert (Version('1-5', separator='-') == '1-6-5') is False
+    with pytest.raises(ValueError):
+        assert not (Version('1-5', separator='-') == '1.6.5')
+
+    # Test all comparison methods
+    assert Version('1.0') == Version('1.0')
+    assert (Version('1.0') != Version('1.0')) is False
+
+    assert Version('1.0') < Version('1.1')
+    assert Version('1.1') <= Version('1.5')
+    assert Version('1.1') <= Version('1.1')
+    assert (Version('1.1') < Version('1.1')) is False
+
+    assert Version('1.1') > Version('0.5')
+    assert Version('1.1') >= Version('0.9')
+    assert Version('1.1') >= Version('1.1')
+    assert (Version('1.1') >= Version('1.9')) is False