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
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1"""
2Common validation functions for NexusLIMS TUI applications.
4These validators return (is_valid, error_message) tuples for use in
5form validation UI.
6"""
8from pathlib import Path
9from urllib.parse import urlparse
11import pytz
14def validate_required(value: str | None, field_name: str = "Field") -> tuple[bool, str]:
15 """
16 Validate that a required field has a value.
18 Parameters
19 ----------
20 value : str | None
21 Field value to validate
22 field_name : str
23 Human-readable field name for error messages
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, ""
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.
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
50 Returns
51 -------
52 tuple[bool, str]
53 (is_valid, error_message)
54 """
55 if value is None:
56 return True, ""
58 if len(value) > max_len:
59 return False, f"{field_name} must be at most {max_len} characters"
60 return True, ""
63def validate_timezone(tz_str: str | None) -> tuple[bool, str]:
64 """
65 Validate IANA timezone string.
67 Parameters
68 ----------
69 tz_str : str | None
70 Timezone string to validate (e.g., "America/New_York")
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"
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 )
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 = []
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
109 return matches
112def validate_url(url: str | None, field_name: str = "URL") -> tuple[bool, str]:
113 """
114 Validate HTTP(S) URL.
116 Parameters
117 ----------
118 url : str | None
119 URL to validate
120 field_name : str
121 Human-readable field name for error messages
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"
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"
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]
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"
148 return True, ""
149 except Exception:
150 return False, f"{field_name} is not a valid URL"
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.
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
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"
176 path_obj = Path(path)
178 if must_exist and not path_obj.exists():
179 return False, f"{field_name} does not exist: {path}"
181 return True, ""
184def validate_ip_address(ip: str | None) -> tuple[bool, str]:
185 """
186 Validate IPv4 address format.
188 Parameters
189 ----------
190 ip : str | None
191 IP address to validate
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
201 parts = ip.split(".")
202 if len(parts) != 4:
203 return False, "IP address must have 4 octets (e.g., 192.168.1.1)"
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"