Source code for nexusLIMS.tui.common.validators

"""
Common validation functions for NexusLIMS TUI applications.

These validators return (is_valid, error_message) tuples for use in
form validation UI.
"""

from pathlib import Path
from urllib.parse import urlparse

import pytz


[docs] def validate_required(value: str | None, field_name: str = "Field") -> tuple[bool, str]: """ Validate that a required field has a value. Parameters ---------- value : str | None Field value to validate field_name : str Human-readable field name for error messages Returns ------- tuple[bool, str] (is_valid, error_message) """ if value is None or value.strip() == "": return False, f"{field_name} is required" return True, ""
[docs] def validate_max_length( value: str | None, max_len: int, field_name: str = "Field" ) -> tuple[bool, str]: """ Validate that a field does not exceed maximum length. Parameters ---------- value : str | None Field value to validate max_len : int Maximum allowed length field_name : str Human-readable field name for error messages Returns ------- tuple[bool, str] (is_valid, error_message) """ if value is None: return True, "" if len(value) > max_len: return False, f"{field_name} must be at most {max_len} characters" return True, ""
[docs] def validate_timezone(tz_str: str | None) -> tuple[bool, str]: """ Validate IANA timezone string. Parameters ---------- tz_str : str | None Timezone string to validate (e.g., "America/New_York") Returns ------- tuple[bool, str] (is_valid, error_message) """ if tz_str is None or tz_str.strip() == "": return False, "Timezone is required" try: pytz.timezone(tz_str) return True, "" except pytz.UnknownTimeZoneError: # Try to suggest similar timezones suggestions = _find_similar_timezones(tz_str) if suggestions: suggestion_str = ", ".join(suggestions[:3]) return ( False, f'Unknown timezone "{tz_str}". Did you mean: "{suggestion_str}"?', ) return ( False, f'Unknown timezone "{tz_str}". Use IANA format (e.g., America/New_York)', )
def _find_similar_timezones(tz_str: str, limit: int = 5) -> list[str]: """Find timezones similar to the input string (fuzzy matching).""" tz_str_lower = tz_str.lower() matches = [] for tz in pytz.all_timezones: if tz_str_lower in tz.lower(): matches.append(tz) if len(matches) >= limit: break return matches
[docs] def validate_url(url: str | None, field_name: str = "URL") -> tuple[bool, str]: """ Validate HTTP(S) URL. Parameters ---------- url : str | None URL to validate field_name : str Human-readable field name for error messages Returns ------- tuple[bool, str] (is_valid, error_message) """ if url is None or url.strip() == "": return False, f"{field_name} is required" try: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False, f"{field_name} must start with http:// or https://" if not parsed.netloc: return False, f"{field_name} is not a valid URL" # Validate netloc format to catch malformed URLs like "user:pass@:port/path" netloc = parsed.netloc # Remove userinfo if present (everything before @) if "@" in netloc: netloc = netloc.split("@", 1)[1] # Check for invalid port specifications (e.g., ":port" with no hostname) if netloc.startswith(":"): return False, f"{field_name} is not a valid URL" return True, "" except Exception: return False, f"{field_name} is not a valid URL"
[docs] def validate_path( path: str | None, must_exist: bool = False, field_name: str = "Path" ) -> tuple[bool, str]: """ Validate file system path. Parameters ---------- path : str | None Path to validate must_exist : bool If True, path must already exist on disk field_name : str Human-readable field name for error messages Returns ------- tuple[bool, str] (is_valid, error_message) """ if path is None or path.strip() == "": return False, f"{field_name} is required" path_obj = Path(path) if must_exist and not path_obj.exists(): return False, f"{field_name} does not exist: {path}" return True, ""
[docs] def validate_ip_address(ip: str | None) -> tuple[bool, str]: """ Validate IPv4 address format. Parameters ---------- ip : str | None IP address to validate Returns ------- tuple[bool, str] (is_valid, error_message) """ if ip is None or ip.strip() == "": return True, "" # Optional field parts = ip.split(".") if len(parts) != 4: return False, "IP address must have 4 octets (e.g., 192.168.1.1)" try: for part in parts: num = int(part) if num < 0 or num > 255: return False, "Each octet must be between 0 and 255" return True, "" except ValueError: return False, "IP address must contain only numbers and dots"