Coverage for nexusLIMS/tui/common/validators.py: 100%

72 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1""" 

2Common validation functions for NexusLIMS TUI applications. 

3 

4These validators return (is_valid, error_message) tuples for use in 

5form validation UI. 

6""" 

7 

8from pathlib import Path 

9from urllib.parse import urlparse 

10 

11import pytz 

12 

13 

14def validate_required(value: str | None, field_name: str = "Field") -> tuple[bool, str]: 

15 """ 

16 Validate that a required field has a value. 

17 

18 Parameters 

19 ---------- 

20 value : str | None 

21 Field value to validate 

22 field_name : str 

23 Human-readable field name for error messages 

24 

25 Returns 

26 ------- 

27 tuple[bool, str] 

28 (is_valid, error_message) 

29 """ 

30 if value is None or value.strip() == "": 

31 return False, f"{field_name} is required" 

32 return True, "" 

33 

34 

35def validate_max_length( 

36 value: str | None, max_len: int, field_name: str = "Field" 

37) -> tuple[bool, str]: 

38 """ 

39 Validate that a field does not exceed maximum length. 

40 

41 Parameters 

42 ---------- 

43 value : str | None 

44 Field value to validate 

45 max_len : int 

46 Maximum allowed length 

47 field_name : str 

48 Human-readable field name for error messages 

49 

50 Returns 

51 ------- 

52 tuple[bool, str] 

53 (is_valid, error_message) 

54 """ 

55 if value is None: 

56 return True, "" 

57 

58 if len(value) > max_len: 

59 return False, f"{field_name} must be at most {max_len} characters" 

60 return True, "" 

61 

62 

63def validate_timezone(tz_str: str | None) -> tuple[bool, str]: 

64 """ 

65 Validate IANA timezone string. 

66 

67 Parameters 

68 ---------- 

69 tz_str : str | None 

70 Timezone string to validate (e.g., "America/New_York") 

71 

72 Returns 

73 ------- 

74 tuple[bool, str] 

75 (is_valid, error_message) 

76 """ 

77 if tz_str is None or tz_str.strip() == "": 

78 return False, "Timezone is required" 

79 

80 try: 

81 pytz.timezone(tz_str) 

82 return True, "" 

83 except pytz.UnknownTimeZoneError: 

84 # Try to suggest similar timezones 

85 suggestions = _find_similar_timezones(tz_str) 

86 if suggestions: 

87 suggestion_str = ", ".join(suggestions[:3]) 

88 return ( 

89 False, 

90 f'Unknown timezone "{tz_str}". Did you mean: "{suggestion_str}"?', 

91 ) 

92 return ( 

93 False, 

94 f'Unknown timezone "{tz_str}". Use IANA format (e.g., America/New_York)', 

95 ) 

96 

97 

98def _find_similar_timezones(tz_str: str, limit: int = 5) -> list[str]: 

99 """Find timezones similar to the input string (fuzzy matching).""" 

100 tz_str_lower = tz_str.lower() 

101 matches = [] 

102 

103 for tz in pytz.all_timezones: 

104 if tz_str_lower in tz.lower(): 

105 matches.append(tz) 

106 if len(matches) >= limit: 

107 break 

108 

109 return matches 

110 

111 

112def validate_url(url: str | None, field_name: str = "URL") -> tuple[bool, str]: 

113 """ 

114 Validate HTTP(S) URL. 

115 

116 Parameters 

117 ---------- 

118 url : str | None 

119 URL to validate 

120 field_name : str 

121 Human-readable field name for error messages 

122 

123 Returns 

124 ------- 

125 tuple[bool, str] 

126 (is_valid, error_message) 

127 """ 

128 if url is None or url.strip() == "": 

129 return False, f"{field_name} is required" 

130 

131 try: 

132 parsed = urlparse(url) 

133 if parsed.scheme not in ("http", "https"): 

134 return False, f"{field_name} must start with http:// or https://" 

135 if not parsed.netloc: 

136 return False, f"{field_name} is not a valid URL" 

137 

138 # Validate netloc format to catch malformed URLs like "user:pass@:port/path" 

139 netloc = parsed.netloc 

140 # Remove userinfo if present (everything before @) 

141 if "@" in netloc: 

142 netloc = netloc.split("@", 1)[1] 

143 

144 # Check for invalid port specifications (e.g., ":port" with no hostname) 

145 if netloc.startswith(":"): 

146 return False, f"{field_name} is not a valid URL" 

147 

148 return True, "" 

149 except Exception: 

150 return False, f"{field_name} is not a valid URL" 

151 

152 

153def validate_path( 

154 path: str | None, must_exist: bool = False, field_name: str = "Path" 

155) -> tuple[bool, str]: 

156 """ 

157 Validate file system path. 

158 

159 Parameters 

160 ---------- 

161 path : str | None 

162 Path to validate 

163 must_exist : bool 

164 If True, path must already exist on disk 

165 field_name : str 

166 Human-readable field name for error messages 

167 

168 Returns 

169 ------- 

170 tuple[bool, str] 

171 (is_valid, error_message) 

172 """ 

173 if path is None or path.strip() == "": 

174 return False, f"{field_name} is required" 

175 

176 path_obj = Path(path) 

177 

178 if must_exist and not path_obj.exists(): 

179 return False, f"{field_name} does not exist: {path}" 

180 

181 return True, "" 

182 

183 

184def validate_ip_address(ip: str | None) -> tuple[bool, str]: 

185 """ 

186 Validate IPv4 address format. 

187 

188 Parameters 

189 ---------- 

190 ip : str | None 

191 IP address to validate 

192 

193 Returns 

194 ------- 

195 tuple[bool, str] 

196 (is_valid, error_message) 

197 """ 

198 if ip is None or ip.strip() == "": 

199 return True, "" # Optional field 

200 

201 parts = ip.split(".") 

202 if len(parts) != 4: 

203 return False, "IP address must have 4 octets (e.g., 192.168.1.1)" 

204 

205 try: 

206 for part in parts: 

207 num = int(part) 

208 if num < 0 or num > 255: 

209 return False, "Each octet must be between 0 and 255" 

210 return True, "" 

211 except ValueError: 

212 return False, "IP address must contain only numbers and dots"