From 6e7919fbc9c7b2d2a125ddba37b4091339918a36 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Wed, 10 Apr 2024 12:36:47 +0100 Subject: [PATCH 01/29] Started rewriting zon's API to better mirror zod's --- CHANGELOG.md | 16 + pyproject.toml | 12 + requirements.txt | 3 +- zon-old/__init__-old.py | 113 ++++ zon-old/__init__.py | 703 ++++++++++++++++++++++ {zon => zon-old}/any.py | 0 {zon => zon-old}/base.py | 17 +- {zon => zon-old}/bool.py | 8 +- {zon => zon-old}/element_list/__init__.py | 8 +- zon-old/error.py | 61 ++ {zon => zon-old}/none.py | 4 +- {zon => zon-old}/number/float.py | 4 +- {zon => zon-old}/number/int.py | 4 +- {zon => zon-old}/record.py | 12 +- {zon => zon-old}/union.py | 0 zon/__init__.py | 162 ++--- zon/error.py | 58 +- zon/number/__init__.py | 181 ------ zon/str/__init__.py | 110 ---- zon/traits/collection.py | 78 --- 20 files changed, 1076 insertions(+), 478 deletions(-) create mode 100644 zon-old/__init__-old.py create mode 100644 zon-old/__init__.py rename {zon => zon-old}/any.py (100%) rename {zon => zon-old}/base.py (93%) rename {zon => zon-old}/bool.py (80%) rename {zon => zon-old}/element_list/__init__.py (74%) create mode 100644 zon-old/error.py rename {zon => zon-old}/none.py (75%) rename {zon => zon-old}/number/float.py (77%) rename {zon => zon-old}/number/int.py (77%) rename {zon => zon-old}/record.py (68%) rename {zon => zon-old}/union.py (100%) delete mode 100644 zon/number/__init__.py delete mode 100644 zon/str/__init__.py delete mode 100644 zon/traits/collection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e368f55..81ba844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added more string validation methods. +- `parse` and `safe_parse` methods. +- Added `opts` arg to `ZonString.regex` method to allow defining custom error messages. +- Added `opts` arg to `ZonString.ip` method to allow defining custom error messages and specify the IP version to check against. +- Added `unwrap` method to `ZonOptional`. + +### Changed +- `optional` is now a method inside every `Zon` object. +- Moved `ZonCollection` and `ZonString` to the base file. +- Deprecated `validate` (and its private variant `_validate`) in favor of `parse` and `safe_parse` methods. +- Deprecated `ValidationError` in favor of `ZonError`. + +### Removed +- Removed `between`, `__eq__` and `equals` methods from `ZonNumber`. + ## [1.1.0] - 2024-04-10 ### Added diff --git a/pyproject.toml b/pyproject.toml index 1efafbd..eb7120f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,18 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +dependencies = [ + "validators>0.28", + "typing_extensions>4.12" +] + +[project.optional-dependencies] +dev = [ + "black", + "build", + "pylint", + "pytest", +] [project.urls] Homepage = "https://github.com/Naapperas/zon" diff --git a/requirements.txt b/requirements.txt index 2004392..014145f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,4 @@ build==1.0.3 pylint==3.0.2 pytest==7.4.3 twine==4.0.2 -validate-email==1.3 - +validators==0.28.3 diff --git a/zon-old/__init__-old.py b/zon-old/__init__-old.py new file mode 100644 index 0000000..367a2cc --- /dev/null +++ b/zon-old/__init__-old.py @@ -0,0 +1,113 @@ +"""Validator package. + +The purpose of this package is to provide a set of functions to validate data, using a Zod-like syntax. +""" + +__version__ = "1.0.0" +__author__ = "Nuno Pereira" +__email__ = "nunoafonso2002@gmail.com" +__license__ = "MIT" +__copyright__ = "Copyright 2023, Nuno Pereira" + +from zon.number.float import ZonFloat +from zon.number.int import ZonInteger +from zon.base import Zon +from zon.bool import ZonBoolean +from zon.element_list import ZonList +from zon.str import ZonString +from zon.union import ZonUnion +from zon.record import ZonRecord +from zon.any import ZonAny +from zon.none import ZonNone + + +def string() -> "ZonString": + """Creates a new Zon that validates that the data is a string. + + Returns: + ZonString: a new validator that validates that the data is a string. + """ + return ZonString() + + +def integer() -> "ZonInteger": + """Creates a new Zon that validates that the data is an integer. + + Returns: + ZonInteger: a new validator that validates that the data is an integer + """ + return ZonInteger() + + +def floating_point() -> "ZonFloat": + """Creates a new Zon that validates that the data is a floating point number. + + Returns: + ZonFloat: a new validator that validates that the data is a floating point number. + """ + return ZonFloat() + + +def boolean() -> "ZonBoolean": + """Creates a new Zon that validates that the data is a boolean. + + Returns: + ZonBoolean: a new validator that validates that the data is a boolean. + """ + return ZonBoolean() + + +def element_list(element_type: "Zon") -> "ZonList": + """Creates a new Zon that validates that the data is a list of elements of the specified type. + + Args: + element_type (Zon): the type of the elements of the list. + + Returns: + ZonList: a new validator that validates that the data is + a list of elements of the specified type. + """ + return ZonList(element_type) + + +def union(types: list["Zon"]) -> "ZonUnion": + """Creates a new Zon that validates that the data is one of the specified types. + + Args: + types (list[Zon]): the types of the data to be validated. + + Returns: + ZonUnion: a new validator that validates that the data is one of the specified types. + """ + return ZonUnion(types) + + +def record(properties: dict[str, "Zon"]) -> "ZonRecord": + """Creates a new Zon that validates that the data is an object with the specified properties. + + Args: + properties (dict[str, Zon]): the properties of the object to be validated. + + Returns: + ZonRecord: a new validator that validates that the data is + an object with the specified properties. + """ + return ZonRecord(properties) + + +def none() -> "ZonNone": + """Creates a new Zon that validates that the data is None. + + Returns: + ZonNone: a new validator that validates that the data is None. + """ + return ZonNone() + + +def anything() -> "ZonAny": + """Creates a new Zon that validates anything. + + Returns: + ZonAny: a new validator that validates anything. + """ + return ZonAny() diff --git a/zon-old/__init__.py b/zon-old/__init__.py new file mode 100644 index 0000000..5721850 --- /dev/null +++ b/zon-old/__init__.py @@ -0,0 +1,703 @@ +"""Validator package. + +The purpose of this package is to provide a set of functions to validate data, using a Zod-like syntax. +""" + +from __future__ import annotations + +__version__ = "2.0.0" +__author__ = "Nuno Pereira" +__email__ = "nunoafonso2002@gmail.com" +__license__ = "MIT" +__copyright__ = "Copyright 2023, Nuno Pereira" + +from abc import ABC, abstractmethod +from typing import final, Callable, TypeVar, Self, Any +import copy +import re +from deprecation import deprecated + +import validators +from dateutil.parser import parse + + +from .error import ZonError + +T = TypeVar("T") +ValidationRule = Callable[[T], bool] + + +class Zon(ABC): + """ + Base class for all Zons. + + A Zon is the basic unit of validation in Zon. + It is used to validate data, and can be composed with other Zons + to create more complex validations. + """ + + def __init__(self): + self.errors: list[ZonError] = [] + """List of validation errors accumulated""" + + self.validators: dict[str, ValidationRule] = {} + """validators that will run when 'validate' is invoked.""" + + self._setup() + + def _clone(self) -> Self: + """Creates a copy of this Zon.""" + return copy.deepcopy(self) + + def _add_error(self, error: ZonError): + """Adds an error to this Zon's error output. + + Args: + error (ZonError): the validation error to add. + """ + self.errors.append(error) + + @abstractmethod + def _setup(self) -> None: + """Sets up the Zon with default validation rules. + + This implies that a '_default_' rule will be present, otherwise the validation fails. + + This method is called when the Zon is created. + """ + + @final + @deprecated(deprecated_in="2.0.0", current_version=__version__) + def _validate(self, data: T) -> bool: + """Validates the supplied data. + + Args: + data (Any): the piece of data to be validated. + + Returns: + bool: True if the data is valid, False otherwise. + """ + + if "_default_" not in self.validators or not self.validators["_default_"]: + self._add_error( + ZonError(f"Zon of type {type(self)} must have a valid '_default_' rule") + ) + return False + + # TODO: better error messages + return all(validator(data) for (_, validator) in self.validators.items()) + + @deprecated( + deprecated_in="2.0.0", + current_version=__version__, + details="This method does not exist in the original 'zod' code. Please use 'parse' and 'safe_parse' to better reflect zod's API", + ) + @final + def validate(self, data: T) -> bool: + """Validates the supplied data. + + Args: + data (Any): the piece of data to be validated. + + Raises: + NotImplementedError: the default implementation of this method + is not implemented on base Zon class. + """ + if type(self) is Zon: # pylint: disable=unidiomatic-typecheck + raise NotImplementedError( + "validate() method not implemented on base Zon class" + ) + + return self._validate(data) + + def parse(self, data: T) -> T: + """Parses the supplied data, validating it and returning it if it is valid. Raises an exception if the data is invalid + + Args: + data (T): the data to be parsed. + + Raises: + NotImplementedError: the default implementation of this method is not implemented, so an exception is raised + ZonError: if a some member of the validation chains fails when validation some of the data. + + Returns: + T: the data given as input if it is valid. + """ + + if type(self) is Zon: # pylint: disable=unidiomatic-typecheck + raise NotImplementedError( + "validate() method not implemented on base Zon class" + ) + + if not all(validator(data) for validator in self.validators.values()): + raise ZonError(f"Error parsing data: {self.errors}") + + return data + + def safe_parse(self, data: T) -> tuple[bool, T] | tuple[bool, ZonError]: + """Parses the supplied data, but unlike `parse` this method does not throw. Instead it returns a tuple in the form () + + Args: + data (T): _description_ + + Raises: + NotImplementedError: _description_ + e: _description_ + + Returns: + dict[str, bool | T]: _description_ + """ + + if type(self) is Zon: # pylint: disable=unidiomatic-typecheck + raise NotImplementedError( + "validate() method not implemented on base Zon class" + ) + + try: + result = self.parse(data) + + return {"success": True, "data": result} + except ZonError as e: + return {"success": False, "errors": str(e)} # TODO: improve this + except NotImplementedError as e: + raise e # we should never get here, just rethrow for good measure + + def and_also(self, zon: "Zon") -> "ZonAnd": + """Creates a new Zon that validates that + the data is valid for both this Zon and the supplied Zon. + + Args: + zon (Zon): the Zon to be validated. + + Returns: + ZonAnd: a new validator that validates that + the data is valid for both this Zon and the supplied Zon. + """ + return ZonAnd(self, zon) + + def __and__(self, zon: "Zon") -> "ZonAnd": + return self.and_also(zon) + + def refine(self, refinement: Callable[[T], bool]) -> "Self": + """Creates a new Zon that validates the data with the supplied refinement. + + A refinement is a function that takes a piece of data and returns True if the data is valid or throws otherwise. + + Args: + refinement (Callable[[T], bool]): the refinement to be applied. + + Returns: + ZonRefined: a new validator that validates the data with the supplied refinement. + """ + _clone = self._clone() + + def _refinement_validate(data): + try: + return refinement(data) + except ZonError as e: + _clone._add_error(e) + return False + + if "_refined_" not in _clone.validators: + _clone.validators["_refined_"] = _refinement_validate + else: + current_refinement = _clone.validators["_refined_"] + + def _refined_validator(data): + return current_refinement(data) and _refinement_validate(data) + + _clone.validators["_refined_"] = _refined_validator + + return _clone + + def optional(self) -> "ZonOptional": + """Creates a new Zon that makes this validation chain optional. + + Returns: + ZonOptional: a new validator that makes any validation optional. + """ + return ZonOptional(self) + + +class ZonOptional(Zon): + """A Zon that makes its data validation optional.""" + + def __init__(self, zon): + super().__init__() + self.zon = zon + + def _setup(self): + self.validators["_default_"] = self._default_validate + + def _default_validate(self, data): + if data is None or not data: + return True + return self.zon.validate(data) + + def unwrap(self) -> Zon: + """Extracts the wrapped Zon from this ZonOptional. + + Returns: + Zon: the wrapped Zon + """ + + return self.zon + +class ZonAnd(Zon): + """A Zon that validates that the data is valid for both this Zon and the supplied Zon.""" + + def __init__(self, zon1: Zon, zon2: Zon): + super().__init__() + self.zon1 = zon1 + self.zon2 = zon2 + + def _setup(self): + self.validators["_default_"] = self._default_validate + + def _default_validate(self, data): + if not (self.zon1.validate(data) and self.zon2.validate(data)): + for error in self.zon1.errors: + self._add_error(error) + + for error in self.zon2.errors: + self._add_error(error) + + return False + + return True + + +class ZonCollection(Zon): + """ + A ZonCollection is a validator that abstracts any piece of data that might be a collection of something else. + """ + + def length(self, length: int) -> "Self": + """Assert that the value under validation has exactly 'length' elements. + + Args: + length (int): the exact length of the collection. + + Returns: + Self: a new Zon with the validation rule added + """ + + other = self._clone() + + def len_validate(data): + if len(data) != length: + other._add_error( + ZonError(f"Expected length to be {length}, got {len(data)}") + ) + return False + return True + + other.validators["len"] = len_validate + return other + + def min(self, min_length: int) -> "Self": + """Assert that the value under validation has at least as many elements as specified. + + Args: + min_length (int): the minimum length of the collection. + + Returns: + Self: a new zon with the validation rule added + """ + + other = self._clone() + + def min_len_validate(data): + if len(data) < min_length: + other._add_error( + ZonError( + f"Expected minimum length to be {min_length}, got {len(data)}" + ) + ) + return False + return True + + other.validators["min_len"] = min_len_validate + return other + + def max(self, max_length: int) -> "Self": + """Assert that the value under validation has at most as many elements as specified. + + Args: + max_length (int): the maximum length of the collection. + + Returns: + Self: a new zon with the validation rule added + """ + + other = self._clone() + + def max_len_validate(data): + if len(data) > max_length: + other._add_error( + ZonError( + f"Expected maximum length to be {max_length}, got {len(data)}" + ) + ) + return False + return True + + other.validators["max_len"] = max_len_validate + return other + + +class ZonString(ZonCollection): + """ + A Zon that validates that the data is a string. + + For all purposes, a string is a collection of characters. + """ + + def _setup(self) -> None: + self.validators["_default_"] = self._default_validate + + def _default_validate(self, data): + if not isinstance(data, str): + self._add_error(ZonError(f"Expected string, got {type(data)}")) + return False + return True + + def regex( + self, regex: str | re.Pattern[str], opts: dict[str, Any] | None = None + ) -> "ZonString": + """Assert that the value under validation matches a given regular expression. + + Args: + regex (str): the regex to use. + opts (dict[str, Any]): additional options. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def regex_validate(data): + if not re.match(regex, data): + if opts and "message" in opts: + other._add_error(ZonError(opts["message"])) + else: + other._add_error( + ZonError( + f"Expected string matching regex /{regex}/, got {data}" + ) + ) + + return False + return True + + other.validators["regex"] = regex_validate + return other + + def uuid(self) -> "ZonString": + """Assert that the value under validation is a valid UUID. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def uuid_validate(data): + if not validators.uuid(data): + other._add_error(ZonError(f"Expected valid UUID, got {data}")) + return False + return True + + other.validators["uuid"] = uuid_validate + return other + + def email(self): + """Assert that the value under validation is a valid email address. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def email_validate(data): + if not validators.email(data): + other._add_error(ZonError(f"Expected valid email address, got {data}")) + return False + + return True + + other.validators["email"] = email_validate + return other + + def emoji(self): + """Assert that the value under validation is a valid emoji. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def emoji_validate(data): + if not re.match( + "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$", data + ): + other._add_error(ZonError(f"Expected valid email address, got {data}")) + return False + + return True + + other.validators["emoji"] = emoji_validate + return other + + def url(self): + """Assert that the value under validation is a valid URL. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def url_validate(data): + if not validators.url(data): + other._add_error(ZonError(f"Expected valid url, got {data}")) + return False + + return True + + other.validators["url"] = url_validate + return other + + def ip(self, opts: dict[str, Any] | None = None): + """Assert that the value under validation is a valid IP address. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def ip_validate(data): + + valid = False + ipv4 = validators.ipv4(data) + ipv6 = validators.ipv6(data) + + match opts.get("version", None): + case None: + valid = ipv4 or ipv6 + case 6 | "6": + valid = ipv6 + case 4 | "4": + valid = ipv4 + + if not valid: + if opts and "message" in opts: + other._add_error(ZonError(opts["message"])) + else: + other._add_error(ZonError(f"Expected valid IP address, got {data}")) + return False + + return True + + other.validators["ip"] = ip_validate + return other + + def date(self): + """Assert that the value under validation is a valid IP address. + + Returns: + ZonString: a new zon with the validation rule added + """ + + raise NotImplementedError("Not implemented yet") + # return self.regex("[0-9]{4}-[0-9]{2}-[0-9]{2}") + + def time(self): + """Assert that the value under validation is a valid IP address. + + Returns: + ZonString: a new zon with the validation rule added + """ + + raise NotImplementedError("Not implemented yet") + # return self.regex("[0-9]{4}-[0-9]{2}-[0-9]{2}") + + def includes(self, substr: str): + """Assert that the value under validation includes the given string as a substring. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def includes_validate(data: str): + try: + data.index(substr) + return True + except ValueError: + other._add_error(ZonError(f"Expected '{data}' to include '{substr}'")) + return False + + other.validators["includes"] = includes_validate + return other + + def startswith(self, prefix: str): + """Assert that the value under validation is a string that starts with the given prefix. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def startswith_validate(data: str): + if not data.startswith(prefix): + other._add_error( + ZonError(f"Expected '{data}' to start with '{prefix}'") + ) + return False + + return True + + other.validators["startswith"] = startswith_validate + return other + + def endswith(self, suffix: str): + """Assert that the value under validation is a string that ends with the given suffix. + + Returns: + ZonString: a new zon with the validation rule added + """ + + other = self._clone() + + def endswith_validate(data: str): + if not data.endswith(suffix): + other._add_error(ZonError(f"Expected '{data}' to end with '{suffix}'")) + return False + + return True + + other.validators["endswith"] = endswith_validate + return other + + def nanoid(self, data: str): + + raise NotImplementedError("Not yet implemented") + + def cuid(self, data: str): + + raise NotImplementedError("Not yet implemented") + + def cuid2(self, data: str): + + raise NotImplementedError("Not yet implemented") + + def ulid(self, data: str): + + raise NotImplementedError("Not yet implemented") + + +class ZonNumber(Zon): + """A Zon that validates that the data is a number, i.e., an int or a float.""" + + def __gt__(self, other: int | float) -> "ZonNumber": + return self.gt(other) + + def gt(self, value: int | float ) -> "ZonNumber": + """Assert that the value under validation is greater than a given value. + + Args: + value (int | float): the minimum value + + Returns: + ZonNumber: a new zon with the validation rule added + """ + + other = self._clone() + + def gt_validate(data: int | float): + if data <= value: + other._add_error(ZonError(f"Expected number > {value}, got {data}")) + return False + return True + + other.validators["gt"] = gt_validate + return other + + def __ge__(self, other: int | float) -> "ZonNumber": + return self.gte(other) + + def gte(self, value: int | float) -> "ZonNumber": + """Assert that the value under validation is greater than or equal to a minimum value. + + Args: + value (int | float): the minimum value + + Returns: + ZonNumber: a new zon with the validation rule added + """ + + other = self._clone() + + def gte_validate(data: int | float): + if data < value: + other._add_error(ZonError(f"Expected number >= {value}, got {data}")) + return False + return True + + other.validators["gte"] = gte_validate + return other + + def __lt__(self, other: int | float) -> "ZonNumber": + return self.lt(other) + + def lt(self, value: int | float) -> "ZonNumber": + """Assert that the value under validation is less than a given value. + + Args: + value (int | float): the maximum value + + Returns: + ZonNumber: a new zon with the validation rule added + """ + + other = self._clone() + + def lt_validate(data: int | float): + if data >= value: + other._add_error(ZonError(f"Expected number < {value}, got {data}")) + return False + return True + + other.validators["lt"] = lt_validate + return other + + def __le__(self, other: int | float) -> "ZonNumber": + return self.lte(other) + + def lte(self, value: int | float) -> "ZonNumber": + """Assert that the value under validation is less than or equal to a maximum value. + + Args: + value (int | float): the maximum value + + Returns: + ZonNumber: a new zon with the validation rule added + """ + + other = self._clone() + + def lte_validate(data: int | float): + if data > value: + other._add_error(ZonError(f"Expected number <= {value}, got {data}")) + return False + return True + + other.validators["lte"] = lte_validate + return other diff --git a/zon/any.py b/zon-old/any.py similarity index 100% rename from zon/any.py rename to zon-old/any.py diff --git a/zon/base.py b/zon-old/base.py similarity index 93% rename from zon/base.py rename to zon-old/base.py index a9de5f9..78b249d 100644 --- a/zon/base.py +++ b/zon-old/base.py @@ -1,9 +1,10 @@ """File containing base Zon class and helper utilities.""" + from abc import ABC, abstractmethod from typing import final, Callable, TypeVar, Self import copy -from .error import ValidationError +from .error import ZonError T = TypeVar("T") ValidationRule = Callable[[T], bool] @@ -21,7 +22,7 @@ class Zon(ABC): """ def __init__(self): - self.errors: list[ValidationError] = [] + self.errors: list[ZonError] = [] """List of validation errors accumulated""" self.validators: dict[str, ValidationRule] = {} @@ -33,7 +34,7 @@ def _clone(self) -> Self: """Creates a copy of this Zon.""" return copy.deepcopy(self) - def _add_error(self, error: ValidationError): + def _add_error(self, error: ZonError): self.errors.append(error) @abstractmethod @@ -58,9 +59,7 @@ def _validate(self, data: T) -> bool: if "_default_" not in self.validators or not self.validators["_default_"]: self._add_error( - ValidationError( - f"Zon of type {type(self)} must have a valid '_default_' rule" - ) + ZonError(f"Zon of type {type(self)} must have a valid '_default_' rule") ) return False @@ -117,7 +116,7 @@ def refine(self, refinement: Callable[[T], bool]) -> "Self": def _refinement_validate(data): try: return refinement(data) - except ValidationError as e: + except ZonError as e: _clone._add_error(e) return False @@ -127,9 +126,7 @@ def _refinement_validate(data): current_refinement = _clone.validators["_refined_"] def _refined_validator(data): - return current_refinement(data) and _refinement_validate( - data - ) + return current_refinement(data) and _refinement_validate(data) _clone.validators["_refined_"] = _refined_validator diff --git a/zon/bool.py b/zon-old/bool.py similarity index 80% rename from zon/bool.py rename to zon-old/bool.py index 3dd02bd..4b92d42 100644 --- a/zon/bool.py +++ b/zon-old/bool.py @@ -1,7 +1,7 @@ """Class and methods related to the ZonBoolean validator.""" from .base import Zon -from .error import ValidationError +from .error import ZonError class ZonBoolean(Zon): @@ -12,7 +12,7 @@ def _setup(self) -> None: def _default_validate(self, data): if not isinstance(data, bool): - self._add_error(ValidationError(f"Expected boolean, got {type(data)}")) + self._add_error(ZonError(f"Expected boolean, got {type(data)}")) return False return True @@ -27,7 +27,7 @@ def true(self): def true_validate(data): if not data: - other._add_error(ValidationError(f"Expected True, got {data}")) + other._add_error(ZonError(f"Expected True, got {data}")) return False return True @@ -45,7 +45,7 @@ def false(self): def false_validate(data): if data: - other._add_error(ValidationError(f"Expected False, got {data}")) + other._add_error(ZonError(f"Expected False, got {data}")) return False return True diff --git a/zon/element_list/__init__.py b/zon-old/element_list/__init__.py similarity index 74% rename from zon/element_list/__init__.py rename to zon-old/element_list/__init__.py index 1a9243e..e23fb50 100644 --- a/zon/element_list/__init__.py +++ b/zon-old/element_list/__init__.py @@ -1,7 +1,7 @@ """Class and methods related to the ZonList validator.""" from zon.base import Zon -from zon.error import ValidationError +from zon.error import ZonError from zon.traits.collection import ZonCollection @@ -18,20 +18,20 @@ def _setup(self): def _default_validate(self, data): if not isinstance(data, list): - self._add_error(ValidationError(f"Expected list, got {type(data)}")) + self._add_error(ZonError(f"Expected list, got {type(data)}")) return False error = False for i, element in enumerate(data): if not self.element_type.validate(element): self._add_error( - ValidationError(f"Element {i} of list failed validation: {element}") + ZonError(f"Element {i} of list failed validation: {element}") ) error = True if error: for error in self.element_type.errors: - self._add_error(ValidationError(f"Error validating elements: {error}")) + self._add_error(ZonError(f"Error validating elements: {error}")) return not error diff --git a/zon-old/error.py b/zon-old/error.py new file mode 100644 index 0000000..1afe3d2 --- /dev/null +++ b/zon-old/error.py @@ -0,0 +1,61 @@ +"""Validation errors for Zons""" + +from deprecation import deprecated + +from . import __version__ + + +@deprecated( + deprecated_in="2.0.0", + current_version=__version__, + details="Use the new ZonError class instead.", +) +class ValidationError(Exception): + """ + Validation error thrown when a validation fails. + + Deprecated: + This class was deprecated in 2.0.0 and will be removed soon. Use ZonError instead. + """ + + def __init__(self, message: str): + """Builds a new ValidationError with the supplied message. + + Args: + message (str): The message to be displayed when the exception is thrown. + """ + super() + self.message = message + + def __str__(self): + """Used to covert this exception into a string.""" + + return repr(self.message) + + def __repr__(self) -> str: + """Used to covert this exception into a string.""" + + return f"ValidationError({self.message})" + + +class ZonError(Exception): + """Validation error thrown when a validation fails.""" + + def __init__(self, message: str): + """Builds a new ValidationError with the supplied message. + + Args: + message (str): The message to be displayed when the exception is thrown. + """ + super() + self.message = message + + def __str__(self): + """Used to covert this exception into a string.""" + + return repr(self.message) + + def __repr__(self) -> str: + """Used to covert this exception into a string.""" + + return f"ValidationError({self.message})" diff --git a/zon/none.py b/zon-old/none.py similarity index 75% rename from zon/none.py rename to zon-old/none.py index 211da64..c39f919 100644 --- a/zon/none.py +++ b/zon-old/none.py @@ -1,7 +1,7 @@ """Class and methods related to the ZonNone validator.""" from .base import Zon -from .error import ValidationError +from .error import ZonError class ZonNone(Zon): @@ -12,6 +12,6 @@ def _setup(self) -> None: def _default_validate(self, data): if data is not None: - self._add_error(ValidationError(f"Expected None, got {type(data)}")) + self._add_error(ZonError(f"Expected None, got {type(data)}")) return False return True diff --git a/zon/number/float.py b/zon-old/number/float.py similarity index 77% rename from zon/number/float.py rename to zon-old/number/float.py index 15f647c..5db1e19 100644 --- a/zon/number/float.py +++ b/zon-old/number/float.py @@ -1,6 +1,6 @@ """Class and methods related to the ZonFloat validator.""" -from zon.error import ValidationError +from zon.error import ZonError from . import ZonNumber @@ -13,6 +13,6 @@ def _setup(self) -> None: def _default_validate(self, data): if not isinstance(data, float): - self._add_error(ValidationError(f"Expected float, got {type(data)}")) + self._add_error(ZonError(f"Expected float, got {type(data)}")) return False return True diff --git a/zon/number/int.py b/zon-old/number/int.py similarity index 77% rename from zon/number/int.py rename to zon-old/number/int.py index b1628dd..4d02af8 100644 --- a/zon/number/int.py +++ b/zon-old/number/int.py @@ -1,6 +1,6 @@ """Class and methods related to ZonInteger validator.""" -from zon.error import ValidationError +from zon.error import ZonError import zon.number as zon_number @@ -13,6 +13,6 @@ def _setup(self) -> None: def _default_validate(self, data): if not isinstance(data, int): - self._add_error(ValidationError(f"Expected integer, got {type(data)}")) + self._add_error(ZonError(f"Expected integer, got {type(data)}")) return False return True diff --git a/zon/record.py b/zon-old/record.py similarity index 68% rename from zon/record.py rename to zon-old/record.py index d7549f8..612656d 100644 --- a/zon/record.py +++ b/zon-old/record.py @@ -1,7 +1,7 @@ """Class and methods related to the ZonRecord validator.""" from .base import Zon -from .error import ValidationError +from .error import ZonError # TODO: better error messages @@ -18,24 +18,20 @@ def _setup(self): def _default_validate(self, data): if not isinstance(data, dict): - self._add_error(ValidationError(f"Expected object, got {type(data)}")) + self._add_error(ZonError(f"Expected object, got {type(data)}")) return False error = False for key, zon in self.properties.items(): if not zon.validate(data.get(key)): self._add_error( - ValidationError( - f"Property {key} failed validation: {data.get(key)}" - ) + ZonError(f"Property {key} failed validation: {data.get(key)}") ) error = True if error: for zon in self.properties.values(): for error in zon.errors: - self._add_error( - ValidationError(f"Error validating properties: {error}") - ) + self._add_error(ZonError(f"Error validating properties: {error}")) return not error diff --git a/zon/union.py b/zon-old/union.py similarity index 100% rename from zon/union.py rename to zon-old/union.py diff --git a/zon/__init__.py b/zon/__init__.py index ea98d0e..f193da5 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -3,112 +3,126 @@ The purpose of this package is to provide a set of functions to validate data, using a Zod-like syntax. """ +from __future__ import annotations -__version__ = "1.0.0" +__version__ = "2.0.0" __author__ = "Nuno Pereira" __email__ = "nunoafonso2002@gmail.com" __license__ = "MIT" __copyright__ = "Copyright 2023, Nuno Pereira" -from zon.number.float import ZonFloat -from zon.number.int import ZonInteger -from zon.base import Zon -from zon.bool import ZonBoolean -from zon.element_list import ZonList -from zon.str import ZonString -from zon.union import ZonUnion -from zon.record import ZonRecord -from zon.any import ZonAny -from zon.none import ZonNone +import copy +from abc import ABC, abstractmethod +from typing import Callable, Self, TypeVar, final +from .error import ZonError -def string() -> "ZonString": - """Creates a new Zon that validates that the data is a string. +T = TypeVar("T") +ValidationRule = Callable[[T], bool] - Returns: - ZonString: a new validator that validates that the data is a string. - """ - return ZonString() - - -def integer() -> "ZonInteger": - """Creates a new Zon that validates that the data is an integer. - Returns: - ZonInteger: a new validator that validates that the data is an integer +class Zon(ABC): + """ + Base class for all Zons. + + A Zon is the basic unit of validation in Zon. + It is used to validate data, and can be composed with other Zons + to create more complex validations. """ - return ZonInteger() + def __init__(self): + self.errors: list[ZonError] = [] + """List of validation errors accumulated""" -def floating_point() -> "ZonFloat": - """Creates a new Zon that validates that the data is a floating point number. + self.validators: dict[str, ValidationRule] = {} + """validators that will run when 'validate' is invoked.""" - Returns: - ZonFloat: a new validator that validates that the data is a floating point number. - """ - return ZonFloat() + self._setup() + def _clone(self) -> Self: + """Creates a copy of this Zon.""" + return copy.deepcopy(self) -def boolean() -> "ZonBoolean": - """Creates a new Zon that validates that the data is a boolean. + def _add_error(self, error: ZonError): + """Adds an error to this Zon's error output. - Returns: - ZonBoolean: a new validator that validates that the data is a boolean. - """ - return ZonBoolean() + Args: + error (ZonError): the validation error to add. + """ + self.errors.append(error) + @abstractmethod + def _default_validate(self) -> bool: + """Default validation for any Zon validator -def element_list(element_type: "Zon") -> "ZonList": - """Creates a new Zon that validates that the data is a list of elements of the specified type. + The contract for this method is the same for any other ValidationRule: + - If the validation succeeds, return True + - If the validation false, raise a ZonError containing the relevant data + """ - Args: - element_type (Zon): the type of the elements of the list. + raise NotImplementedError("This method is not implemented for the base Zon class. You need to provide your ") - Returns: - ZonList: a new validator that validates that the data is - a list of elements of the specified type. - """ - return ZonList(element_type) + @final + def _validate(self, data: T) -> bool: + """Validates the supplied data. + Args: + data (Any): the piece of data to be validated. -def union(types: list["Zon"]) -> "ZonUnion": - """Creates a new Zon that validates that the data is one of the specified types. + Returns: + bool: True if the data is valid. This method will never return false as that case raises an error, as documented. - Args: - types (list[Zon]): the types of the data to be validated. + Raises: + ZonError: if validation against the supplied data fails. + """ - Returns: - ZonUnion: a new validator that validates that the data is one of the specified types. - """ - return ZonUnion(types) + for validator_type, validator in self.validators.items(): + try: + _passed = validator(data) + return True + except ZonError as ze: + pass -def record(properties: dict[str, "Zon"]) -> "ZonRecord": - """Creates a new Zon that validates that the data is an object with the specified properties. + @final + def validate(self, data: T) -> bool: + """Validates the supplied data. - Args: - properties (dict[str, Zon]): the properties of the object to be validated. + Args: + data (Any): the piece of data to be validated. - Returns: - ZonRecord: a new validator that validates that the data is - an object with the specified properties. - """ - return ZonRecord(properties) + Raises: + NotImplementedError: the default implementation of this method + is not implemented on base Zon class. + ZonError: if validation fails. + """ + if type(self) is Zon: # pylint: disable=unidiomatic-typecheck + raise NotImplementedError( + "validate() method not implemented on base Zon class" + ) + return self._validate(data) -def none() -> "ZonNone": - """Creates a new Zon that validates that the data is None. + @final + def safe_validate(self, data: T) -> tuple[bool, T] | tuple[bool, ZonError]: + """Validates the supplied data. This method is different from `validate` in the sense that + it does not raise an error when validation fails. Instead, it returns an object encapsulating + either a successful validation result or a validation error. - Returns: - ZonNone: a new validator that validates that the data is None. - """ - return ZonNone() + Args: + data (Any): the piece of data to be validated. + Raises: + NotImplementedError: the default implementation of this method + is not implemented on base Zon class. + Exception: if validation fails + """ -def anything() -> "ZonAny": - """Creates a new Zon that validates anything. + try: + self.validate(data) - Returns: - ZonAny: a new validator that validates anything. - """ - return ZonAny() + return (True, data) + except ZonError as ze: + return (False, ze) + except Exception as e: + raise e \ No newline at end of file diff --git a/zon/error.py b/zon/error.py index 469a0fb..ff24990 100644 --- a/zon/error.py +++ b/zon/error.py @@ -1,8 +1,25 @@ """Validation errors for Zons""" +from typing import Any +from typing_extensions import deprecated +from dataclasses import dataclass + +from . import __version__ + + +@deprecated( + deprecated_in="2.0.0", + current_version=__version__, + details="Use the new ZonError class instead.", +) class ValidationError(Exception): - """Validation error thrown when a validation fails.""" + """ + Validation error thrown when a validation fails. + + Deprecated: + This class was deprecated in 2.0.0 and will be removed soon. Use ZonError instead. + """ def __init__(self, message: str): """Builds a new ValidationError with the supplied message. @@ -22,3 +39,42 @@ def __repr__(self) -> str: """Used to covert this exception into a string.""" return f"ValidationError({self.message})" + + +@dataclass(kw_only=True, frozen=True) +class ZonIssue: + """Some issue with validation""" + + value: Any + message: str + path: list[str] +class ZonError(Exception): + """Validation error thrown when a validation fails.""" + + issues: list[ZonIssue] + + def __init__(self): + """Builds a new ValidationError with the supplied message. + + Args: + message (str): The message to be displayed when the exception is thrown. + """ + super() + self.issues = [] + + def add_issue(self, issue: ZonIssue): + """Adds an existing issue to this validation error""" + + self.issues.append(issue) + + def __str__(self): + """Used to covert this exception into a string.""" + + return repr(self) + + def __repr__(self) -> str: + """Used to covert this exception into a string.""" + + return f"""ZonError( + issues: {self.issues}) + """ diff --git a/zon/number/__init__.py b/zon/number/__init__.py deleted file mode 100644 index 3f2c103..0000000 --- a/zon/number/__init__.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Class and methods related to the ZonNumber validator""" - -from __future__ import annotations - -from zon.base import Zon -from zon.error import ValidationError - - -class ZonNumber(Zon): - """A Zon that validates that the data is a number, i.e., an int or a float.""" - - def __gt__(self, other: (int | float)) -> "ZonNumber": - return self.gt(other) - - def gt(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is greater than a given value. - - Args: - value (int | float): the minimum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def gt_validate(data): - if data <= value: - other._add_error( - ValidationError(f"Expected number > {value}, got {data}") - ) - return False - return True - - other.validators["gt"] = gt_validate - return other - - def __ge__(self, other: int | float) -> "ZonNumber": - return self.gte(other) - - def gte(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is greater than or equal to a minimum value. - - Args: - value (int | float): the minimum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def gte_validate(data): - if data < value: - other._add_error( - ValidationError(f"Expected number >= {value}, got {data}") - ) - return False - return True - - other.validators["gte"] = gte_validate - return other - - def __lt__(self, other: int | float) -> "ZonNumber": - return self.lt(other) - - def lt(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is less than a given value. - - Args: - value (int | float): the maximum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def lt_validate(data): - if data >= value: - other._add_error( - ValidationError(f"Expected number < {value}, got {data}") - ) - return False - return True - - other.validators["lt"] = lt_validate - return other - - def __le__(self, other: int | float) -> "ZonNumber": - return self.lte(other) - - def lte(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is less than or equal to a maximum value. - - Args: - value (int | float): the maximum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def lte_validate(data): - if data > value: - other._add_error( - ValidationError(f"Expected number <= {value}, got {data}") - ) - return False - return True - - other.validators["lte"] = lte_validate - return other - - def __eq__(self, other: int | float) -> "ZonNumber": - return self.eq(other) - - def eq(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is equal to a given value. - - Args: - value (int | float): the value to compare to - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def eq_validate(data): - if data != value: - other._add_error( - ValidationError(f"Expected number == {value}, got {data}") - ) - return False - return True - - other.validators["eq"] = eq_validate - return other - - def between( - self, - min_value: int | float, - max_value: int | float, - *, - min_exclusive=True, - max_exclusive=True, - ) -> "ZonNumber": - """Assert that the value under validation is between two values. - The comparison is exclusive on both ends by default. - - Args: - min_value (int | float): the minimum value - max_value (int | float): the maximum value - min_exclusive (bool, optional): whether `min_value` should be considered valid. - Defaults to True. - max_exclusive (bool, optional): whether `max_value` should be considered valid. - Defaults to True. - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def between_validate(data): - min_cond = data < min_value if min_exclusive else data <= min_value - max_cond = data > max_value if max_exclusive else data >= max_value - - if min_cond or max_cond: - other._add_error( - ValidationError( - f"Expected {min_value} <= number <= {max_value}, got {data}" - ) - ) - return False - return True - - other.validators["between"] = between_validate - return other diff --git a/zon/str/__init__.py b/zon/str/__init__.py deleted file mode 100644 index 37c94f3..0000000 --- a/zon/str/__init__.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Class and methods related to the ZonString validator.""" - -from __future__ import annotations - -import re -import uuid -import ipaddress - -from validate_email import validate_email - -from zon.error import ValidationError - -from zon.traits.collection import ZonCollection - - -class ZonString(ZonCollection): - """A Zon that validates that the data is a string. - - For all purposes, a string is a collection of characters. - """ - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, str): - self._add_error(ValidationError(f"Expected string, got {type(data)}")) - return False - return True - - def regex(self, regex: str | re.Pattern[str]) -> "ZonString": - """Assert that the value under validation matches a given regular expression. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def regex_validate(data): - if not re.match(regex, data): - other._add_error( - ValidationError( - f"Expected string matching regex /{regex}/, got {data}" - ) - ) - return False - return True - - other.validators["regex"] = regex_validate - return other - - def uuid(self) -> "ZonString": - """Assert that the value under validation is a valid UUID. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def uuid_validate(data): - try: - uuid.UUID(data) - except ValueError: - other._add_error(ValidationError(f"Expected UUID, got {data}")) - return False - return True - - other.validators["uuid"] = uuid_validate - return other - - def email(self): - """Assert that the value under validation is a valid email address. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def email_validate(data): - if not validate_email(data): - other._add_error(ValidationError(f"Expected email, got {data}")) - return False - - return True - - other.validators["email"] = email_validate - return other - - def ip(self): - """Assert that the value under validation is a valid IP address. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def ip_validate(data): - try: - ipaddress.ip_address(data) - except ValueError: - other._add_error(ValidationError(f"Expected IPv4, got {data}")) - return False - return True - - other.validators["ip"] = ip_validate - return other diff --git a/zon/traits/collection.py b/zon/traits/collection.py deleted file mode 100644 index 147f307..0000000 --- a/zon/traits/collection.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Class and methods related to Zon that act has collections of objects.""" - -from typing import Self - -from zon import Zon -from zon.error import ValidationError - - -class ZonCollection(Zon): - def length(self, length) -> "Self": - """Assert that the value under validation has at least as many el. - - Returns: - Self: a new zon with the validation rule added - """ - - other = self._clone() - - def len_validate(data): - if len(data) != length: - other._add_error( - ValidationError(f"Expected length to be {length}, got {len(data)}") - ) - return False - return True - - other.validators["len"] = len_validate - return other - - def min(self, min_length) -> "Self": - """Assert that the value under validation has at least as many elements as specified. - - Args: - min_length (_type_): the minimum length of the collection. - - Returns: - Self: a new zon with the validation rule added - """ - - other = self._clone() - - def min_len_validate(data): - if len(data) < min_length: - other._add_error( - ValidationError( - f"Expected minimum length to be {min_length}, got {len(data)}" - ) - ) - return False - return True - - other.validators["min_len"] = min_len_validate - return other - - def max(self, max_length) -> "Self": - """Assert that the value under validation has at most as many elements as specified. - - Args: - max_length (_type_): the maximum length of the collection. - - Returns: - Self: a new zon with the validation rule added - """ - - other = self._clone() - - def max_len_validate(data): - if len(data) > max_length: - other._add_error( - ValidationError( - f"Expected maximum length to be {max_length}, got {len(data)}" - ) - ) - return False - return True - - other.validators["max_len"] = max_len_validate - return other From 81bcb7dd723ae78a5c6638fef171266f24e643f5 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 01:54:57 +0100 Subject: [PATCH 02/29] Re-added basic string validation back, setup tests --- .github/workflows/ci.yaml | 17 ++- pylintrc | 4 +- pyproject.toml | 2 +- requirements.txt | 1 + {tests => tests-old}/any_test.py | 0 {tests => tests-old}/base_test.py | 4 +- {tests => tests-old}/int_test.py | 0 {tests => tests-old}/list_test.py | 0 tests-old/str_test.py | 93 +++++++++++++ .../traits/collection_test.py | 0 tests/str_test.py | 50 ++++--- zon-old/__init__.py | 5 +- zon/__init__.py | 126 +++++++++++++++--- zon/error.py | 18 +-- zon/traits.py | 41 ++++++ 15 files changed, 298 insertions(+), 63 deletions(-) rename {tests => tests-old}/any_test.py (100%) rename {tests => tests-old}/base_test.py (84%) rename {tests => tests-old}/int_test.py (100%) rename {tests => tests-old}/list_test.py (100%) create mode 100644 tests-old/str_test.py rename {tests => tests-old}/traits/collection_test.py (100%) create mode 100644 zon/traits.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b4abb58..4f41b21 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,18 +5,23 @@ on: [push] jobs: lint: runs-on: ubuntu-latest + strategy: + max-parallel: 5 + matrix: + version: [3.12] steps: - uses: actions/checkout@v3 - - name: Set up Python 3.11 + - name: Set up Python ${{ matrix.version}} uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: "${{ matrix.version }}" - name: Install dependencies run: | python -m pip install --upgrade pip + python -m pip install -r requirements.txt - name: Install Pylint run: pip install pylint @@ -28,14 +33,16 @@ jobs: runs-on: ubuntu-latest strategy: max-parallel: 5 + matrix: + version: [3.12] steps: - uses: actions/checkout@v3 - - name: Set up Python 3.11 + - name: Set up Python ${{ matrix.version}} uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: "${{ matrix.version }}" - name: Install dependencies run: | @@ -49,4 +56,4 @@ jobs: run: python -m pip install -e . - name: Run tests - run: pytest + run: pytest tests \ No newline at end of file diff --git a/pylintrc b/pylintrc index 50168a9..d743e6d 100644 --- a/pylintrc +++ b/pylintrc @@ -40,7 +40,7 @@ load-plugins= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. -jobs=1 +jobs=0 # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. @@ -64,7 +64,7 @@ py-version = 3.8.0 limit-inference-results=100 # Specify a score threshold under which the program will exit with error. -fail-under=9.5 +fail-under=9.0 # Return non-zero exit code if any of these messages/categories are detected, # even if score is above --fail-under value. Syntax same as enable. Messages diff --git a/pyproject.toml b/pyproject.toml index eb7120f..4e88d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "A Zod-like validation library for Python" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index 014145f..739fdf0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pylint==3.0.2 pytest==7.4.3 twine==4.0.2 validators==0.28.3 +typing_extensions==4.12.2 \ No newline at end of file diff --git a/tests/any_test.py b/tests-old/any_test.py similarity index 100% rename from tests/any_test.py rename to tests-old/any_test.py diff --git a/tests/base_test.py b/tests-old/base_test.py similarity index 84% rename from tests/base_test.py rename to tests-old/base_test.py index 7032ec4..e6da954 100644 --- a/tests/base_test.py +++ b/tests-old/base_test.py @@ -2,10 +2,12 @@ import zon + @pytest.fixture def validator(): return zon.anything() + def test_refine(validator): assert validator.validate(1) @@ -13,4 +15,4 @@ def test_refine(validator): refined_validator = validator.refine(lambda x: x == 1) assert refined_validator.validate(1) - assert not refined_validator.validate(2) \ No newline at end of file + assert not refined_validator.validate(2) diff --git a/tests/int_test.py b/tests-old/int_test.py similarity index 100% rename from tests/int_test.py rename to tests-old/int_test.py diff --git a/tests/list_test.py b/tests-old/list_test.py similarity index 100% rename from tests/list_test.py rename to tests-old/list_test.py diff --git a/tests-old/str_test.py b/tests-old/str_test.py new file mode 100644 index 0000000..50e9db3 --- /dev/null +++ b/tests-old/str_test.py @@ -0,0 +1,93 @@ +import pytest + +import zon + +import uuid + + +@pytest.fixture +def validator(): + return zon.string() + + +def test_str(validator): + assert validator.validate("1") + + +def test_not_float(validator): + assert not validator.validate(1.5) + + +def test_not_int(validator): + assert not validator.validate(1) + + +def test_not_list(validator): + assert not validator.validate([1]) + + +def test_not_record(validator): + assert not validator.validate({"a": 1}) + + +def test_email(validator): + _validator = validator.email() + + assert _validator.validate("test@host.com") + assert _validator.validate("test@host") + + assert not _validator.validate("@host.com") + assert not _validator.validate("host.com") + + +def test_ipv4(validator): + _validator = validator.ip() + + assert _validator.validate("255.255.255.255") + assert _validator.validate("0.0.0.0") + + assert not _validator.validate("1.1.1") + assert not _validator.validate("0.0.0.0.0") + assert not _validator.validate("256.256.256.256") + assert not _validator.validate("255.255.255.256") + assert not _validator.validate("255.255.256.255") + assert not _validator.validate("255.256.255.255") + assert not _validator.validate("256.255.255.255") + + +def test_ipv6(validator): + _validator = validator.ip() + + # Ipv6 addresses + assert _validator.validate("::") + assert _validator.validate("::1") + assert _validator.validate("::ffff:127.0.0.1") + assert _validator.validate("::ffff:7f00:1") + assert _validator.validate("::ffff:127.0.0.1") + + assert not _validator.validate("::1.1.1") + assert not _validator.validate("::ffff:127.0.0.1.1.1") + assert not _validator.validate("::ffff:127.0.0.256") + assert not _validator.validate("::ffff:127.0.0.256.256") + + # Example taken from https://zod.dev/?id=ip-addresses + assert not _validator.validate("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003") + + +def test_uuid(validator): + _validator = validator.uuid() + + assert _validator.validate(uuid.uuid4().hex) + + assert not _validator.validate("not_a_UUID") + + +def test_regex(validator): + _validator = validator.regex(r"^[a-z ]+$") + + assert _validator.validate("abc") + assert _validator.validate("def") + assert _validator.validate("abc def") + + assert not _validator.validate("abc1") + assert not _validator.validate("abc def1") diff --git a/tests/traits/collection_test.py b/tests-old/traits/collection_test.py similarity index 100% rename from tests/traits/collection_test.py rename to tests-old/traits/collection_test.py diff --git a/tests/str_test.py b/tests/str_test.py index 50e9db3..6fc60c5 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -2,34 +2,41 @@ import zon -import uuid - @pytest.fixture def validator(): return zon.string() +@pytest.fixture +def fail_fast_validator(): + return zon.string(fast_termination=True) -def test_str(validator): +def test_str_validate(validator): assert validator.validate("1") - - -def test_not_float(validator): - assert not validator.validate(1.5) - - -def test_not_int(validator): - assert not validator.validate(1) - - -def test_not_list(validator): - assert not validator.validate([1]) - - -def test_not_record(validator): - assert not validator.validate({"a": 1}) - - + + def _assert_failure(data): + try: + validator.validate(data) + assert False, "Not a string" + except zon.error.ZonError: + assert True + + _assert_failure(1.5) + _assert_failure(1) + _assert_failure([1]) + _assert_failure({"a": 1}) + +def test_str_safe_validate(validator): + assert validator.safe_validate("1") == (True, "1") + + # TODO: check for the specific error? + assert validator.safe_validate(1.5)[0] is False + assert validator.safe_validate(1)[0] is False + assert validator.safe_validate([1])[0] is False + assert validator.safe_validate({"a": 1})[0] is False + + +""" def test_email(validator): _validator = validator.email() @@ -91,3 +98,4 @@ def test_regex(validator): assert not _validator.validate("abc1") assert not _validator.validate("abc def1") +""" \ No newline at end of file diff --git a/zon-old/__init__.py b/zon-old/__init__.py index 5721850..b92be3b 100644 --- a/zon-old/__init__.py +++ b/zon-old/__init__.py @@ -30,7 +30,7 @@ class Zon(ABC): """ Base class for all Zons. - + A Zon is the basic unit of validation in Zon. It is used to validate data, and can be composed with other Zons to create more complex validations. @@ -243,6 +243,7 @@ def unwrap(self) -> Zon: return self.zon + class ZonAnd(Zon): """A Zon that validates that the data is valid for both this Zon and the supplied Zon.""" @@ -609,7 +610,7 @@ class ZonNumber(Zon): def __gt__(self, other: int | float) -> "ZonNumber": return self.gt(other) - def gt(self, value: int | float ) -> "ZonNumber": + def gt(self, value: int | float) -> "ZonNumber": """Assert that the value under validation is greater than a given value. Args: diff --git a/zon/__init__.py b/zon/__init__.py index f193da5..2b22f10 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -1,6 +1,6 @@ """Validator package. -The purpose of this package is to provide a set of functions to validate data, using a Zod-like syntax. +Flexible validation powered by Python with the expressiveness of a Zod-like API. """ from __future__ import annotations @@ -15,7 +15,8 @@ from abc import ABC, abstractmethod from typing import Callable, Self, TypeVar, final -from .error import ZonError +from .error import ZonError, ZonIssue +from .traits import HasMax, HasMin T = TypeVar("T") ValidationRule = Callable[[T], bool] @@ -24,43 +25,34 @@ class Zon(ABC): """ Base class for all Zons. - + A Zon is the basic unit of validation in Zon. It is used to validate data, and can be composed with other Zons to create more complex validations. """ - def __init__(self): - self.errors: list[ZonError] = [] - """List of validation errors accumulated""" - + def __init__(self, **kwargs): self.validators: dict[str, ValidationRule] = {} """validators that will run when 'validate' is invoked.""" - self._setup() + self._terminate_early = kwargs.get("terminate_early", False) def _clone(self) -> Self: """Creates a copy of this Zon.""" return copy.deepcopy(self) - def _add_error(self, error: ZonError): - """Adds an error to this Zon's error output. - - Args: - error (ZonError): the validation error to add. - """ - self.errors.append(error) - @abstractmethod - def _default_validate(self) -> bool: + def _default_validate(self, data: T) -> bool: """Default validation for any Zon validator - The contract for this method is the same for any other ValidationRule: + The contract for this method is the same for any other `ValidationRule`: - If the validation succeeds, return True - If the validation false, raise a ZonError containing the relevant data """ - raise NotImplementedError("This method is not implemented for the base Zon class. You need to provide your ") + raise NotImplementedError( + "This method is not implemented for the base Zon class. You need to provide your " + ) @final def _validate(self, data: T) -> bool: @@ -74,15 +66,43 @@ def _validate(self, data: T) -> bool: Raises: ZonError: if validation against the supplied data fails. + NotImplementedError: if the default validation rule was not overriden for this Zon object. """ + _error: ZonError = None + + def _update_error(ze: ZonError): + nonlocal _error + if not _error: + _error = ze + else: + _error.add_issues(ze.issues) + + try: + _passed = self._default_validate(data) + except ZonError as ze: + if self._terminate_early: + raise ze + + _update_error(ze) + except NotImplementedError as ni: + raise ni + for validator_type, validator in self.validators.items(): try: _passed = validator(data) return True except ZonError as ze: - pass + _update_error(ze) + + if self._terminate_early: + raise _error from ze + + if _error is not None: + raise _error from None + + return True @final def validate(self, data: T) -> bool: @@ -115,7 +135,7 @@ def safe_validate(self, data: T) -> tuple[bool, T] | tuple[bool, ZonError]: Raises: NotImplementedError: the default implementation of this method is not implemented on base Zon class. - Exception: if validation fails + Exception: if any unexpected exception is encountered """ try: @@ -125,4 +145,66 @@ def safe_validate(self, data: T) -> tuple[bool, T] | tuple[bool, ZonError]: except ZonError as ze: return (False, ze) except Exception as e: - raise e \ No newline at end of file + raise e + + +class ZonContainer(Zon, HasMax, HasMin): + """A Zon that acts as a container for other types of data. + + Contains container specific validator rules. + """ + def max(self, max_value: int | float): + """Validates that this container as at most `max_value` elements (exclusive). + + Args: + max_value (int | float): the maximum number of elements that this container can have + """ + + # TODO: add check + + def min(self, min_value: int | float): + """Validates that this container as at least `max_value` elements (exclusive). + + Args: + min_value (int | float): the minimum number of elements that this container can have + """ + + # TODO: add check + + def length(self, length: int): + """Validates that this container as exactly `length` elements. + + Args: + length (int): the exact number of elements that this container can have + """ + + # TODO: add check + + +def string(*, fast_termination = False) -> ZonString: + """Returns a validator for string data. + + Args: + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + + Returns: + ZonString: The string data validator. + """ + return ZonString(fast_termination=fast_termination) + +class ZonString(ZonContainer): + """A Zon that validates that the data is a string. + + For all purposes, a string is a collection of characters. + """ + + def _default_validate(self, data: T) -> bool: + + if not isinstance(data, str): + + err = ZonError() + err.add_issue(ZonIssue(value=data, message="Not a string", path=[])) + + raise err + + return True diff --git a/zon/error.py b/zon/error.py index ff24990..703be86 100644 --- a/zon/error.py +++ b/zon/error.py @@ -1,18 +1,11 @@ """Validation errors for Zons""" - from typing import Any -from typing_extensions import deprecated from dataclasses import dataclass -from . import __version__ - +from typing_extensions import deprecated -@deprecated( - deprecated_in="2.0.0", - current_version=__version__, - details="Use the new ZonError class instead.", -) +@deprecated("Use the new ZonError class instead.") class ValidationError(Exception): """ Validation error thrown when a validation fails. @@ -48,6 +41,8 @@ class ZonIssue: value: Any message: str path: list[str] + + class ZonError(Exception): """Validation error thrown when a validation fails.""" @@ -67,6 +62,11 @@ def add_issue(self, issue: ZonIssue): self.issues.append(issue) + def add_issues(self, issues: list[ZonIssue]): + """Adds a batch of existing issues to this validation error""" + + self.issues.extend(issues) + def __str__(self): """Used to covert this exception into a string.""" diff --git a/zon/traits.py b/zon/traits.py new file mode 100644 index 0000000..1a57b30 --- /dev/null +++ b/zon/traits.py @@ -0,0 +1,41 @@ +""" +Traits useful for various its and bits of Zon code. +""" + +from __future__ import annotations + +from abc import abstractmethod + + +class HasMax: + """ + Validation helper that indicates that the validation value has some attribute that must be upper-bound by some value + """ + + @abstractmethod + def max(self, max_value: int | float): + """ + Defines that a given attribute of the value being validated must be upper-bound by the given parameter. + + Args: + max_value (int | float): the maximum value (exclusive) that the attribute being validated can have. + """ + + raise NotImplementedError("'max' must be implemented by subclasses") + + +class HasMin: + """ + Validation helper that indicates that the validation value has some attribute that must be lower-bound by some value + """ + + @abstractmethod + def min(self, min_value: int | float): + """ + Defines that a given attribute of the value being validated must be lower-bound by the given parameter. + + Args: + min_value (int | float): the minimum value (exclusive) that the attribute being validated can have. + """ + + raise NotImplementedError("'min' must be implemented by subclasses") From 4ddb7692a33ec001a6eee0b5e166646a0c3f3bfa Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 01:59:16 +0100 Subject: [PATCH 03/29] Updated changelog --- CHANGELOG.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ba844..c1dfc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added more string validation methods. -- `parse` and `safe_parse` methods. -- Added `opts` arg to `ZonString.regex` method to allow defining custom error messages. -- Added `opts` arg to `ZonString.ip` method to allow defining custom error messages and specify the IP version to check against. -- Added `unwrap` method to `ZonOptional`. ### Changed -- `optional` is now a method inside every `Zon` object. -- Moved `ZonCollection` and `ZonString` to the base file. -- Deprecated `validate` (and its private variant `_validate`) in favor of `parse` and `safe_parse` methods. +- Moved everything into a single file to combat circular reference issues - Deprecated `ValidationError` in favor of `ZonError`. ### Removed From a21fefa5e43ce139c5cbdf4707b1d6c7d0eacc55 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 11:22:41 +0100 Subject: [PATCH 04/29] Started using --- CHANGELOG.md | 1 + pyproject.toml | 2 +- tests/str_test.py | 26 +++++++------- zon/__init__.py | 87 +++++++++++++++++++++++++++++++++-------------- zon/error.py | 1 + 5 files changed, 77 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1dfc93..7b86510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/pyproject.toml b/pyproject.toml index 4e88d61..eb7120f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "A Zod-like validation library for Python" readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", diff --git a/tests/str_test.py b/tests/str_test.py index 6fc60c5..878d4ba 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -7,24 +7,24 @@ def validator(): return zon.string() + @pytest.fixture def fail_fast_validator(): return zon.string(fast_termination=True) + def test_str_validate(validator): assert validator.validate("1") - - def _assert_failure(data): - try: - validator.validate(data) - assert False, "Not a string" - except zon.error.ZonError: - assert True - - _assert_failure(1.5) - _assert_failure(1) - _assert_failure([1]) - _assert_failure({"a": 1}) + + with pytest.raises(zon.error.ZonError): + validator.validate(1.5) + with pytest.raises(zon.error.ZonError): + validator.validate(1) + with pytest.raises(zon.error.ZonError): + validator.validate([1]) + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 1}) + def test_str_safe_validate(validator): assert validator.safe_validate("1") == (True, "1") @@ -98,4 +98,4 @@ def test_regex(validator): assert not _validator.validate("abc1") assert not _validator.validate("abc def1") -""" \ No newline at end of file +""" diff --git a/zon/__init__.py b/zon/__init__.py index 2b22f10..9d23ab8 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -14,13 +14,48 @@ import copy from abc import ABC, abstractmethod from typing import Callable, Self, TypeVar, final +from dataclasses import dataclass, field from .error import ZonError, ZonIssue from .traits import HasMax, HasMin -T = TypeVar("T") -ValidationRule = Callable[[T], bool] +@dataclass +class ValidationContext: + """Context used throughout an entire validation run + """ + + _error: ZonError = None + path: list[str] = field(default_factory=list) + + def _ensure_error(self): + if self._error is None: + self._error = ZonError() + + def add_issue(self, issue: ZonIssue): + """Adds the given `ZodIssue` to this context's `ZonError` + """ + self._ensure_error() + self._error.add_issue(issue) + + def add_issues(self, issues: list[ZonIssue]): + """Adds the given `ZodIssue`s to this context's `ZonError` + """ + self._ensure_error() + self._error.add_issues(issues) + + def raise_error(self): + """ + Raises the current validation error in this context if it exists. + """ + + raise self._error + @property + def dirty(self): + return self._error is not None and len(self._error.issues) >= 0 + +T = TypeVar("T") +ValidationRule = Callable[[T, ValidationContext], bool] class Zon(ABC): """ @@ -42,12 +77,18 @@ def _clone(self) -> Self: return copy.deepcopy(self) @abstractmethod - def _default_validate(self, data: T) -> bool: + def _default_validate(self, data: T, ctx: ValidationContext) -> bool: """Default validation for any Zon validator The contract for this method is the same for any other `ValidationRule`: - If the validation succeeds, return True - - If the validation false, raise a ZonError containing the relevant data + - If the validation false, raise a ZonError containing the relevant data. + + The default implementation raises a NotImplementedError. + + Args: + data (Any): the piece of data to be validated. + ctx (ValidationContext): the context of the validation. """ raise NotImplementedError( @@ -69,38 +110,34 @@ def _validate(self, data: T) -> bool: NotImplementedError: if the default validation rule was not overriden for this Zon object. """ - _error: ZonError = None - - def _update_error(ze: ZonError): - nonlocal _error - if not _error: - _error = ze - else: - _error.add_issues(ze.issues) + ctx = ValidationContext() try: - _passed = self._default_validate(data) + _passed = self._default_validate(data, ctx) except ZonError as ze: if self._terminate_early: + # since we want to terminate early, we can just directly raise the error raise ze - _update_error(ze) + ctx.add_issues(ze.issues) except NotImplementedError as ni: raise ni for validator_type, validator in self.validators.items(): try: - _passed = validator(data) + _passed = validator(data, ctx) return True except ZonError as ze: - _update_error(ze) + ctx.add_issues(ze.issues) if self._terminate_early: - raise _error from ze + # Since we want to terminate early, we can just directly raise the error + # Following the sequence of the code, we are guaranteed to have errors at this point. + ctx.raise_error() - if _error is not None: - raise _error from None + if ctx.dirty: + ctx.raise_error() return True @@ -153,6 +190,7 @@ class ZonContainer(Zon, HasMax, HasMin): Contains container specific validator rules. """ + def max(self, max_value: int | float): """Validates that this container as at most `max_value` elements (exclusive). @@ -181,7 +219,7 @@ def length(self, length: int): # TODO: add check -def string(*, fast_termination = False) -> ZonString: +def string(*, fast_termination=False) -> ZonString: """Returns a validator for string data. Args: @@ -192,19 +230,16 @@ def string(*, fast_termination = False) -> ZonString: """ return ZonString(fast_termination=fast_termination) + class ZonString(ZonContainer): """A Zon that validates that the data is a string. For all purposes, a string is a collection of characters. """ - def _default_validate(self, data: T) -> bool: + def _default_validate(self, data: T, ctx: ValidationContext) -> bool: if not isinstance(data, str): + ctx.add_issue(ZonIssue(value=data, message="Not a string", path=[])) - err = ZonError() - err.add_issue(ZonIssue(value=data, message="Not a string", path=[])) - - raise err - return True diff --git a/zon/error.py b/zon/error.py index 703be86..12e199d 100644 --- a/zon/error.py +++ b/zon/error.py @@ -5,6 +5,7 @@ from typing_extensions import deprecated + @deprecated("Use the new ZonError class instead.") class ValidationError(Exception): """ From fcf64014b87c4056a1fcd9d8b351616d6600ff96 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 12:15:27 +0100 Subject: [PATCH 05/29] Simplified validation logic --- CHANGELOG.md | 1 + zon/__init__.py | 43 +++++++++++++++++++------------------------ 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b86510..4a1f088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Moved everything into a single file to combat circular reference issues - Deprecated `ValidationError` in favor of `ZonError`. +- Simplified validation logic ### Removed - Removed `between`, `__eq__` and `equals` methods from `ZonNumber`. diff --git a/zon/__init__.py b/zon/__init__.py index 9d23ab8..8ce5ce3 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -13,7 +13,7 @@ import copy from abc import ABC, abstractmethod -from typing import Callable, Self, TypeVar, final +from typing import Any, Callable, Self, TypeVar, final from dataclasses import dataclass, field from .error import ZonError, ZonIssue @@ -57,6 +57,13 @@ def dirty(self): T = TypeVar("T") ValidationRule = Callable[[T, ValidationContext], bool] +class ValidationRule: + def __init__(self, fn: Callable[[T, ValidationContext], bool]): + self.fn = fn + + def check(self, data: Any, ctx: ValidationContext): + return self.fn(data, ctx) + class Zon(ABC): """ Base class for all Zons. @@ -67,7 +74,7 @@ class Zon(ABC): """ def __init__(self, **kwargs): - self.validators: dict[str, ValidationRule] = {} + self.validators: list[ValidationRule] = [] """validators that will run when 'validate' is invoked.""" self._terminate_early = kwargs.get("terminate_early", False) @@ -77,7 +84,7 @@ def _clone(self) -> Self: return copy.deepcopy(self) @abstractmethod - def _default_validate(self, data: T, ctx: ValidationContext) -> bool: + def _default_validate(self, data: T, ctx: ValidationContext): """Default validation for any Zon validator The contract for this method is the same for any other `ValidationRule`: @@ -111,30 +118,21 @@ def _validate(self, data: T) -> bool: """ ctx = ValidationContext() + def check_early_termination(): + if self._terminate_early and ctx.dirty: + ctx.raise_error() try: - _passed = self._default_validate(data, ctx) - except ZonError as ze: - if self._terminate_early: - # since we want to terminate early, we can just directly raise the error - raise ze - - ctx.add_issues(ze.issues) + self._default_validate(data, ctx) except NotImplementedError as ni: raise ni - for validator_type, validator in self.validators.items(): - try: - _passed = validator(data, ctx) + check_early_termination() - return True - except ZonError as ze: - ctx.add_issues(ze.issues) + for validator in self.validators: + validator.check(data, ctx) - if self._terminate_early: - # Since we want to terminate early, we can just directly raise the error - # Following the sequence of the code, we are guaranteed to have errors at this point. - ctx.raise_error() + check_early_termination() if ctx.dirty: ctx.raise_error() @@ -237,9 +235,6 @@ class ZonString(ZonContainer): For all purposes, a string is a collection of characters. """ - def _default_validate(self, data: T, ctx: ValidationContext) -> bool: - + def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, str): ctx.add_issue(ZonIssue(value=data, message="Not a string", path=[])) - - return True From 84431f79ea8ac5549589f6e17a85c4533f955957 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 12:34:40 +0100 Subject: [PATCH 06/29] Added non-default validations --- CHANGELOG.md | 2 ++ tests/str_test.py | 25 +++++++++++++++++++++++++ zon/__init__.py | 43 ++++++++++++++++++++++++++++++++++--------- zon/traits.py | 5 +++-- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1f088..292b39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. +- Implemented non-default validation +- Added dimension tests for `ZonString` ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/tests/str_test.py b/tests/str_test.py index 878d4ba..767b5f1 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -35,6 +35,31 @@ def test_str_safe_validate(validator): assert validator.safe_validate([1])[0] is False assert validator.safe_validate({"a": 1})[0] is False +def test_str_length_equal(validator): + _validator = validator.length(2) + + assert _validator.validate("12") + + with pytest.raises(zon.error.ZonError): + _validator.validate("1") + +def test_str_length_less_than(validator): + _validator = validator.max(3) + + assert _validator.validate("1") + assert _validator.validate("12") + + with pytest.raises(zon.error.ZonError): + _validator.validate("123") + +def test_str_length_greater_than(validator): + _validator = validator.min(1) + + assert _validator.validate("123") + assert _validator.validate("12") + + with pytest.raises(zon.error.ZonError): + _validator.validate("1") """ def test_email(validator): diff --git a/zon/__init__.py b/zon/__init__.py index 8ce5ce3..07cac8d 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -55,14 +55,17 @@ def dirty(self): return self._error is not None and len(self._error.issues) >= 0 T = TypeVar("T") -ValidationRule = Callable[[T, ValidationContext], bool] class ValidationRule: - def __init__(self, fn: Callable[[T, ValidationContext], bool]): + def __init__(self, name: str, fn: Callable[[T], bool]): self.fn = fn + self.name = name def check(self, data: Any, ctx: ValidationContext): - return self.fn(data, ctx) + valid = self.fn(data) + + if not valid: + ctx.add_issue(ZonIssue(value=data, message=f"Validation failed for type {self.name}", path=[])) class Zon(ABC): """ @@ -189,33 +192,53 @@ class ZonContainer(Zon, HasMax, HasMin): Contains container specific validator rules. """ - def max(self, max_value: int | float): + def max(self, max_value: int | float) -> Self: """Validates that this container as at most `max_value` elements (exclusive). Args: max_value (int | float): the maximum number of elements that this container can have """ - # TODO: add check + _clone = self._clone() + + _clone.validators.append(ValidationRule( + "length", + lambda data: len(data) < max_value, + )) - def min(self, min_value: int | float): + return _clone + + def min(self, min_value: int | float) -> Self: """Validates that this container as at least `max_value` elements (exclusive). Args: min_value (int | float): the minimum number of elements that this container can have """ - # TODO: add check + _clone = self._clone() + + _clone.validators.append(ValidationRule( + "length_min", + lambda data: len(data) > min_value, + )) - def length(self, length: int): + return _clone + + def length(self, length: int) -> Self: """Validates that this container as exactly `length` elements. Args: length (int): the exact number of elements that this container can have """ - # TODO: add check + _clone = self._clone() + + _clone.validators.append(ValidationRule( + "length_equal", + lambda data: len(data) == length, + )) + return _clone def string(*, fast_termination=False) -> ZonString: """Returns a validator for string data. @@ -238,3 +261,5 @@ class ZonString(ZonContainer): def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, str): ctx.add_issue(ZonIssue(value=data, message="Not a string", path=[])) + + \ No newline at end of file diff --git a/zon/traits.py b/zon/traits.py index 1a57b30..a4f19d4 100644 --- a/zon/traits.py +++ b/zon/traits.py @@ -5,6 +5,7 @@ from __future__ import annotations from abc import abstractmethod +from typing import Self class HasMax: @@ -13,7 +14,7 @@ class HasMax: """ @abstractmethod - def max(self, max_value: int | float): + def max(self, max_value: int | float) -> Self: """ Defines that a given attribute of the value being validated must be upper-bound by the given parameter. @@ -30,7 +31,7 @@ class HasMin: """ @abstractmethod - def min(self, min_value: int | float): + def min(self, min_value: int | float) -> Self: """ Defines that a given attribute of the value being validated must be lower-bound by the given parameter. From d1569c8996625c37591af21c44eeca7c3fbbfc2a Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 15:16:09 +0100 Subject: [PATCH 07/29] Added examples --- CHANGELOG.md | 3 +-- README.md | 11 ++++++-- examples/simple.py | 11 ++++++++ tests/str_test.py | 12 ++++++--- zon/__init__.py | 65 ++++++++++++++++++++++++++++------------------ 5 files changed, 69 insertions(+), 33 deletions(-) create mode 100644 examples/simple.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 292b39b..5104269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -> This changelog was generated some commits after the [v1.0.0 tag](https://github.com/Naapperas/zon/releases/tag/v1.0.0), so the changelog will have some inconsistencies until the next release. - ## [Unreleased] ### Added - Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. - Implemented non-default validation - Added dimension tests for `ZonString` +- Added `examples` folder ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/README.md b/README.md index 4821702..ccdf145 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ pip install . In its essence, `zon` behaves much like `zod`. If you have used `zod`, you will feel right at home with `zon`. +> [!NOTE] +> There are some differences in the public API between `zon` and `zod`. Those mostly stem from the fact that Python does not have type inference like Typescript has. + ### Basic types `zon` features most of `zod`'s basic types: @@ -49,7 +52,7 @@ zon.none() zon.anything() ``` -Besides this, theres also a `zon.optional()` type, which allows for a value to be either of the type passed as an argument or `None`. +Besides this, there's also a `zon.optional()` type, which allows for a value to be either of the type passed as an argument or `None`. ```python zon.optional(zon.string()) @@ -130,6 +133,10 @@ schema = zon.record({ }) ``` +## Examples + +Example usage of `zon` can be found in the `examples` directory. + ## Documentation Documentation is still not available, but it will be soon. @@ -141,7 +148,7 @@ Tests can be found in the [tests](tests) folder. `zon` uses `pytest` for unit te To run the tests, simply run: ```bash -pytest /test +pytest test ``` ## Contributing diff --git a/examples/simple.py b/examples/simple.py new file mode 100644 index 0000000..d5ad121 --- /dev/null +++ b/examples/simple.py @@ -0,0 +1,11 @@ +import zon + +validator = zon.string().min(2).max(5) + +for i in range(7): + print(f"Checking for length={i}") + + try: + validator.validate('a' * i) + except zon.error.ZonError as e: + print(f"Failed: {e}") \ No newline at end of file diff --git a/tests/str_test.py b/tests/str_test.py index 767b5f1..a4a90d8 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -17,13 +17,13 @@ def test_str_validate(validator): assert validator.validate("1") with pytest.raises(zon.error.ZonError): - validator.validate(1.5) + validator.validate(1.5) with pytest.raises(zon.error.ZonError): - validator.validate(1) + validator.validate(1) with pytest.raises(zon.error.ZonError): - validator.validate([1]) + validator.validate([1]) with pytest.raises(zon.error.ZonError): - validator.validate({"a": 1}) + validator.validate({"a": 1}) def test_str_safe_validate(validator): @@ -35,6 +35,7 @@ def test_str_safe_validate(validator): assert validator.safe_validate([1])[0] is False assert validator.safe_validate({"a": 1})[0] is False + def test_str_length_equal(validator): _validator = validator.length(2) @@ -43,6 +44,7 @@ def test_str_length_equal(validator): with pytest.raises(zon.error.ZonError): _validator.validate("1") + def test_str_length_less_than(validator): _validator = validator.max(3) @@ -52,6 +54,7 @@ def test_str_length_less_than(validator): with pytest.raises(zon.error.ZonError): _validator.validate("123") + def test_str_length_greater_than(validator): _validator = validator.min(1) @@ -61,6 +64,7 @@ def test_str_length_greater_than(validator): with pytest.raises(zon.error.ZonError): _validator.validate("1") + """ def test_email(validator): _validator = validator.email() diff --git a/zon/__init__.py b/zon/__init__.py index 07cac8d..a4fd028 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -13,16 +13,17 @@ import copy from abc import ABC, abstractmethod -from typing import Any, Callable, Self, TypeVar, final +from typing import Any, Self, TypeVar, final +from collections.abc import Callable from dataclasses import dataclass, field from .error import ZonError, ZonIssue from .traits import HasMax, HasMin + @dataclass class ValidationContext: - """Context used throughout an entire validation run - """ + """Context used throughout an entire validation run""" _error: ZonError = None path: list[str] = field(default_factory=list) @@ -32,14 +33,12 @@ def _ensure_error(self): self._error = ZonError() def add_issue(self, issue: ZonIssue): - """Adds the given `ZodIssue` to this context's `ZonError` - """ + """Adds the given `ZodIssue` to this context's `ZonError`""" self._ensure_error() self._error.add_issue(issue) def add_issues(self, issues: list[ZonIssue]): - """Adds the given `ZodIssue`s to this context's `ZonError` - """ + """Adds the given `ZodIssue`s to this context's `ZonError`""" self._ensure_error() self._error.add_issues(issues) @@ -47,25 +46,35 @@ def raise_error(self): """ Raises the current validation error in this context if it exists. """ - + raise self._error @property def dirty(self): return self._error is not None and len(self._error.issues) >= 0 + T = TypeVar("T") + class ValidationRule: - def __init__(self, name: str, fn: Callable[[T], bool]): + def __init__(self, name: str, fn: Callable[[T], bool], *, additional_data: dict[str, Any] = {}): self.fn = fn self.name = name + self.additional_data = additional_data def check(self, data: Any, ctx: ValidationContext): valid = self.fn(data) if not valid: - ctx.add_issue(ZonIssue(value=data, message=f"Validation failed for type {self.name}", path=[])) + ctx.add_issue( + ZonIssue( + value=data, + message=f"Validation failed for type {self.name}", + path=[], + ) + ) + class Zon(ABC): """ @@ -121,12 +130,13 @@ def _validate(self, data: T) -> bool: """ ctx = ValidationContext() + def check_early_termination(): if self._terminate_early and ctx.dirty: ctx.raise_error() try: - self._default_validate(data, ctx) + self._default_validate(data, ctx) except NotImplementedError as ni: raise ni @@ -201,10 +211,12 @@ def max(self, max_value: int | float) -> Self: _clone = self._clone() - _clone.validators.append(ValidationRule( - "length", - lambda data: len(data) < max_value, - )) + _clone.validators.append( + ValidationRule( + "length", + lambda data: len(data) < max_value, + ) + ) return _clone @@ -217,10 +229,12 @@ def min(self, min_value: int | float) -> Self: _clone = self._clone() - _clone.validators.append(ValidationRule( - "length_min", - lambda data: len(data) > min_value, - )) + _clone.validators.append( + ValidationRule( + "length_min", + lambda data: len(data) > min_value, + ) + ) return _clone @@ -233,13 +247,16 @@ def length(self, length: int) -> Self: _clone = self._clone() - _clone.validators.append(ValidationRule( - "length_equal", - lambda data: len(data) == length, - )) + _clone.validators.append( + ValidationRule( + "length_equal", + lambda data: len(data) == length, + ) + ) return _clone + def string(*, fast_termination=False) -> ZonString: """Returns a validator for string data. @@ -261,5 +278,3 @@ class ZonString(ZonContainer): def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, str): ctx.add_issue(ZonIssue(value=data, message="Not a string", path=[])) - - \ No newline at end of file From 8f1372b6d98ec885bf862fffd358e72c5e19ab30 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 15:24:28 +0100 Subject: [PATCH 08/29] Changed behaviour of and functions to be bound-inclusive --- examples/simple.py | 2 +- tests/str_test.py | 4 ++-- zon/__init__.py | 17 +++++++++-------- zon/traits.py | 4 ++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index d5ad121..d9ec81a 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,6 +1,6 @@ import zon -validator = zon.string().min(2).max(5) +validator = zon.string().min(2).max(5).length(3) for i in range(7): print(f"Checking for length={i}") diff --git a/tests/str_test.py b/tests/str_test.py index a4a90d8..950ea87 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -46,7 +46,7 @@ def test_str_length_equal(validator): def test_str_length_less_than(validator): - _validator = validator.max(3) + _validator = validator.max(2) assert _validator.validate("1") assert _validator.validate("12") @@ -56,7 +56,7 @@ def test_str_length_less_than(validator): def test_str_length_greater_than(validator): - _validator = validator.min(1) + _validator = validator.min(2) assert _validator.validate("123") assert _validator.validate("12") diff --git a/zon/__init__.py b/zon/__init__.py index a4fd028..1a4af07 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -203,7 +203,7 @@ class ZonContainer(Zon, HasMax, HasMin): """ def max(self, max_value: int | float) -> Self: - """Validates that this container as at most `max_value` elements (exclusive). + """Validates that this container as at most `max_value` elements (inclusive). Args: max_value (int | float): the maximum number of elements that this container can have @@ -213,15 +213,15 @@ def max(self, max_value: int | float) -> Self: _clone.validators.append( ValidationRule( - "length", - lambda data: len(data) < max_value, + "max_length", + lambda data: hasattr(data, '__len__') and len(data) <= max_value, ) ) return _clone def min(self, min_value: int | float) -> Self: - """Validates that this container as at least `max_value` elements (exclusive). + """Validates that this container as at least `max_value` elements (inclusive). Args: min_value (int | float): the minimum number of elements that this container can have @@ -231,13 +231,14 @@ def min(self, min_value: int | float) -> Self: _clone.validators.append( ValidationRule( - "length_min", - lambda data: len(data) > min_value, + "min_length", + lambda data: hasattr(data, '__len__') and len(data) >= min_value, ) ) return _clone + def length(self, length: int) -> Self: """Validates that this container as exactly `length` elements. @@ -249,8 +250,8 @@ def length(self, length: int) -> Self: _clone.validators.append( ValidationRule( - "length_equal", - lambda data: len(data) == length, + "equal_length", + lambda data: hasattr(data, '__len__') and len(data) == length, ) ) diff --git a/zon/traits.py b/zon/traits.py index a4f19d4..30923ff 100644 --- a/zon/traits.py +++ b/zon/traits.py @@ -19,7 +19,7 @@ def max(self, max_value: int | float) -> Self: Defines that a given attribute of the value being validated must be upper-bound by the given parameter. Args: - max_value (int | float): the maximum value (exclusive) that the attribute being validated can have. + max_value (int | float): the maximum value (inclusive) that the attribute being validated can have. """ raise NotImplementedError("'max' must be implemented by subclasses") @@ -36,7 +36,7 @@ def min(self, min_value: int | float) -> Self: Defines that a given attribute of the value being validated must be lower-bound by the given parameter. Args: - min_value (int | float): the minimum value (exclusive) that the attribute being validated can have. + min_value (int | float): the minimum value (inclusive) that the attribute being validated can have. """ raise NotImplementedError("'min' must be implemented by subclasses") From e36693bf37d6b9287f7d459ba6c1fdd2b59bd691 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 15:43:15 +0100 Subject: [PATCH 09/29] Fixed ZonString early-failure behavior --- CHANGELOG.md | 1 + tests/str_test.py | 5 ----- tests/zon_test.py | 32 ++++++++++++++++++++++++++++++++ zon/__init__.py | 6 ++++-- 4 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 tests/zon_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5104269..b61669b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implemented non-default validation - Added dimension tests for `ZonString` - Added `examples` folder +- Added `early failure` mechanism to prevent validation on objects after the first validation error ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/tests/str_test.py b/tests/str_test.py index 950ea87..42035f4 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -8,11 +8,6 @@ def validator(): return zon.string() -@pytest.fixture -def fail_fast_validator(): - return zon.string(fast_termination=True) - - def test_str_validate(validator): assert validator.validate("1") diff --git a/tests/zon_test.py b/tests/zon_test.py new file mode 100644 index 0000000..a09c5be --- /dev/null +++ b/tests/zon_test.py @@ -0,0 +1,32 @@ +""" +Tests related to core Zon functionality that is not necessarily bound to any specific Zon implementation. +""" + +import pytest + +import zon + +@pytest.fixture +def normal_validator(): + # This is just for demonstration purposes + return zon.string(fast_termination=False).length(3).max(2) + +@pytest.fixture +def fail_fast_validator(): + # This is just for demonstration purposes + return zon.string(fast_termination=True).length(3).max(2) + + +def test_fail_fast(normal_validator, fail_fast_validator): + + test_input = "12345" + + with pytest.raises(zon.error.ZonError) as fail_fast_result: + fail_fast_validator.validate(test_input) + + assert len(fail_fast_result.value.issues) == 1 + + with pytest.raises(zon.error.ZonError) as normal_result: + normal_validator.validate(test_input) + + assert len(normal_result.value.issues) == 2 \ No newline at end of file diff --git a/zon/__init__.py b/zon/__init__.py index 1a4af07..c9fd3a4 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -63,7 +63,7 @@ def __init__(self, name: str, fn: Callable[[T], bool], *, additional_data: dict[ self.name = name self.additional_data = additional_data - def check(self, data: Any, ctx: ValidationContext): + def check(self, data: Any, ctx: ValidationContext) -> bool: valid = self.fn(data) if not valid: @@ -75,6 +75,7 @@ def check(self, data: Any, ctx: ValidationContext): ) ) + return valid class Zon(ABC): """ @@ -132,6 +133,7 @@ def _validate(self, data: T) -> bool: ctx = ValidationContext() def check_early_termination(): + print(self._terminate_early, ctx) if self._terminate_early and ctx.dirty: ctx.raise_error() @@ -267,7 +269,7 @@ def string(*, fast_termination=False) -> ZonString: Returns: ZonString: The string data validator. """ - return ZonString(fast_termination=fast_termination) + return ZonString(terminate_early=fast_termination) class ZonString(ZonContainer): From cf26bdb36b86e159b7a88cf3779cb65b1651e16d Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 17:50:15 +0100 Subject: [PATCH 10/29] Reimplementeed more string methods --- pyproject.toml | 3 +- tests/str_test.py | 61 +++++++++++++++++++++++++++--------- zon/__init__.py | 78 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb7120f..d77fcfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ classifiers = [ ] dependencies = [ "validators>0.28", - "typing_extensions>4.12" + "typing_extensions>4.12", + "uuid==1.30" ] [project.optional-dependencies] diff --git a/tests/str_test.py b/tests/str_test.py index 42035f4..0c46203 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -1,3 +1,4 @@ +import uuid import pytest import zon @@ -60,17 +61,58 @@ def test_str_length_greater_than(validator): _validator.validate("1") -""" -def test_email(validator): +def test_str_email(validator): _validator = validator.email() assert _validator.validate("test@host.com") - assert _validator.validate("test@host") + + with pytest.raises(zon.error.ZonError): + assert _validator.validate("test@host") + + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("@host.com") + + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("host.com") + +def test_str_url(validator): + _validator = validator.url() + + assert _validator.validate("https://www.google.com") + assert _validator.validate("http://www.google.com") + + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("www.google.com") + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("google.com") + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("google.com/") + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("google.com") + +def test_uuid(validator): + _validator = validator.uuid() + + assert _validator.validate(uuid.uuid4().hex) + + with pytest.raises(zon.error.ZonError): + _validator.validate("not_a_UUID") + +""" +def test_str_emoji(validator): + _validator = validator.emoji() - assert not _validator.validate("@host.com") - assert not _validator.validate("host.com") + assert _validator.validate("😀") + assert _validator.validate("🤩") + assert _validator.validate("🤨") + assert _validator.validate("😐") + assert _validator.validate("😑") + with pytest.raises(zon.error.ZonError): + assert not _validator.validate("1") +""" +""" def test_ipv4(validator): _validator = validator.ip() @@ -104,15 +146,6 @@ def test_ipv6(validator): # Example taken from https://zod.dev/?id=ip-addresses assert not _validator.validate("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003") - -def test_uuid(validator): - _validator = validator.uuid() - - assert _validator.validate(uuid.uuid4().hex) - - assert not _validator.validate("not_a_UUID") - - def test_regex(validator): _validator = validator.regex(r"^[a-z ]+$") diff --git a/zon/__init__.py b/zon/__init__.py index c9fd3a4..60c58f1 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -13,9 +13,12 @@ import copy from abc import ABC, abstractmethod -from typing import Any, Self, TypeVar, final +from typing import Any, Self, TypeVar, final, Literal from collections.abc import Callable from dataclasses import dataclass, field +import re + +import validators from .error import ZonError, ZonIssue from .traits import HasMax, HasMin @@ -133,7 +136,6 @@ def _validate(self, data: T) -> bool: ctx = ValidationContext() def check_early_termination(): - print(self._terminate_early, ctx) if self._terminate_early and ctx.dirty: ctx.raise_error() @@ -174,7 +176,7 @@ def validate(self, data: T) -> bool: return self._validate(data) @final - def safe_validate(self, data: T) -> tuple[bool, T] | tuple[bool, ZonError]: + def safe_validate(self, data: T) -> tuple[Literal[True], T] | tuple[Literal[False], ZonError]: """Validates the supplied data. This method is different from `validate` in the sense that it does not raise an error when validation fails. Instead, it returns an object encapsulating either a successful validation result or a validation error. @@ -281,3 +283,73 @@ class ZonString(ZonContainer): def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, str): ctx.add_issue(ZonIssue(value=data, message="Not a string", path=[])) + + def email(self) -> Self: + """ + Assert that the value under validation is a valid email. + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "email", + validators.email, + ) + ) + + return _clone + + def url(self) -> Self: + """ + Assert that the value under validation is a valid url. + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "url", + validators.url, + ) + ) + + return _clone + + def emoji(self) -> Self: + """Assert that the value under validation is a valid emoji. + + Returns: + ZonString: a new zon with the validation rule added + """ + + raise NotImplementedError + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "emoji", + lambda data: re.compile(r"^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$").match(data) is not None, + ) + ) + + return _clone + + def uuid(self) -> Self: + """Assert that the value under validation is a valid uuid. + + Returns: + ZonString: a new zon with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "uuid", + validators.uuid, + ) + ) + + return _clone \ No newline at end of file From d4c60c87dfe14fe91f45d66e8cd22fbf2b8eccf6 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 22:49:39 +0100 Subject: [PATCH 11/29] Added datetime validation --- CHANGELOG.md | 1 + README.md | 7 ++ examples/simple.py | 4 +- tests/str_test.py | 121 ++++++++++++++++++++++++----- tests/zon_test.py | 8 +- zon/__init__.py | 190 +++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 290 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b61669b..3da581d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added dimension tests for `ZonString` - Added `examples` folder - Added `early failure` mechanism to prevent validation on objects after the first validation error +- Added explanation regarding `ZonString.datetime()` decisions. ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/README.md b/README.md index ccdf145..efe274e 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,15 @@ zon.string().email() zon.string().regex(r"^\d{3}-\d{3}-\d{4}$") zon.string().uuid() zon.string().ip() +zon.string().datetime() ``` +#### Datetime + +`zod` uses regex-based validation for datetimes, which must be valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) strings. However, due to an issue with most JavaScript engines' datetime validation, offsets cannot specify only hours, and `zod` reflects this in their API. + +While `zon` could reflect `zod`'s API in this matter, it is best to not constrain users to the problems of another platform, making this one of the aspects where `zon` deviates from `zod`. + ### List Lists are defined by calling the `zon.list()` method, passing as an argument a `Zon` instance. All elements in this list must be of the same type. diff --git a/examples/simple.py b/examples/simple.py index d9ec81a..c5371c9 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -6,6 +6,6 @@ print(f"Checking for length={i}") try: - validator.validate('a' * i) + validator.validate("a" * i) except zon.error.ZonError as e: - print(f"Failed: {e}") \ No newline at end of file + print(f"Failed: {e}") diff --git a/tests/str_test.py b/tests/str_test.py index 0c46203..93fc611 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -65,16 +65,17 @@ def test_str_email(validator): _validator = validator.email() assert _validator.validate("test@host.com") - + with pytest.raises(zon.error.ZonError): assert _validator.validate("test@host") with pytest.raises(zon.error.ZonError): assert not _validator.validate("@host.com") - + with pytest.raises(zon.error.ZonError): assert not _validator.validate("host.com") + def test_str_url(validator): _validator = validator.url() @@ -90,13 +91,6 @@ def test_str_url(validator): with pytest.raises(zon.error.ZonError): assert not _validator.validate("google.com") -def test_uuid(validator): - _validator = validator.uuid() - - assert _validator.validate(uuid.uuid4().hex) - - with pytest.raises(zon.error.ZonError): - _validator.validate("not_a_UUID") """ def test_str_emoji(validator): @@ -112,6 +106,105 @@ def test_str_emoji(validator): assert not _validator.validate("1") """ + +def test_str_uuid(validator): + _validator = validator.uuid() + + assert _validator.validate(uuid.uuid4().hex) + + with pytest.raises(zon.error.ZonError): + _validator.validate("not_a_UUID") + + +# TODO: cuid, cuid2, nanoid, ulid + + +def test_str_regex(validator): + _validator = validator.regex(r"^[a-z ]+$") + + assert _validator.validate("abc") + assert _validator.validate("def") + assert _validator.validate("abc def") + + with pytest.raises(zon.error.ZonError): + _validator.validate("abc1") + + with pytest.raises(zon.error.ZonError): + _validator.validate("abc def1") + + +def test_str_includes(validator): + _validator = validator.includes("abc") + + assert _validator.validate("abc") + assert _validator.validate("abc def") + + with pytest.raises(zon.error.ZonError): + _validator.validate("def") + + +def test_str_starts_with(validator): + _validator = validator.starts_with("abc") + + assert _validator.validate("abc") + assert _validator.validate("abc def") + + with pytest.raises(zon.error.ZonError): + _validator.validate("def") + + +def test_str_ends_with(validator): + _validator = validator.ends_with("abc") + + assert _validator.validate("abc") + assert _validator.validate("def abc") + + with pytest.raises(zon.error.ZonError): + _validator.validate("def") + + +def test_str_datetime_no_opts(validator): + _validator = ( + validator.datetime() + ) # this also validates the case where precision is passed in as 'None', since the behavior is the same + + assert _validator.validate("2020-01-01T00:00:00Z") + assert _validator.validate("2020-01-01T00:00:00.123Z") + assert _validator.validate("2020-01-01T00:00:00.123456Z") + + with pytest.raises(zon.error.ZonError): + _validator.validate("2020-01-01T00:00:00+02:00") + + +def test_str_datetime_precision(validator): + _validator = validator.datetime({"precision": 3}) + + assert _validator.validate("2020-01-01T00:00:00.123Z") + + with pytest.raises(zon.error.ZonError): + _validator.validate("2020-01-01T00:00:00.12345678Z") + with pytest.raises(zon.error.ZonError): + _validator.validate("2020-01-01T00:00:00Z") + + +def test_str_datetime_offset(validator): + _validator = validator.datetime({"offset": True}) + + # test examples taken from https://zod.dev/?id=datetimes + assert _validator.validate("2020-01-01T00:00:00+02:00") + assert _validator.validate("2020-01-01T00:00:00.123+02:00") + assert _validator.validate("2020-01-01T00:00:00.123-0200") + assert _validator.validate("2020-01-01T00:00:00.123-02") + assert _validator.validate("2020-01-01T00:00:00Z") + + +def test_str_datetime_local(validator): + _validator = validator.datetime({"local": True}) + + assert _validator.validate("2020-01-01T00:00:00Z") + assert _validator.validate("2020-01-01T00:00:00") + + """ def test_ipv4(validator): _validator = validator.ip() @@ -145,14 +238,4 @@ def test_ipv6(validator): # Example taken from https://zod.dev/?id=ip-addresses assert not _validator.validate("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003") - -def test_regex(validator): - _validator = validator.regex(r"^[a-z ]+$") - - assert _validator.validate("abc") - assert _validator.validate("def") - assert _validator.validate("abc def") - - assert not _validator.validate("abc1") - assert not _validator.validate("abc def1") """ diff --git a/tests/zon_test.py b/tests/zon_test.py index a09c5be..518c441 100644 --- a/tests/zon_test.py +++ b/tests/zon_test.py @@ -6,11 +6,13 @@ import zon + @pytest.fixture def normal_validator(): # This is just for demonstration purposes return zon.string(fast_termination=False).length(3).max(2) + @pytest.fixture def fail_fast_validator(): # This is just for demonstration purposes @@ -23,10 +25,10 @@ def test_fail_fast(normal_validator, fail_fast_validator): with pytest.raises(zon.error.ZonError) as fail_fast_result: fail_fast_validator.validate(test_input) - + assert len(fail_fast_result.value.issues) == 1 with pytest.raises(zon.error.ZonError) as normal_result: normal_validator.validate(test_input) - - assert len(normal_result.value.issues) == 2 \ No newline at end of file + + assert len(normal_result.value.issues) == 2 diff --git a/zon/__init__.py b/zon/__init__.py index 60c58f1..4f8135b 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -3,6 +3,7 @@ Flexible validation powered by Python with the expressiveness of a Zod-like API. """ +# Why is this needed even? from __future__ import annotations __version__ = "2.0.0" @@ -14,7 +15,7 @@ import copy from abc import ABC, abstractmethod from typing import Any, Self, TypeVar, final, Literal -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass, field import re @@ -61,10 +62,16 @@ def dirty(self): class ValidationRule: - def __init__(self, name: str, fn: Callable[[T], bool], *, additional_data: dict[str, Any] = {}): + def __init__( + self, + name: str, + fn: Callable[[T], bool], + *, + additional_data: Mapping[str, Any] = None, + ): self.fn = fn self.name = name - self.additional_data = additional_data + self.additional_data = additional_data if additional_data is not None else {} def check(self, data: Any, ctx: ValidationContext) -> bool: valid = self.fn(data) @@ -80,6 +87,7 @@ def check(self, data: Any, ctx: ValidationContext) -> bool: return valid + class Zon(ABC): """ Base class for all Zons. @@ -176,7 +184,9 @@ def validate(self, data: T) -> bool: return self._validate(data) @final - def safe_validate(self, data: T) -> tuple[Literal[True], T] | tuple[Literal[False], ZonError]: + def safe_validate( + self, data: T + ) -> tuple[Literal[True], T] | tuple[Literal[False], ZonError]: """Validates the supplied data. This method is different from `validate` in the sense that it does not raise an error when validation fails. Instead, it returns an object encapsulating either a successful validation result or a validation error. @@ -218,7 +228,7 @@ def max(self, max_value: int | float) -> Self: _clone.validators.append( ValidationRule( "max_length", - lambda data: hasattr(data, '__len__') and len(data) <= max_value, + lambda data: hasattr(data, "__len__") and len(data) <= max_value, ) ) @@ -236,13 +246,12 @@ def min(self, min_value: int | float) -> Self: _clone.validators.append( ValidationRule( "min_length", - lambda data: hasattr(data, '__len__') and len(data) >= min_value, + lambda data: hasattr(data, "__len__") and len(data) >= min_value, ) ) return _clone - def length(self, length: int) -> Self: """Validates that this container as exactly `length` elements. @@ -255,7 +264,7 @@ def length(self, length: int) -> Self: _clone.validators.append( ValidationRule( "equal_length", - lambda data: hasattr(data, '__len__') and len(data) == length, + lambda data: hasattr(data, "__len__") and len(data) == length, ) ) @@ -277,7 +286,7 @@ def string(*, fast_termination=False) -> ZonString: class ZonString(ZonContainer): """A Zon that validates that the data is a string. - For all purposes, a string is a collection of characters. + For all purposes, a string is a container of characters. """ def _default_validate(self, data: T, ctx: ValidationContext): @@ -287,6 +296,9 @@ def _default_validate(self, data: T, ctx: ValidationContext): def email(self) -> Self: """ Assert that the value under validation is a valid email. + + Returns: + ZonString: a new `Zon` with the validation rule added """ _clone = self._clone() @@ -299,10 +311,13 @@ def email(self) -> Self: ) return _clone - + def url(self) -> Self: """ - Assert that the value under validation is a valid url. + Assert that the value under validation is a valid URL. + + Returns: + ZonString: a new `Zon` with the validation rule added """ _clone = self._clone() @@ -320,7 +335,7 @@ def emoji(self) -> Self: """Assert that the value under validation is a valid emoji. Returns: - ZonString: a new zon with the validation rule added + ZonString: a new `Zon` with the validation rule added """ raise NotImplementedError @@ -330,17 +345,20 @@ def emoji(self) -> Self: _clone.validators.append( ValidationRule( "emoji", - lambda data: re.compile(r"^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$").match(data) is not None, + lambda data: re.compile( + r"^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$" + ).match(data) + is not None, ) ) return _clone - + def uuid(self) -> Self: - """Assert that the value under validation is a valid uuid. + """Assert that the value under validation is a valid UUID. Returns: - ZonString: a new zon with the validation rule added + ZonString: a new `Zon` with the validation rule added """ _clone = self._clone() @@ -352,4 +370,142 @@ def uuid(self) -> Self: ) ) - return _clone \ No newline at end of file + return _clone + + # TODO: cuid, cuid2, nanoid, ulid + + def regex(self, regex: str | re.Pattern[str]) -> Self: + """Assert that the value under validation matches the given regular expression. + + Args: + regex (str): the regex to use. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "regex", + lambda data: re.match(regex, data) is not None, + ) + ) + + return _clone + + def includes(self, needle: str) -> Self: + """Assert that the value under validation includes the given string. + + Args: + needle (str): the string to look for. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "includes", + lambda data: needle in data, + ) + ) + + return _clone + + def starts_with(self, prefix: str) -> Self: + """Assert that the value under validation starts with the given string. + + Args: + prefix (str): the string to look for. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "starts_with", + lambda data: data.startswith(prefix), + ) + ) + + return _clone + + def ends_with(self, suffix: str) -> Self: + """Assert that the value under validation ends with the given string. + + Args: + suffix (str): the string to look for. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "ends_with", + lambda data: data.endswith(suffix), + ) + ) + + return _clone + + def datetime(self, opts: Mapping[str, Any] | None = None) -> Self: + """Assert that the value under validation is a valid datetime. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + if opts is None: + opts = {} + + # code ported from https://github.com/colinhacks/zod. All credit goes to the original author. + def _time_regex_source(opts: Mapping[str, Any]): + regex = r"([01]\d|2[0-3]):[0-5]\d:[0-5]\d" + + if "precision" in opts: + precision = opts["precision"] + + regex = rf"{regex}\.\d{{{precision}}}" + else: + regex = rf"{regex}(\.\d+)?" + + return regex + + def _datetime_regex(opts: Mapping[str, Any]): + dateRegexSource = r"((\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|(02)-(0[1-9]|1\d|2[0-8])))" + + regex = f"{dateRegexSource}T{_time_regex_source(opts)}" + + branches: list[str] = [] + branches.append("Z?" if opts.get("local", False) else "Z") + if opts.get("offset", False): + branches.append( + r"([+-]\d{2}(:?\d{2})?)" + ) # slight deviation from zod's regex, allowing for hour-only offsets + + regex = f"{regex}({'|'.join(branches)})" + + print(regex) + + return re.compile(f"^{regex}$") + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "datetime", + lambda data: _datetime_regex(opts).match(data) is not None, + ) + ) + + return _clone From c1d026096e7cc593b622b3edd72a6bb70fa9019f Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 23:23:32 +0100 Subject: [PATCH 12/29] Re-implemented IP validation --- tests/str_test.py | 52 +++++++++++++++++++++------------------ zon/__init__.py | 62 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/tests/str_test.py b/tests/str_test.py index 93fc611..bb193bf 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -205,37 +205,43 @@ def test_str_datetime_local(validator): assert _validator.validate("2020-01-01T00:00:00") -""" -def test_ipv4(validator): +def test_str_datetime_complex(validator): + _validator = validator.datetime({"local": True, "offset": True, "precision": 3}) + + # local (no 'Z'), with hour-only offset, 3 digit precision + assert _validator.validate("2020-01-01T00:00:00.123+02") + +# TODO: use classes to group tests +def test_str_ip_all(validator): _validator = validator.ip() - assert _validator.validate("255.255.255.255") + assert _validator.validate("127.0.0.1") assert _validator.validate("0.0.0.0") + assert _validator.validate("::") + assert _validator.validate("::1") + assert _validator.validate("::ffff:127.0.0.1") - assert not _validator.validate("1.1.1") - assert not _validator.validate("0.0.0.0.0") - assert not _validator.validate("256.256.256.256") - assert not _validator.validate("255.255.255.256") - assert not _validator.validate("255.255.256.255") - assert not _validator.validate("255.256.255.255") - assert not _validator.validate("256.255.255.255") + with pytest.raises(zon.error.ZonError): + _validator.validate("1.1.1") + with pytest.raises(zon.error.ZonError): + _validator.validate("::1.1.1") -def test_ipv6(validator): - _validator = validator.ip() +def test_str_ip_v4(validator): + _validator = validator.ip({"version": "v4"}) + + assert _validator.validate("255.255.255.255") + assert _validator.validate("0.0.0.0") + + with pytest.raises(zon.error.ZonError): + _validator.validate("::ffff:127.0.0.1") + +def test_str_ip_v6(validator): + _validator = validator.ip({"version": "v6"}) - # Ipv6 addresses assert _validator.validate("::") assert _validator.validate("::1") assert _validator.validate("::ffff:127.0.0.1") - assert _validator.validate("::ffff:7f00:1") - assert _validator.validate("::ffff:127.0.0.1") - - assert not _validator.validate("::1.1.1") - assert not _validator.validate("::ffff:127.0.0.1.1.1") - assert not _validator.validate("::ffff:127.0.0.256") - assert not _validator.validate("::ffff:127.0.0.256.256") - # Example taken from https://zod.dev/?id=ip-addresses - assert not _validator.validate("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003") -""" + with pytest.raises(zon.error.ZonError): + _validator.validate("255.255.255.255") diff --git a/zon/__init__.py b/zon/__init__.py index 4f8135b..fb9446e 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -62,6 +62,8 @@ def dirty(self): class ValidationRule: + """_summary_""" + def __init__( self, name: str, @@ -74,16 +76,29 @@ def __init__( self.additional_data = additional_data if additional_data is not None else {} def check(self, data: Any, ctx: ValidationContext) -> bool: - valid = self.fn(data) + """ """ - if not valid: + valid = False + try: + valid = self.fn(data) + + if not valid: + ctx.add_issue( + ZonIssue( + value=data, + message=f"Validation failed for type {self.name}", + path=[], + ) + ) + except validators.ValidationError as e: ctx.add_issue( ZonIssue( value=data, - message=f"Validation failed for type {self.name}", + message=f"Validation failed for type {self.name}: {e}", path=[], ) ) + valid = False return valid @@ -509,3 +524,44 @@ def _datetime_regex(opts: Mapping[str, Any]): ) return _clone + + def ip(self, opts: Mapping[str, Any] | None = None) -> Self: + """Assert that the value under validation is a valid IP address. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + if opts is None: + opts = {} + + _clone = self._clone() + + def _validator(data, opts: Mapping[str, Any]): + + doesnt_specify_version = "version" not in opts + + if doesnt_specify_version or opts["version"] == "v4": + try: + if validators.ipv4(data): + return True + except validators.ValidationError: + pass + + if doesnt_specify_version or opts["version"] == "v6": + try: + if validators.ipv6(data): + return True + except validators.ValidationError: + pass + + return False + + _clone.validators.append( + ValidationRule( + "ip", + lambda data: _validator(data, opts), + ) + ) + + return _clone From e3b532678fcff1299f5fa06b369521d0b9ca6f98 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Sun, 16 Jun 2024 23:39:21 +0100 Subject: [PATCH 13/29] Started re-implementing --- tests/number_test.py | 74 ++++++++++++++++++++++++++++++ tests/str_test.py | 3 ++ zon/__init__.py | 104 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 tests/number_test.py diff --git a/tests/number_test.py b/tests/number_test.py new file mode 100644 index 0000000..801f082 --- /dev/null +++ b/tests/number_test.py @@ -0,0 +1,74 @@ +import pytest + +import zon + + +@pytest.fixture +def validator(): + return zon.number() + + +def test_number_validate(validator): + assert validator.validate(1) + assert validator.validate(1.5) + + with pytest.raises(zon.error.ZonError): + validator.validate("1") + with pytest.raises(zon.error.ZonError): + validator.validate([1]) + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 1}) + + +def test_number_safe_validate(validator): + assert validator.safe_validate(1) == (True, 1) + assert validator.safe_validate(1.5) == (True, 1.5) + + # TODO: check for the specific error? + assert validator.safe_validate("1")[0] is False + assert validator.safe_validate([1])[0] is False + assert validator.safe_validate({"a": 1})[0] is False + + +def test_number_gt(validator): + _validator = validator.gt(2) + + assert _validator.validate(3) + + with pytest.raises(zon.error.ZonError): + _validator.validate(2) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1) + + +def test_number_gte(validator): + _validator = validator.gte(2) + + assert _validator.validate(2) + assert _validator.validate(3) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1) + + +def test_number_lt(validator): + _validator = validator.lt(2) + + assert _validator.validate(1) + + with pytest.raises(zon.error.ZonError): + _validator.validate(2) + + with pytest.raises(zon.error.ZonError): + _validator.validate(3) + + +def test_number_lte(validator): + _validator = validator.lte(2) + + assert _validator.validate(2) + assert _validator.validate(1) + + with pytest.raises(zon.error.ZonError): + _validator.validate(3) diff --git a/tests/str_test.py b/tests/str_test.py index bb193bf..a132110 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -211,6 +211,7 @@ def test_str_datetime_complex(validator): # local (no 'Z'), with hour-only offset, 3 digit precision assert _validator.validate("2020-01-01T00:00:00.123+02") + # TODO: use classes to group tests def test_str_ip_all(validator): _validator = validator.ip() @@ -227,6 +228,7 @@ def test_str_ip_all(validator): with pytest.raises(zon.error.ZonError): _validator.validate("::1.1.1") + def test_str_ip_v4(validator): _validator = validator.ip({"version": "v4"}) @@ -236,6 +238,7 @@ def test_str_ip_v4(validator): with pytest.raises(zon.error.ZonError): _validator.validate("::ffff:127.0.0.1") + def test_str_ip_v6(validator): _validator = validator.ip({"version": "v6"}) diff --git a/zon/__init__.py b/zon/__init__.py index fb9446e..caa97da 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -565,3 +565,107 @@ def _validator(data, opts: Mapping[str, Any]): ) return _clone + + +def number(*, fast_termination=False) -> ZonString: + """Returns a validator for numeric data. + + Args: + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + + Returns: + ZonNumber: The number data validator. + """ + return ZonNumber(terminate_early=fast_termination) + + +class ZonNumber(Zon): + """A Zon that validates that the data is a number.""" + + def _default_validate(self, data: T, ctx: ValidationContext): + if not isinstance(data, (int, float)): + ctx.add_issue(ZonIssue(value=data, message="Not a valid number", path=[])) + + def gt(self, min_ex: float | int) -> Self: + """Assert that the value under validation is greater than the given number. + + Args: + min_ex (float | int): the number to compare against. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "gt", + lambda data: data > min_ex, + ) + ) + + return _clone + + def gte(self, min_in: float | int) -> Self: + """Assert that the value under validation is greater than or equal to the given number. + + Args: + min_in (float | int): the number to compare against. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "gte", + lambda data: data >= min_in, + ) + ) + + return _clone + + def lt(self, max_ex: float | int) -> Self: + """Assert that the value under validation is less than the given number. + + Args: + max_ex (float | int): the number to compare against. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "lt", + lambda data: data < max_ex, + ) + ) + + return _clone + + def lte(self, max_in: float | int) -> Self: + """Assert that the value under validation is less than or equal to the given number. + + Args: + max_in (float | int): the number to compare against. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "lte", + lambda data: data <= max_in, + ) + ) + + return _clone From d105613b708bf6a5bbd02853c3fceec111a41929 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Mon, 17 Jun 2024 01:26:39 +0100 Subject: [PATCH 14/29] Re-implemented ZonBoolean --- CHANGELOG.md | 2 + tests/boolean_test.py | 34 ++++++++ tests/number_test.py | 95 +++++++++++++++++++++- zon/__init__.py | 180 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 tests/boolean_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da581d..f5a34d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - Removed `between`, `__eq__` and `equals` methods from `ZonNumber`. +- Removed `ZonInteger` and `ZonFloat` in favor of new validation rules in `ZonNumber` +- Removed `true` and `false` methods from `ZonBoolean` ## [1.1.0] - 2024-04-10 diff --git a/tests/boolean_test.py b/tests/boolean_test.py new file mode 100644 index 0000000..de0c442 --- /dev/null +++ b/tests/boolean_test.py @@ -0,0 +1,34 @@ +import pytest + +import zon + + +@pytest.fixture +def validator(): + return zon.boolean() + +def test_boolean_validate(validator): + assert validator.validate(True) + assert validator.validate(False) + + with pytest.raises(zon.error.ZonError): + validator.validate("1") + with pytest.raises(zon.error.ZonError): + validator.validate([1]) + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 1}) + with pytest.raises(zon.error.ZonError): + validator.validate(1) + with pytest.raises(zon.error.ZonError): + validator.validate(1.5) + +def test_boolean_safe_validate(validator): + assert validator.safe_validate(True) == (True, True) + assert validator.safe_validate(False) == (True, False) + + # TODO: check for the specific error? + assert validator.safe_validate("1")[0] is False + assert validator.safe_validate([1])[0] is False + assert validator.safe_validate({"a": 1})[0] is False + assert validator.safe_validate(1)[0] is False + assert validator.safe_validate(1.5)[0] is False \ No newline at end of file diff --git a/tests/number_test.py b/tests/number_test.py index 801f082..88a6824 100644 --- a/tests/number_test.py +++ b/tests/number_test.py @@ -1,3 +1,5 @@ +import math + import pytest import zon @@ -19,7 +21,6 @@ def test_number_validate(validator): with pytest.raises(zon.error.ZonError): validator.validate({"a": 1}) - def test_number_safe_validate(validator): assert validator.safe_validate(1) == (True, 1) assert validator.safe_validate(1.5) == (True, 1.5) @@ -72,3 +73,95 @@ def test_number_lte(validator): with pytest.raises(zon.error.ZonError): _validator.validate(3) + + +def test_number_int(validator): + _validator = validator.int() + + assert _validator.validate(1) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1.5) + + +def test_number_float(validator): + _validator = validator.float() + + assert _validator.validate(1.5) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1) + + +def test_number_positive(validator): + _validator = validator.positive() + + assert _validator.validate(1) + assert _validator.validate(1.5) + + with pytest.raises(zon.error.ZonError): + _validator.validate(0) + + with pytest.raises(zon.error.ZonError): + _validator.validate(-1) + + +def test_number_non_negative(validator): + _validator = validator.non_negative() + + assert _validator.validate(0) + assert _validator.validate(1) + assert _validator.validate(1.5) + + with pytest.raises(zon.error.ZonError): + _validator.validate(-1) + + +def test_number_negative(validator): + _validator = validator.negative() + + assert _validator.validate(-1) + assert _validator.validate(-1.5) + + with pytest.raises(zon.error.ZonError): + _validator.validate(0) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1) + + +def test_number_non_positive(validator): + _validator = validator.non_positive() + + assert _validator.validate(0) + assert _validator.validate(-1) + assert _validator.validate(-1.5) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1) + + +def test_number_multiple_of(validator): + _validator = validator.multiple_of(2) + + assert _validator.validate(2) + assert _validator.validate(4) + + with pytest.raises(zon.error.ZonError): + _validator.validate(1) + + with pytest.raises(zon.error.ZonError): + _validator.validate(3) + + +def test_number_finite(validator): + _validator = validator.finite() + + assert _validator.validate(1) + assert _validator.validate(1.5) + + with pytest.raises(zon.error.ZonError): + _validator.validate(math.inf) + + with pytest.raises(zon.error.ZonError): + _validator.validate(-math.inf) diff --git a/zon/__init__.py b/zon/__init__.py index caa97da..e7d725e 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -18,6 +18,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field import re +import math import validators @@ -567,7 +568,7 @@ def _validator(data, opts: Mapping[str, Any]): return _clone -def number(*, fast_termination=False) -> ZonString: +def number(*, fast_termination=False) -> ZonNumber: """Returns a validator for numeric data. Args: @@ -579,7 +580,7 @@ def number(*, fast_termination=False) -> ZonString: return ZonNumber(terminate_early=fast_termination) -class ZonNumber(Zon): +class ZonNumber(Zon, HasMax, HasMin): """A Zon that validates that the data is a number.""" def _default_validate(self, data: T, ctx: ValidationContext): @@ -628,6 +629,9 @@ def gte(self, min_in: float | int) -> Self: return _clone + def max(self, max_value: int | float) -> Self: + return self.gte(max_value) + def lt(self, max_ex: float | int) -> Self: """Assert that the value under validation is less than the given number. @@ -669,3 +673,175 @@ def lte(self, max_in: float | int) -> Self: ) return _clone + + def min(self, min_value: int | float) -> Self: + return self.lte(min_value) + + def int(self) -> Self: + """Assert that the value under validation is an integer. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "int", + lambda data: isinstance(data, int), + ) + ) + + return _clone + + def float(self) -> Self: + """Assert that the value under validation is a float. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "float", + lambda data: isinstance(data, float), + ) + ) + + return _clone + + def positive(self) -> Self: + """Assert that the value under validation is a positive number. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "positive", + lambda data: data > 0, + ) + ) + + return _clone + + def negative(self) -> Self: + """Assert that the value under validation is a negative number. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "negative", + lambda data: data < 0, + ) + ) + + return _clone + + def non_negative(self) -> Self: + """Assert that the value under validation is a non-negative number. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "non_negative", + lambda data: data >= 0, + ) + ) + + return _clone + + def non_positive(self) -> Self: + """Assert that the value under validation is a non-positive number. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "non_positive", + lambda data: data <= 0, + ) + ) + + return _clone + + def multiple_of(self, base: int | float) -> Self: + """Assert that the value under validation is a multiple of the given number. + + Args: + base (int): the number to compare against. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "multiple_of", + lambda data: data % base == 0, + ) + ) + + return _clone + + def step(self, base: int | float) -> Self: + return self.multiple_of(base) + + def finite(self) -> Self: + """Assert that the value under validation is a finite number. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "finite", + lambda data: not math.isinf(data), + ) + ) + + return _clone + +def boolean(*, fast_termination=False) -> ZonBoolean: + """Returns a validator for boolean data. + + Args: + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + + Returns: + ZonBoolean: The boolean data validator. + """ + return ZonBoolean(terminate_early=fast_termination) + +class ZonBoolean(Zon): + """A Zon that validates that the data is a boolean. + """ + + def _default_validate(self, data: T, ctx: ValidationContext): + if not isinstance(data, bool): + ctx.add_issue(ZonIssue(value=data, message="Not a valid boolean", path=[])) From 000bdaa0b62527c7b4e7e313f0bbb13cc0bf6d64 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Mon, 17 Jun 2024 22:23:57 +0100 Subject: [PATCH 15/29] Added class --- CHANGELOG.md | 3 +-- tests/boolean_test.py | 4 +++- tests/literal_test.py | 31 +++++++++++++++++++++++++++++++ tests/number_test.py | 1 + zon/__init__.py | 36 ++++++++++++++++++++++++++++++++++-- 5 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 tests/literal_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a34d3..82acd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. -- Implemented non-default validation -- Added dimension tests for `ZonString` - Added `examples` folder - Added `early failure` mechanism to prevent validation on objects after the first validation error - Added explanation regarding `ZonString.datetime()` decisions. +- Added `ZonLiteral` class ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/tests/boolean_test.py b/tests/boolean_test.py index de0c442..8ce5519 100644 --- a/tests/boolean_test.py +++ b/tests/boolean_test.py @@ -7,6 +7,7 @@ def validator(): return zon.boolean() + def test_boolean_validate(validator): assert validator.validate(True) assert validator.validate(False) @@ -22,6 +23,7 @@ def test_boolean_validate(validator): with pytest.raises(zon.error.ZonError): validator.validate(1.5) + def test_boolean_safe_validate(validator): assert validator.safe_validate(True) == (True, True) assert validator.safe_validate(False) == (True, False) @@ -31,4 +33,4 @@ def test_boolean_safe_validate(validator): assert validator.safe_validate([1])[0] is False assert validator.safe_validate({"a": 1})[0] is False assert validator.safe_validate(1)[0] is False - assert validator.safe_validate(1.5)[0] is False \ No newline at end of file + assert validator.safe_validate(1.5)[0] is False diff --git a/tests/literal_test.py b/tests/literal_test.py new file mode 100644 index 0000000..cdd961c --- /dev/null +++ b/tests/literal_test.py @@ -0,0 +1,31 @@ +import random +import pytest + +import zon + + +@pytest.fixture +def value(): + return random.random() + + +@pytest.fixture +def validator(value): + return zon.literal(value) + + +def test_literal_get_value(validator, value): + assert validator.value == value + + +def test_literal_validate(validator, value): + assert validator.validate(value) + + with pytest.raises(zon.error.ZonError): + validator.validate(value + 1) + + +def test_literal_safe_validate(validator, value): + assert validator.safe_validate(value) == (True, value) + + assert validator.safe_validate(value + 1)[0] is False diff --git a/tests/number_test.py b/tests/number_test.py index 88a6824..9fb75a7 100644 --- a/tests/number_test.py +++ b/tests/number_test.py @@ -21,6 +21,7 @@ def test_number_validate(validator): with pytest.raises(zon.error.ZonError): validator.validate({"a": 1}) + def test_number_safe_validate(validator): assert validator.safe_validate(1) == (True, 1) assert validator.safe_validate(1.5) == (True, 1.5) diff --git a/zon/__init__.py b/zon/__init__.py index e7d725e..2c8a2ec 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -827,6 +827,7 @@ def finite(self) -> Self: return _clone + def boolean(*, fast_termination=False) -> ZonBoolean: """Returns a validator for boolean data. @@ -838,10 +839,41 @@ def boolean(*, fast_termination=False) -> ZonBoolean: """ return ZonBoolean(terminate_early=fast_termination) + class ZonBoolean(Zon): - """A Zon that validates that the data is a boolean. - """ + """A Zon that validates that the data is a boolean.""" def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, bool): ctx.add_issue(ZonIssue(value=data, message="Not a valid boolean", path=[])) + + +def literal(value: Any, /, *, fast_termination=False) -> ZonLiteral: + """Returns a validator for a given literal value. + + Args: + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + value: the value that must be matched + + Returns: + ZonBoolean: The literal data validator. + """ + return ZonLiteral(value, terminate_early=fast_termination) + + +class ZonLiteral(Zon): + """A Zon that validates that the data is one of the given literals.""" + + def __init__(self, value: Any, /, **kwargs): + super().__init__(**kwargs) + self._value = value + + @property + def value(self): + return self._value + + def _default_validate(self, data: T, ctx: ValidationContext): + if data != self._value: + ctx.add_issue( + ZonIssue(value=data, message=f"Expected {self._value}", path=[]) + ) From 2f60d6306acc87335be9e49b62e9d0d3f6dfb9ec Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Tue, 18 Jun 2024 00:51:32 +0100 Subject: [PATCH 16/29] Added validator intersection --- tests/intersection_test.py | 47 ++++++++++++++++++++++++++ zon/__init__.py | 67 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 tests/intersection_test.py diff --git a/tests/intersection_test.py b/tests/intersection_test.py new file mode 100644 index 0000000..896af94 --- /dev/null +++ b/tests/intersection_test.py @@ -0,0 +1,47 @@ +import pytest + +import zon + + +@pytest.fixture +def validator_prefix(): + return zon.string().starts_with("abc") + + +@pytest.fixture +def validator_suffix(): + return zon.string().ends_with("def") + + +class TestIntersection: + @pytest.fixture + def validator(self, validator_prefix, validator_suffix): + return zon.intersection(validator_prefix, validator_suffix) + + def test_intersection_validate(self, validator): + assert validator.validate("abcdef") + + with pytest.raises(zon.error.ZonError): + validator.validate("abc") + + def test_intersection_safe_validate(self, validator): + assert validator.safe_validate("abcdef") == (True, "abcdef") + + assert validator.safe_validate("abc")[0] is False + + +class TestAndAlso: + @pytest.fixture + def validator(self, validator_prefix, validator_suffix): + return validator_prefix.and_also(validator_suffix) + + def test_intersection_validate(self, validator): + assert validator.validate("abcdef") + + with pytest.raises(zon.error.ZonError): + validator.validate("abc") + + def test_intersection_safe_validate(self, validator): + assert validator.safe_validate("abcdef") == (True, "abcdef") + + assert validator.safe_validate("abc")[0] is False diff --git a/zon/__init__.py b/zon/__init__.py index 2c8a2ec..ccbfb0b 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -63,7 +63,9 @@ def dirty(self): class ValidationRule: - """_summary_""" + """ + Custom validation rul used to add more complex validation rules to an existing `Zon` + """ def __init__( self, @@ -77,7 +79,16 @@ def __init__( self.additional_data = additional_data if additional_data is not None else {} def check(self, data: Any, ctx: ValidationContext) -> bool: - """ """ + """ + Check this validation rule against the supplied data. + + Args: + data (Any): the piece of data to be validated. + ctx (ValidationContext): the context in which the validation is being run. + + Returns: + bool: True if the data is valid, False otherwise. + """ valid = False try: @@ -225,6 +236,54 @@ def safe_validate( except Exception as e: raise e + def and_also(self, other: Zon) -> Self: + """Returns a validator that validates that the data is valid for both this and the supplied validators. + + Args: + other (Zon): the second validator + + Returns: + ZonIntersection: The intersection data validator. + """ + + return intersection(self, other) + + +def intersection(zon1: Zon, zon2: Zon, *, fast_termination=False) -> ZonIntersection: + """Returns a validator that validates that the data is valid for both validators supplied. + + Args: + zon1 (Zon): the first validator + zon2 (Zon): the second validator + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + + Returns: + ZonIntersection: The intersection data validator. + """ + return ZonIntersection(zon1, zon2, terminate_early=fast_termination) + + +class ZonIntersection(Zon): + """A Zon that validates that the data is valid for both this Zon and the supplied Zon.""" + + def __init__(self, zon1: Zon, zon2: Zon, /, **kwargs): + super().__init__(**kwargs) + self.zon1 = zon1 + self.zon2 = zon2 + + def _default_validate(self, data: T, ctx: ValidationContext): + (zon_1_parsed, data1_or_error) = self.zon1.safe_validate(data) + + if not zon_1_parsed: + ctx.add_issues(data1_or_error.issues) + return + + (zon_2_parsed, data2_or_error) = self.zon2.safe_validate(data) + + if not zon_2_parsed: + ctx.add_issues(data2_or_error.issues) + return + class ZonContainer(Zon, HasMax, HasMin): """A Zon that acts as a container for other types of data. @@ -296,6 +355,7 @@ def string(*, fast_termination=False) -> ZonString: Returns: ZonString: The string data validator. """ + return ZonString(terminate_early=fast_termination) @@ -577,6 +637,7 @@ def number(*, fast_termination=False) -> ZonNumber: Returns: ZonNumber: The number data validator. """ + return ZonNumber(terminate_early=fast_termination) @@ -837,6 +898,7 @@ def boolean(*, fast_termination=False) -> ZonBoolean: Returns: ZonBoolean: The boolean data validator. """ + return ZonBoolean(terminate_early=fast_termination) @@ -858,6 +920,7 @@ def literal(value: Any, /, *, fast_termination=False) -> ZonLiteral: Returns: ZonBoolean: The literal data validator. """ + return ZonLiteral(value, terminate_early=fast_termination) From 2d580aa097a5dad41126334e2e0600dfb42437fa Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Tue, 18 Jun 2024 01:35:44 +0100 Subject: [PATCH 17/29] Re-implemented --- tests/optional_test.py | 49 ++++++++++++++++++++++++++++++++++++++++++ zon/__init__.py | 45 +++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/optional_test.py diff --git a/tests/optional_test.py b/tests/optional_test.py new file mode 100644 index 0000000..07c017a --- /dev/null +++ b/tests/optional_test.py @@ -0,0 +1,49 @@ +import pytest + +import zon + +@pytest.fixture +def base_validator(): + return zon.string() + +def test_optional_get_value(base_validator): + assert base_validator.optional().zon is base_validator + +class TestOptionalMethod: + @pytest.fixture + def validator(self, base_validator): + return base_validator.optional() + + def test_optional_validate(self, validator): + assert validator.validate(None) + assert validator.validate("abc") + + with pytest.raises(zon.error.ZonError): + validator.validate(1.5) + + def test_optional_safe_validate(self, validator): + assert validator.safe_validate(None) == (True, None) + assert validator.safe_validate("abc") == (True, "abc") + + # TODO: check for the specific error? + assert validator.safe_validate(1.5)[0] is False + + +class TestOptionalOuterFunction: + @pytest.fixture + def validator(self, base_validator): + return zon.optional(base_validator) + + def test_optional_validate(self, validator): + assert validator.validate(None) + assert validator.validate("abc") + + with pytest.raises(zon.error.ZonError): + validator.validate(1.5) + + def test_optional_safe_validate(self, validator): + assert validator.safe_validate(None) == (True, None) + assert validator.safe_validate("abc") == (True, "abc") + + # TODO: check for the specific error? + assert validator.safe_validate(1.5)[0] is False \ No newline at end of file diff --git a/zon/__init__.py b/zon/__init__.py index ccbfb0b..d2bcf2a 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -236,7 +236,7 @@ def safe_validate( except Exception as e: raise e - def and_also(self, other: Zon) -> Self: + def and_also(self, other: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for both this and the supplied validators. Args: @@ -248,6 +248,14 @@ def and_also(self, other: Zon) -> Self: return intersection(self, other) + def optional(self) -> ZonOptional: + """Returns a validator that validates that the data is valid for this validator if it exists. + + Returns: + ZonOptional: The optional data validator. + """ + + return optional(self) def intersection(zon1: Zon, zon2: Zon, *, fast_termination=False) -> ZonIntersection: """Returns a validator that validates that the data is valid for both validators supplied. @@ -285,6 +293,41 @@ def _default_validate(self, data: T, ctx: ValidationContext): return +def optional(zon: Zon, fast_termination=False) -> ZonIntersection: + """Returns a validator that validates that the data is valid for this validator if it exists. + + Args: + zon (Zon): the supplied validator + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + + Returns: + ZonIntersection: The intersection data validator. + """ + return ZonOptional(zon, terminate_early=fast_termination) + +class ZonOptional(Zon): + """A Zon that makes its data validation optional.""" + + def __init__(self, zon: Zon, **kwargs): + super().__init__(**kwargs) + self.zon = zon + + def _default_validate(self, data, ctx): + if data: + (passed, data_or_error) = self.zon.safe_validate(data) + + if not passed: + ctx.add_issues(data_or_error.issues) + + def unwrap(self) -> Zon: + """Extracts the wrapped Zon from this ZonOptional. + + Returns: + Zon: the wrapped Zon + """ + + return self.zon + class ZonContainer(Zon, HasMax, HasMin): """A Zon that acts as a container for other types of data. From 957b8cef72aa9bc0aea048a3ed6b1c3d06c5f2a6 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Tue, 18 Jun 2024 14:43:11 +0100 Subject: [PATCH 18/29] Implemented more methods --- CHANGELOG.md | 3 +- pylintrc | 1 + pyproject.toml | 4 + tests/enum_test.py | 71 +++++++++ tests/optional_test.py | 11 +- tests/record_test.py | 238 ++++++++++++++++++++++++++++++ zon/__init__.py | 319 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 628 insertions(+), 19 deletions(-) create mode 100644 tests/enum_test.py create mode 100644 tests/record_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82acd65..591ab57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `examples` folder - Added `early failure` mechanism to prevent validation on objects after the first validation error - Added explanation regarding `ZonString.datetime()` decisions. -- Added `ZonLiteral` class +- Added `ZonLiteral` and `ZonEnum` classes +- Added more `ZonRecord` methods ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/pylintrc b/pylintrc index d743e6d..a7093c3 100644 --- a/pylintrc +++ b/pylintrc @@ -37,6 +37,7 @@ load-plugins= pylint.extensions.broad_try_clause, pylint.extensions.dict_init_mutate, pylint.extensions.consider_refactoring_into_while_condition, + pylint_pytest # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. diff --git a/pyproject.toml b/pyproject.toml index d77fcfc..260c686 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,9 @@ version = "1.1.0" authors = [ { name="Nuno Pereira", email="nunoafonso2002@gmail.com" }, ] +maintainers = [ + { name="Nuno Pereira", email="nunoafonso2002@gmail.com" }, +] description = "A Zod-like validation library for Python" readme = "README.md" requires-python = ">=3.11" @@ -28,6 +31,7 @@ dev = [ "build", "pylint", "pytest", + "pylint-pytest", ] [project.urls] diff --git a/tests/enum_test.py b/tests/enum_test.py new file mode 100644 index 0000000..5d94543 --- /dev/null +++ b/tests/enum_test.py @@ -0,0 +1,71 @@ +import pytest + +import zon + + +@pytest.fixture +def values(): + return {"1", "2", "3", "4", "5"} + + +@pytest.fixture +def validator(values): + return zon.enum(values) + + +def test_enum_get_options(validator, values): + assert validator.enum == values + + +def test_enum_validate(validator): + assert validator.validate("1") + assert validator.validate("2") + assert validator.validate("3") + assert validator.validate("4") + assert validator.validate("5") + + with pytest.raises(zon.error.ZonError): + validator.validate("6") + + +def test_enum_safe_validate(validator): + assert validator.safe_validate("1") == (True, "1") + assert validator.safe_validate("2") == (True, "2") + assert validator.safe_validate("3") == (True, "3") + assert validator.safe_validate("4") == (True, "4") + assert validator.safe_validate("5") == (True, "5") + + # TODO: check for the specific error? + assert validator.safe_validate("6")[0] is False + + +def test_enum_exclude(validator): + + # TODO: test through validation + + assert validator.exclude(["1"]).enum == {"2", "3", "4", "5"} + assert validator.exclude(["2"]).enum == {"1", "3", "4", "5"} + assert validator.exclude(["3"]).enum == {"1", "2", "4", "5"} + assert validator.exclude(["4"]).enum == {"1", "2", "3", "5"} + assert validator.exclude(["5"]).enum == {"1", "2", "3", "4"} + + +def test_enum_extract(validator): + + # TODO: test through validation + + assert validator.extract(["1"]).enum == { + "1", + } + assert validator.extract(["2"]).enum == { + "2", + } + assert validator.extract(["3"]).enum == { + "3", + } + assert validator.extract(["4"]).enum == { + "4", + } + assert validator.extract(["5"]).enum == { + "5", + } diff --git a/tests/optional_test.py b/tests/optional_test.py index 07c017a..b55b2c1 100644 --- a/tests/optional_test.py +++ b/tests/optional_test.py @@ -2,18 +2,21 @@ import zon + @pytest.fixture def base_validator(): return zon.string() + def test_optional_get_value(base_validator): - assert base_validator.optional().zon is base_validator + assert base_validator.optional().unwrap() is base_validator + class TestOptionalMethod: @pytest.fixture def validator(self, base_validator): return base_validator.optional() - + def test_optional_validate(self, validator): assert validator.validate(None) assert validator.validate("abc") @@ -33,7 +36,7 @@ class TestOptionalOuterFunction: @pytest.fixture def validator(self, base_validator): return zon.optional(base_validator) - + def test_optional_validate(self, validator): assert validator.validate(None) assert validator.validate("abc") @@ -46,4 +49,4 @@ def test_optional_safe_validate(self, validator): assert validator.safe_validate("abc") == (True, "abc") # TODO: check for the specific error? - assert validator.safe_validate(1.5)[0] is False \ No newline at end of file + assert validator.safe_validate(1.5)[0] is False diff --git a/tests/record_test.py b/tests/record_test.py new file mode 100644 index 0000000..6fbb1ad --- /dev/null +++ b/tests/record_test.py @@ -0,0 +1,238 @@ +import pytest + +import zon + + +@pytest.fixture +def string_validator(): + return zon.string().min(1) + + +@pytest.fixture +def integer_validator(): + return zon.number().int().positive() + + +@pytest.fixture +def validator_dict(string_validator, integer_validator): + return { + "name": string_validator, + "age": integer_validator, + } + + +@pytest.fixture +def validator(validator_dict): + return zon.record(validator_dict) + + +def test_record_validate(validator): + assert validator.validate( + { + "name": "John", + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + validator.validate( + { + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + validator.validate( + { + "name": "", + } + ) + + +def test_record_sage_validate(validator): + assert validator.safe_validate( + { + "name": "John", + "age": 1, + } + ) == ( + True, + { + "name": "John", + "age": 1, + }, + ) + + assert ( + validator.safe_validate( + { + "age": 1, + } + )[0] + is False + ) + + assert ( + validator.safe_validate( + { + "name": "", + } + )[0] + is False + ) + + +def test_record_shape(validator, validator_dict): + assert validator.shape is validator_dict + + +def test_record_keyof(validator, validator_dict): + _validator = validator.keyof() + + for key in validator_dict.keys(): + assert _validator.validate(key) + + +def test_record_extend(validator): + _validator = validator.extend({"male": zon.boolean()}) + + assert _validator.validate( + { + "name": "John", + "age": 1, + "male": True, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "John", + "age": 1, + } + ) + + +def test_record_merge(validator): + extra_validator = zon.record({"male": zon.boolean()}) + _validator = validator.merge(extra_validator) + + assert _validator.validate( + { + "name": "John", + "age": 1, + "male": True, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "John", + "age": 1, + } + ) + + +def test_record_pick(validator): + # TODO: improve testing on this + _validator = validator.pick({"name": True}) + + assert _validator.validate( + { + "name": "John", + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "age": 1, + } + ) + + +def test_record_omit(validator): + # TODO: improve testing on this + _validator = validator.omit({"name": True}) + + assert _validator.validate( + { + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "John", + } + ) + + +def test_record_partial_all(validator): + _validator = validator.partial() + + assert _validator.validate( + { + "name": "John", + "age": 1, + } + ) + + assert _validator.validate( + { + "age": 1, + } + ) + + assert _validator.validate( + { + "name": "", + } + ) + + assert _validator.validate({}) + + +def test_record_partial_some(validator): + _validator = validator.partial({"name": True}) + + assert _validator.validate( + { + "name": "John", + "age": 1, + } + ) + + assert _validator.validate( + { + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "", + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate({}) + + +def test_record_deep_partial(validator): + _validator = validator.extend( + {"sub": zon.record({"sub_number": zon.number()})} + ).deep_partial() + + assert _validator.validate({"name": "John", "age": 1, "sub": {"sub_number": 1}}) + + assert _validator.validate({"age": 1, "sub": {"sub_number": 1}}) + + assert _validator.validate({"name": "", "sub": {"sub_number": 1}}) + assert _validator.validate({"sub": {"sub_number": 1}}) + assert _validator.validate({"sub": {}}) + + assert _validator.validate({}) diff --git a/zon/__init__.py b/zon/__init__.py index d2bcf2a..260e196 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -12,10 +12,15 @@ __license__ = "MIT" __copyright__ = "Copyright 2023, Nuno Pereira" +# TODO: better typing. +# Things to consider: +# - Container and other collections.abc types +# - Typing with Self + import copy from abc import ABC, abstractmethod from typing import Any, Self, TypeVar, final, Literal -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence # TODO: explore Container type from dataclasses import dataclass, field import re import math @@ -25,6 +30,31 @@ from .error import ZonError, ZonIssue from .traits import HasMax, HasMin +__all__ = [ + "Zon", + # "ZonArray", + "ZonBoolean", + "boolean", + # "ZonDate", + # "ZonDateTime", + # "ZonNone", + "ZonNumber", + "number", + "ZonRecord", + "record", + "ZonString", + "string", + "ZonOptional", + "optional", + "ZonIntersection", + "intersection", + "ZonEnum", + "enum", + "ZonLiteral", + "literal", + # "ZonUnion", +] + @dataclass class ValidationContext: @@ -257,6 +287,7 @@ def optional(self) -> ZonOptional: return optional(self) + def intersection(zon1: Zon, zon2: Zon, *, fast_termination=False) -> ZonIntersection: """Returns a validator that validates that the data is valid for both validators supplied. @@ -305,16 +336,17 @@ def optional(zon: Zon, fast_termination=False) -> ZonIntersection: """ return ZonOptional(zon, terminate_early=fast_termination) + class ZonOptional(Zon): """A Zon that makes its data validation optional.""" def __init__(self, zon: Zon, **kwargs): super().__init__(**kwargs) - self.zon = zon + self._zon = zon def _default_validate(self, data, ctx): if data: - (passed, data_or_error) = self.zon.safe_validate(data) + (passed, data_or_error) = self._zon.safe_validate(data) if not passed: ctx.add_issues(data_or_error.issues) @@ -326,7 +358,8 @@ def unwrap(self) -> Zon: Zon: the wrapped Zon """ - return self.zon + return self._zon + class ZonContainer(Zon, HasMax, HasMin): """A Zon that acts as a container for other types of data. @@ -706,7 +739,7 @@ def gt(self, min_ex: float | int) -> Self: _clone.validators.append( ValidationRule( "gt", - lambda data: data > min_ex, + lambda data: data is not None and data > min_ex, ) ) @@ -727,7 +760,7 @@ def gte(self, min_in: float | int) -> Self: _clone.validators.append( ValidationRule( "gte", - lambda data: data >= min_in, + lambda data: data is not None and data >= min_in, ) ) @@ -751,7 +784,7 @@ def lt(self, max_ex: float | int) -> Self: _clone.validators.append( ValidationRule( "lt", - lambda data: data < max_ex, + lambda data: data is not None and data < max_ex, ) ) @@ -772,7 +805,7 @@ def lte(self, max_in: float | int) -> Self: _clone.validators.append( ValidationRule( "lte", - lambda data: data <= max_in, + lambda data: data is not None and data <= max_in, ) ) @@ -829,7 +862,7 @@ def positive(self) -> Self: _clone.validators.append( ValidationRule( "positive", - lambda data: data > 0, + lambda data: data is not None and data > 0, ) ) @@ -847,7 +880,7 @@ def negative(self) -> Self: _clone.validators.append( ValidationRule( "negative", - lambda data: data < 0, + lambda data: data is not None and data < 0, ) ) @@ -865,7 +898,7 @@ def non_negative(self) -> Self: _clone.validators.append( ValidationRule( "non_negative", - lambda data: data >= 0, + lambda data: data is not None and data >= 0, ) ) @@ -883,7 +916,7 @@ def non_positive(self) -> Self: _clone.validators.append( ValidationRule( "non_positive", - lambda data: data <= 0, + lambda data: data is not None and data <= 0, ) ) @@ -904,7 +937,7 @@ def multiple_of(self, base: int | float) -> Self: _clone.validators.append( ValidationRule( "multiple_of", - lambda data: data % base == 0, + lambda data: data is not None and data % base == 0, ) ) @@ -925,7 +958,7 @@ def finite(self) -> Self: _clone.validators.append( ValidationRule( "finite", - lambda data: not math.isinf(data), + lambda data: data is not None and not math.isinf(data), ) ) @@ -983,3 +1016,261 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issue( ZonIssue(value=data, message=f"Expected {self._value}", path=[]) ) + + +def enum(options: Sequence[str], /, *, fast_termination=False) -> ZonEnum: + """Returns a validator for an enum. + + Args: + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + options: the values that must be matched + + Returns: + ZodEnum: The enum data validator. + """ + + return ZonEnum(options, terminate_early=fast_termination) + + +class ZonEnum(Zon): + """A Zon that validates that the data is one of the given values + + This class mimics the behavior of `zod`'s own enums, not TypeScript enums. + """ + + def __init__(self, options: Sequence[str], **kwargs): + super().__init__(**kwargs) + self._options: set[str] = options + + @property + def enum(self) -> set[str]: + """ + The enum variants that this validator allows. + """ + return set(self._options) + + def _default_validate(self, data, ctx): + if data not in self._options: + ctx.add_issue( + ZonIssue( + value=data, message=f"Expected one of {self._options}", path=[] + ) + ) + + def exclude(self, options: Sequence[str]) -> Self: + """ + Excludes the given options from the enum. + + Args: + options (Sequence[str]): the options to exclude. + + Returns: + ZonEnum: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone._options = self._options - set(options) + + return _clone + + def extract(self, options: Sequence[str]) -> Self: + """ + Extracts the given options from the enum. + + Args: + options (Sequence[str]): the options to extract. + + Returns: + ZonEnum: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone._options = self._options & set(options) + + return _clone + + +def record(properties: dict[str, Zon], /, *, fast_termination=False) -> ZonRecord: + """Returns a validator for a record. + + Args: + properties (dict[str, Zon]): the shape of the record + fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. + + Returns: + ZonRecord: The record data validator. + """ + + return ZonRecord(properties, terminate_early=fast_termination) + + +class ZonRecord(Zon): + """A Zon that validates that the data is a record with the provided shape.""" + + def __init__(self, shape: Mapping[str, Zon], **kwargs): + super().__init__(**kwargs) + self._shape = shape + + def _default_validate(self, data, ctx: ValidationContext): + + if not isinstance(data, dict): + ctx.add_issue(ZonIssue(value=data, message="Not a valid object", path=[])) + + if self._terminate_early: + return + + for key, zon in self._shape.items(): + # TODO: need to verify path + # maybe instantiate new context here and compute path from there? + + # default to None since this way we also validate the attribute if it is optional + validation_value = data.get(key, None) + + (validated, data_or_error) = zon.safe_validate(validation_value) + + if not validated: + ctx.add_issues(data_or_error.issues) + + if self._terminate_early: + return + + @property + def shape(self) -> Mapping[str, Zon]: + return self._shape + + def keyof(self) -> ZonEnum: + """Returns a validator for the keys of an object""" + + return enum(self._shape.keys()) + + def extend(self, extra_properties: Mapping[str, Zon]) -> Self: + """ + Extends the shape of the record with the given properties. + + Args: + extra_properties (Mapping[str, Zon]): the properties to extend the shape with. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + return ZonRecord( + {**self.shape, **extra_properties}, terminate_early=self._terminate_early + ) + + def merge(self, other: ZonRecord) -> Self: + """ + Merges the shape of the record with the given properties. + + Args: + other (ZonRecord): the properties to extend the shape with. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + return self.extend(other.shape) + + def pick(self, attributes: Mapping[str, Literal[True]]) -> ZonRecord: + """ + Picks the given attributes from the record. + + Args: + attributes (Mapping[str, Literal[True]]): the attributes to pick. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + return ZonRecord( + {k: v for k, v in self.shape.items() if attributes.get(k, False)}, + terminate_early=self._terminate_early, + ) + + def omit(self, attributes: Mapping[str, Literal[True]]) -> ZonRecord: + """ + Omits the given attributes from the record. + + Args: + attributes (Mapping[str, Literal[True]]): the attributes to omit. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + return ZonRecord( + {k: v for k, v in self.shape.items() if not attributes.get(k, False)}, + terminate_early=self._terminate_early, + ) + + def partial( + self, /, optional_properties: Mapping[str, Literal[True]] | None = None + ) -> ZonRecord: + """ + Marks the given attributes as optional. + + Args: + optional_properties (Mapping[str, Literal[True]]): the attributes to mark as optional. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + if not optional_properties: + optional_properties = {k: True for k in self.shape.keys()} + + return ZonRecord( + { + k: (v.optional() if optional_properties.get(k, False) else v) + for k, v in self.shape.items() + }, + terminate_early=self._terminate_early, + ) + + def deep_partial(self) -> ZonRecord: + """ + Marks all attributes as optional. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + def _partialify(v: Zon) -> ZonOptional: + if isinstance(v, ZonRecord): + return ZonRecord( + {k: _partialify(_v) for k, _v in v.shape.items()}, + terminate_early=v._terminate_early, + ).optional() + + # if isinstance(v, ZonArray): + # return ZonArray(_partialify(v.item_type)) + + return v.optional() + + return ZonRecord( + {k: _partialify(v) for k, v in self.shape.items()}, + terminate_early=self._terminate_early, + ) + + def required( + self, /, required_properties: Mapping[str, Literal[True]] | None = None + ) -> ZonRecord: + + if not optional_properties: + optional_properties = {k: True for k in self.shape.keys()} + + return ZonRecord( + { + k: ( + (v.unwrap() if isinstance(v, ZonOptional) else v) + if required_properties.get(k, False) + else v + ) + for k, v in self.shape.items() + }, + terminate_early=self._terminate_early, + ) + + From eb23c6f7811f47008e9fbee9f49e65d2a2f50ecf Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Wed, 19 Jun 2024 20:16:20 +0100 Subject: [PATCH 19/29] Implemented UnknownKeyPolicy and Catchall behaviors --- tests/record_test.py | 68 +++++++++++++++++++++++ zon/__init__.py | 125 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 4 deletions(-) diff --git a/tests/record_test.py b/tests/record_test.py index 6fbb1ad..0ab4e7b 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -236,3 +236,71 @@ def test_record_deep_partial(validator): assert _validator.validate({"sub": {}}) assert _validator.validate({}) + + +def test_record_required_all(validator): + _validator = validator.partial().required() + + assert _validator.validate( + { + "name": "John", + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "", + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate({}) + + +def test_record_required_some(validator): + _validator = validator.partial().required({"age": True}) + + assert _validator.validate( + { + "name": "John", + "age": 1, + } + ) + + assert _validator.validate( + { + "age": 1, + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "", + } + ) + + with pytest.raises(zon.error.ZonError): + _validator.validate({}) + + +def test_record_unknown_key_policy_strict(validator): + _validator = validator.strict() + + with pytest.raises(zon.error.ZonError): + _validator.validate( + { + "name": "John", + "age": 1, + "unknown": 1, + } + ) diff --git a/zon/__init__.py b/zon/__init__.py index 260e196..01f6d15 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -24,6 +24,7 @@ from dataclasses import dataclass, field import re import math +from enum import Enum, auto import validators @@ -1109,9 +1110,23 @@ def record(properties: dict[str, Zon], /, *, fast_termination=False) -> ZonRecor class ZonRecord(Zon): """A Zon that validates that the data is a record with the provided shape.""" - def __init__(self, shape: Mapping[str, Zon], **kwargs): + class UnknownKeyPolicy(Enum): + STRIP = auto() + PASSTHROUGH = auto() + STRICT = auto() + + def __init__( + self, + shape: Mapping[str, Zon], + /, + unknown_key_policy: UnknownKeyPolicy = UnknownKeyPolicy.STRIP, + catchall: Zon | None = None, + **kwargs, + ): super().__init__(**kwargs) self._shape = shape + self.unknown_key_policy = unknown_key_policy + self._catchall = catchall def _default_validate(self, data, ctx: ValidationContext): @@ -1121,6 +1136,13 @@ def _default_validate(self, data, ctx: ValidationContext): if self._terminate_early: return + extra_keys: set[str] = {} + if ( + self._catchall is not None + or self.unknown_key_policy is not ZonRecord.UnknownKeyPolicy.STRIP + ): + extra_keys = set(data.keys()) - set(self._shape.keys()) + for key, zon in self._shape.items(): # TODO: need to verify path # maybe instantiate new context here and compute path from there? @@ -1136,6 +1158,39 @@ def _default_validate(self, data, ctx: ValidationContext): if self._terminate_early: return + if self._catchall is None: + match self.unknown_key_policy: + case ZonRecord.UnknownKeyPolicy.STRIP: + # ignore extra keys + pass + case ZonRecord.UnknownKeyPolicy.PASSTHROUGH: + # TODO: rework validation to return data + + # data_to_return.extend({k: data[k] for k in extra_keys}) + + pass + case ZonRecord.UnknownKeyPolicy.STRICT: + if len(extra_keys) > 0: + ctx.add_issue( + ZonIssue( + value=extra_keys, + message=f"Unexpected keys: {extra_keys}", + path=[], + ) + ) + + else: + for key in extra_keys: + value = data.get(key) + + (valid, data_or_error) = self._catchall.safe_validate(value) + + if not valid: + ctx.add_issues(data_or_error.issues) + + if self._terminate_early: + return + @property def shape(self) -> Mapping[str, Zon]: return self._shape @@ -1258,8 +1313,8 @@ def required( self, /, required_properties: Mapping[str, Literal[True]] | None = None ) -> ZonRecord: - if not optional_properties: - optional_properties = {k: True for k in self.shape.keys()} + if not required_properties: + required_properties = {k: True for k in self.shape.keys()} return ZonRecord( { @@ -1273,4 +1328,66 @@ def required( terminate_early=self._terminate_early, ) - + def passthrough(self) -> ZonRecord: + """ + Returns a validator for the same record shape that adds unknown keys to the returned. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + # TODO: tests + + return ZonRecord( + self.shape, + unknown_key_policy=ZonRecord.UnknownKeyPolicy.PASSTHROUGH, + catchall=self._catchall, + terminate_early=self._terminate_early, + ) + + def strict(self) -> ZonRecord: + """ + Returns a validator for the same record shape but that fails validation if the data under validation does not match this validator's shape exactly. + + Returns: + ZonRecord: a new `ZonRecord` with the new shape + """ + + return ZonRecord( + self.shape, + unknown_key_policy=ZonRecord.UnknownKeyPolicy.STRICT, + catchall=self._catchall, + terminate_early=self._terminate_early, + ) + + def strip(self) -> ZonRecord: + """ + Returns a validator for the same record shape that unknown keys from the returned data. + + Returns: + ZonRecord: a new `ZonRecord` with the new policy + """ + + # TODO: tests + + return ZonRecord( + self.shape, + unknown_key_policy=ZonRecord.UnknownKeyPolicy.STRIP, + catchall=self._catchall, + terminate_early=self._terminate_early, + ) + + def catchall(self, catchall_validator: Zon) -> ZonRecord: + """ + Returns a validator for the same record shape that pipes unknown keys and their values through a general, "catch-all" validator. + + Returns: + ZonRecord: a new `ZonRecord` with the new catchall validator + """ + + return ZonRecord( + self.shape, + unknown_key_policy=self.unknown_key_policy, + catchall=catchall_validator, + terminate_early=self._terminate_early, + ) From 86724809751c922eb25d2d1b757ec2dca58d9ee9 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Wed, 19 Jun 2024 20:19:19 +0100 Subject: [PATCH 20/29] Re-implemented `ZonArray` --- CHANGELOG.md | 1 - tests/list_test.py | 69 +++++++++++++++++++ tests/zon_test.py | 34 ---------- zon/__init__.py | 164 ++++++++++++++++++++++++++++----------------- 4 files changed, 173 insertions(+), 95 deletions(-) create mode 100644 tests/list_test.py delete mode 100644 tests/zon_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 591ab57..e5ff7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. - Added `examples` folder -- Added `early failure` mechanism to prevent validation on objects after the first validation error - Added explanation regarding `ZonString.datetime()` decisions. - Added `ZonLiteral` and `ZonEnum` classes - Added more `ZonRecord` methods diff --git a/tests/list_test.py b/tests/list_test.py new file mode 100644 index 0000000..5b98af3 --- /dev/null +++ b/tests/list_test.py @@ -0,0 +1,69 @@ +import pytest + +import zon + +@pytest.fixture +def element_validator(): + return zon.string() + +@pytest.fixture +def validator(element_validator): + return zon.element_list(element_validator) + +def test_list_get_element(validator, element_validator): + assert validator.element == element_validator + +def test_list_validate(validator): + assert validator.validate([""]) + + with pytest.raises(zon.error.ZonError): + validator.validate(1) + with pytest.raises(zon.error.ZonError): + validator.validate("") + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 2}) + with pytest.raises(zon.error.ZonError): + validator.validate([1]) + +def test_list_safe_validate(validator): + assert validator.safe_validate([""]) == (True, [""]) + + assert validator.safe_validate(1)[0] is False + assert validator.safe_validate("")[0] is False + assert validator.safe_validate({"a": 2})[0] is False + assert validator.safe_validate([1])[0] is False + +def test_list_nonempty(validator): + assert validator.nonempty().validate([""]) + + with pytest.raises(zon.error.ZonError): + validator.nonempty().validate([]) + +def test_list_length(validator): + _validator = validator.length(2) + + assert _validator.validate(["1", "2"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1", "2", "3"]) + +def test_list_min(validator): + _validator = validator.min(2) + + assert _validator.validate(["1", "2"]) + assert _validator.validate(["1", "2", "3"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1"]) + +def test_list_max(validator): + _validator = validator.max(2) + + assert _validator.validate(["1"]) + assert _validator.validate(["1", "2"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1", "2", "3"]) \ No newline at end of file diff --git a/tests/zon_test.py b/tests/zon_test.py deleted file mode 100644 index 518c441..0000000 --- a/tests/zon_test.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Tests related to core Zon functionality that is not necessarily bound to any specific Zon implementation. -""" - -import pytest - -import zon - - -@pytest.fixture -def normal_validator(): - # This is just for demonstration purposes - return zon.string(fast_termination=False).length(3).max(2) - - -@pytest.fixture -def fail_fast_validator(): - # This is just for demonstration purposes - return zon.string(fast_termination=True).length(3).max(2) - - -def test_fail_fast(normal_validator, fail_fast_validator): - - test_input = "12345" - - with pytest.raises(zon.error.ZonError) as fail_fast_result: - fail_fast_validator.validate(test_input) - - assert len(fail_fast_result.value.issues) == 1 - - with pytest.raises(zon.error.ZonError) as normal_result: - normal_validator.validate(test_input) - - assert len(normal_result.value.issues) == 2 diff --git a/zon/__init__.py b/zon/__init__.py index 01f6d15..d411463 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -17,9 +17,11 @@ # - Container and other collections.abc types # - Typing with Self +# TODO: remove early_termination (was giving errors in list validators) + import copy -from abc import ABC, abstractmethod -from typing import Any, Self, TypeVar, final, Literal +from abc import ABC, abstractmethod, update_abstractmethods +from typing import Any, Self, TypeVar, final, Literal, Never from collections.abc import Callable, Mapping, Sequence # TODO: explore Container type from dataclasses import dataclass, field import re @@ -78,7 +80,7 @@ def add_issues(self, issues: list[ZonIssue]): self._ensure_error() self._error.add_issues(issues) - def raise_error(self): + def raise_error(self) -> Never: """ Raises the current validation error in this context if it exists. """ @@ -146,6 +148,7 @@ def check(self, data: Any, ctx: ValidationContext) -> bool: return valid +@update_abstractmethods class Zon(ABC): """ Base class for all Zons. @@ -159,8 +162,6 @@ def __init__(self, **kwargs): self.validators: list[ValidationRule] = [] """validators that will run when 'validate' is invoked.""" - self._terminate_early = kwargs.get("terminate_early", False) - def _clone(self) -> Self: """Creates a copy of this Zon.""" return copy.deepcopy(self) @@ -201,8 +202,8 @@ def _validate(self, data: T) -> bool: ctx = ValidationContext() - def check_early_termination(): - if self._terminate_early and ctx.dirty: + def raise_if_dirty(): + if ctx.dirty: ctx.raise_error() try: @@ -210,15 +211,12 @@ def check_early_termination(): except NotImplementedError as ni: raise ni - check_early_termination() + raise_if_dirty() for validator in self.validators: validator.check(data, ctx) - check_early_termination() - - if ctx.dirty: - ctx.raise_error() + raise_if_dirty() return True @@ -288,19 +286,27 @@ def optional(self) -> ZonOptional: return optional(self) + def list(self) -> ZonList: + """Returns a validator that validates a list whose elements are valid under this validator. + + Returns: + ZonArray: The list data validator. + """ -def intersection(zon1: Zon, zon2: Zon, *, fast_termination=False) -> ZonIntersection: + return element_list(self) + +def intersection(zon1: Zon, zon2: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for both validators supplied. Args: zon1 (Zon): the first validator zon2 (Zon): the second validator - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. Returns: ZonIntersection: The intersection data validator. """ - return ZonIntersection(zon1, zon2, terminate_early=fast_termination) + + return ZonIntersection(zon1, zon2) class ZonIntersection(Zon): @@ -325,17 +331,16 @@ def _default_validate(self, data: T, ctx: ValidationContext): return -def optional(zon: Zon, fast_termination=False) -> ZonIntersection: +def optional(zon: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for this validator if it exists. Args: zon (Zon): the supplied validator - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. Returns: ZonIntersection: The intersection data validator. """ - return ZonOptional(zon, terminate_early=fast_termination) + return ZonOptional(zon) class ZonOptional(Zon): @@ -423,17 +428,14 @@ def length(self, length: int) -> Self: return _clone -def string(*, fast_termination=False) -> ZonString: +def string() -> ZonString: """Returns a validator for string data. - Args: - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. - Returns: ZonString: The string data validator. """ - return ZonString(terminate_early=fast_termination) + return ZonString() class ZonString(ZonContainer): @@ -705,17 +707,14 @@ def _validator(data, opts: Mapping[str, Any]): return _clone -def number(*, fast_termination=False) -> ZonNumber: +def number() -> ZonNumber: """Returns a validator for numeric data. - Args: - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. - Returns: ZonNumber: The number data validator. """ - return ZonNumber(terminate_early=fast_termination) + return ZonNumber() class ZonNumber(Zon, HasMax, HasMin): @@ -966,17 +965,14 @@ def finite(self) -> Self: return _clone -def boolean(*, fast_termination=False) -> ZonBoolean: +def boolean() -> ZonBoolean: """Returns a validator for boolean data. - Args: - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. - Returns: ZonBoolean: The boolean data validator. """ - return ZonBoolean(terminate_early=fast_termination) + return ZonBoolean() class ZonBoolean(Zon): @@ -987,18 +983,17 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issue(ZonIssue(value=data, message="Not a valid boolean", path=[])) -def literal(value: Any, /, *, fast_termination=False) -> ZonLiteral: +def literal(value: Any, /) -> ZonLiteral: """Returns a validator for a given literal value. Args: - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. value: the value that must be matched Returns: ZonBoolean: The literal data validator. """ - return ZonLiteral(value, terminate_early=fast_termination) + return ZonLiteral(value) class ZonLiteral(Zon): @@ -1019,18 +1014,17 @@ def _default_validate(self, data: T, ctx: ValidationContext): ) -def enum(options: Sequence[str], /, *, fast_termination=False) -> ZonEnum: +def enum(options: Sequence[str], /) -> ZonEnum: """Returns a validator for an enum. Args: - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. options: the values that must be matched Returns: ZodEnum: The enum data validator. """ - return ZonEnum(options, terminate_early=fast_termination) + return ZonEnum(options) class ZonEnum(Zon): @@ -1093,18 +1087,17 @@ def extract(self, options: Sequence[str]) -> Self: return _clone -def record(properties: dict[str, Zon], /, *, fast_termination=False) -> ZonRecord: +def record(properties: dict[str, Zon], /) -> ZonRecord: """Returns a validator for a record. Args: properties (dict[str, Zon]): the shape of the record - fast_termination (bool, optional): whether this validator's validation should stop as soon as an error occurs. Defaults to False. Returns: ZonRecord: The record data validator. """ - return ZonRecord(properties, terminate_early=fast_termination) + return ZonRecord(properties) class ZonRecord(Zon): @@ -1133,8 +1126,7 @@ def _default_validate(self, data, ctx: ValidationContext): if not isinstance(data, dict): ctx.add_issue(ZonIssue(value=data, message="Not a valid object", path=[])) - if self._terminate_early: - return + return extra_keys: set[str] = {} if ( @@ -1155,8 +1147,6 @@ def _default_validate(self, data, ctx: ValidationContext): if not validated: ctx.add_issues(data_or_error.issues) - if self._terminate_early: - return if self._catchall is None: match self.unknown_key_policy: @@ -1188,9 +1178,6 @@ def _default_validate(self, data, ctx: ValidationContext): if not valid: ctx.add_issues(data_or_error.issues) - if self._terminate_early: - return - @property def shape(self) -> Mapping[str, Zon]: return self._shape @@ -1212,7 +1199,9 @@ def extend(self, extra_properties: Mapping[str, Zon]) -> Self: """ return ZonRecord( - {**self.shape, **extra_properties}, terminate_early=self._terminate_early + {**self.shape, **extra_properties}, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, ) def merge(self, other: ZonRecord) -> Self: @@ -1241,7 +1230,8 @@ def pick(self, attributes: Mapping[str, Literal[True]]) -> ZonRecord: return ZonRecord( {k: v for k, v in self.shape.items() if attributes.get(k, False)}, - terminate_early=self._terminate_early, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, ) def omit(self, attributes: Mapping[str, Literal[True]]) -> ZonRecord: @@ -1257,7 +1247,8 @@ def omit(self, attributes: Mapping[str, Literal[True]]) -> ZonRecord: return ZonRecord( {k: v for k, v in self.shape.items() if not attributes.get(k, False)}, - terminate_early=self._terminate_early, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, ) def partial( @@ -1281,7 +1272,8 @@ def partial( k: (v.optional() if optional_properties.get(k, False) else v) for k, v in self.shape.items() }, - terminate_early=self._terminate_early, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, ) def deep_partial(self) -> ZonRecord: @@ -1296,7 +1288,6 @@ def _partialify(v: Zon) -> ZonOptional: if isinstance(v, ZonRecord): return ZonRecord( {k: _partialify(_v) for k, _v in v.shape.items()}, - terminate_early=v._terminate_early, ).optional() # if isinstance(v, ZonArray): @@ -1306,7 +1297,8 @@ def _partialify(v: Zon) -> ZonOptional: return ZonRecord( {k: _partialify(v) for k, v in self.shape.items()}, - terminate_early=self._terminate_early, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, ) def required( @@ -1325,7 +1317,8 @@ def required( ) for k, v in self.shape.items() }, - terminate_early=self._terminate_early, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, ) def passthrough(self) -> ZonRecord: @@ -1342,7 +1335,6 @@ def passthrough(self) -> ZonRecord: self.shape, unknown_key_policy=ZonRecord.UnknownKeyPolicy.PASSTHROUGH, catchall=self._catchall, - terminate_early=self._terminate_early, ) def strict(self) -> ZonRecord: @@ -1357,7 +1349,6 @@ def strict(self) -> ZonRecord: self.shape, unknown_key_policy=ZonRecord.UnknownKeyPolicy.STRICT, catchall=self._catchall, - terminate_early=self._terminate_early, ) def strip(self) -> ZonRecord: @@ -1374,7 +1365,6 @@ def strip(self) -> ZonRecord: self.shape, unknown_key_policy=ZonRecord.UnknownKeyPolicy.STRIP, catchall=self._catchall, - terminate_early=self._terminate_early, ) def catchall(self, catchall_validator: Zon) -> ZonRecord: @@ -1389,5 +1379,59 @@ def catchall(self, catchall_validator: Zon) -> ZonRecord: self.shape, unknown_key_policy=self.unknown_key_policy, catchall=catchall_validator, - terminate_early=self._terminate_early, ) + +def element_list(element: Zon, /) -> ZonList: + """ + Returns a validator for an array with the given element type. + + Args: + element (Zon): the element type of the array. + + Returns: + ZonArray: a new `ZonArray` validator + """ + + return ZonList(element) + +class ZonList(ZonContainer): + """A Zon that validates that the input is a list with the given element type""" + + def __init__(self, element, **kwargs): + super().__init__(**kwargs) + + self._element = element + + def _default_validate(self, data: T, ctx: ValidationContext): + if not isinstance(data, list): + ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) + return + + for element in data: + (valid, data_or_error) = self._element.safe_validate(element) + + if not valid: + ctx.add_issues(data_or_error.issues) + + @property + def element(self): + return self._element + + def nonempty(self): + """ + Returns a validator for a list with at least one element. + + Returns: + ZonList: a new `ZonList` validator + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "nonempty", + lambda data: hasattr(data, "__len__") and len(data) > 0, + ) + ) + + return _clone From 50151716474fb90634a4307fa8bc208cf0cdcfdf Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Wed, 19 Jun 2024 21:22:24 +0100 Subject: [PATCH 21/29] Added coverage reporting --- .github/workflows/ci.yaml | 9 ++++++--- .gitignore | 1 + CHANGELOG.md | 1 + pyproject.toml | 6 +++++- requirements.txt | 3 ++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4f41b21..1bd1d1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 5 matrix: - version: [3.12] + version: [3.11, 3.12] steps: - uses: actions/checkout@v3 @@ -34,7 +34,7 @@ jobs: strategy: max-parallel: 5 matrix: - version: [3.12] + version: [3.11, 3.12] steps: - uses: actions/checkout@v3 @@ -56,4 +56,7 @@ jobs: run: python -m pip install -e . - name: Run tests - run: pytest tests \ No newline at end of file + run: pytest tests + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index be1ef47..f20ecd1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ htmlcov/ .nox/ .coverage .coverage.* +coverage.* .cache nosetests.xml coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ff7bd..c17f86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added explanation regarding `ZonString.datetime()` decisions. - Added `ZonLiteral` and `ZonEnum` classes - Added more `ZonRecord` methods +- Added coverage ### Changed - Moved everything into a single file to combat circular reference issues diff --git a/pyproject.toml b/pyproject.toml index 260c686..dfbe1d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "pylint", "pytest", "pylint-pytest", + "pytest-cov", ] [project.urls] @@ -39,4 +40,7 @@ Homepage = "https://github.com/Naapperas/zon" Issues = "https://github.com/Naapperas/zon/issues" [tool.setuptools] -packages = ["zon"] \ No newline at end of file +packages = ["zon"] + +[tool.pytest.ini_options] +addopts = "--cov=zon --cov-report lcov" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 739fdf0..3bca0ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pylint==3.0.2 pytest==7.4.3 twine==4.0.2 validators==0.28.3 -typing_extensions==4.12.2 \ No newline at end of file +typing_extensions==4.12.2 +pytest-cov==5.0.0 \ No newline at end of file From e47fdf4f20e77ff09d6ad85ff0500f3553ad7257 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Wed, 19 Jun 2024 21:29:12 +0100 Subject: [PATCH 22/29] Updated README with coverage information --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index efe274e..cc0c2c7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # zon - Zod-like validator library for Python +[![Coverage Status](https://coveralls.io/repos/github/Naapperas/zon/badge.svg?branch=refactor/refactor-api)](https://coveralls.io/github/Naapperas/zon?branch=refactor/refactor-api) + Want to have the validation power of [zod](https://zod.dev/) but with the ease-of-use of Python? Enter `zon`. @@ -158,6 +160,8 @@ To run the tests, simply run: pytest test ``` +Coverage can be found on [Coveralls](https://coveralls.io/github/Naapperas/zon). + ## Contributing Contribution guidelines can be found in [CONTRIBUTING](CONTRIBUTING.md) From 11fdcd7c973f503dd7efb854270d4f6c7921f0c4 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 01:50:10 +0100 Subject: [PATCH 23/29] Re-implemented --- tests/list_test.py | 157 +++++++++++++++++++++++++++++++------------- tests/union_test.py | 38 +++++++++++ zon/__init__.py | 118 +++++++++++++++++++++++++++++++-- 3 files changed, 263 insertions(+), 50 deletions(-) create mode 100644 tests/union_test.py diff --git a/tests/list_test.py b/tests/list_test.py index 5b98af3..6171c62 100644 --- a/tests/list_test.py +++ b/tests/list_test.py @@ -1,69 +1,136 @@ -import pytest +import pytest import zon + @pytest.fixture def element_validator(): return zon.string() -@pytest.fixture -def validator(element_validator): - return zon.element_list(element_validator) -def test_list_get_element(validator, element_validator): - assert validator.element == element_validator +def test_list_get_element(element_validator): + assert element_validator.list().element == element_validator + + +class TestElementListOuterFunction: + + @pytest.fixture + def validator(self, element_validator): + return zon.element_list(element_validator) + + def test_list_validate(self, validator): + assert validator.validate([""]) + + with pytest.raises(zon.error.ZonError): + validator.validate(1) + with pytest.raises(zon.error.ZonError): + validator.validate("") + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 2}) + with pytest.raises(zon.error.ZonError): + validator.validate([1]) + + def test_list_safe_validate(self, validator): + assert validator.safe_validate([""]) == (True, [""]) + + assert validator.safe_validate(1)[0] is False + assert validator.safe_validate("")[0] is False + assert validator.safe_validate({"a": 2})[0] is False + assert validator.safe_validate([1])[0] is False + + def test_list_nonempty(self, validator): + assert validator.nonempty().validate([""]) + + with pytest.raises(zon.error.ZonError): + validator.nonempty().validate([]) + + def test_list_length(self, validator): + _validator = validator.length(2) + + assert _validator.validate(["1", "2"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1", "2", "3"]) + + def test_list_min(self, validator): + _validator = validator.min(2) + + assert _validator.validate(["1", "2"]) + assert _validator.validate(["1", "2", "3"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1"]) + + def test_list_max(self, validator): + _validator = validator.max(2) + + assert _validator.validate(["1"]) + assert _validator.validate(["1", "2"]) + + with pytest.raises(zon.error.ZonError): + _validator.validate(["1", "2", "3"]) + + +class TestElementListMethod: + + @pytest.fixture + def validator(self, element_validator): + return element_validator.list() -def test_list_validate(validator): - assert validator.validate([""]) + def test_list_validate(self, validator): + assert validator.validate([""]) - with pytest.raises(zon.error.ZonError): - validator.validate(1) - with pytest.raises(zon.error.ZonError): - validator.validate("") - with pytest.raises(zon.error.ZonError): - validator.validate({"a": 2}) - with pytest.raises(zon.error.ZonError): - validator.validate([1]) + with pytest.raises(zon.error.ZonError): + validator.validate(1) + with pytest.raises(zon.error.ZonError): + validator.validate("") + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 2}) + with pytest.raises(zon.error.ZonError): + validator.validate([1]) -def test_list_safe_validate(validator): - assert validator.safe_validate([""]) == (True, [""]) + def test_list_safe_validate(self, validator): + assert validator.safe_validate([""]) == (True, [""]) - assert validator.safe_validate(1)[0] is False - assert validator.safe_validate("")[0] is False - assert validator.safe_validate({"a": 2})[0] is False - assert validator.safe_validate([1])[0] is False + assert validator.safe_validate(1)[0] is False + assert validator.safe_validate("")[0] is False + assert validator.safe_validate({"a": 2})[0] is False + assert validator.safe_validate([1])[0] is False -def test_list_nonempty(validator): - assert validator.nonempty().validate([""]) + def test_list_nonempty(self, validator): + assert validator.nonempty().validate([""]) - with pytest.raises(zon.error.ZonError): - validator.nonempty().validate([]) + with pytest.raises(zon.error.ZonError): + validator.nonempty().validate([]) -def test_list_length(validator): - _validator = validator.length(2) + def test_list_length(self, validator): + _validator = validator.length(2) - assert _validator.validate(["1", "2"]) + assert _validator.validate(["1", "2"]) - with pytest.raises(zon.error.ZonError): - _validator.validate(["1"]) + with pytest.raises(zon.error.ZonError): + _validator.validate(["1"]) - with pytest.raises(zon.error.ZonError): - _validator.validate(["1", "2", "3"]) + with pytest.raises(zon.error.ZonError): + _validator.validate(["1", "2", "3"]) -def test_list_min(validator): - _validator = validator.min(2) + def test_list_min(self, validator): + _validator = validator.min(2) - assert _validator.validate(["1", "2"]) - assert _validator.validate(["1", "2", "3"]) + assert _validator.validate(["1", "2"]) + assert _validator.validate(["1", "2", "3"]) - with pytest.raises(zon.error.ZonError): - _validator.validate(["1"]) + with pytest.raises(zon.error.ZonError): + _validator.validate(["1"]) -def test_list_max(validator): - _validator = validator.max(2) + def test_list_max(self, validator): + _validator = validator.max(2) - assert _validator.validate(["1"]) - assert _validator.validate(["1", "2"]) + assert _validator.validate(["1"]) + assert _validator.validate(["1", "2"]) - with pytest.raises(zon.error.ZonError): - _validator.validate(["1", "2", "3"]) \ No newline at end of file + with pytest.raises(zon.error.ZonError): + _validator.validate(["1", "2", "3"]) diff --git a/tests/union_test.py b/tests/union_test.py new file mode 100644 index 0000000..a19c1ea --- /dev/null +++ b/tests/union_test.py @@ -0,0 +1,38 @@ +import pytest + +import zon + + +@pytest.fixture +def validator_options(): + return [zon.string(), zon.number().int()] + + +@pytest.fixture +def validator(validator_options): + return zon.union(validator_options) + + +def test_get_validator_options(validator, validator_options): + assert validator.options == validator_options + + +def test_union_validate(validator): + assert validator.validate("1") + assert validator.validate(1) + + with pytest.raises(zon.error.ZonError): + validator.validate(1.5) + with pytest.raises(zon.error.ZonError): + validator.validate([""]) + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 2}) + + +def test_union_safe_validate(validator): + assert validator.safe_validate("1") == (True, "1") + assert validator.safe_validate(1) == (True, 1) + + assert validator.safe_validate(1.5)[0] is False + assert validator.safe_validate([""])[0] is False + assert validator.safe_validate({"a": 2})[0] is False diff --git a/zon/__init__.py b/zon/__init__.py index d411463..4fd495b 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -35,7 +35,8 @@ __all__ = [ "Zon", - # "ZonArray", + "element_list", + "ZonList", "ZonBoolean", "boolean", # "ZonDate", @@ -55,7 +56,8 @@ "enum", "ZonLiteral", "literal", - # "ZonUnion", + "ZonUnion", + "union", ] @@ -277,6 +279,15 @@ def and_also(self, other: Zon) -> ZonIntersection: return intersection(self, other) + def or_else(self, other: Sequence[Zon]) -> ZonUnion: + """Returns a validator that validates that the data is valid for at least one of the supplied validators. + + Args: + other (Sequence(Zon)) + """ + + return union([self, *other]) + def optional(self) -> ZonOptional: """Returns a validator that validates that the data is valid for this validator if it exists. @@ -295,6 +306,7 @@ def list(self) -> ZonList: return element_list(self) + def intersection(zon1: Zon, zon2: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for both validators supplied. @@ -1147,7 +1159,6 @@ def _default_validate(self, data, ctx: ValidationContext): if not validated: ctx.add_issues(data_or_error.issues) - if self._catchall is None: match self.unknown_key_policy: case ZonRecord.UnknownKeyPolicy.STRIP: @@ -1381,6 +1392,7 @@ def catchall(self, catchall_validator: Zon) -> ZonRecord: catchall=catchall_validator, ) + def element_list(element: Zon, /) -> ZonList: """ Returns a validator for an array with the given element type. @@ -1394,6 +1406,7 @@ def element_list(element: Zon, /) -> ZonList: return ZonList(element) + class ZonList(ZonContainer): """A Zon that validates that the input is a list with the given element type""" @@ -1406,7 +1419,7 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, list): ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) return - + for element in data: (valid, data_or_error) = self._element.safe_validate(element) @@ -1416,7 +1429,7 @@ def _default_validate(self, data: T, ctx: ValidationContext): @property def element(self): return self._element - + def nonempty(self): """ Returns a validator for a list with at least one element. @@ -1435,3 +1448,98 @@ def nonempty(self): ) return _clone + + +def union(options: Sequence[Zon], /) -> ZonUnion: + """ + Returns a validator for a union of the given types. + + Args: + options (Sequence[Zon]): the types to validate against. + + Returns: + ZonUnion: a new `ZonUnion` validator + """ + + return ZonUnion(options) + + +class ZonUnion(Zon): + """A Zon that validates that the input is one of the given types""" + + def __init__(self, options: Sequence[Zon], /, **kwargs): + super().__init__(**kwargs) + self._options = options + + @property + def options(self) -> Sequence[Zon]: + return self._options + + def _default_validate(self, data: T, ctx: ValidationContext): + + issues = [] + for option in self._options: + (valid, data_or_error) = option.safe_validate(data) + + if valid: + return + + issues.extend(data_or_error.issues) + + if len(issues) > 0: + ctx.add_issues(issues) + ctx.add_issue(ZonIssue(value=data, message="Not a valid union", path=[])) + +class ZonTuple(Zon): + """A Zon that validates that the input is a tuple whose elements might have different types""" + + def __init__(self, items: Sequence[Zon], rest: Zon | None = None, /, **kwargs): + super().__init__(**kwargs) + self._items = items + self._rest = rest + + def _default_validate(self, data: T, ctx: ValidationContext): + if not isinstance(data, tuple): + ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) + return + + if len(data) < len(self._items): + ctx.add_issue(ZonIssue(value=data, message="Not enough elements", path=[])) + return + + if self._rest is None and len(data) > len(self._items): + ctx.add_issue(ZonIssue(value=data, message="Too many elements", path=[])) + return + + for i, _validator in enumerate(self._items): + if _validator is None: + continue + + (valid, data_or_error) = _validator.safe_validate(data[i]) + + if not valid: + ctx.add_issues(data_or_error.issues) + + if self._rest is not None: + for extra_value in data[len(self.items):]: + (valid, data_or_error) = self._rest.safe_validate(extra_value) + + if not valid: + ctx.add_issues(data_or_error.issues) + + @property + def items(self): + return self._items + + def rest(self, rest: Zon) -> ZonTuple: + """ + Returns a validator for a tuple that might accept more elements of a given type + + Args: + rest (Zon): the element type of the rest of the tuple's elements. + + Returns: + ZonTuple: a new `ZonTuple` validator + """ + + return ZonTuple(self._items, rest) \ No newline at end of file From efdeedf611912d99304afc74038bf754b370439d Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 02:00:21 +0100 Subject: [PATCH 24/29] Added tests for ZonTuple --- CHANGELOG.md | 2 +- tests/tuple_test.py | 37 +++++++++++++++++++++++++++++++++++++ zon/__init__.py | 13 +++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/tuple_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c17f86b..c202b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. - Added `examples` folder - Added explanation regarding `ZonString.datetime()` decisions. -- Added `ZonLiteral` and `ZonEnum` classes +- Added `ZonLiteral`, `ZonTuple` and `ZonEnum` classes - Added more `ZonRecord` methods - Added coverage diff --git a/tests/tuple_test.py b/tests/tuple_test.py new file mode 100644 index 0000000..3f8cd80 --- /dev/null +++ b/tests/tuple_test.py @@ -0,0 +1,37 @@ +import pytest + +import zon + +@pytest.fixture +def items(): + return [ + zon.string(), + zon.number(), + zon.boolean(), + ] + +@pytest.fixture +def validator(items): + return zon.element_tuple(items) + +def test_tuple_get_items(validator, items): + assert validator.items == items + +def test_tuple_validate(validator): + assert validator.validate(("1", 1.5, True)) + + with pytest.raises(zon.error.ZonError): + validator.validate(("1", 1.5)) + + with pytest.raises(zon.error.ZonError): + validator.validate(("1", 1.5, True, 1)) + + with pytest.raises(zon.error.ZonError): + validator.validate(("1", 1.5, "True")) + +def test_tuple_safe_validate(validator): + assert validator.safe_validate(("1", 1.5, True)) == (True, ("1", 1.5, True)) + + assert validator.safe_validate(("1", 1.5))[0] is False + assert validator.safe_validate(("1", 1.5, True, 1))[0] is False + assert validator.safe_validate(("1", 1.5, "True"))[0] is False diff --git a/zon/__init__.py b/zon/__init__.py index 4fd495b..d6f3db1 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -1490,6 +1490,19 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issues(issues) ctx.add_issue(ZonIssue(value=data, message="Not a valid union", path=[])) +def element_tuple(items: Sequence[Zon], /) -> ZonTuple: + """ + Returns a validator for a tuple with the given element types. + + Args: + items (Sequence[Zon]): the element types of the tuple. + + Returns: + ZonTuple: a new `ZonTuple` validator + """ + + return ZonTuple(items) + class ZonTuple(Zon): """A Zon that validates that the input is a tuple whose elements might have different types""" From c25278474229f64babf11a909b76a2799d037dc1 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 12:06:15 +0100 Subject: [PATCH 25/29] Redid validation logic --- CHANGELOG.md | 1 + tests/boolean_test.py | 2 +- tests/number_test.py | 4 +- tests/optional_test.py | 4 +- tests/record_test.py | 4 +- tests/str_test.py | 19 +++--- tests/tuple_test.py | 18 +++++- zon/__init__.py | 138 +++++++++++++++++++---------------------- 8 files changed, 102 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c202b21..f8868f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved everything into a single file to combat circular reference issues - Deprecated `ValidationError` in favor of `ZonError`. - Simplified validation logic +- Now returns a (deep-) copy of the original data after validation. This is more useful for `ZonRecord` and `ZonString` validators that can transform, while transformers are not added. ### Removed - Removed `between`, `__eq__` and `equals` methods from `ZonNumber`. diff --git a/tests/boolean_test.py b/tests/boolean_test.py index 8ce5519..f492d7e 100644 --- a/tests/boolean_test.py +++ b/tests/boolean_test.py @@ -10,7 +10,7 @@ def validator(): def test_boolean_validate(validator): assert validator.validate(True) - assert validator.validate(False) + assert not validator.validate(False) with pytest.raises(zon.error.ZonError): validator.validate("1") diff --git a/tests/number_test.py b/tests/number_test.py index 9fb75a7..2b5d129 100644 --- a/tests/number_test.py +++ b/tests/number_test.py @@ -110,7 +110,7 @@ def test_number_positive(validator): def test_number_non_negative(validator): _validator = validator.non_negative() - assert _validator.validate(0) + assert _validator.validate(0) == 0 assert _validator.validate(1) assert _validator.validate(1.5) @@ -134,7 +134,7 @@ def test_number_negative(validator): def test_number_non_positive(validator): _validator = validator.non_positive() - assert _validator.validate(0) + assert _validator.validate(0) == 0 assert _validator.validate(-1) assert _validator.validate(-1.5) diff --git a/tests/optional_test.py b/tests/optional_test.py index b55b2c1..ed94ab5 100644 --- a/tests/optional_test.py +++ b/tests/optional_test.py @@ -18,7 +18,7 @@ def validator(self, base_validator): return base_validator.optional() def test_optional_validate(self, validator): - assert validator.validate(None) + assert validator.validate(None) is None assert validator.validate("abc") with pytest.raises(zon.error.ZonError): @@ -38,7 +38,7 @@ def validator(self, base_validator): return zon.optional(base_validator) def test_optional_validate(self, validator): - assert validator.validate(None) + assert validator.validate(None) is None assert validator.validate("abc") with pytest.raises(zon.error.ZonError): diff --git a/tests/record_test.py b/tests/record_test.py index 0ab4e7b..cc26506 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -192,7 +192,7 @@ def test_record_partial_all(validator): } ) - assert _validator.validate({}) + assert _validator.validate({}) == {} def test_record_partial_some(validator): @@ -235,7 +235,7 @@ def test_record_deep_partial(validator): assert _validator.validate({"sub": {"sub_number": 1}}) assert _validator.validate({"sub": {}}) - assert _validator.validate({}) + assert _validator.validate({}) == {} def test_record_required_all(validator): diff --git a/tests/str_test.py b/tests/str_test.py index a132110..fa04343 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -67,13 +67,13 @@ def test_str_email(validator): assert _validator.validate("test@host.com") with pytest.raises(zon.error.ZonError): - assert _validator.validate("test@host") + _validator.validate("test@host") with pytest.raises(zon.error.ZonError): - assert not _validator.validate("@host.com") + _validator.validate("@host.com") with pytest.raises(zon.error.ZonError): - assert not _validator.validate("host.com") + _validator.validate("host.com") def test_str_url(validator): @@ -83,13 +83,18 @@ def test_str_url(validator): assert _validator.validate("http://www.google.com") with pytest.raises(zon.error.ZonError): - assert not _validator.validate("www.google.com") + _validator.validate("www.google.com") with pytest.raises(zon.error.ZonError): - assert not _validator.validate("google.com") + _validator.validate("google.com") with pytest.raises(zon.error.ZonError): - assert not _validator.validate("google.com/") + _validator.validate("google.com/") with pytest.raises(zon.error.ZonError): - assert not _validator.validate("google.com") + _validator.validate("google.com") + + +def test_str_emoji(validator): + with pytest.raises(NotImplementedError): + _validator = validator.emoji() """ diff --git a/tests/tuple_test.py b/tests/tuple_test.py index 3f8cd80..ddc5f30 100644 --- a/tests/tuple_test.py +++ b/tests/tuple_test.py @@ -2,6 +2,7 @@ import zon + @pytest.fixture def items(): return [ @@ -10,16 +11,19 @@ def items(): zon.boolean(), ] + @pytest.fixture def validator(items): return zon.element_tuple(items) + def test_tuple_get_items(validator, items): assert validator.items == items + def test_tuple_validate(validator): assert validator.validate(("1", 1.5, True)) - + with pytest.raises(zon.error.ZonError): validator.validate(("1", 1.5)) @@ -29,9 +33,21 @@ def test_tuple_validate(validator): with pytest.raises(zon.error.ZonError): validator.validate(("1", 1.5, "True")) + def test_tuple_safe_validate(validator): assert validator.safe_validate(("1", 1.5, True)) == (True, ("1", 1.5, True)) assert validator.safe_validate(("1", 1.5))[0] is False assert validator.safe_validate(("1", 1.5, True, 1))[0] is False assert validator.safe_validate(("1", 1.5, "True"))[0] is False + + +def test_tuple_rest(validator): + _validator = validator.rest(zon.number()) + + assert _validator.validate(("1", 1.5, True)) + assert _validator.validate(("1", 1.5, True, 1)) + assert _validator.validate(("1", 1.5, True, 1, 2, 3, 4)) + + with pytest.raises(zon.error.ZonError): + _validator.validate(("1", 1.5, True, "1")) diff --git a/zon/__init__.py b/zon/__init__.py index d6f3db1..2f72748 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -65,33 +65,26 @@ class ValidationContext: """Context used throughout an entire validation run""" - _error: ZonError = None + error: ZonError = None path: list[str] = field(default_factory=list) def _ensure_error(self): - if self._error is None: - self._error = ZonError() + if self.error is None: + self.error = ZonError() def add_issue(self, issue: ZonIssue): """Adds the given `ZodIssue` to this context's `ZonError`""" self._ensure_error() - self._error.add_issue(issue) + self.error.add_issue(issue) def add_issues(self, issues: list[ZonIssue]): """Adds the given `ZodIssue`s to this context's `ZonError`""" self._ensure_error() - self._error.add_issues(issues) - - def raise_error(self) -> Never: - """ - Raises the current validation error in this context if it exists. - """ - - raise self._error + self.error.add_issues(issues) @property def dirty(self): - return self._error is not None and len(self._error.issues) >= 0 + return self.error is not None and len(self.error.issues) >= 0 T = TypeVar("T") @@ -105,7 +98,7 @@ class ValidationRule: def __init__( self, name: str, - fn: Callable[[T], bool], + fn: Callable[[T], tuple[T, bool]], *, additional_data: Mapping[str, Any] = None, ): @@ -113,21 +106,20 @@ def __init__( self.name = name self.additional_data = additional_data if additional_data is not None else {} - def check(self, data: Any, ctx: ValidationContext) -> bool: + def check(self, data: T, ctx: ValidationContext) -> T: """ Check this validation rule against the supplied data. Args: - data (Any): the piece of data to be validated. + data (T): the piece of data to be validated. ctx (ValidationContext): the context in which the validation is being run. Returns: - bool: True if the data is valid, False otherwise. + T: The original data """ - valid = False try: - valid = self.fn(data) + new_data, valid = self.fn(data) if not valid: ctx.add_issue( @@ -137,6 +129,8 @@ def check(self, data: Any, ctx: ValidationContext) -> bool: path=[], ) ) + + return new_data except validators.ValidationError as e: ctx.add_issue( ZonIssue( @@ -145,9 +139,8 @@ def check(self, data: Any, ctx: ValidationContext) -> bool: path=[], ) ) - valid = False - return valid + return data @update_abstractmethods @@ -160,7 +153,7 @@ class Zon(ABC): to create more complex validations. """ - def __init__(self, **kwargs): + def __init__(self): self.validators: list[ValidationRule] = [] """validators that will run when 'validate' is invoked.""" @@ -204,23 +197,17 @@ def _validate(self, data: T) -> bool: ctx = ValidationContext() - def raise_if_dirty(): - if ctx.dirty: - ctx.raise_error() + cloned_data = copy.deepcopy(data) try: - self._default_validate(data, ctx) + self._default_validate(cloned_data, ctx) except NotImplementedError as ni: raise ni - raise_if_dirty() - for validator in self.validators: - validator.check(data, ctx) + cloned_data = validator.check(cloned_data, ctx) - raise_if_dirty() - - return True + return (not ctx.dirty, cloned_data if not ctx.dirty else ctx.error) @final def validate(self, data: T) -> bool: @@ -234,12 +221,13 @@ def validate(self, data: T) -> bool: is not implemented on base Zon class. ZonError: if validation fails. """ - if type(self) is Zon: # pylint: disable=unidiomatic-typecheck - raise NotImplementedError( - "validate() method not implemented on base Zon class" - ) - return self._validate(data) + valid, data_or_error = self.safe_validate(data) + + if valid: + return data_or_error + + raise data_or_error @final def safe_validate( @@ -259,11 +247,7 @@ def safe_validate( """ try: - self.validate(data) - - return (True, data) - except ZonError as ze: - return (False, ze) + return self._validate(data) except Exception as e: raise e @@ -397,7 +381,10 @@ def max(self, max_value: int | float) -> Self: _clone.validators.append( ValidationRule( "max_length", - lambda data: hasattr(data, "__len__") and len(data) <= max_value, + lambda data: ( + data, + hasattr(data, "__len__") and len(data) <= max_value, + ), ) ) @@ -415,7 +402,10 @@ def min(self, min_value: int | float) -> Self: _clone.validators.append( ValidationRule( "min_length", - lambda data: hasattr(data, "__len__") and len(data) >= min_value, + lambda data: ( + data, + hasattr(data, "__len__") and len(data) >= min_value, + ), ) ) @@ -433,7 +423,7 @@ def length(self, length: int) -> Self: _clone.validators.append( ValidationRule( "equal_length", - lambda data: hasattr(data, "__len__") and len(data) == length, + lambda data: (data, hasattr(data, "__len__") and len(data) == length), ) ) @@ -473,7 +463,7 @@ def email(self) -> Self: _clone.validators.append( ValidationRule( "email", - validators.email, + lambda data: (data, validators.email(data)), ) ) @@ -492,7 +482,7 @@ def url(self) -> Self: _clone.validators.append( ValidationRule( "url", - validators.url, + lambda data: (data, validators.url(data)), ) ) @@ -533,7 +523,7 @@ def uuid(self) -> Self: _clone.validators.append( ValidationRule( "uuid", - validators.uuid, + lambda data: (data, validators.uuid(data)), ) ) @@ -556,7 +546,7 @@ def regex(self, regex: str | re.Pattern[str]) -> Self: _clone.validators.append( ValidationRule( "regex", - lambda data: re.match(regex, data) is not None, + lambda data: (data, re.match(regex, data) is not None), ) ) @@ -577,7 +567,7 @@ def includes(self, needle: str) -> Self: _clone.validators.append( ValidationRule( "includes", - lambda data: needle in data, + lambda data: (data, needle in data), ) ) @@ -598,7 +588,7 @@ def starts_with(self, prefix: str) -> Self: _clone.validators.append( ValidationRule( "starts_with", - lambda data: data.startswith(prefix), + lambda data: (data, data.startswith(prefix)), ) ) @@ -619,7 +609,7 @@ def ends_with(self, suffix: str) -> Self: _clone.validators.append( ValidationRule( "ends_with", - lambda data: data.endswith(suffix), + lambda data: (data, data.endswith(suffix)), ) ) @@ -671,7 +661,7 @@ def _datetime_regex(opts: Mapping[str, Any]): _clone.validators.append( ValidationRule( "datetime", - lambda data: _datetime_regex(opts).match(data) is not None, + lambda data: (data, _datetime_regex(opts).match(data) is not None), ) ) @@ -712,7 +702,7 @@ def _validator(data, opts: Mapping[str, Any]): _clone.validators.append( ValidationRule( "ip", - lambda data: _validator(data, opts), + lambda data: (data, _validator(data, opts)), ) ) @@ -751,7 +741,7 @@ def gt(self, min_ex: float | int) -> Self: _clone.validators.append( ValidationRule( "gt", - lambda data: data is not None and data > min_ex, + lambda data: (data, data is not None and data > min_ex), ) ) @@ -772,7 +762,7 @@ def gte(self, min_in: float | int) -> Self: _clone.validators.append( ValidationRule( "gte", - lambda data: data is not None and data >= min_in, + lambda data: (data, data is not None and data >= min_in), ) ) @@ -796,7 +786,7 @@ def lt(self, max_ex: float | int) -> Self: _clone.validators.append( ValidationRule( "lt", - lambda data: data is not None and data < max_ex, + lambda data: (data, data is not None and data < max_ex), ) ) @@ -817,7 +807,7 @@ def lte(self, max_in: float | int) -> Self: _clone.validators.append( ValidationRule( "lte", - lambda data: data is not None and data <= max_in, + lambda data: (data, data is not None and data <= max_in), ) ) @@ -838,7 +828,7 @@ def int(self) -> Self: _clone.validators.append( ValidationRule( "int", - lambda data: isinstance(data, int), + lambda data: (data, isinstance(data, int)), ) ) @@ -856,7 +846,7 @@ def float(self) -> Self: _clone.validators.append( ValidationRule( "float", - lambda data: isinstance(data, float), + lambda data: (data, isinstance(data, float)), ) ) @@ -874,7 +864,7 @@ def positive(self) -> Self: _clone.validators.append( ValidationRule( "positive", - lambda data: data is not None and data > 0, + lambda data: (data, data is not None and data > 0), ) ) @@ -892,7 +882,7 @@ def negative(self) -> Self: _clone.validators.append( ValidationRule( "negative", - lambda data: data is not None and data < 0, + lambda data: (data, data is not None and data < 0), ) ) @@ -910,7 +900,7 @@ def non_negative(self) -> Self: _clone.validators.append( ValidationRule( "non_negative", - lambda data: data is not None and data >= 0, + lambda data: (data, data is not None and data >= 0), ) ) @@ -928,7 +918,7 @@ def non_positive(self) -> Self: _clone.validators.append( ValidationRule( "non_positive", - lambda data: data is not None and data <= 0, + lambda data: (data, data is not None and data <= 0), ) ) @@ -949,7 +939,7 @@ def multiple_of(self, base: int | float) -> Self: _clone.validators.append( ValidationRule( "multiple_of", - lambda data: data is not None and data % base == 0, + lambda data: (data, data is not None and data % base == 0), ) ) @@ -970,7 +960,7 @@ def finite(self) -> Self: _clone.validators.append( ValidationRule( "finite", - lambda data: data is not None and not math.isinf(data), + lambda data: (data, data is not None and not math.isinf(data)), ) ) @@ -1443,7 +1433,7 @@ def nonempty(self): _clone.validators.append( ValidationRule( "nonempty", - lambda data: hasattr(data, "__len__") and len(data) > 0, + lambda data: (data, hasattr(data, "__len__") and len(data) > 0), ) ) @@ -1490,6 +1480,7 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issues(issues) ctx.add_issue(ZonIssue(value=data, message="Not a valid union", path=[])) + def element_tuple(items: Sequence[Zon], /) -> ZonTuple: """ Returns a validator for a tuple with the given element types. @@ -1503,6 +1494,7 @@ def element_tuple(items: Sequence[Zon], /) -> ZonTuple: return ZonTuple(items) + class ZonTuple(Zon): """A Zon that validates that the input is a tuple whose elements might have different types""" @@ -1515,11 +1507,11 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, tuple): ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) return - + if len(data) < len(self._items): ctx.add_issue(ZonIssue(value=data, message="Not enough elements", path=[])) return - + if self._rest is None and len(data) > len(self._items): ctx.add_issue(ZonIssue(value=data, message="Too many elements", path=[])) return @@ -1534,7 +1526,7 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issues(data_or_error.issues) if self._rest is not None: - for extra_value in data[len(self.items):]: + for extra_value in data[len(self.items) :]: (valid, data_or_error) = self._rest.safe_validate(extra_value) if not valid: @@ -1543,16 +1535,16 @@ def _default_validate(self, data: T, ctx: ValidationContext): @property def items(self): return self._items - + def rest(self, rest: Zon) -> ZonTuple: """ Returns a validator for a tuple that might accept more elements of a given type Args: rest (Zon): the element type of the rest of the tuple's elements. - + Returns: ZonTuple: a new `ZonTuple` validator """ - return ZonTuple(self._items, rest) \ No newline at end of file + return ZonTuple(self._items, rest) From 8f8c3f53ec16821d9599ebcb53d95c563632cf61 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 12:47:46 +0100 Subject: [PATCH 26/29] Implemented other unkown key policies for ZonRecord --- tests/record_test.py | 29 +++++++++++ tests/str_test.py | 19 +++++++- zon/__init__.py | 114 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 144 insertions(+), 18 deletions(-) diff --git a/tests/record_test.py b/tests/record_test.py index cc26506..127ec28 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -304,3 +304,32 @@ def test_record_unknown_key_policy_strict(validator): "unknown": 1, } ) + +def test_record_unknown_key_policy_strip(validator): + _validator = validator.strip() + + assert _validator.validate( + { + "name": "John", + "age": 1, + "unknown": 1, + } + ) == { + "name": "John", + "age": 1, + } + +def test_record_unknown_key_policy_passthrough(validator): + _validator = validator.passthrough() + + assert _validator.validate( + { + "name": "John", + "age": 1, + "unknown": 1, + } + ) == { + "name": "John", + "age": 1, + "unknown": 1, + } \ No newline at end of file diff --git a/tests/str_test.py b/tests/str_test.py index fa04343..8e769ae 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -98,7 +98,6 @@ def test_str_emoji(validator): """ -def test_str_emoji(validator): _validator = validator.emoji() assert _validator.validate("😀") @@ -253,3 +252,21 @@ def test_str_ip_v6(validator): with pytest.raises(zon.error.ZonError): _validator.validate("255.255.255.255") + + +def test_str_trim(validator): + _validator = validator.trim() + + assert _validator.validate(" abc ") == "abc" + + +def test_str_to_lower_case(validator): + _validator = validator.to_lower_case() + + assert _validator.validate("AbC") == "abc" + + +def test_str_to_upper_case(validator): + _validator = validator.to_upper_case() + + assert _validator.validate("aBc") == "ABC" diff --git a/zon/__init__.py b/zon/__init__.py index 2f72748..82a5dfd 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -17,11 +17,9 @@ # - Container and other collections.abc types # - Typing with Self -# TODO: remove early_termination (was giving errors in list validators) - import copy from abc import ABC, abstractmethod, update_abstractmethods -from typing import Any, Self, TypeVar, final, Literal, Never +from typing import Any, Self, TypeVar, final, Literal from collections.abc import Callable, Mapping, Sequence # TODO: explore Container type from dataclasses import dataclass, field import re @@ -58,6 +56,8 @@ "literal", "ZonUnion", "union", + "ZonTuple", + "element_tuple", ] @@ -200,10 +200,13 @@ def _validate(self, data: T) -> bool: cloned_data = copy.deepcopy(data) try: - self._default_validate(cloned_data, ctx) + cloned_data = self._default_validate(cloned_data, ctx) except NotImplementedError as ni: raise ni + if ctx.dirty: + return (False, ctx.error) + for validator in self.validators: cloned_data = validator.check(cloned_data, ctx) @@ -318,14 +321,15 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not zon_1_parsed: ctx.add_issues(data1_or_error.issues) - return + return data (zon_2_parsed, data2_or_error) = self.zon2.safe_validate(data) if not zon_2_parsed: ctx.add_issues(data2_or_error.issues) - return + return data + return data def optional(zon: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for this validator if it exists. @@ -353,6 +357,8 @@ def _default_validate(self, data, ctx): if not passed: ctx.add_issues(data_or_error.issues) + return data + def unwrap(self) -> Zon: """Extracts the wrapped Zon from this ZonOptional. @@ -450,6 +456,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, str): ctx.add_issue(ZonIssue(value=data, message="Not a string", path=[])) + return data + def email(self) -> Self: """ Assert that the value under validation is a valid email. @@ -708,6 +716,60 @@ def _validator(data, opts: Mapping[str, Any]): return _clone + def trim(self) -> Self: + """Trim whitespace from both sides of the value. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "trim", + lambda data: (data.strip(), True), + ) + ) + + return _clone + + def to_lower_case(self) -> Self: + """Convert the value to lowercase. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "to_lower_case", + lambda data: (data.lower(), True), + ) + ) + + return _clone + + def to_upper_case(self) -> Self: + """Convert the value to uppercase. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "to_upper_case", + lambda data: (data.upper(), True), + ) + ) + + return _clone + def number() -> ZonNumber: """Returns a validator for numeric data. @@ -726,6 +788,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, (int, float)): ctx.add_issue(ZonIssue(value=data, message="Not a valid number", path=[])) + return data + def gt(self, min_ex: float | int) -> Self: """Assert that the value under validation is greater than the given number. @@ -984,6 +1048,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, bool): ctx.add_issue(ZonIssue(value=data, message="Not a valid boolean", path=[])) + return data + def literal(value: Any, /) -> ZonLiteral: """Returns a validator for a given literal value. @@ -1015,6 +1081,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): ZonIssue(value=data, message=f"Expected {self._value}", path=[]) ) + return data + def enum(options: Sequence[str], /) -> ZonEnum: """Returns a validator for an enum. @@ -1054,6 +1122,8 @@ def _default_validate(self, data, ctx): ) ) + return data + def exclude(self, options: Sequence[str]) -> Self: """ Excludes the given options from the enum. @@ -1128,7 +1198,9 @@ def _default_validate(self, data, ctx: ValidationContext): if not isinstance(data, dict): ctx.add_issue(ZonIssue(value=data, message="Not a valid object", path=[])) - return + return data + + data_to_return = {} extra_keys: set[str] = {} if ( @@ -1148,6 +1220,8 @@ def _default_validate(self, data, ctx: ValidationContext): if not validated: ctx.add_issues(data_or_error.issues) + elif data_or_error is not None: # in case of optional data + data_to_return[key] = data_or_error if self._catchall is None: match self.unknown_key_policy: @@ -1155,11 +1229,7 @@ def _default_validate(self, data, ctx: ValidationContext): # ignore extra keys pass case ZonRecord.UnknownKeyPolicy.PASSTHROUGH: - # TODO: rework validation to return data - - # data_to_return.extend({k: data[k] for k in extra_keys}) - - pass + data_to_return.update({k: data[k] for k in extra_keys}) case ZonRecord.UnknownKeyPolicy.STRICT: if len(extra_keys) > 0: ctx.add_issue( @@ -1178,6 +1248,10 @@ def _default_validate(self, data, ctx: ValidationContext): if not valid: ctx.add_issues(data_or_error.issues) + elif data_or_error is not None: # in case of optional data + data_to_return[key] = data_or_error + + return data_to_return @property def shape(self) -> Mapping[str, Zon]: @@ -1408,7 +1482,7 @@ def __init__(self, element, **kwargs): def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, list): ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) - return + return data for element in data: (valid, data_or_error) = self._element.safe_validate(element) @@ -1416,6 +1490,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not valid: ctx.add_issues(data_or_error.issues) + return data + @property def element(self): return self._element @@ -1472,7 +1548,7 @@ def _default_validate(self, data: T, ctx: ValidationContext): (valid, data_or_error) = option.safe_validate(data) if valid: - return + return data issues.extend(data_or_error.issues) @@ -1480,6 +1556,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issues(issues) ctx.add_issue(ZonIssue(value=data, message="Not a valid union", path=[])) + return data + def element_tuple(items: Sequence[Zon], /) -> ZonTuple: """ @@ -1506,15 +1584,15 @@ def __init__(self, items: Sequence[Zon], rest: Zon | None = None, /, **kwargs): def _default_validate(self, data: T, ctx: ValidationContext): if not isinstance(data, tuple): ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) - return + return data if len(data) < len(self._items): ctx.add_issue(ZonIssue(value=data, message="Not enough elements", path=[])) - return + return data if self._rest is None and len(data) > len(self._items): ctx.add_issue(ZonIssue(value=data, message="Too many elements", path=[])) - return + return data for i, _validator in enumerate(self._items): if _validator is None: @@ -1532,6 +1610,8 @@ def _default_validate(self, data: T, ctx: ValidationContext): if not valid: ctx.add_issues(data_or_error.issues) + return data + @property def items(self): return self._items From d106368065db6cbc5296d63adac4045341c9fbaf Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 14:52:31 +0100 Subject: [PATCH 27/29] Re-implemented refinements --- tests/record_test.py | 4 +++- tests/zon_test.py | 18 ++++++++++++++++++ zon/__init__.py | 40 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/zon_test.py diff --git a/tests/record_test.py b/tests/record_test.py index 127ec28..a78d570 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -305,6 +305,7 @@ def test_record_unknown_key_policy_strict(validator): } ) + def test_record_unknown_key_policy_strip(validator): _validator = validator.strip() @@ -319,6 +320,7 @@ def test_record_unknown_key_policy_strip(validator): "age": 1, } + def test_record_unknown_key_policy_passthrough(validator): _validator = validator.passthrough() @@ -332,4 +334,4 @@ def test_record_unknown_key_policy_passthrough(validator): "name": "John", "age": 1, "unknown": 1, - } \ No newline at end of file + } diff --git a/tests/zon_test.py b/tests/zon_test.py new file mode 100644 index 0000000..63f7059 --- /dev/null +++ b/tests/zon_test.py @@ -0,0 +1,18 @@ +import pytest + +import zon + + +@pytest.fixture +def validator(): + return zon.string() + + +def test_refinement(validator): + _validator = validator.refine(lambda data: data[0] == data[-1]) + + assert _validator.validate("1") + assert _validator.validate("212") + + with pytest.raises(zon.error.ZonError): + _validator.validate("21") diff --git a/zon/__init__.py b/zon/__init__.py index 82a5dfd..8ba63f3 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -131,7 +131,7 @@ def check(self, data: T, ctx: ValidationContext) -> T: ) return new_data - except validators.ValidationError as e: + except Exception as e: ctx.add_issue( ZonIssue( value=data, @@ -293,6 +293,30 @@ def list(self) -> ZonList: return element_list(self) + def refine( + self, refinement: Callable[[T], bool], /, message: str | None = None + ) -> Self: + """Returns a validator that validates that the data is valid under this validator and that it is valid under the provided refinement function. + + Args: + refinement (Callable[[T], tuple[T, bool]]): the refinement function + message (str | None): custom message to be used in the error. + + Returns: + Zon: The refined data validator. + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + message if message is not None else "custom", + lambda data: (data, refinement(data)), + ) + ) + + return _clone + def intersection(zon1: Zon, zon2: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for both validators supplied. @@ -331,6 +355,7 @@ def _default_validate(self, data: T, ctx: ValidationContext): return data + def optional(zon: Zon) -> ZonIntersection: """Returns a validator that validates that the data is valid for this validator if it exists. @@ -678,6 +703,15 @@ def _datetime_regex(opts: Mapping[str, Any]): def ip(self, opts: Mapping[str, Any] | None = None) -> Self: """Assert that the value under validation is a valid IP address. + By default this checks if the IP is either a valid IPv4 or IPv6 address. + Clients can constrain this behavior by specifying the version in the `opts` parameter, like so: + ```py + zon.string().ip({"version": "v4"}) + ``` + + Args: + opts (Mapping[str, Any]): the options to use. Defaults to None + Returns: ZonString: a new `Zon` with the validation rule added """ @@ -1220,7 +1254,7 @@ def _default_validate(self, data, ctx: ValidationContext): if not validated: ctx.add_issues(data_or_error.issues) - elif data_or_error is not None: # in case of optional data + elif data_or_error is not None: # in case of optional data data_to_return[key] = data_or_error if self._catchall is None: @@ -1248,7 +1282,7 @@ def _default_validate(self, data, ctx: ValidationContext): if not valid: ctx.add_issues(data_or_error.issues) - elif data_or_error is not None: # in case of optional data + elif data_or_error is not None: # in case of optional data data_to_return[key] = data_or_error return data_to_return From dcdff9cbef68522c83d17e2cc8f1fe5a33b25381 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 17:25:39 +0100 Subject: [PATCH 28/29] Updated README --- README.md | 190 ++++++-- examples/README.md | 4 + tests-old/any_test.py | 28 -- tests-old/base_test.py | 18 - tests-old/int_test.py | 82 ---- tests-old/list_test.py | 20 - tests-old/str_test.py | 93 ---- tests-old/traits/collection_test.py | 31 -- tests/anything_test.py | 24 + tests/never_test.py | 29 ++ zon-old/__init__-old.py | 113 ----- zon-old/__init__.py | 704 ---------------------------- zon-old/any.py | 13 - zon-old/base.py | 185 -------- zon-old/bool.py | 53 --- zon-old/element_list/__init__.py | 37 -- zon-old/error.py | 61 --- zon-old/none.py | 17 - zon-old/number/float.py | 18 - zon-old/number/int.py | 18 - zon-old/record.py | 37 -- zon-old/union.py | 24 - zon/__init__.py | 47 +- 23 files changed, 255 insertions(+), 1591 deletions(-) create mode 100644 examples/README.md delete mode 100644 tests-old/any_test.py delete mode 100644 tests-old/base_test.py delete mode 100644 tests-old/int_test.py delete mode 100644 tests-old/list_test.py delete mode 100644 tests-old/str_test.py delete mode 100644 tests-old/traits/collection_test.py create mode 100644 tests/anything_test.py create mode 100644 tests/never_test.py delete mode 100644 zon-old/__init__-old.py delete mode 100644 zon-old/__init__.py delete mode 100644 zon-old/any.py delete mode 100644 zon-old/base.py delete mode 100644 zon-old/bool.py delete mode 100644 zon-old/element_list/__init__.py delete mode 100644 zon-old/error.py delete mode 100644 zon-old/none.py delete mode 100644 zon-old/number/float.py delete mode 100644 zon-old/number/int.py delete mode 100644 zon-old/record.py delete mode 100644 zon-old/union.py diff --git a/README.md b/README.md index cc0c2c7..ddfc787 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Want to have the validation power of [zod](https://zod.dev/) but with the ease-o Enter `zon`. -`zon` is a Python library that aims to provide a simple, easy-to-use API for validating data, similiar to `zod`'s own API'. In fact, the whole library and its name were inspired by `zod`: **Z**od + Pyth**on** = **Zon** !!!. +`zon` is a Python library that aims to provide a simple, easy-to-use API for validating data, similar to `zod`'s own API'. In fact, the whole library and its name were inspired by `zod`: **Z**od + Pyth**on** = **Zon** !!!. ## Why @@ -39,7 +39,7 @@ pip install . In its essence, `zon` behaves much like `zod`. If you have used `zod`, you will feel right at home with `zon`. > [!NOTE] -> There are some differences in the public API between `zon` and `zod`. Those mostly stem from the fact that Python does not have type inference like Typescript has. +> There are some differences in the public API between `zon` and `zod`. Those mostly stem from the fact that Python does not have type inference like Typescript has. There are other slight deviations between `zon` and `zod`. ### Basic types @@ -47,30 +47,44 @@ In its essence, `zon` behaves much like `zod`. If you have used `zod`, you will ```python zon.string() -zon.integer() -zon.floating_point() +zon.number() zon.boolean() -zon.none() -zon.anything() -``` +zon.literal() +zon.enum() -Besides this, there's also a `zon.optional()` type, which allows for a value to be either of the type passed as an argument or `None`. +zon.record() +zon.element_list() +zon.element_tuple() -```python -zon.optional(zon.string()) -``` +zon.union() +zon.intersection() +zon.optional() -### Integers and Floats +zon.never() +zon.anything() +``` -`zon`'z integer and floating point types derive from a common `ZonNumber` class that defines some methods that can be applied to all numbers: +### Numbers ```python -validator = zon.integer() # (for floats, use zon.floating_point()) +validator = zon.number() validator.gt(5) -validator.gte(5) +validator.gte(5) # alias for .min(5) validator.lt(5) -validator.lte(5) +validator.lte(5) # alias for .max(5) + +validator.int() +validator.float() + +validator.positive() +validator.negative() +validator.non_negative() +validator.non_positive() + +validator.multiple_of(5) # alias for .step(5) + +validator.finite() ``` ### Strings @@ -78,25 +92,53 @@ validator.lte(5) For strings, there are also some extra methods: ```python -zon.string().min(5) -zon.string().max(10) -zon.string().length(5) -zon.string().email() -zon.string().regex(r"^\d{3}-\d{3}-\d{4}$") -zon.string().uuid() -zon.string().ip() -zon.string().datetime() +validator = zon.string() + +validator.min(5) +validator.max(10) +validator.length(5) + +validator.email() +validator.url() +validator.uuid() +validator.regex(r"^\d{3}-\d{3}-\d{4}$") +validator.includes("needle") +validator.starts_with("prefix") +validator.ends_with("suffix") +validator.datetime() +validator.ip() + +# transformers +validator.trim() +validator.to_lower_case() +validator.to_upper_case() ``` #### Datetime -`zod` uses regex-based validation for datetimes, which must be valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) strings. However, due to an issue with most JavaScript engines' datetime validation, offsets cannot specify only hours, and `zod` reflects this in their API. +`zon` accepts the same options as `zod` for datetime string validation: +```python +validator.datetime({"precision": 3}) +validator.datetime({"local": True}) +validator.datetime({"offset": True}) +``` + +> [!NOTE] +> `zod` uses regex-based validation for datetimes, which must be valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) strings. However, due to an issue with most JavaScript engines' datetime validation, offsets cannot specify only hours, and `zod` reflects this in their API. +> While `zon` could reflect `zod`'s API in this matter, it is best to not constrain users to the problems of another platform, making this one of the aspects where `zon` deviates from `zod`. + +#### IP addresses + +`zon` accepts the same options as `zod` for ip address string validation: +```python +validator.ip({"version": "v4"}) +validator.ip({"version": "v6"}) +``` -While `zon` could reflect `zod`'s API in this matter, it is best to not constrain users to the problems of another platform, making this one of the aspects where `zon` deviates from `zod`. ### List -Lists are defined by calling the `zon.list()` method, passing as an argument a `Zon` instance. All elements in this list must be of the same type. +Lists are defined by calling the `zon.element_list()` method, passing as an argument a `Zon` instance. All elements in this list must be of the same type. ```python zon.element_list(zon.string()) @@ -112,24 +154,36 @@ validator.max(10) validator.length(5) ``` +There is also a method for validating that the list has at least one element, `nonempty`: +```py +validator.nonempty() +``` + +You can get the type of the list's elements by accessing the `element` property: +```py +zon.element_list(zon.string()).element is ZonString +``` + ### Union `zon` supports unions of types, which are defined by calling the `zon.union()` method, passing as arguments the `Zon` instances that are part of the union. ```python -zod.union([zon.string(), zon.integer()]) +zod.union([zon.string(), zon.number()]) +``` + +To access the various options, access the `options` property: + +```python +zod.union([zon.string(), zon.number()]).options = [ZonString, ZonNumber] ``` ### Record `zon` supports validating objects according to a specified schema, using the `zon.schema()` method. This method takes as an argument a dictionary, where the keys are the keys of the object to be validated and the values are the `Zon` instances that define the type of each key. -This method is probably the most useful in the library since it can be used to, for example, validate JSON data from a HTTP response, like such: - ```python -import zon - -schema = zon.record({ +validator = zon.record({ "name": zon.string(), "age": zon.number(), "isAwesome": zon.boolean(), @@ -142,9 +196,77 @@ schema = zon.record({ }) ``` +Useful methods for `ZonRecord` instances: +```py +validator.extend({...}) +validator.merge(otherZon) == validator.extend(otherZon.shape) +validator.pick({"name": True}) == ZonRecord({"name": ...}) +validator.omit({"name": True}) == ZonRecord({"": ..., ...}) +validator.partial() # makes all attributes optional, shallowly +validator.partial({"name": True}) # makes only "name" optional (in this example) +validator.deepPartial() # makes all attributes optional, recursively +validator.required() # makes all attributes required, shallowly +``` + +You can access the shape of objects validated by any `ZonRecord` instance by accessing the `shape` property: +```py +shape = validator.shape +``` + +If you want to validate only the keys of the shape, use the `keyof` method: +```py +validator.keyof() == ZonEnum(["name", "age", "isAwesome", "friends", "address"]) +``` + +#### Unknown Keys + +As `zod`, `zon` normally strips unknown keys from records. This, however, can be configured: +```py +validator.strict() # presence of unknown keys makes validation fail +validator.passthrough() # add unknown keys to the resulting record +validator.strip() # the default behavior, strip unknown keys from the resulting record +``` + +#### Catchall + +In case you want to validate unknown keys, you can use the `catchall` method to specify the validator that is used: +```py +validator.catchall(zon.string()) # unknown keys *must* be associated with string values +``` + +### Tuple + +`zon` supports tuples out-of-the-box, which are fixed-size containers whose elements might not have the same type: +```py +validator = zon.tuple([...]) +``` + +If you want to access the items of the tuples, use the `items` property: +```py +validator.items = [...] +``` + +Variadic items can be validated using the `rest` method: +```py +validator.rest(zon.string()) # after the defined items, everything must be a string +``` + +### Enums + +`zon` supports enumerations, which allow validating that any given data is one of many values: + +```py +validator = zon.enum([...]) +``` + +If you want to access the possible valid values, use the `enum` property: +```py +validator.enum = [...] +``` + ## Examples -Example usage of `zon` can be found in the `examples` directory. +Example usage of `zon` can be found in the [`examples`](./examples/) directory. ## Documentation diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..6604aca --- /dev/null +++ b/examples/README.md @@ -0,0 +1,4 @@ +# zon - Zod-like validator library for Python - Examples + +This directory contains samples of validation schemas that can be built using `zon`. +Each different type of validation is separated into its own file \ No newline at end of file diff --git a/tests-old/any_test.py b/tests-old/any_test.py deleted file mode 100644 index 77e4005..0000000 --- a/tests-old/any_test.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -import zon - - -@pytest.fixture -def validator(): - return zon.anything() - - -def test_int(validator): - assert validator.validate(1) - - -def test_float(validator): - assert validator.validate(1.5) - - -def test_str(validator): - assert validator.validate("1") - - -def test_list(validator): - assert validator.validate([1]) - - -def test_record(validator): - assert validator.validate({"a": 1}) diff --git a/tests-old/base_test.py b/tests-old/base_test.py deleted file mode 100644 index e6da954..0000000 --- a/tests-old/base_test.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -import zon - - -@pytest.fixture -def validator(): - return zon.anything() - - -def test_refine(validator): - - assert validator.validate(1) - - refined_validator = validator.refine(lambda x: x == 1) - - assert refined_validator.validate(1) - assert not refined_validator.validate(2) diff --git a/tests-old/int_test.py b/tests-old/int_test.py deleted file mode 100644 index 04be194..0000000 --- a/tests-old/int_test.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest - -import zon - - -@pytest.fixture -def validator(): - return zon.integer() - - -def test_int(validator): - assert validator.validate(1) - - -def test_not_float(validator): - assert not validator.validate(1.5) - - -def test_not_str(validator): - assert not validator.validate("1") - - -def test_not_list(validator): - assert not validator.validate([1]) - - -def test_not_record(validator): - assert not validator.validate({"a": 1}) - - -def test_gt_func(validator): - assert not validator.gt(1).validate(0) - - -def test_gt_func_op(validator): - new_validator = validator > 1 - - assert not new_validator.validate(0) - - -def test_gte_func(validator): - assert not validator.gte(1).validate(0) - - -def test_gte_func_op(validator): - new_validator = validator >= 1 - - assert not new_validator.validate(0) - - -def test_lt_func(validator): - assert not validator.lt(1).validate(2) - - -def test_lt_func_op(validator): - new_validator = validator < 1 - - assert not new_validator.validate(2) - - -def test_lte_func(validator): - assert not validator.lte(1).validate(2) - - -def test_lte_func_op(validator): - new_validator = validator <= 1 - - assert not new_validator.validate(2) - - -def test_eq_func(validator): - assert not validator.eq(1).validate(2) - - -def test_eq_func_op(validator): - new_validator = validator == 1 - - assert not new_validator.validate(2) - - -def test_between_func(validator): - assert not validator.between(1, 2).validate(0) diff --git a/tests-old/list_test.py b/tests-old/list_test.py deleted file mode 100644 index f0b8397..0000000 --- a/tests-old/list_test.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -import zon - - -@pytest.fixture -def validator(): - return zon.element_list(zon.anything()) - - -def test_list(validator): - assert validator.validate([1]) - - -def test_not_list(validator): - assert not validator.validate(1) - assert not validator.validate({"a": 1}) - assert not validator.validate("1") - assert not validator.validate(1.5) - assert not validator.validate(True) diff --git a/tests-old/str_test.py b/tests-old/str_test.py deleted file mode 100644 index 50e9db3..0000000 --- a/tests-old/str_test.py +++ /dev/null @@ -1,93 +0,0 @@ -import pytest - -import zon - -import uuid - - -@pytest.fixture -def validator(): - return zon.string() - - -def test_str(validator): - assert validator.validate("1") - - -def test_not_float(validator): - assert not validator.validate(1.5) - - -def test_not_int(validator): - assert not validator.validate(1) - - -def test_not_list(validator): - assert not validator.validate([1]) - - -def test_not_record(validator): - assert not validator.validate({"a": 1}) - - -def test_email(validator): - _validator = validator.email() - - assert _validator.validate("test@host.com") - assert _validator.validate("test@host") - - assert not _validator.validate("@host.com") - assert not _validator.validate("host.com") - - -def test_ipv4(validator): - _validator = validator.ip() - - assert _validator.validate("255.255.255.255") - assert _validator.validate("0.0.0.0") - - assert not _validator.validate("1.1.1") - assert not _validator.validate("0.0.0.0.0") - assert not _validator.validate("256.256.256.256") - assert not _validator.validate("255.255.255.256") - assert not _validator.validate("255.255.256.255") - assert not _validator.validate("255.256.255.255") - assert not _validator.validate("256.255.255.255") - - -def test_ipv6(validator): - _validator = validator.ip() - - # Ipv6 addresses - assert _validator.validate("::") - assert _validator.validate("::1") - assert _validator.validate("::ffff:127.0.0.1") - assert _validator.validate("::ffff:7f00:1") - assert _validator.validate("::ffff:127.0.0.1") - - assert not _validator.validate("::1.1.1") - assert not _validator.validate("::ffff:127.0.0.1.1.1") - assert not _validator.validate("::ffff:127.0.0.256") - assert not _validator.validate("::ffff:127.0.0.256.256") - - # Example taken from https://zod.dev/?id=ip-addresses - assert not _validator.validate("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003") - - -def test_uuid(validator): - _validator = validator.uuid() - - assert _validator.validate(uuid.uuid4().hex) - - assert not _validator.validate("not_a_UUID") - - -def test_regex(validator): - _validator = validator.regex(r"^[a-z ]+$") - - assert _validator.validate("abc") - assert _validator.validate("def") - assert _validator.validate("abc def") - - assert not _validator.validate("abc1") - assert not _validator.validate("abc def1") diff --git a/tests-old/traits/collection_test.py b/tests-old/traits/collection_test.py deleted file mode 100644 index cd09dae..0000000 --- a/tests-old/traits/collection_test.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest - -import zon - - -@pytest.fixture -def validator(): - # use strings as the default collection, should work for all other implementations - return zon.string() - - -def test_length(validator): - assert validator.length(1).validate("1") - - assert not validator.length(0).validate("1") - - -def test_min_length(validator): - assert validator.min(1).validate("1") - assert validator.min(1).validate("12") - assert validator.min(1).validate("123") - - assert not validator.min(2).validate("1") - - -def test_max_length(validator): - assert validator.max(1).validate("1") - - assert not validator.max(0).validate("1") - assert not validator.max(0).validate("12") - assert not validator.max(0).validate("123") diff --git a/tests/anything_test.py b/tests/anything_test.py new file mode 100644 index 0000000..4c06cc5 --- /dev/null +++ b/tests/anything_test.py @@ -0,0 +1,24 @@ +import pytest + +import zon + + +@pytest.fixture +def validator(): + return zon.anything() + + +def test_anything_validate(validator): + assert validator.validate(1) + assert validator.validate(1.5) + assert validator.validate("1") + assert validator.validate([1]) + assert validator.validate({"a": 1}) + + +def test_anything_safe_validate(validator): + assert validator.safe_validate(1)[0] is True + assert validator.safe_validate(1.5)[0] is True + assert validator.safe_validate("1")[0] is True + assert validator.safe_validate([1])[0] is True + assert validator.safe_validate({"a": 1})[0] is True diff --git a/tests/never_test.py b/tests/never_test.py new file mode 100644 index 0000000..ea4df1c --- /dev/null +++ b/tests/never_test.py @@ -0,0 +1,29 @@ +import pytest + +import zon + + +@pytest.fixture +def validator(): + return zon.never() + + +def test_never_validate(validator): + with pytest.raises(zon.error.ZonError): + validator.validate(1) + with pytest.raises(zon.error.ZonError): + validator.validate(1.5) + with pytest.raises(zon.error.ZonError): + validator.validate("1") + with pytest.raises(zon.error.ZonError): + validator.validate([1]) + with pytest.raises(zon.error.ZonError): + validator.validate({"a": 1}) + + +def test_never_safe_validate(validator): + assert validator.safe_validate(1)[0] is False + assert validator.safe_validate(1.5)[0] is False + assert validator.safe_validate("1")[0] is False + assert validator.safe_validate([1])[0] is False + assert validator.safe_validate({"a": 1})[0] is False diff --git a/zon-old/__init__-old.py b/zon-old/__init__-old.py deleted file mode 100644 index 367a2cc..0000000 --- a/zon-old/__init__-old.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Validator package. - -The purpose of this package is to provide a set of functions to validate data, using a Zod-like syntax. -""" - -__version__ = "1.0.0" -__author__ = "Nuno Pereira" -__email__ = "nunoafonso2002@gmail.com" -__license__ = "MIT" -__copyright__ = "Copyright 2023, Nuno Pereira" - -from zon.number.float import ZonFloat -from zon.number.int import ZonInteger -from zon.base import Zon -from zon.bool import ZonBoolean -from zon.element_list import ZonList -from zon.str import ZonString -from zon.union import ZonUnion -from zon.record import ZonRecord -from zon.any import ZonAny -from zon.none import ZonNone - - -def string() -> "ZonString": - """Creates a new Zon that validates that the data is a string. - - Returns: - ZonString: a new validator that validates that the data is a string. - """ - return ZonString() - - -def integer() -> "ZonInteger": - """Creates a new Zon that validates that the data is an integer. - - Returns: - ZonInteger: a new validator that validates that the data is an integer - """ - return ZonInteger() - - -def floating_point() -> "ZonFloat": - """Creates a new Zon that validates that the data is a floating point number. - - Returns: - ZonFloat: a new validator that validates that the data is a floating point number. - """ - return ZonFloat() - - -def boolean() -> "ZonBoolean": - """Creates a new Zon that validates that the data is a boolean. - - Returns: - ZonBoolean: a new validator that validates that the data is a boolean. - """ - return ZonBoolean() - - -def element_list(element_type: "Zon") -> "ZonList": - """Creates a new Zon that validates that the data is a list of elements of the specified type. - - Args: - element_type (Zon): the type of the elements of the list. - - Returns: - ZonList: a new validator that validates that the data is - a list of elements of the specified type. - """ - return ZonList(element_type) - - -def union(types: list["Zon"]) -> "ZonUnion": - """Creates a new Zon that validates that the data is one of the specified types. - - Args: - types (list[Zon]): the types of the data to be validated. - - Returns: - ZonUnion: a new validator that validates that the data is one of the specified types. - """ - return ZonUnion(types) - - -def record(properties: dict[str, "Zon"]) -> "ZonRecord": - """Creates a new Zon that validates that the data is an object with the specified properties. - - Args: - properties (dict[str, Zon]): the properties of the object to be validated. - - Returns: - ZonRecord: a new validator that validates that the data is - an object with the specified properties. - """ - return ZonRecord(properties) - - -def none() -> "ZonNone": - """Creates a new Zon that validates that the data is None. - - Returns: - ZonNone: a new validator that validates that the data is None. - """ - return ZonNone() - - -def anything() -> "ZonAny": - """Creates a new Zon that validates anything. - - Returns: - ZonAny: a new validator that validates anything. - """ - return ZonAny() diff --git a/zon-old/__init__.py b/zon-old/__init__.py deleted file mode 100644 index b92be3b..0000000 --- a/zon-old/__init__.py +++ /dev/null @@ -1,704 +0,0 @@ -"""Validator package. - -The purpose of this package is to provide a set of functions to validate data, using a Zod-like syntax. -""" - -from __future__ import annotations - -__version__ = "2.0.0" -__author__ = "Nuno Pereira" -__email__ = "nunoafonso2002@gmail.com" -__license__ = "MIT" -__copyright__ = "Copyright 2023, Nuno Pereira" - -from abc import ABC, abstractmethod -from typing import final, Callable, TypeVar, Self, Any -import copy -import re -from deprecation import deprecated - -import validators -from dateutil.parser import parse - - -from .error import ZonError - -T = TypeVar("T") -ValidationRule = Callable[[T], bool] - - -class Zon(ABC): - """ - Base class for all Zons. - - A Zon is the basic unit of validation in Zon. - It is used to validate data, and can be composed with other Zons - to create more complex validations. - """ - - def __init__(self): - self.errors: list[ZonError] = [] - """List of validation errors accumulated""" - - self.validators: dict[str, ValidationRule] = {} - """validators that will run when 'validate' is invoked.""" - - self._setup() - - def _clone(self) -> Self: - """Creates a copy of this Zon.""" - return copy.deepcopy(self) - - def _add_error(self, error: ZonError): - """Adds an error to this Zon's error output. - - Args: - error (ZonError): the validation error to add. - """ - self.errors.append(error) - - @abstractmethod - def _setup(self) -> None: - """Sets up the Zon with default validation rules. - - This implies that a '_default_' rule will be present, otherwise the validation fails. - - This method is called when the Zon is created. - """ - - @final - @deprecated(deprecated_in="2.0.0", current_version=__version__) - def _validate(self, data: T) -> bool: - """Validates the supplied data. - - Args: - data (Any): the piece of data to be validated. - - Returns: - bool: True if the data is valid, False otherwise. - """ - - if "_default_" not in self.validators or not self.validators["_default_"]: - self._add_error( - ZonError(f"Zon of type {type(self)} must have a valid '_default_' rule") - ) - return False - - # TODO: better error messages - return all(validator(data) for (_, validator) in self.validators.items()) - - @deprecated( - deprecated_in="2.0.0", - current_version=__version__, - details="This method does not exist in the original 'zod' code. Please use 'parse' and 'safe_parse' to better reflect zod's API", - ) - @final - def validate(self, data: T) -> bool: - """Validates the supplied data. - - Args: - data (Any): the piece of data to be validated. - - Raises: - NotImplementedError: the default implementation of this method - is not implemented on base Zon class. - """ - if type(self) is Zon: # pylint: disable=unidiomatic-typecheck - raise NotImplementedError( - "validate() method not implemented on base Zon class" - ) - - return self._validate(data) - - def parse(self, data: T) -> T: - """Parses the supplied data, validating it and returning it if it is valid. Raises an exception if the data is invalid - - Args: - data (T): the data to be parsed. - - Raises: - NotImplementedError: the default implementation of this method is not implemented, so an exception is raised - ZonError: if a some member of the validation chains fails when validation some of the data. - - Returns: - T: the data given as input if it is valid. - """ - - if type(self) is Zon: # pylint: disable=unidiomatic-typecheck - raise NotImplementedError( - "validate() method not implemented on base Zon class" - ) - - if not all(validator(data) for validator in self.validators.values()): - raise ZonError(f"Error parsing data: {self.errors}") - - return data - - def safe_parse(self, data: T) -> tuple[bool, T] | tuple[bool, ZonError]: - """Parses the supplied data, but unlike `parse` this method does not throw. Instead it returns a tuple in the form () - - Args: - data (T): _description_ - - Raises: - NotImplementedError: _description_ - e: _description_ - - Returns: - dict[str, bool | T]: _description_ - """ - - if type(self) is Zon: # pylint: disable=unidiomatic-typecheck - raise NotImplementedError( - "validate() method not implemented on base Zon class" - ) - - try: - result = self.parse(data) - - return {"success": True, "data": result} - except ZonError as e: - return {"success": False, "errors": str(e)} # TODO: improve this - except NotImplementedError as e: - raise e # we should never get here, just rethrow for good measure - - def and_also(self, zon: "Zon") -> "ZonAnd": - """Creates a new Zon that validates that - the data is valid for both this Zon and the supplied Zon. - - Args: - zon (Zon): the Zon to be validated. - - Returns: - ZonAnd: a new validator that validates that - the data is valid for both this Zon and the supplied Zon. - """ - return ZonAnd(self, zon) - - def __and__(self, zon: "Zon") -> "ZonAnd": - return self.and_also(zon) - - def refine(self, refinement: Callable[[T], bool]) -> "Self": - """Creates a new Zon that validates the data with the supplied refinement. - - A refinement is a function that takes a piece of data and returns True if the data is valid or throws otherwise. - - Args: - refinement (Callable[[T], bool]): the refinement to be applied. - - Returns: - ZonRefined: a new validator that validates the data with the supplied refinement. - """ - _clone = self._clone() - - def _refinement_validate(data): - try: - return refinement(data) - except ZonError as e: - _clone._add_error(e) - return False - - if "_refined_" not in _clone.validators: - _clone.validators["_refined_"] = _refinement_validate - else: - current_refinement = _clone.validators["_refined_"] - - def _refined_validator(data): - return current_refinement(data) and _refinement_validate(data) - - _clone.validators["_refined_"] = _refined_validator - - return _clone - - def optional(self) -> "ZonOptional": - """Creates a new Zon that makes this validation chain optional. - - Returns: - ZonOptional: a new validator that makes any validation optional. - """ - return ZonOptional(self) - - -class ZonOptional(Zon): - """A Zon that makes its data validation optional.""" - - def __init__(self, zon): - super().__init__() - self.zon = zon - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if data is None or not data: - return True - return self.zon.validate(data) - - def unwrap(self) -> Zon: - """Extracts the wrapped Zon from this ZonOptional. - - Returns: - Zon: the wrapped Zon - """ - - return self.zon - - -class ZonAnd(Zon): - """A Zon that validates that the data is valid for both this Zon and the supplied Zon.""" - - def __init__(self, zon1: Zon, zon2: Zon): - super().__init__() - self.zon1 = zon1 - self.zon2 = zon2 - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not (self.zon1.validate(data) and self.zon2.validate(data)): - for error in self.zon1.errors: - self._add_error(error) - - for error in self.zon2.errors: - self._add_error(error) - - return False - - return True - - -class ZonCollection(Zon): - """ - A ZonCollection is a validator that abstracts any piece of data that might be a collection of something else. - """ - - def length(self, length: int) -> "Self": - """Assert that the value under validation has exactly 'length' elements. - - Args: - length (int): the exact length of the collection. - - Returns: - Self: a new Zon with the validation rule added - """ - - other = self._clone() - - def len_validate(data): - if len(data) != length: - other._add_error( - ZonError(f"Expected length to be {length}, got {len(data)}") - ) - return False - return True - - other.validators["len"] = len_validate - return other - - def min(self, min_length: int) -> "Self": - """Assert that the value under validation has at least as many elements as specified. - - Args: - min_length (int): the minimum length of the collection. - - Returns: - Self: a new zon with the validation rule added - """ - - other = self._clone() - - def min_len_validate(data): - if len(data) < min_length: - other._add_error( - ZonError( - f"Expected minimum length to be {min_length}, got {len(data)}" - ) - ) - return False - return True - - other.validators["min_len"] = min_len_validate - return other - - def max(self, max_length: int) -> "Self": - """Assert that the value under validation has at most as many elements as specified. - - Args: - max_length (int): the maximum length of the collection. - - Returns: - Self: a new zon with the validation rule added - """ - - other = self._clone() - - def max_len_validate(data): - if len(data) > max_length: - other._add_error( - ZonError( - f"Expected maximum length to be {max_length}, got {len(data)}" - ) - ) - return False - return True - - other.validators["max_len"] = max_len_validate - return other - - -class ZonString(ZonCollection): - """ - A Zon that validates that the data is a string. - - For all purposes, a string is a collection of characters. - """ - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, str): - self._add_error(ZonError(f"Expected string, got {type(data)}")) - return False - return True - - def regex( - self, regex: str | re.Pattern[str], opts: dict[str, Any] | None = None - ) -> "ZonString": - """Assert that the value under validation matches a given regular expression. - - Args: - regex (str): the regex to use. - opts (dict[str, Any]): additional options. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def regex_validate(data): - if not re.match(regex, data): - if opts and "message" in opts: - other._add_error(ZonError(opts["message"])) - else: - other._add_error( - ZonError( - f"Expected string matching regex /{regex}/, got {data}" - ) - ) - - return False - return True - - other.validators["regex"] = regex_validate - return other - - def uuid(self) -> "ZonString": - """Assert that the value under validation is a valid UUID. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def uuid_validate(data): - if not validators.uuid(data): - other._add_error(ZonError(f"Expected valid UUID, got {data}")) - return False - return True - - other.validators["uuid"] = uuid_validate - return other - - def email(self): - """Assert that the value under validation is a valid email address. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def email_validate(data): - if not validators.email(data): - other._add_error(ZonError(f"Expected valid email address, got {data}")) - return False - - return True - - other.validators["email"] = email_validate - return other - - def emoji(self): - """Assert that the value under validation is a valid emoji. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def emoji_validate(data): - if not re.match( - "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$", data - ): - other._add_error(ZonError(f"Expected valid email address, got {data}")) - return False - - return True - - other.validators["emoji"] = emoji_validate - return other - - def url(self): - """Assert that the value under validation is a valid URL. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def url_validate(data): - if not validators.url(data): - other._add_error(ZonError(f"Expected valid url, got {data}")) - return False - - return True - - other.validators["url"] = url_validate - return other - - def ip(self, opts: dict[str, Any] | None = None): - """Assert that the value under validation is a valid IP address. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def ip_validate(data): - - valid = False - ipv4 = validators.ipv4(data) - ipv6 = validators.ipv6(data) - - match opts.get("version", None): - case None: - valid = ipv4 or ipv6 - case 6 | "6": - valid = ipv6 - case 4 | "4": - valid = ipv4 - - if not valid: - if opts and "message" in opts: - other._add_error(ZonError(opts["message"])) - else: - other._add_error(ZonError(f"Expected valid IP address, got {data}")) - return False - - return True - - other.validators["ip"] = ip_validate - return other - - def date(self): - """Assert that the value under validation is a valid IP address. - - Returns: - ZonString: a new zon with the validation rule added - """ - - raise NotImplementedError("Not implemented yet") - # return self.regex("[0-9]{4}-[0-9]{2}-[0-9]{2}") - - def time(self): - """Assert that the value under validation is a valid IP address. - - Returns: - ZonString: a new zon with the validation rule added - """ - - raise NotImplementedError("Not implemented yet") - # return self.regex("[0-9]{4}-[0-9]{2}-[0-9]{2}") - - def includes(self, substr: str): - """Assert that the value under validation includes the given string as a substring. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def includes_validate(data: str): - try: - data.index(substr) - return True - except ValueError: - other._add_error(ZonError(f"Expected '{data}' to include '{substr}'")) - return False - - other.validators["includes"] = includes_validate - return other - - def startswith(self, prefix: str): - """Assert that the value under validation is a string that starts with the given prefix. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def startswith_validate(data: str): - if not data.startswith(prefix): - other._add_error( - ZonError(f"Expected '{data}' to start with '{prefix}'") - ) - return False - - return True - - other.validators["startswith"] = startswith_validate - return other - - def endswith(self, suffix: str): - """Assert that the value under validation is a string that ends with the given suffix. - - Returns: - ZonString: a new zon with the validation rule added - """ - - other = self._clone() - - def endswith_validate(data: str): - if not data.endswith(suffix): - other._add_error(ZonError(f"Expected '{data}' to end with '{suffix}'")) - return False - - return True - - other.validators["endswith"] = endswith_validate - return other - - def nanoid(self, data: str): - - raise NotImplementedError("Not yet implemented") - - def cuid(self, data: str): - - raise NotImplementedError("Not yet implemented") - - def cuid2(self, data: str): - - raise NotImplementedError("Not yet implemented") - - def ulid(self, data: str): - - raise NotImplementedError("Not yet implemented") - - -class ZonNumber(Zon): - """A Zon that validates that the data is a number, i.e., an int or a float.""" - - def __gt__(self, other: int | float) -> "ZonNumber": - return self.gt(other) - - def gt(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is greater than a given value. - - Args: - value (int | float): the minimum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def gt_validate(data: int | float): - if data <= value: - other._add_error(ZonError(f"Expected number > {value}, got {data}")) - return False - return True - - other.validators["gt"] = gt_validate - return other - - def __ge__(self, other: int | float) -> "ZonNumber": - return self.gte(other) - - def gte(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is greater than or equal to a minimum value. - - Args: - value (int | float): the minimum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def gte_validate(data: int | float): - if data < value: - other._add_error(ZonError(f"Expected number >= {value}, got {data}")) - return False - return True - - other.validators["gte"] = gte_validate - return other - - def __lt__(self, other: int | float) -> "ZonNumber": - return self.lt(other) - - def lt(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is less than a given value. - - Args: - value (int | float): the maximum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def lt_validate(data: int | float): - if data >= value: - other._add_error(ZonError(f"Expected number < {value}, got {data}")) - return False - return True - - other.validators["lt"] = lt_validate - return other - - def __le__(self, other: int | float) -> "ZonNumber": - return self.lte(other) - - def lte(self, value: int | float) -> "ZonNumber": - """Assert that the value under validation is less than or equal to a maximum value. - - Args: - value (int | float): the maximum value - - Returns: - ZonNumber: a new zon with the validation rule added - """ - - other = self._clone() - - def lte_validate(data: int | float): - if data > value: - other._add_error(ZonError(f"Expected number <= {value}, got {data}")) - return False - return True - - other.validators["lte"] = lte_validate - return other diff --git a/zon-old/any.py b/zon-old/any.py deleted file mode 100644 index 371b122..0000000 --- a/zon-old/any.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Class and methods related to the ZonAny validator.""" - -from .base import Zon - - -class ZonAny(Zon): - """A Zon that validates that the data is any data type.""" - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, _data): - return True diff --git a/zon-old/base.py b/zon-old/base.py deleted file mode 100644 index 78b249d..0000000 --- a/zon-old/base.py +++ /dev/null @@ -1,185 +0,0 @@ -"""File containing base Zon class and helper utilities.""" - -from abc import ABC, abstractmethod -from typing import final, Callable, TypeVar, Self -import copy - -from .error import ZonError - -T = TypeVar("T") -ValidationRule = Callable[[T], bool] - - -class AggregateValidator: - """A validator that aggregates multiple validation calls""" - - -class Zon(ABC): - """Base class for all Zons. - A Zon is the basic unit of validation in Zon. - It is used to validate data, and can be composed with other Zons - to create more complex validations. - """ - - def __init__(self): - self.errors: list[ZonError] = [] - """List of validation errors accumulated""" - - self.validators: dict[str, ValidationRule] = {} - """validators that will run when 'validate' is invoked.""" - - self._setup() - - def _clone(self) -> Self: - """Creates a copy of this Zon.""" - return copy.deepcopy(self) - - def _add_error(self, error: ZonError): - self.errors.append(error) - - @abstractmethod - def _setup(self) -> None: - """Sets up the Zon with default validation rules. - - This implies that a '_default_' rule will be present, otherwise the validation fails. - - This method is called when the Zon is created. - """ - - @final - def _validate(self, data: T) -> bool: - """Validates the supplied data. - - Args: - data (Any): the piece of data to be validated. - - Returns: - bool: True if the data is valid, False otherwise. - """ - - if "_default_" not in self.validators or not self.validators["_default_"]: - self._add_error( - ZonError(f"Zon of type {type(self)} must have a valid '_default_' rule") - ) - return False - - # TODO: better error messages - return all(validator(data) for (_, validator) in self.validators.items()) - - @final - def validate(self, data: T) -> bool: - """Validates the supplied data. - - Args: - data (Any): the piece of data to be validated. - - Raises: - NotImplementedError: the default implementation of this method - is not implemented on base Zon class. - """ - if type(self) is Zon: # pylint: disable=unidiomatic-typecheck - raise NotImplementedError( - "validate() method not implemented on base Zon class" - ) - - return self._validate(data) - - def and_also(self, zon: "Zon") -> "ZonAnd": - """Creates a new Zon that validates that - the data is valid for both this Zon and the supplied Zon. - - Args: - zon (Zon): the Zon to be validated. - - Returns: - ZonAnd: a new validator that validates that - the data is valid for both this Zon and the supplied Zon. - """ - return ZonAnd(self, zon) - - def __and__(self, zon: "Zon") -> "ZonAnd": - return self.and_also(zon) - - def refine(self, refinement: Callable[[T], bool]) -> "Self": - """Creates a new Zon that validates the data with the supplied refinement. - - A refinement is a function that takes a piece of data and returns True if the data is valid or throws otherwise. - - Args: - refinement (Callable[[T], bool]): the refinement to be applied. - - Returns: - ZonRefined: a new validator that validates the data with the supplied refinement. - """ - _clone = self._clone() - - def _refinement_validate(data): - try: - return refinement(data) - except ZonError as e: - _clone._add_error(e) - return False - - if "_refined_" not in _clone.validators: - _clone.validators["_refined_"] = _refinement_validate - else: - current_refinement = _clone.validators["_refined_"] - - def _refined_validator(data): - return current_refinement(data) and _refinement_validate(data) - - _clone.validators["_refined_"] = _refined_validator - - return _clone - - -def optional(zon: Zon) -> "ZonOptional": - """Marks this validation chain as optional, making it so the data supplied need not be defined. - - Args: - zon (Zon): the validator to be marked as optional. - - Returns: - ZonOptional: a new validator that makes any validation optional. - """ - return ZonOptional(zon) - - -class ZonOptional(Zon): - """A Zon that makes its data validation optional.""" - - def __init__(self, zon): - super().__init__() - self.zon = zon - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if data is None: - return True - return self.zon.validate(data) - - -class ZonAnd(Zon): - """A Zon that validates that the data is valid for both this Zon and the supplied Zon.""" - - def __init__(self, zon1: Zon, zon2: Zon): - super().__init__() - self.zon1 = zon1 - self.zon2 = zon2 - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not (self.zon1.validate(data) and self.zon2.validate(data)): - for error in self.zon1.errors: - self._add_error(error) - - for error in self.zon2.errors: - self._add_error(error) - - return False - - return True diff --git a/zon-old/bool.py b/zon-old/bool.py deleted file mode 100644 index 4b92d42..0000000 --- a/zon-old/bool.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Class and methods related to the ZonBoolean validator.""" - -from .base import Zon -from .error import ZonError - - -class ZonBoolean(Zon): - """A Zon that validates that the data is a boolean.""" - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, bool): - self._add_error(ZonError(f"Expected boolean, got {type(data)}")) - return False - return True - - def true(self): - """Assert that the value under validation is True. - - Returns: - ZonBoolean: a new zon with the validation rule added - """ - - other = self._clone() - - def true_validate(data): - if not data: - other._add_error(ZonError(f"Expected True, got {data}")) - return False - return True - - other.validators["true"] = true_validate - return other - - def false(self): - """Assert that the value under validation is False. - - Returns: - ZonBoolean: a new zon with the validation rule added - """ - - other = self._clone() - - def false_validate(data): - if data: - other._add_error(ZonError(f"Expected False, got {data}")) - return False - return True - - other.validators["false"] = false_validate - return other diff --git a/zon-old/element_list/__init__.py b/zon-old/element_list/__init__.py deleted file mode 100644 index e23fb50..0000000 --- a/zon-old/element_list/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Class and methods related to the ZonList validator.""" - -from zon.base import Zon -from zon.error import ZonError - -from zon.traits.collection import ZonCollection - - -class ZonList(ZonCollection): - """A Zon that validates that the data is a list of elements of the specified type.""" - - def __init__(self, element_type: Zon): - super().__init__() - self.element_type = element_type - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, list): - self._add_error(ZonError(f"Expected list, got {type(data)}")) - return False - - error = False - for i, element in enumerate(data): - if not self.element_type.validate(element): - self._add_error( - ZonError(f"Element {i} of list failed validation: {element}") - ) - - error = True - - if error: - for error in self.element_type.errors: - self._add_error(ZonError(f"Error validating elements: {error}")) - - return not error diff --git a/zon-old/error.py b/zon-old/error.py deleted file mode 100644 index 1afe3d2..0000000 --- a/zon-old/error.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Validation errors for Zons""" - -from deprecation import deprecated - -from . import __version__ - - -@deprecated( - deprecated_in="2.0.0", - current_version=__version__, - details="Use the new ZonError class instead.", -) -class ValidationError(Exception): - """ - Validation error thrown when a validation fails. - - Deprecated: - This class was deprecated in 2.0.0 and will be removed soon. Use ZonError instead. - """ - - def __init__(self, message: str): - """Builds a new ValidationError with the supplied message. - - Args: - message (str): The message to be displayed when the exception is thrown. - """ - super() - self.message = message - - def __str__(self): - """Used to covert this exception into a string.""" - - return repr(self.message) - - def __repr__(self) -> str: - """Used to covert this exception into a string.""" - - return f"ValidationError({self.message})" - - -class ZonError(Exception): - """Validation error thrown when a validation fails.""" - - def __init__(self, message: str): - """Builds a new ValidationError with the supplied message. - - Args: - message (str): The message to be displayed when the exception is thrown. - """ - super() - self.message = message - - def __str__(self): - """Used to covert this exception into a string.""" - - return repr(self.message) - - def __repr__(self) -> str: - """Used to covert this exception into a string.""" - - return f"ValidationError({self.message})" diff --git a/zon-old/none.py b/zon-old/none.py deleted file mode 100644 index c39f919..0000000 --- a/zon-old/none.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Class and methods related to the ZonNone validator.""" - -from .base import Zon -from .error import ZonError - - -class ZonNone(Zon): - """A Zon that validates that the data is None.""" - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if data is not None: - self._add_error(ZonError(f"Expected None, got {type(data)}")) - return False - return True diff --git a/zon-old/number/float.py b/zon-old/number/float.py deleted file mode 100644 index 5db1e19..0000000 --- a/zon-old/number/float.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Class and methods related to the ZonFloat validator.""" - -from zon.error import ZonError - -from . import ZonNumber - - -class ZonFloat(ZonNumber): - """A Zon that validates that the data is a floating point number.""" - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, float): - self._add_error(ZonError(f"Expected float, got {type(data)}")) - return False - return True diff --git a/zon-old/number/int.py b/zon-old/number/int.py deleted file mode 100644 index 4d02af8..0000000 --- a/zon-old/number/int.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Class and methods related to ZonInteger validator.""" - -from zon.error import ZonError - -import zon.number as zon_number - - -class ZonInteger(zon_number.ZonNumber): - """A Zon that validates that the data is an integer.""" - - def _setup(self) -> None: - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, int): - self._add_error(ZonError(f"Expected integer, got {type(data)}")) - return False - return True diff --git a/zon-old/record.py b/zon-old/record.py deleted file mode 100644 index 612656d..0000000 --- a/zon-old/record.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Class and methods related to the ZonRecord validator.""" - -from .base import Zon -from .error import ZonError - -# TODO: better error messages - - -class ZonRecord(Zon): - """A Zon that validates that the data is an object with the specified properties.""" - - def __init__(self, properties: dict[str, Zon]): - super().__init__() - self.properties = properties - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if not isinstance(data, dict): - self._add_error(ZonError(f"Expected object, got {type(data)}")) - return False - - error = False - for key, zon in self.properties.items(): - if not zon.validate(data.get(key)): - self._add_error( - ZonError(f"Property {key} failed validation: {data.get(key)}") - ) - error = True - - if error: - for zon in self.properties.values(): - for error in zon.errors: - self._add_error(ZonError(f"Error validating properties: {error}")) - - return not error diff --git a/zon-old/union.py b/zon-old/union.py deleted file mode 100644 index af14544..0000000 --- a/zon-old/union.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Class and methods related to the ZonUnion validator.""" - -from .base import Zon - - -class ZonUnion(Zon): - """A Zon that validates that the data is one of the specified types.""" - - def __init__(self, types: list[Zon]): - super().__init__() - self.types = types - - def _setup(self): - self.validators["_default_"] = self._default_validate - - def _default_validate(self, data): - if any(zon.validate(data) for zon in self.types): - return True - - for zon in self.types: - for error in zon.errors: - self._add_error(error) - - return False diff --git a/zon/__init__.py b/zon/__init__.py index 8ba63f3..3a6eb58 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -866,8 +866,8 @@ def gte(self, min_in: float | int) -> Self: return _clone - def max(self, max_value: int | float) -> Self: - return self.gte(max_value) + def min(self, min_value: int | float) -> Self: + return self.gte(min_value) def lt(self, max_ex: float | int) -> Self: """Assert that the value under validation is less than the given number. @@ -911,8 +911,8 @@ def lte(self, max_in: float | int) -> Self: return _clone - def min(self, min_value: int | float) -> Self: - return self.lte(min_value) + def max(self, max_value: int | float) -> Self: + return self.lte(max_value) def int(self) -> Self: """Assert that the value under validation is an integer. @@ -1400,7 +1400,7 @@ def _partialify(v: Zon) -> ZonOptional: ).optional() # if isinstance(v, ZonArray): - # return ZonArray(_partialify(v.item_type)) + # return ZonArray(_partialify(v.item_type).unwrap()).optional() return v.optional() @@ -1662,3 +1662,40 @@ def rest(self, rest: Zon) -> ZonTuple: """ return ZonTuple(self._items, rest) + + +def anything() -> ZonAnything: + """ + Returns a validator for anything. + + Returns: + ZonAnything: a new `ZonAnything` validator + """ + + return ZonAnything() + + +class ZonAnything(Zon): + """A Zon that validates that the input is anything""" + + def _default_validate(self, data: T, ctx: ValidationContext): + return data + + +def never() -> ZonNever: + """ + Returns a validator for nothing. + + Returns: + ZonNever: a new `ZonNever` validator + """ + + return ZonNever() + + +class ZonNever(Zon): + """A Zon that validates no input.""" + + def _default_validate(self, data: T, ctx: ValidationContext): + ctx.add_issue(ZonIssue(value=data, message="No data allowed", path=[])) + return data From 00577d8ce7ae5de9c012e3f73ae39b1167ebeac3 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 20 Jun 2024 18:30:54 +0100 Subject: [PATCH 29/29] Prepared changelog for new release --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8868f4..f413599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.0] - 2024-06-20 ### Added - Added `ValidationContext` class to keep track of current validation path and errors up until a certain point. diff --git a/pyproject.toml b/pyproject.toml index dfbe1d7..f9f8f96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "zon" -version = "1.1.0" +version = "2.0.0" authors = [ { name="Nuno Pereira", email="nunoafonso2002@gmail.com" }, ]