Skip to content

Commit a236cc9

Browse files
committed
feat: validatior errors
1 parent af81e59 commit a236cc9

File tree

4 files changed

+611
-14
lines changed

4 files changed

+611
-14
lines changed

README.md

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ applications where use can choose login names and sub-domains.
1616
- Validates against list of [banned
1717
words](https://github.com/theskumar/python-usernames/blob/master/usernames/reserved_words.py)
1818
that should not be used as username.
19+
- **Detailed validation results** with specific error messages and suggestions
1920
- Python 3.8+
2021

2122
## Installation
2223

2324
pip install python-usernames
2425

25-
## Usages
26+
## Usage
27+
28+
### Simple Validation (Boolean Result)
2629

2730
```python
2831
from python_usernames import is_safe_username
@@ -34,7 +37,27 @@ False # contains one of the banned words
3437
False # contains non-url friendly `!`
3538
```
3639

37-
**is\_safe\_username** takes the following optional arguments:
40+
### Detailed Validation (With Error Messages)
41+
42+
```python
43+
from python_usernames import validate_username
44+
45+
>>> result = validate_username("user@name")
46+
>>> result.is_valid
47+
False
48+
>>> result.message
49+
"Username contains invalid characters: '@'. Only alphanumeric characters, underscore (_), dash (-), and dot (.) are allowed"
50+
>>> result.suggested_fix
51+
'username'
52+
53+
>>> result = validate_username("john_doe")
54+
>>> result.is_valid
55+
True
56+
```
57+
58+
### Options
59+
60+
Both **is\_safe\_username** and **validate\_username** take the following optional arguments:
3861

3962
- `whitelist`: a case insensitive list of words that should be
4063
considered as always safe. Default: `[]`
@@ -43,10 +66,24 @@ False # contains non-url friendly `!`
4366
- `max_length`: specify the maximun character a username can have.
4467
Default: `None`
4568

46-
- `regex`: regular expression string that must pass before the banned
47-
: words is checked.
69+
- `regex`: regular expression string that must pass before the banned
70+
words is checked. **Note**: When a custom regex is provided, only that
71+
regex, max_length, and reserved word checks are performed. Built-in format
72+
checks (like consecutive special characters) are skipped.
73+
74+
### Validation Reasons
75+
76+
The `validate_username()` function returns a `ValidationResult` with:
4877

49-
The default regular expression is as follows:
78+
- **is_valid**: `True` if valid, `False` otherwise
79+
- **reason**: Specific validation failure reason (enum)
80+
- **message**: Human-readable error message
81+
- **suggested_fix**: Automatic correction suggestion (when available)
82+
83+
Available reasons: `VALID`, `TOO_LONG`, `EMPTY`, `ONLY_UNDERSCORE`, `STARTS_WITH_SPECIAL`,
84+
`ENDS_WITH_SPECIAL`, `CONSECUTIVE_SPECIAL`, `INVALID_CHARACTERS`, `RESERVED_WORD`
85+
86+
### Default Regular Expression
5087

5188
^ # beginning of string
5289
(?!_$) # no only _
@@ -56,6 +93,30 @@ The default regular expression is as follows:
5693
(?<![.-]) # no - or . at the end
5794
$ # end of string
5895

96+
### Real-World Example
97+
98+
```python
99+
from python_usernames import validate_username
100+
101+
def register_user(username: str):
102+
result = validate_username(username, max_length=20)
103+
104+
if not result.is_valid:
105+
print(f"Error: {result.message}")
106+
if result.suggested_fix:
107+
print(f"Try: '{result.suggested_fix}'")
108+
return False
109+
110+
# Create user account
111+
return True
112+
```
113+
114+
## Documentation
115+
116+
- [Detailed Validation Guide](DETAILED_VALIDATION.md) - Comprehensive usage examples
117+
- [Migration Guide](MIGRATION_GUIDE.md) - How to upgrade from `is_safe_username()`
118+
- [Examples](examples/detailed_validation.py) - Real-world usage patterns
119+
59120
## Credits
60121

61122
- [The-Big-Username-Blocklist](https://github.com/marteinn/The-Big-Username-Blocklist)

src/python_usernames/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
from .validators import is_safe_username
1+
from .validators import (
2+
is_safe_username,
3+
validate_username,
4+
ValidationResult,
5+
ValidationReason,
6+
)
27

38
__version__ = "0.4.1"
49

5-
__all__ = ["is_safe_username"]
10+
__all__ = [
11+
"is_safe_username",
12+
"validate_username",
13+
"ValidationResult",
14+
"ValidationReason",
15+
]

src/python_usernames/validators.py

Lines changed: 180 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,34 @@
11
import re
2+
from dataclasses import dataclass
3+
from enum import Enum
24

35
from .reserved_words import get_reserved_words
46

57

8+
class ValidationReason(Enum):
9+
"""Enum representing different validation failure reasons."""
10+
11+
VALID = "valid"
12+
TOO_LONG = "too_long"
13+
EMPTY = "empty"
14+
ONLY_UNDERSCORE = "only_underscore"
15+
STARTS_WITH_SPECIAL = "starts_with_special"
16+
ENDS_WITH_SPECIAL = "ends_with_special"
17+
CONSECUTIVE_SPECIAL = "consecutive_special"
18+
INVALID_CHARACTERS = "invalid_characters"
19+
RESERVED_WORD = "reserved_word"
20+
21+
22+
@dataclass
23+
class ValidationResult:
24+
"""Result of username validation with detailed information."""
25+
26+
is_valid: bool
27+
reason: ValidationReason
28+
message: str
29+
suggested_fix: str | None = None
30+
31+
632
username_regex = re.compile(
733
r"""
834
^ # beginning of string
@@ -17,16 +43,127 @@
1743
)
1844

1945

20-
def is_safe_username(
46+
def validate_username(
2147
username: str, whitelist=None, blacklist=None, regex=username_regex, max_length=None
22-
) -> bool:
48+
) -> ValidationResult:
49+
"""
50+
Validate a username and return detailed validation result.
51+
52+
Args:
53+
username: The username to validate
54+
whitelist: List of words that should be considered safe (case insensitive)
55+
blacklist: List of words that should be considered unsafe (case insensitive)
56+
regex: Regular expression pattern for validation. If a custom regex is provided,
57+
only that regex will be used (skipping detailed format checks)
58+
max_length: Maximum allowed length for username
59+
60+
Returns:
61+
ValidationResult with is_valid, reason, message, and optional suggested_fix
62+
63+
Note:
64+
When a custom regex is provided, only max_length, regex, and reserved word
65+
checks are performed. Built-in format checks are skipped.
66+
"""
67+
# Determine if custom regex is provided
68+
is_custom_regex = regex is not username_regex
69+
2370
# check for max length
2471
if max_length and len(username) > max_length:
25-
return False
72+
return ValidationResult(
73+
is_valid=False,
74+
reason=ValidationReason.TOO_LONG,
75+
message=f"Username must be {max_length} characters or less (currently {len(username)} characters)",
76+
suggested_fix=username[:max_length] if username[:max_length] else None,
77+
)
78+
79+
# If custom regex provided, skip detailed checks and only use regex + reserved words
80+
if is_custom_regex:
81+
# Check against custom regex
82+
if not re.match(regex, username):
83+
return ValidationResult(
84+
is_valid=False,
85+
reason=ValidationReason.INVALID_CHARACTERS,
86+
message="Username does not match the required format",
87+
suggested_fix=None,
88+
)
89+
else:
90+
# Use detailed built-in validation checks
91+
# check for empty username
92+
if not username:
93+
return ValidationResult(
94+
is_valid=False,
95+
reason=ValidationReason.EMPTY,
96+
message="Username cannot be empty",
97+
suggested_fix=None,
98+
)
99+
100+
# check for only underscore
101+
if username == "_":
102+
return ValidationResult(
103+
is_valid=False,
104+
reason=ValidationReason.ONLY_UNDERSCORE,
105+
message="Username cannot be only an underscore",
106+
suggested_fix=None,
107+
)
26108

27-
# check against provided regex
28-
if not re.match(regex, username):
29-
return False
109+
# check if starts with special character
110+
if username[0] in "-.":
111+
return ValidationResult(
112+
is_valid=False,
113+
reason=ValidationReason.STARTS_WITH_SPECIAL,
114+
message="Username cannot start with a dash (-) or dot (.)",
115+
suggested_fix=username.lstrip("-."),
116+
)
117+
118+
# check if ends with special character
119+
if username[-1] in "-.":
120+
return ValidationResult(
121+
is_valid=False,
122+
reason=ValidationReason.ENDS_WITH_SPECIAL,
123+
message="Username cannot end with a dash (-) or dot (.)",
124+
suggested_fix=username.rstrip("-."),
125+
)
126+
127+
# check for consecutive special characters
128+
consecutive_patterns = ["__", "..", "--", "_.", "._", "-.", ".-"]
129+
found_pattern = next((p for p in consecutive_patterns if p in username), None)
130+
if found_pattern:
131+
return ValidationResult(
132+
is_valid=False,
133+
reason=ValidationReason.CONSECUTIVE_SPECIAL,
134+
message=f"Username cannot contain consecutive special characters like '{found_pattern}'",
135+
suggested_fix=None,
136+
)
137+
138+
# check for invalid characters
139+
valid_chars = set(
140+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-"
141+
)
142+
invalid_chars = [c for c in username if c not in valid_chars]
143+
if invalid_chars:
144+
unique_invalid = list(
145+
dict.fromkeys(invalid_chars)
146+
) # preserve order, remove duplicates
147+
invalid_display = ", ".join(
148+
f"'{c}'" for c in unique_invalid[:5]
149+
) # show first 5
150+
if len(unique_invalid) > 5:
151+
invalid_display += ", ..."
152+
return ValidationResult(
153+
is_valid=False,
154+
reason=ValidationReason.INVALID_CHARACTERS,
155+
message=f"Username contains invalid characters: {invalid_display}. Only alphanumeric characters, underscore (_), dash (-), and dot (.) are allowed",
156+
suggested_fix="".join(c for c in username if c in valid_chars) or None,
157+
)
158+
159+
# final regex check (should pass if all above checks passed)
160+
if not re.match(regex, username):
161+
return ValidationResult(
162+
is_valid=False,
163+
reason=ValidationReason.INVALID_CHARACTERS,
164+
message="Username format is invalid",
165+
suggested_fix=None,
166+
)
30167

31168
# ensure the word is not in the blacklist and is not a reserved word
32169
if whitelist is None:
@@ -47,4 +184,40 @@ def is_safe_username(
47184
default_words = default_words - whitelist
48185
default_words = default_words.union(blacklist)
49186

50-
return False if username.lower() in default_words else True
187+
if username.lower() in default_words:
188+
return ValidationResult(
189+
is_valid=False,
190+
reason=ValidationReason.RESERVED_WORD,
191+
message=f"Username '{username}' is not allowed (reserved word or in blocklist)",
192+
suggested_fix=None,
193+
)
194+
195+
return ValidationResult(
196+
is_valid=True,
197+
reason=ValidationReason.VALID,
198+
message="Username is valid",
199+
suggested_fix=None,
200+
)
201+
202+
203+
def is_safe_username(
204+
username: str, whitelist=None, blacklist=None, regex=username_regex, max_length=None
205+
) -> bool:
206+
"""
207+
Check if a username is safe/valid (backward compatible function).
208+
209+
Args:
210+
username: The username to validate
211+
whitelist: List of words that should be considered safe (case insensitive)
212+
blacklist: List of words that should be considered unsafe (case insensitive)
213+
regex: Regular expression pattern for validation
214+
max_length: Maximum allowed length for username
215+
216+
Returns:
217+
bool: True if username is valid, False otherwise
218+
219+
Note:
220+
For detailed validation information, use validate_username() instead.
221+
"""
222+
result = validate_username(username, whitelist, blacklist, regex, max_length)
223+
return result.is_valid

0 commit comments

Comments
 (0)