Repository: web2py/web2py

Issue:
IS_STRONG behavior changed in 2.15
I am upgrading an app from 2.5.1 (!) to 2.17.2.

I noticed that I could not log in after the upgrade because my password "May not include any numbers", which is funny because my password had numbers in it before the upgrade.

I looked through the app and found the following in `db.py`:

```
custom_auth_table.password.requires = [IS_STRONG(min=8,special=False,number=False), CRYPT()]
```

I found that [this commit](https://github.com/web2py/web2py/commit/a5599f3eab14155d9dff589e7275665892a39eb1#diff-1e4f774e944ce88607f6bc3debe6f0b7) changed the semantics of IS_STRONG by switching type() checks to isinstance() checks-- bool is a subclass of int.

```
>>> isinstance(False, int)
True
```

**Previous behavior:** passing `number=False` meant "0 or more numbers"
**New behavior in 2.15:** passing `number=False` means "exactly 0 numbers"

There's now no way for me to tell IS_STRONG to have no requirements for numbers. If I pass something larger than 0, that's the minimum. If I pass None, the default path sets the minimum to 1. If I pass 0 or False, the meaning of the argument changes from a minimum to a prohibition (which seems weird to me).

I can submit a pull request to change the behavior but I wanted feedback on what you thought the correct behavior was.


Discussion:
Huh, that's very strange!  I wouldn't expect that behavior of an existing function would change in a web2py version after 1.0, due to their backwards compatibility promise.  This sounds like a bug in web2py that was introduced during the switch from `type()` to `isinstance()`.

Yeah, if you've got a pull request ready to send, then send away!

Resolving patch:
diff --git a/gluon/validators.py b/gluon/validators.py
--- a/gluon/validators.py
+++ b/gluon/validators.py
@@ -3163,6 +3163,10 @@ def __call__(self, value):
                 if not all_special.count(True) >= self.special:
                     failures.append(translate("Must include at least %s of the following: %s")
                                     % (self.special, self.specials))
+            elif self.special is 0:
+                if len(all_special) > 0:
+                    failures.append(translate("May not contain any of the following: %s")
+                                    % self.specials)
         if self.invalid:
             all_invalid = [ch in value for ch in self.invalid]
             if all_invalid.count(True) > 0:
@@ -3174,7 +3178,7 @@ def __call__(self, value):
                 if not len(all_upper) >= self.upper:
                     failures.append(translate("Must include at least %s uppercase")
                                     % str(self.upper))
-            else:
+            elif self.upper is 0:
                 if len(all_upper) > 0:
                     failures.append(
                         translate("May not include any uppercase letters"))
@@ -3184,7 +3188,7 @@ def __call__(self, value):
                 if not len(all_lower) >= self.lower:
                     failures.append(translate("Must include at least %s lowercase")
                                     % str(self.lower))
-            else:
+            elif self.lower is 0:
                 if len(all_lower) > 0:
                     failures.append(
                         translate("May not include any lowercase letters"))
@@ -3197,7 +3201,7 @@ def __call__(self, value):
                 if not len(all_number) >= self.number:
                     failures.append(translate("Must include at least %s %s")
                                     % (str(self.number), numbers))
-            else:
+            elif self.number is 0:
                 if len(all_number) > 0:
                     failures.append(translate("May not include any numbers"))
         if len(failures) == 0:


Test patch:
diff --git a/gluon/tests/test_validators.py b/gluon/tests/test_validators.py
--- a/gluon/tests/test_validators.py
+++ b/gluon/tests/test_validators.py
@@ -943,6 +943,14 @@ def test_IS_STRONG(self):
                                     'May not include any lowercase letters',
                                     'May not include any numbers']))
                          )
+        rtn = IS_STRONG(special=0, es=True)('Abcde1!')
+        self.assertEqual(rtn,
+                         ('Abcde1!',
+                          '|'.join(['Minimum length is 8',
+                                    'May not contain any of the following: ~!@#$%^&*()_+-=?<>,.:;{}[]|']))
+                         )
+        rtn = IS_STRONG(upper=False, number=False, special=False, es=True)('Abcde1!')
+        self.assertEqual(rtn, ('Abcde1!', 'Minimum length is 8'))
 
     def test_IS_IMAGE(self):
         class DummyImageFile(object):