11import re
2+ from dataclasses import dataclass
3+ from enum import Enum
24
35from .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+
632username_regex = re .compile (
733 r"""
834 ^ # beginning of string
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