diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b4abb58..1bd1d1c 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.11, 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.11, 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,7 @@ jobs: run: python -m pip install -e . - name: Run tests - run: pytest + 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 52acce1..f20ecd1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ htmlcov/ .nox/ .coverage .coverage.* +coverage.* .cache nosetests.xml coverage.xml @@ -160,3 +161,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +.vscode \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f413599 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +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). + +## [2.0.0] - 2024-06-20 + +### Added +- 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`, `ZonTuple` and `ZonEnum` classes +- Added more `ZonRecord` methods +- Added coverage + +### Changed +- 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`. +- 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 + +### Added +- `zon` now has a changelog. +- Added `zon.traits` module for common functionality that is specific to no validator. +- Added the `zon.traits.collection` file which contains the `ZonCollection` class: this is the base class for all collection types. +- Added testing for `ZonCollection` and added more tests for `ZonString`. +- Scripts that automate the building and publishing of the package to PyPI. +- Added `refine` method to the base `Zon` class. + +### Changed +- `ZonString` now inherits from `ZonCollection` instead of `Zon`. +- `ZonList` now inherits from `ZonCollection` instead of `Zon`. +- Updated `README.md` to include more information and examples of code usage. + +### Removed +- Removed the `len` function from `ZonString` and `ZonList` as it was not being used and did too much. + +## [1.0.0] - 2023-11-26 + +### Added +- Added base source code files for the project. +- Base `README.md` file. + +[unreleased]: https://github.com/Naapperas/zon/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/Naapperas/zon/compare/v1.1.0...v1.1.0 +[1.0.0]: https://github.com/Naapperas/zon/releases/tag/v1.0.0 \ No newline at end of file diff --git a/README.md b/README.md index 4821702..ddfc787 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # 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`. -`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 @@ -36,36 +38,53 @@ 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 other slight deviations between `zon` and `zod`. + ### Basic types `zon` features most of `zod`'s basic types: ```python zon.string() -zon.integer() -zon.floating_point() +zon.number() zon.boolean() -zon.none() -zon.anything() -``` +zon.literal() +zon.enum() -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`. +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 @@ -73,18 +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() +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 + +`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"}) +``` + + ### 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()) @@ -100,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(), @@ -130,6 +196,78 @@ 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`](./examples/) directory. + ## Documentation Documentation is still not available, but it will be soon. @@ -141,9 +279,11 @@ 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 ``` +Coverage can be found on [Coveralls](https://coveralls.io/github/Naapperas/zon). + ## Contributing Contribution guidelines can be found in [CONTRIBUTING](CONTRIBUTING.md) 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/examples/simple.py b/examples/simple.py new file mode 100644 index 0000000..c5371c9 --- /dev/null +++ b/examples/simple.py @@ -0,0 +1,11 @@ +import zon + +validator = zon.string().min(2).max(5).length(3) + +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}") diff --git a/pylintrc b/pylintrc index 50168a9..a7093c3 100644 --- a/pylintrc +++ b/pylintrc @@ -37,10 +37,11 @@ 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. -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 +65,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 1ff1a01..f9f8f96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "zon" -version = "1.0.0" +version = "2.0.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" @@ -16,10 +19,28 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +dependencies = [ + "validators>0.28", + "typing_extensions>4.12", + "uuid==1.30" +] + +[project.optional-dependencies] +dev = [ + "black", + "build", + "pylint", + "pytest", + "pylint-pytest", + "pytest-cov", +] [project.urls] 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 be9b3a8..3bca0ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -black==23.11.0 +black==24.3.0 build==1.0.3 pylint==3.0.2 pytest==7.4.3 twine==4.0.2 -validate-email==1.3 - +validators==0.28.3 +typing_extensions==4.12.2 +pytest-cov==5.0.0 \ No newline at end of file diff --git a/tests/any_test.py b/tests/any_test.py deleted file mode 100644 index 77e4005..0000000 --- a/tests/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/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/boolean_test.py b/tests/boolean_test.py new file mode 100644 index 0000000..f492d7e --- /dev/null +++ b/tests/boolean_test.py @@ -0,0 +1,36 @@ +import pytest + +import zon + + +@pytest.fixture +def validator(): + return zon.boolean() + + +def test_boolean_validate(validator): + assert validator.validate(True) + assert not 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 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/int_test.py b/tests/int_test.py deleted file mode 100644 index 04be194..0000000 --- a/tests/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/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/tests/list_test.py b/tests/list_test.py index f0b8397..6171c62 100644 --- a/tests/list_test.py +++ b/tests/list_test.py @@ -4,17 +4,133 @@ @pytest.fixture -def validator(): - return zon.element_list(zon.anything()) +def element_validator(): + return zon.string() -def test_list(validator): - assert validator.validate([1]) +def test_list_get_element(element_validator): + assert element_validator.list().element == element_validator -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) +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(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"]) 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/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/tests/number_test.py b/tests/number_test.py new file mode 100644 index 0000000..2b5d129 --- /dev/null +++ b/tests/number_test.py @@ -0,0 +1,168 @@ +import math + +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) + + +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) == 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) == 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/tests/optional_test.py b/tests/optional_test.py new file mode 100644 index 0000000..ed94ab5 --- /dev/null +++ b/tests/optional_test.py @@ -0,0 +1,52 @@ +import pytest + +import zon + + +@pytest.fixture +def base_validator(): + return zon.string() + + +def test_optional_get_value(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) is 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) is 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 diff --git a/tests/record_test.py b/tests/record_test.py new file mode 100644 index 0000000..a78d570 --- /dev/null +++ b/tests/record_test.py @@ -0,0 +1,337 @@ +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({}) == {} + + +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, + } + ) + + +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, + } diff --git a/tests/str_test.py b/tests/str_test.py index 50e9db3..8e769ae 100644 --- a/tests/str_test.py +++ b/tests/str_test.py @@ -1,93 +1,272 @@ +import uuid import pytest import zon -import uuid - @pytest.fixture def validator(): return zon.string() -def test_str(validator): +def test_str_validate(validator): assert 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_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_not_float(validator): - assert not validator.validate(1.5) +def test_str_length_equal(validator): + _validator = validator.length(2) -def test_not_int(validator): - assert not validator.validate(1) + assert _validator.validate("12") + with pytest.raises(zon.error.ZonError): + _validator.validate("1") -def test_not_list(validator): - assert not validator.validate([1]) +def test_str_length_less_than(validator): + _validator = validator.max(2) -def test_not_record(validator): - assert not validator.validate({"a": 1}) + assert _validator.validate("1") + assert _validator.validate("12") + with pytest.raises(zon.error.ZonError): + _validator.validate("123") -def test_email(validator): + +def test_str_length_greater_than(validator): + _validator = validator.min(2) + + assert _validator.validate("123") + assert _validator.validate("12") + + with pytest.raises(zon.error.ZonError): + _validator.validate("1") + + +def test_str_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") + with pytest.raises(zon.error.ZonError): + _validator.validate("test@host") + with pytest.raises(zon.error.ZonError): + _validator.validate("@host.com") -def test_ipv4(validator): - _validator = validator.ip() + with pytest.raises(zon.error.ZonError): + _validator.validate("host.com") - 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_str_url(validator): + _validator = validator.url() + assert _validator.validate("https://www.google.com") + assert _validator.validate("http://www.google.com") -def test_ipv6(validator): - _validator = validator.ip() + with pytest.raises(zon.error.ZonError): + _validator.validate("www.google.com") + with pytest.raises(zon.error.ZonError): + _validator.validate("google.com") + with pytest.raises(zon.error.ZonError): + _validator.validate("google.com/") + with pytest.raises(zon.error.ZonError): + _validator.validate("google.com") + + +def test_str_emoji(validator): + with pytest.raises(NotImplementedError): + _validator = validator.emoji() - # 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") +""" + _validator = validator.emoji() - # Example taken from https://zod.dev/?id=ip-addresses - assert not _validator.validate("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003") + 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_uuid(validator): + +def test_str_uuid(validator): _validator = validator.uuid() assert _validator.validate(uuid.uuid4().hex) - assert not _validator.validate("not_a_UUID") + with pytest.raises(zon.error.ZonError): + _validator.validate("not_a_UUID") + +# TODO: cuid, cuid2, nanoid, ulid -def test_regex(validator): + +def test_str_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") + 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_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("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") + + with pytest.raises(zon.error.ZonError): + _validator.validate("1.1.1") + + with pytest.raises(zon.error.ZonError): + _validator.validate("::1.1.1") + + +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"}) + + assert _validator.validate("::") + assert _validator.validate("::1") + assert _validator.validate("::ffff:127.0.0.1") + + 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/tests/traits/collection_test.py b/tests/traits/collection_test.py deleted file mode 100644 index cd09dae..0000000 --- a/tests/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/tuple_test.py b/tests/tuple_test.py new file mode 100644 index 0000000..ddc5f30 --- /dev/null +++ b/tests/tuple_test.py @@ -0,0 +1,53 @@ +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 + + +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/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/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 ea98d0e..3a6eb58 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -1,114 +1,1701 @@ """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. """ +# Why is this needed even? +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 +# TODO: better typing. +# Things to consider: +# - Container and other collections.abc types +# - Typing with Self +import copy +from abc import ABC, abstractmethod, update_abstractmethods +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 +import math +from enum import Enum, auto -def string() -> "ZonString": - """Creates a new Zon that validates that the data is a string. +import validators + +from .error import ZonError, ZonIssue +from .traits import HasMax, HasMin + +__all__ = [ + "Zon", + "element_list", + "ZonList", + "ZonBoolean", + "boolean", + # "ZonDate", + # "ZonDateTime", + # "ZonNone", + "ZonNumber", + "number", + "ZonRecord", + "record", + "ZonString", + "string", + "ZonOptional", + "optional", + "ZonIntersection", + "intersection", + "ZonEnum", + "enum", + "ZonLiteral", + "literal", + "ZonUnion", + "union", + "ZonTuple", + "element_tuple", +] + + +@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) + + @property + def dirty(self): + return self.error is not None and len(self.error.issues) >= 0 + + +T = TypeVar("T") + + +class ValidationRule: + """ + Custom validation rul used to add more complex validation rules to an existing `Zon` + """ + + def __init__( + self, + name: str, + fn: Callable[[T], tuple[T, bool]], + *, + additional_data: Mapping[str, Any] = None, + ): + self.fn = fn + self.name = name + self.additional_data = additional_data if additional_data is not None else {} + + def check(self, data: T, ctx: ValidationContext) -> T: + """ + Check this validation rule against the supplied data. + + Args: + data (T): the piece of data to be validated. + ctx (ValidationContext): the context in which the validation is being run. + + Returns: + T: The original data + """ + + try: + new_data, valid = self.fn(data) + + if not valid: + ctx.add_issue( + ZonIssue( + value=data, + message=f"Validation failed for type {self.name}", + path=[], + ) + ) + + return new_data + except Exception as e: + ctx.add_issue( + ZonIssue( + value=data, + message=f"Validation failed for type {self.name}: {e}", + path=[], + ) + ) + + return data + + +@update_abstractmethods +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.validators: list[ValidationRule] = [] + """validators that will run when 'validate' is invoked.""" + + def _clone(self) -> Self: + """Creates a copy of this Zon.""" + return copy.deepcopy(self) + + @abstractmethod + 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`: + - If the validation succeeds, return True + - 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( + "This method is not implemented for the base Zon class. You need to provide your " + ) + + @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. This method will never return false as that case raises an error, as documented. + + Raises: + ZonError: if validation against the supplied data fails. + NotImplementedError: if the default validation rule was not overriden for this Zon object. + """ + + ctx = ValidationContext() + + cloned_data = copy.deepcopy(data) + + try: + 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) + + return (not ctx.dirty, cloned_data if not ctx.dirty else ctx.error) + + @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. + ZonError: if validation fails. + """ + + valid, data_or_error = self.safe_validate(data) + + if valid: + return data_or_error + + raise data_or_error + + @final + 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. + + 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 any unexpected exception is encountered + """ + + try: + return self._validate(data) + except Exception as e: + raise e + + 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: + other (Zon): the second validator + + Returns: + ZonIntersection: The intersection data validator. + """ + + 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. + + Returns: + ZonOptional: The optional data validator. + """ + + 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. + """ + + 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. + + Args: + zon1 (Zon): the first validator + zon2 (Zon): the second validator Returns: - ZonString: a new validator that validates that the data is a string. + ZonIntersection: The intersection data validator. """ - return ZonString() + return ZonIntersection(zon1, zon2) + + +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 integer() -> "ZonInteger": - """Creates a new Zon that validates that the data is an integer. + 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 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 data + + return data + + +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 Returns: - ZonInteger: a new validator that validates that the data is an integer + ZonIntersection: The intersection data validator. """ - return ZonInteger() + return ZonOptional(zon) + + +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) + + return data + + 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. + + Contains container specific validator rules. + """ + + def max(self, max_value: int | float) -> Self: + """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 + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "max_length", + lambda data: ( + 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 (inclusive). + + Args: + min_value (int | float): the minimum number of elements that this container can have + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "min_length", + lambda data: ( + 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. + + Args: + length (int): the exact number of elements that this container can have + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "equal_length", + lambda data: (data, hasattr(data, "__len__") and len(data) == length), + ) + ) + + return _clone -def floating_point() -> "ZonFloat": - """Creates a new Zon that validates that the data is a floating point number. +def string() -> ZonString: + """Returns a validator for string data. Returns: - ZonFloat: a new validator that validates that the data is a floating point number. + ZonString: The string data validator. """ - return ZonFloat() + return ZonString() + + +class ZonString(ZonContainer): + """A Zon that validates that the data is a string. + + For all purposes, a string is a container of characters. + """ + + 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. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "email", + lambda data: (data, validators.email(data)), + ) + ) + + return _clone + + def url(self) -> Self: + """ + Assert that the value under validation is a valid URL. + + Returns: + ZonString: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "url", + lambda data: (data, validators.url(data)), + ) + ) + + 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", + lambda data: (data, validators.uuid(data)), + ) + ) + + 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: (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: (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, 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, 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+)?" -def boolean() -> "ZonBoolean": - """Creates a new Zon that validates that the data is a boolean. + 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: (data, _datetime_regex(opts).match(data) is not None), + ) + ) + + return _clone + + 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 + """ + + 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: (data, _validator(data, opts)), + ) + ) + + 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. Returns: - ZonBoolean: a new validator that validates that the data is a boolean. + ZonNumber: The number data validator. """ + + return ZonNumber() + + +class ZonNumber(Zon, HasMax, HasMin): + """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=[])) + + return data + + 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, data is not None and 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, data is not None and data >= min_in), + ) + ) + + return _clone + + 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. + + 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, data is not None and 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, data is not None and data <= max_in), + ) + ) + + return _clone + + 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. + + Returns: + ZonNumber: a new `Zon` with the validation rule added + """ + + _clone = self._clone() + + _clone.validators.append( + ValidationRule( + "int", + lambda data: (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: (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, data is not None and 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, data is not None and 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, data is not None and 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, data is not None and 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, data is not None and 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: (data, data is not None and not math.isinf(data)), + ) + ) + + return _clone + + +def boolean() -> ZonBoolean: + """Returns a validator for boolean data. + + Returns: + ZonBoolean: The boolean data validator. + """ + 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. +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=[])) + + return data + + +def literal(value: Any, /) -> ZonLiteral: + """Returns a validator for a given literal value. Args: - element_type (Zon): the type of the elements of the list. + value: the value that must be matched Returns: - ZonList: a new validator that validates that the data is - a list of elements of the specified type. + ZonBoolean: The literal data validator. """ - return ZonList(element_type) + return ZonLiteral(value) + + +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 union(types: list["Zon"]) -> "ZonUnion": - """Creates a new Zon that validates that the data is one of the specified types. + def _default_validate(self, data: T, ctx: ValidationContext): + if data != self._value: + ctx.add_issue( + ZonIssue(value=data, message=f"Expected {self._value}", path=[]) + ) + + return data + + +def enum(options: Sequence[str], /) -> ZonEnum: + """Returns a validator for an enum. Args: - types (list[Zon]): the types of the data to be validated. + options: the values that must be matched Returns: - ZonUnion: a new validator that validates that the data is one of the specified types. + ZodEnum: The enum data validator. + """ + + return ZonEnum(options) + + +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. """ - return ZonUnion(types) + 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=[] + ) + ) + + return data + + 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) -def record(properties: dict[str, "Zon"]) -> "ZonRecord": - """Creates a new Zon that validates that the data is an object with the specified properties. + return _clone + + +def record(properties: dict[str, Zon], /) -> ZonRecord: + """Returns a validator for a record. Args: - properties (dict[str, Zon]): the properties of the object to be validated. + properties (dict[str, Zon]): the shape of the record Returns: - ZonRecord: a new validator that validates that the data is - an object with the specified properties. + ZonRecord: The record data validator. """ + return ZonRecord(properties) -def none() -> "ZonNone": - """Creates a new Zon that validates that the data is None. +class ZonRecord(Zon): + """A Zon that validates that the data is a record with the provided shape.""" + + 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): + + if not isinstance(data, dict): + ctx.add_issue(ZonIssue(value=data, message="Not a valid object", path=[])) + + return data + + data_to_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? + + # 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) + 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: + case ZonRecord.UnknownKeyPolicy.STRIP: + # ignore extra keys + pass + case ZonRecord.UnknownKeyPolicy.PASSTHROUGH: + data_to_return.update({k: data[k] for k in extra_keys}) + 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) + 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]: + 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}, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, + ) + + 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)}, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, + ) + + 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)}, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, + ) + + 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() + }, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, + ) + + 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()}, + ).optional() + + # if isinstance(v, ZonArray): + # return ZonArray(_partialify(v.item_type).unwrap()).optional() + + return v.optional() + + return ZonRecord( + {k: _partialify(v) for k, v in self.shape.items()}, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, + ) + + def required( + self, /, required_properties: Mapping[str, Literal[True]] | None = None + ) -> ZonRecord: + + if not required_properties: + required_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() + }, + unknown_key_policy=self.unknown_key_policy, + catchall=self._catchall, + ) + + 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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + + +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: - ZonNone: a new validator that validates that the data is None. + ZonArray: a new `ZonArray` validator """ - return ZonNone() + 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) -def anything() -> "ZonAny": - """Creates a new Zon that validates anything. + 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 data + + for element in data: + (valid, data_or_error) = self._element.safe_validate(element) + + if not valid: + ctx.add_issues(data_or_error.issues) + + return data + + @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: (data, hasattr(data, "__len__") and len(data) > 0), + ) + ) + + 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 data + + 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=[])) + + return data + + +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""" + + 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 data + + if len(data) < len(self._items): + ctx.add_issue(ZonIssue(value=data, message="Not enough elements", path=[])) + 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 data + + 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) + + return data + + @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) + + +def anything() -> ZonAnything: + """ + Returns a validator for anything. Returns: - ZonAny: a new validator that validates anything. + ZonAnything: a new `ZonAnything` validator """ - return ZonAny() + + 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 diff --git a/zon/any.py b/zon/any.py deleted file mode 100644 index 371b122..0000000 --- a/zon/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/base.py b/zon/base.py deleted file mode 100644 index 4d8b7e0..0000000 --- a/zon/base.py +++ /dev/null @@ -1,150 +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 ValidationError - -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[ValidationError] = [] - """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: ValidationError): - 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( - ValidationError( - 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 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, zon2): - 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/bool.py b/zon/bool.py deleted file mode 100644 index 3dd02bd..0000000 --- a/zon/bool.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Class and methods related to the ZonBoolean validator.""" - -from .base import Zon -from .error import ValidationError - - -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(ValidationError(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(ValidationError(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(ValidationError(f"Expected False, got {data}")) - return False - return True - - other.validators["false"] = false_validate - return other diff --git a/zon/element_list/__init__.py b/zon/element_list/__init__.py deleted file mode 100644 index 1a9243e..0000000 --- a/zon/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 ValidationError - -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(ValidationError(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}") - ) - - error = True - - if error: - for error in self.element_type.errors: - self._add_error(ValidationError(f"Error validating elements: {error}")) - - return not error diff --git a/zon/error.py b/zon/error.py index 469a0fb..12e199d 100644 --- a/zon/error.py +++ b/zon/error.py @@ -1,8 +1,19 @@ """Validation errors for Zons""" +from typing import Any +from dataclasses import dataclass +from typing_extensions import deprecated + + +@deprecated("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 +33,49 @@ 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 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.""" + + 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/none.py b/zon/none.py deleted file mode 100644 index 211da64..0000000 --- a/zon/none.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Class and methods related to the ZonNone validator.""" - -from .base import Zon -from .error import ValidationError - - -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(ValidationError(f"Expected None, got {type(data)}")) - return False - return True 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/number/float.py b/zon/number/float.py deleted file mode 100644 index 15f647c..0000000 --- a/zon/number/float.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Class and methods related to the ZonFloat validator.""" - -from zon.error import ValidationError - -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(ValidationError(f"Expected float, got {type(data)}")) - return False - return True diff --git a/zon/number/int.py b/zon/number/int.py deleted file mode 100644 index b1628dd..0000000 --- a/zon/number/int.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Class and methods related to ZonInteger validator.""" - -from zon.error import ValidationError - -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(ValidationError(f"Expected integer, got {type(data)}")) - return False - return True diff --git a/zon/record.py b/zon/record.py deleted file mode 100644 index d7549f8..0000000 --- a/zon/record.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Class and methods related to the ZonRecord validator.""" - -from .base import Zon -from .error import ValidationError - -# 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(ValidationError(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)}" - ) - ) - error = True - - if error: - for zon in self.properties.values(): - for error in zon.errors: - self._add_error( - ValidationError(f"Error validating properties: {error}") - ) - - return not error 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.py b/zon/traits.py new file mode 100644 index 0000000..30923ff --- /dev/null +++ b/zon/traits.py @@ -0,0 +1,42 @@ +""" +Traits useful for various its and bits of Zon code. +""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Self + + +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) -> 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 (inclusive) 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) -> 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 (inclusive) that the attribute being validated can have. + """ + + raise NotImplementedError("'min' must be implemented by subclasses") 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 diff --git a/zon/union.py b/zon/union.py deleted file mode 100644 index af14544..0000000 --- a/zon/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