Source code for kstlib.utils.validators

"""Validation utilities used across kstlib."""

from __future__ import annotations

import importlib
import re
from dataclasses import dataclass
from email.utils import formataddr, parseaddr
from typing import TYPE_CHECKING

from kstlib.config.exceptions import KstlibError

if TYPE_CHECKING:
    from collections.abc import Iterable
else:  # pragma: no cover - runtime alias for delayed evaluation
    Iterable = importlib.import_module("collections.abc").Iterable

_EMAIL_PATTERN = re.compile(r"^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$")

LOCAL_PART_MAX_LENGTH = 64
DOMAIN_MAX_LENGTH = 255
LABEL_MAX_LENGTH = 63
MIN_LABEL_COUNT = 2
MIN_TLD_LENGTH = 2

#: Shared pattern for callable targets (``module.path:function_name``).
#: Used by :mod:`kstlib.pipeline.validators` and
#: :mod:`kstlib.transform.validators`. Keep in sync with both modules.
CALLABLE_TARGET_PATTERN: re.Pattern[str] = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]*:[a-zA-Z_][a-zA-Z0-9_]*$")

#: Maximum callable target length (bytes).
MAX_CALLABLE_TARGET_LENGTH: int = 256


[docs] class ValidationError(KstlibError, ValueError): """Raised when user supplied values fail validation."""
def _validate_callable_target_str(target: str) -> None: """Validate a callable target string. Shared by pipeline and transform modules; each caller wraps the raised ``ValueError`` in its module-specific exception type. Args: target: Callable target in the form ``module.path:function_name``. Raises: ValueError: If the target is empty, exceeds the length limit, or does not match :data:`CALLABLE_TARGET_PATTERN`. """ if not target: raise ValueError("Callable target must not be empty") if len(target) > MAX_CALLABLE_TARGET_LENGTH: raise ValueError(f"Callable target too long: {len(target)} > {MAX_CALLABLE_TARGET_LENGTH}") if not CALLABLE_TARGET_PATTERN.match(target): raise ValueError(f"Invalid callable target: {target!r}. Expected format: module.path:function_name")
[docs] @dataclass(frozen=True, slots=True) class EmailAddress: """Normalized representation of an email address.""" name: str address: str @property def formatted(self) -> str: """Return ``"Name <email@domain>"`` if a display name is present.""" if not self.name: return self.address sanitized = self.name.replace("\r", " ").replace("\n", " ").strip() if not sanitized: return self.address sanitized = sanitized.replace('"', "'") return formataddr((sanitized, self.address))
[docs] def parse_email_address(value: str) -> EmailAddress: """Parse *value* into a validated :class:`EmailAddress`. Args: value: Raw email string, optionally containing a display name. Returns: A normalized :class:`EmailAddress` instance. Raises: ValidationError: If the string does not contain a valid address. Examples: >>> parse_email_address("Ada Lovelace <ADA@example.COM>").formatted 'Ada Lovelace <ada@example.com>' >>> parse_email_address("foo@bar") Traceback (most recent call last): ... kstlib.utils.validators.ValidationError: Invalid email address: 'foo@bar' """ if not value: raise ValidationError("Email address cannot be empty") name, address = parseaddr(value) address = address.strip().lower() if name: start = value.find("<") end = value.rfind(">") candidate = value[start + 1 : end].strip() if start != -1 and end != -1 and end > start else address else: candidate = value.strip() if candidate.lower() != address: raise ValidationError(f"Invalid email address: {value!r}") if not _EMAIL_PATTERN.match(address): raise ValidationError(f"Invalid email address: {value!r}") local_part, _, domain_part = address.partition("@") if len(local_part) == 0 or len(local_part) > LOCAL_PART_MAX_LENGTH: raise ValidationError(f"Invalid email address: {value!r}") if len(domain_part) == 0 or len(domain_part) > DOMAIN_MAX_LENGTH: raise ValidationError(f"Invalid email address: {value!r}") labels = domain_part.split(".") if len(labels) < MIN_LABEL_COUNT or any(len(label) == 0 or len(label) > LABEL_MAX_LENGTH for label in labels): raise ValidationError(f"Invalid email address: {value!r}") if len(labels[-1]) < MIN_TLD_LENGTH: raise ValidationError(f"Invalid email address: {value!r}") name = name.strip() return EmailAddress(name=name, address=address)
[docs] def normalize_address_list(values: Iterable[str]) -> list[EmailAddress]: """Validate and normalize a sequence of email addresses. Examples: >>> normalize_address_list([ ... "Ada Lovelace <ada@example.com>", ... "grace@example.net", ... ]) # doctest: +NORMALIZE_WHITESPACE [EmailAddress(name='Ada Lovelace', address='ada@example.com'), EmailAddress(name='', address='grace@example.net')] """ return [parse_email_address(value) for value in values]
__all__ = [ "EmailAddress", "ValidationError", "normalize_address_list", "parse_email_address", ]