Repository: repoze/repoze.who

Issue:
ModuleNotFoundError: No module named 'crypt'
Hello. In python 3.13, crypt module has been removeed. In python 3.12 it's deprecated.

```
Traceback (most recent call last):                                              
  File "/builddir/build/BUILD/python-repoze-who-3.0.0-build/repoze.who-3.0.0/rep
oze/who/plugins/tests/test_htpasswd.py", line 116, in test_crypt_check          
    from crypt import crypt                                                     
ModuleNotFoundError: No module named 'crypt'
```


Discussion:

Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 68cd9f9..875fa2e 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -232,6 +232,9 @@ nominated to act as authenticator plugins. ::
     use = repoze.who.plugins.htpasswd:make_plugin
     filename = %(here)s/passwd
     check_fn = repoze.who.plugins.htpasswd:crypt_check
+    # The stdlib 'crypt' module is not available for Python > 3.13.  Instead,
+    # use: 'bcrypt.checkpw' function (see https://pypi.org/projects/bcrypt/).
+    # check_fn = bcrypt:checkpw
 
     [plugin:sqlusers]
     # authentication
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 2eb7996..4d71f1f 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -133,6 +133,12 @@ authentication, identification, challenge and metadata provision.
   ``repoze.who.plugins.htpasswd:crypt_check``; it assumes the values
   in the htpasswd file are encrypted with the UNIX ``crypt`` function.
 
+.. note::
+   The ``crypt`` module is not available in the standard library for
+   Python >= 3.13.  Recommended replacement for the checker function
+   we provide here (``repoze.who.plugins.htpasswd:crypt_check``) is the
+   ``bcrypt.checkpw`` function (see https://pypi.org/projects/bcrypt/).
+
 .. module:: repoze.who.plugins.redirector
 
 .. class:: RedirectorPlugin(login_url, came_from_param, reason_param, reason_header)
diff --git a/repoze/who/plugins/htpasswd.py b/repoze/who/plugins/htpasswd.py
index ccacf17..1002a83 100644
--- a/repoze/who/plugins/htpasswd.py
+++ b/repoze/who/plugins/htpasswd.py
@@ -1,4 +1,14 @@
+try:
+    import crypt
+except ImportError:
+    # Note: the crypt module is deprecated since Python 3.11
+    # and will be removed in Python 3.13.
+    # win32 does not have a crypt library at all.
+    HAS_CRYPT = False
+else:
+    HAS_CRYPT = True
 import itertools
+import warnings
 
 from zope.interface import implementer
 
@@ -90,13 +100,27 @@ def _same_string(x, y):
         mismatches = list(mismatches)
     return len(mismatches) == 0
 
+
+if not HAS_CRYPT:
+
+    class CryptModuleNotImportable(RuntimeError):
+        def __init__(self):
+            super().__init__(
+                "'crypt' module is not importable. "
+                "Try 'bcrypt.checkpw' instead?"
+            )
+
 def crypt_check(password, hashed):
-    # Note: the crypt module is deprecated since Python 3.11
-    # and will be removed in Python 3.13.
-    # win32 does not have a crypt library at all.
-    from crypt import crypt
+
+    if not HAS_CRYPT:
+        raise CryptModuleNotImportable()
+
+    warnings.warn(
+        "'crypt' module is deprecated -- try 'bcrypt.checkpw' instead?"
+    )
     salt = hashed[:2]
-    return _same_string(hashed, crypt(password, salt))
+    return _same_string(hashed, crypt.crypt(password, salt))
+
 
 def sha1_check(password, hashed):
     from hashlib import sha1


Test patch:
diff --git a/repoze/who/plugins/tests/test_htpasswd.py b/repoze/who/plugins/tests/test_htpasswd.py
index 05f3a71..ecdf17a 100644
--- a/repoze/who/plugins/tests/test_htpasswd.py
+++ b/repoze/who/plugins/tests/test_htpasswd.py
@@ -1,6 +1,3 @@
-import unittest
-
-
 try:
     from crypt import crypt
 except ImportError:
@@ -8,6 +5,10 @@ except ImportError:
     # and will be removed in Python 3.13.
     # win32 does not have a crypt library at all.
     crypt = None
+import unittest
+import warnings
+
+import pytest
 
 
 class TestHTPasswdPlugin(unittest.TestCase):
@@ -116,12 +117,40 @@ class TestHTPasswdPlugin(unittest.TestCase):
         self.assertTrue('could not open htpasswd' in logger.warnings[0])
 
     @unittest.skipIf(crypt is None, "crypt module not available")
-    def test_crypt_check(self):
+    def test_crypt_check_hit(self):
+        from repoze.who.plugins.htpasswd import crypt_check
         salt = '123'
         hashed = crypt('password', salt)
+
+        with warnings.catch_warnings(record=True) as logged:
+            assert crypt_check('password', hashed)
+
+        assert len(logged) == 1
+        record = logged[0]
+        assert record.category is UserWarning
+        assert "'crypt' module is deprecated" in str(record.message)
+
+    @unittest.skipIf(crypt is None, "crypt module not available")
+    def test_crypt_check_miss(self):
+        from repoze.who.plugins.htpasswd import crypt_check
+        salt = '123'
+        hashed = crypt('password', salt)
+
+        with warnings.catch_warnings(record=True) as logged:
+            assert not crypt_check('notpassword', hashed)
+
+        assert len(logged) == 1
+        record = logged[0]
+        assert record.category is UserWarning
+        assert "'crypt' module is deprecated" in str(record.message)
+
+    @unittest.skipIf(crypt is not None, "crypt module available")
+    def test_crypt_check_gone(self):
+        from repoze.who.plugins.htpasswd import CryptModuleNotImportable
         from repoze.who.plugins.htpasswd import crypt_check
-        self.assertEqual(crypt_check('password', hashed), True)
-        self.assertEqual(crypt_check('notpassword', hashed), False)
+
+        with pytest.raises(CryptModuleNotImportable):
+            crypt_check('password', 'hashed')
 
     def test_sha1_check_w_password_str(self):
         from base64 import standard_b64encode