From 3ea78f8b9684fdeab7134a54fdd8ba13197f68d1 Mon Sep 17 00:00:00 2001 From: Hendrik Boeck Date: Thu, 6 Jun 2024 17:49:41 +0200 Subject: [PATCH] initial commit --- .github/workflows/main.yml | 182 ++++++++ .github/workflows/pull.yml | 114 +++++ .gitignore | 180 ++++++++ LICENSE | 21 + README.md | 79 ++++ licenses/zod_mit.txt | 21 + pyproject.toml | 71 +++ src/parasite/__init__.py | 27 ++ src/parasite/_const.py | 79 ++++ src/parasite/any.py | 68 +++ src/parasite/array.py | 202 +++++++++ src/parasite/boolean.py | 179 ++++++++ src/parasite/errors.py | 2 + src/parasite/never.py | 37 ++ src/parasite/null.py | 71 +++ src/parasite/number.py | 307 +++++++++++++ src/parasite/object.py | 250 +++++++++++ src/parasite/string.py | 498 ++++++++++++++++++++++ src/parasite/type.py | 108 +++++ src/parasite/variant.py | 153 +++++++ tests/test_00_parasite/test_00_never.py | 23 + tests/test_00_parasite/test_01_any.py | 28 ++ tests/test_00_parasite/test_02_null.py | 26 ++ tests/test_00_parasite/test_03_boolean.py | 76 ++++ tests/test_00_parasite/test_04_number.py | 112 +++++ tests/test_00_parasite/test_05_string.py | 178 ++++++++ tests/test_00_parasite/test_06_variant.py | 90 ++++ tests/test_00_parasite/test_07_array.py | 124 ++++++ tests/test_00_parasite/test_08_object.py | 186 ++++++++ 29 files changed, 3492 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/pull.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 licenses/zod_mit.txt create mode 100644 pyproject.toml create mode 100644 src/parasite/__init__.py create mode 100644 src/parasite/_const.py create mode 100644 src/parasite/any.py create mode 100644 src/parasite/array.py create mode 100644 src/parasite/boolean.py create mode 100644 src/parasite/errors.py create mode 100644 src/parasite/never.py create mode 100644 src/parasite/null.py create mode 100644 src/parasite/number.py create mode 100644 src/parasite/object.py create mode 100644 src/parasite/string.py create mode 100644 src/parasite/type.py create mode 100644 src/parasite/variant.py create mode 100644 tests/test_00_parasite/test_00_never.py create mode 100644 tests/test_00_parasite/test_01_any.py create mode 100644 tests/test_00_parasite/test_02_null.py create mode 100644 tests/test_00_parasite/test_03_boolean.py create mode 100644 tests/test_00_parasite/test_04_number.py create mode 100644 tests/test_00_parasite/test_05_string.py create mode 100644 tests/test_00_parasite/test_06_variant.py create mode 100644 tests/test_00_parasite/test_07_array.py create mode 100644 tests/test_00_parasite/test_08_object.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..9d03579 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,182 @@ +name: Workflow for Push on Main Branch + +on: + push: + tags: + - '**' + +jobs: + pytest: + name: Run tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry pytest + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Run tests + run: | + poetry run pytest + + pytype: + name: Static type checking + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry pytype + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Run type checking + run: | + poetry run pytype -V ${{ matrix.python-version }} + + pylint: + name: Lint code + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry pylint + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Run linting + run: | + poetry run pylint src/parasite + + build: + name: Build package + needs: [pytest, pytype, pylint] + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Build package + run: | + poetry build + + - name: Upload package + uses: actions/upload-artifact@v3 + with: + name: tracing_py3-latest-py3 + path: dist/* + + # update-documentation: + # name: Update documentation + # needs: build + # runs-on: ubuntu-latest + + # steps: + # - uses: actions/checkout@v4 + + # - name: Set up Python 3.11 + # uses: actions/setup-python@v5 + # with: + # python-version: 3.11 + + # - name: Install dependecies + # run: | + # python -m pip install --upgrade pip + # pip install make poetry + # poetry config virtualenvs.create false --local + # poetry config virtualenvs.in-project false --local + # poetry install + + # - name: Build documentation + # run: | + # cd docs && make html + + # - name: Deploy to GitHub Pages + # uses: peaceiris/actions-gh-pages@v3 + # with: + # publish_branch: gh-pages + # github_token: ${{ secrets.TOKEN }} + # publish_dir: docs/build/html + # force_orphan: true + + publish-to-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: release + + permissions: + id-token: write + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: tracing_py3-latest-py3 + path: dist + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..066f84e --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,114 @@ +name: On pull request + +on: [pull_request] + +jobs: + pytest: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry pytest + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Run tests + run: | + poetry run pytest + + pytype: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry pytype + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Run type checking + run: | + poetry run pytype -V ${{ matrix.python-version }} + + pylint: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry pylint + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Run linting + run: | + poetry run pylint src/parasite + + build: + needs: [pytest, pytype, pylint] + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependecies + run: | + python -m pip install --upgrade pip + pip install poetry + poetry config virtualenvs.create false --local + poetry config virtualenvs.in-project false --local + poetry install + + - name: Build package + run: | + poetry build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f811b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,180 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# 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/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml +poetry.lock + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# pyenv environment files +.python-version + +# End of https://www.toptal.com/developers/gitignore/api/python diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d7e0a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, Hendrik Böck + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..081e160 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# `parasite` - `zod` inspired library for Python 3.11+ + +> **DISCLAIMER:** +> +> This library is under active development, expect things to break or not to work as expected. +> Creating an issue for bugs you encounter would be appreciated. Documentation is currently work in +> progress. + +## Table of Contents + +- [Why?](#why) +- [What about the name?](#what-about-the-name) +- [Getting Started](#getting-started) +- [Documentation](#documentation) +- [License (_MIT License_)](#license-mit-license) + +## Why? + +Data and object validation in Python is essential to ensure that the inputs to a program are +accurate and adhere to expected formats, thereby preventing runtime errors and enhancing code +reliability. The TypeScript library `zod` offers a concise and expressive syntax for schema +validation, making it easier to define and enforce data structures. Implementing a similar library +in Python would greatly benefit developers by providing a streamlined, declarative approach to +validation, reducing boilerplate code and improving maintainability. This would facilitate more +robust data handling and enhance the overall quality of Python applications. + +## What about the name? + +I chose the name "Parasite" for this library because it draws heavy inspiration from the TypeScript +`zod` library, which excels in schema validation with its concise and expressive syntax. The name +"Parasite" is also a nod to one of Superman's iconic supervillains, serving as an homage to the +library that inspired this creation. + +## Getting Started + +Install using `pip`: + +``` +pip install parasite +``` + +Install using `poetry` CLI: + +``` +poetry add parasite +``` + +or using `pyproject.toml`: + +```toml +[tool.poetry.dependencies] +parasite = "^0.1.0" +``` + +## Documentation + +Work in Progress... + +## License (_MIT License_) + +Copyright (c) 2024, Hendrik Böck <> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/licenses/zod_mit.txt b/licenses/zod_mit.txt new file mode 100644 index 0000000..2c93bb5 --- /dev/null +++ b/licenses/zod_mit.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1096869 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[tool.poetry] +name = "parasite" +version = "0.1.0" +description = "Data validation for Python 3" +authors = ["Hendrik Boeck "] +packages = [{ include = "*", from = "src" }] +readme = "README.md" +license = "MIT" +homepage = "https://github.com/hendrikboeck/parasite" +repository = "https://github.com/hendrikboeck/parasite" +keywords = [ + "data", + "validation", + "python", + "zod", + "datastructures", + "types", + "objects", + "schema", + "runtime-evaluation", +] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +[tool.poetry.dependencies] +python = "^3.11" +rusttypes = "^0.1.0" +tracing-py3 = "^0.1.0" + +[tool.poetry.dev-dependencies] +yapf = "^0.31.0" +toml = "^0.10.2" +pylint = "^3.1.0" +pytype = "^2024.4.11" +pytest = "^8.2.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytype] +inputs = ["src/parasite"] +disable = [] + +[tool.pylint.'MESSAGES CONTROL'] +fail-under = 9.0 +disable = "C,R,W1401" + +[tool.yapf] +based_on_style = "google" +column_limit = 100 +indent_width = 4 +dedent_closing_brackets = true +coalesce_brackets = true +blank_line_before_nested_class_or_def = true +indent_dictionary_value = true +spaces_around_default_or_named_assign = true +spaces_before_comment = 3 +split_all_top_level_comma_separated_values = false +split_before_dict_set_generator = true +split_before_dot = true +split_complex_comprehension = true diff --git a/src/parasite/__init__.py b/src/parasite/__init__.py new file mode 100644 index 0000000..548ae6c --- /dev/null +++ b/src/parasite/__init__.py @@ -0,0 +1,27 @@ +# -- STL Imports -- +from abc import ABC as _Namespace +from typing import TypeAlias + +# -- Package Imports -- +from parasite.any import Any_ +from parasite.array import Array +from parasite.boolean import Boolean +from parasite.null import Null +from parasite.number import Number +from parasite.object import Object +from parasite.string import String +from parasite.variant import Variant +from parasite.never import Never + + +class p(_Namespace): + """sudo-namespace for all parasite types. Makes it easier to import and call them.""" + any: TypeAlias = Any_ + null: TypeAlias = Null + number: TypeAlias = Number + string: TypeAlias = String + boolean: TypeAlias = Boolean + never: TypeAlias = Never + variant: TypeAlias = Variant + obj: TypeAlias = Object + array: TypeAlias = Array diff --git a/src/parasite/_const.py b/src/parasite/_const.py new file mode 100644 index 0000000..8e69ddd --- /dev/null +++ b/src/parasite/_const.py @@ -0,0 +1,79 @@ +# -- STL Imports -- +import re + +RE_EMAIL = re.compile( + r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z" + r"0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", +) +""" +Regex pattern for email validation as per RFC2822 standards. + +Attrribution: + - https://regexr.com/2rhq7, by Tripleaxis (from .NET helpfiles) +""" + +RE_URL = re.compile(r"^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)$",) +""" +Regex pattern for URL/URI validation. + +Attrribution: + - https://regexr.com/2ri7q, by Gabriel Mariani +""" + +RE_UUID = re.compile( + r'^[0-9A-Za-z]{8}-[0-9A-Za-z]{4}-4[0-9A-Za-z]{3}-[89ABab][0-9A-Za-z]{3}-[0-9A-Za-z]{12}$' +) +"""Regex pattern validation for UUID v4 as per RFC9562. + +Attribution: + - http://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_.28random.29 + - https://regexr.com/39f77, by clayzermk1 +""" + +# Copyright (c) 2022 Colin McDonnell +# All rights reserved. +# +# Original Project: https://github.com/colinhacks/zod +# License File: licenses/zod_mit.txt +RE_CUID = re.compile(r'^c[^\\s-]{8,}$') +"""Regex pattern for CUID validation.""" + +# Copyright (c) 2022 Colin McDonnell +# All rights reserved. +# +# Original Project: https://github.com/colinhacks/zod +# License File: licenses/zod_mit.txt +RE_CUID2 = re.compile(r'^[a-z][a-z0-9]*$') +"""Regex pattern for CUID2 validation.""" + +# Copyright (c) 2022 Colin McDonnell +# All rights reserved. +# +# Original Project: https://github.com/colinhacks/zod +# License File: licenses/zod_mit.txt +RE_ULID = re.compile(r'^[0-9A-HJKMNP-TV-Z]{26}$') +"""Regex pattern for ULID validation.""" + +# Copyright (c) 2022 Colin McDonnell +# All rights reserved. +# +# Original Project: https://github.com/colinhacks/zod +# License File: licenses/zod_mit.txt +RE_IPV4 = re.compile( + r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$" +) +"""Regex pattern for IPv4 validation.""" + +# Copyright (c) 2022 Colin McDonnell +# All rights reserved. +# +# Original Project: https://github.com/colinhacks/zod +# License File: licenses/zod_mit.txt +RE_IPV6 = re.compile( + r'^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|' + r'([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|' + r'([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]' + r'{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\\.){3}((25[0-5])|(2[0-4][0-9])|' + r'(1[0-9]{2})|([0-9]{1,2})))$' +) +"""Regex pattern for IPv6 validation.""" diff --git a/src/parasite/any.py b/src/parasite/any.py new file mode 100644 index 0000000..dd5e35a --- /dev/null +++ b/src/parasite/any.py @@ -0,0 +1,68 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +from typing import Any, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Some, Option + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Any_(ParasiteType[Any]): + """ + Parasite type for representing any values. This is the default type, when no other type is + specified. + + Inheritance: + ParasiteType[Any] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + """ + _f_optional: bool = False + + def optional(self) -> Any_: + """ + Makes the value optional, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `required(..)`. + + Returns: + Any_: modified instance + """ + self._f_optional = True + return self + + def required(self) -> Any_: + """ + Makes the value required, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `optional(..)`. Default behavior. + + Returns: + Any_: modified instance + """ + self._f_optional = False + return self + + def parse(self, obj: Any) -> Any: + # can never fail, as it accepts any value + return obj + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[Any]: + # if key is found, just package `parse(..)` it into a Some + if (value := parent.get(key, _NotFound)) is not _NotFound: + return Some(self.parse(value)) + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/src/parasite/array.py b/src/parasite/array.py new file mode 100644 index 0000000..562e7f6 --- /dev/null +++ b/src/parasite/array.py @@ -0,0 +1,202 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +from typing import Any, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Array(ParasiteType[list[Any]]): + """ + Parasite type for representing list values. + + Inheritance: + ParasiteType[list] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + _f_nullable (bool): Whether the value can be None. Default: False + _m_ul (int | None): The upper limit of the list. Default: None + _m_ll (int | None): The lower limit of the list. Default: None + _m_element (ParasiteType[T] | None): The element type of the list. Default: None + """ + # NOTE: do not move this attribute, this has to be first in the class, as it will break, reading + # element from constructor functionality + _m_element: ParasiteType | None = None + + _f_optional: bool = False + _f_nullable: bool = False + + _m_ul: int | None = None + _m_ll: int | None = None + + def optional(self) -> Array: + """ + Set the value to be optional. + + Returns: + Array: The updated instance of the class. + """ + self._f_optional = True + return self + + def required(self) -> Array: + """ + Set the value to be required. + + Returns: + Array: The updated instance of the class. + """ + self._f_optional = False + return self + + def nullable(self) -> Array: + """ + Set the value to be nullable. + + Returns: + Array: The updated instance of the class. + """ + self._f_nullable = True + return self + + def non_nullable(self) -> Array: + """ + Set the value to be non-nullable. + + Returns: + Array: The updated instance of the class. + """ + self._f_nullable = False + return self + + def min(self, value: int) -> Array: + """ + Set the minimum length of the list. + + Args: + value (int): The minimum length of the list. + + Returns: + Array: The updated instance of the class. + """ + self._m_ll = value + return self + + def max(self, value: int) -> Array: + """ + Set the maximum length of the list. + + Args: + value (int): The maximum length of the list. + + Returns: + Array: The updated instance of the class. + """ + self._m_ul = value + return self + + def not_empty(self) -> Array: + """ + Set the list to not be empty. + + Returns: + Array: The updated instance of the class. + """ + return self.min(1) + + def empty(self) -> Array: + """ + Set the list to be empty. + + Returns: + Array: The updated instance of the class. + """ + return self.max(0) + + def length(self, value: int) -> Array: + """ + Set the length of the list. + + Args: + value (int): The length of the list. + + Returns: + Array: The updated instance of the class. + """ + return self.min(value).max(value) + + def element(self, element: ParasiteType) -> Array: + """ + Set the element type of the list. + + Args: + element (ParasiteType[T]): The element type of the list. + + Returns: + Array: The updated instance of the class. + """ + self._m_element = element + return self + + def parse(self, obj: Any) -> list[Any]: + if not isinstance(obj, list): + raise ValidationError(f"object has to be a list, but is '{obj!r}'") + + if self._m_ll is not None and len(obj) < self._m_ll: + raise ValidationError( + f"list has to have at least {self._m_ll} elements, but has {len(obj)}" + ) + + if self._m_ul is not None and len(obj) > self._m_ul: + raise ValidationError( + f"list has to have at most {self._m_ul} elements, but has {len(obj)}" + ) + + if self._m_element is not None: + # create a cache to not overwrite the original list on error + cache = [] + + # parse each element individually + for i, element in enumerate(obj): + try: + # may throw a ValidationError exception + cache.append(self._m_element.parse(element)) + + # handle ValidationError exceptions, if parsing fails + except ValidationError as exc: + raise ValidationError(f"element at index {i} is invalid: {exc}") from exc + + # if the parsing was successful, overwrite the original list + obj = cache + + return obj + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[list[Any] | None]: + if (value := parent.get(key, _NotFound)) is not _NotFound: + # if key is found, just package `parse(..)` it into a Some + if value is not None: + return Some(self.parse(value)) + + # if value is None, check if the value is nullable + if self._f_nullable: + return Some(None) + + raise ValidationError(f"key '{key}' cannot be None") + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/src/parasite/boolean.py b/src/parasite/boolean.py new file mode 100644 index 0000000..51e2a95 --- /dev/null +++ b/src/parasite/boolean.py @@ -0,0 +1,179 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +import math +import re +from typing import Any, Optional, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Boolean(ParasiteType[bool]): + """ + Parasite type for representing boolean values. + + Inheritance: + ParasiteType[bool] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + _f_nullable (bool): Whether the value can be None. Default: False + _m_leaniant (tuple[AnyStr, AnyStr]): The regular expressions for the true and false value. + Default: (r"^(true|1|yes|y)$", r"^(false|0|no|n)$") + _m_literal (bool | None): The literal value of the boolean. Default: None + """ + _f_optional: bool = False + _f_nullable: bool = False + _f_leaniant: bool = False + + _m_leaniant: tuple[str, str] = (r"^(true|1|yes|y)$", r"^(false|0|no|n)$") + _m_literal: bool | None = None + + def optional(self) -> Boolean: + """ + Set the value to be optional. + + Returns: + Boolean: The updated instance of the class. + """ + self._f_optional = True + return self + + def required(self) -> Boolean: + """ + Set the value to be required. + + Returns: + Boolean: The updated instance of the class. + """ + self._f_optional = False + return self + + def nullable(self) -> Boolean: + """ + Set the value to be nullable. + + Returns: + Boolean: The updated instance of the class. + """ + self._f_nullable = True + return self + + def non_nullable(self) -> Boolean: + """ + Set the value to be not nullable. + + Returns: + Boolean: The updated instance of the class. + """ + self._f_nullable = False + return self + + def literal(self, value: bool) -> Boolean: + """ + Set the literal value of the boolean. + + Args: + value (bool): The literal value of the boolean. + + Returns: + Boolean: The updated instance of the class. + """ + self._m_literal = value + return self + + def leaniant(self, re_true: Optional[str] = None, re_false: Optional[str] = None) -> Boolean: + """ + Set the value to be leaniant. + + Args: + re_true (Optional[str]): The regular expression for the true value. Default: None + re_false (Optional[str]): The regular expression for the false value. Default: None + + Returns: + Boolean: The updated instance of the class. + """ + self._f_leaniant = True + + if re_true is not None: + self._m_leaniant = (re_true, self._m_leaniant[1]) + + if re_false is not None: + self._m_leaniant = (self._m_leaniant[0], re_false) + + return self + + def parse(self, obj: Any) -> bool: + # if obj is already a boolean, return it + if isinstance(obj, bool): + pass + + # if obj is a string and leaniant mode is active, try to convert it to a boolean + elif isinstance(obj, str) and self._f_leaniant: + # if obj is a string, try to convert it to a boolean + if re.match(self._m_leaniant[0], obj.lower()): + obj = True + + elif re.match(self._m_leaniant[1], obj.lower()): + obj = False + + else: + # raise an error if the value could not be matched to any regex + raise ValidationError( + f"object has to be regex (true: {self._m_leaniant[0]!r}, false: " + f"{self._m_leaniant[1]!r}) accepted boolean value, but is '{obj!r}'" + ) + + # if obj is a number, try to convert it to a boolean + elif isinstance(obj, (int, float)) and self._f_leaniant: + # first check if the number is an actual number + if math.isnan(obj): + obj = False + + elif int(obj) == 1: + obj = True + + elif int(obj) == 0: + obj = False + + else: + raise ValidationError(f"object has to be 1 or 0, but is '{obj!r}'") + + else: + # raise an error if the value could not be parsed + raise ValidationError(f"object has to be a boolean, but is '{obj!r}'") + + # if literal is set, check if obj is the literal value + if self._m_literal is not None and obj != self._m_literal: + raise ValidationError(f"object has to be {self._m_literal}, but is {obj}") + + return obj + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[bool | None]: + if (value := parent.get(key, _NotFound)) is not _NotFound: + # if key is found, just package `parse(..)` it into a Some + if value is not None: + return Some(self.parse(value)) + + # if value is None, check if the value is nullable + if self._f_nullable: + return Some(None) + + raise ValidationError(f"key '{key}' cannot be None") + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/src/parasite/errors.py b/src/parasite/errors.py new file mode 100644 index 0000000..5073e3d --- /dev/null +++ b/src/parasite/errors.py @@ -0,0 +1,2 @@ +class ValidationError(Exception): + """Validation error that is raised when a value does not match the expected type.""" diff --git a/src/parasite/never.py b/src/parasite/never.py new file mode 100644 index 0000000..2ad14e2 --- /dev/null +++ b/src/parasite/never.py @@ -0,0 +1,37 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +from typing import Any, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Never(ParasiteType[None]): + """ + Parasite type for representing never values. + + Inheritance: + ParasiteType[None] + """ + + def parse(self, obj: Any) -> None: + # always raise an error, as this type can never be parsed + raise ValidationError("this type can never be parsed") + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[None]: + # if key is found, raise an error + if parent.get(key, _NotFound) is not _NotFound: + raise ValidationError(f"key '{key}' found, but this type can never be parsed") + + return Nil diff --git a/src/parasite/null.py b/src/parasite/null.py new file mode 100644 index 0000000..2a9507c --- /dev/null +++ b/src/parasite/null.py @@ -0,0 +1,71 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +from typing import Any, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Null(ParasiteType[None]): + """ + Parasite type for representing None values. + + Inheritance: + ParasiteType[None] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + """ + _f_optional: bool = False + + def optional(self) -> Null: + """ + Makes the value optional, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `required(..)`. + + Returns: + Null: modified instance + """ + self._f_optional = True + return self + + def required(self) -> Null: + """ + Makes the value required, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Default behavior. Inverse of `optional(..)`. + + Returns: + Null: modified instance + """ + self._f_optional = False + return self + + def parse(self, obj: Any) -> None: + # if obj is None, return None + if obj is None: + return None + + # else raise an error + raise ValidationError(f"object has to be None, but is '{obj!r}'") + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[None]: + # if key is found, just package `parse(..)` it into a Some + if (value := parent.get(key, _NotFound)) is not _NotFound: + return Some(self.parse(value)) + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/src/parasite/number.py b/src/parasite/number.py new file mode 100644 index 0000000..3acbd4e --- /dev/null +++ b/src/parasite/number.py @@ -0,0 +1,307 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +from typing import Any, TypeAlias, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + +Numerical: TypeAlias = int | float + + +@dataclass +class Number(ParasiteType[Numerical]): + """ + Parasite type for representing numerical values. This type can parse both integers and floats. + + Inheritance: + ParasiteType[Numerical] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + _f_nullable (bool): Whether the value can be None. Default: False + _f_integer (bool): Whether the value has to be an integer. Default: False + _f_lt (bool): Whether the value has to be less than a certain value. Default: False + _f_lte (bool): Whether the value has to be less than or equal to a certain value. + Default: False + _f_gt (bool): Whether the value has to be greater than a certain value. Default: False + _f_gte (bool): Whether the value has to be greater than or equal to a certain value. + Default: False + _m_ul (Numerical | None): Upper limit for the value. Default: None + _m_ll (Numerical | None): Lower limit for the value. Default: None + """ + _f_optional: bool = False + _f_nullable: bool = False + _f_integer: bool = False + + _f_lt: bool = False + _f_lte: bool = False + _f_gt: bool = False + _f_gte: bool = False + + _m_ul: Numerical | None = None + _m_ll: Numerical | None = None + + def optional(self) -> Number: + """ + Makes the value optional, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `required(..)`. + + Returns: + Number: modified instance + """ + self._f_optional = True + return self + + def required(self) -> Number: + """ + Makes the value required, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `optional(..)`. Default behavior. + + Returns: + Number: modified instance + """ + self._f_optional = False + return self + + def nullable(self) -> Number: + """ + Makes the value nullable, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `non_nullable(..)`. + + Returns: + Number: modified instance + """ + self._f_nullable = True + return self + + def non_nullable(self) -> Number: + """ + Makes the value non-nullable, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Default behavior. Inverse of `nullable(..)`. + + Returns: + Number: modified instance + """ + self._f_nullable = False + return self + + def integer(self) -> Number: + """ + Makes the value an integer, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `float(..)`. + + Returns: + Number: modified instance + """ + self._f_integer = True + return self + + def float(self) -> Number: + """ + Makes the value a float, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Default behavior. Inverse of `integer(..)`. + + Returns: + Number: modified instance + """ + self._f_integer = False + return self + + def gt(self, value: Numerical) -> Number: + """ + Sets the lower limit for the value. The value has to be greater than the specified value. + + Args: + value (Numerical): lower limit for the value + + Returns: + Number: modified instance + """ + self._f_gte = False + self._f_gt = True + self._m_ll = value + return self + + def gte(self, value: Numerical) -> Number: + """ + Sets the lower limit for the value. The value has to be greater than or equal to the + specified value. + + Args: + value (Numerical): lower limit for the value + + Returns: + Number: modified instance + """ + self._f_gt = False + self._f_gte = True + self._m_ll = value + return self + + def positive(self) -> Number: + """ + Sets the lower limit for the value. The value has to be greater than 0. + + Returns: + Number: modified instance + """ + return self.gt(0) + + def not_negative(self) -> Number: + """ + Sets the lower limit for the value. The value has to be greater than or equal to 0. + + Returns: + Number: modified instance + """ + return self.gte(0) + + def min(self, value: Numerical) -> Number: + """ + Sets the lower limit for the value. The value has to be greater than or equal to the + specified value. + + Args: + value (Numerical): lower limit for the value + + Returns: + Number: modified instance + """ + return self.gte(value) + + def lt(self, value: Numerical) -> Number: + """ + Sets the upper limit for the value. The value has to be less than the specified value. + + Args: + value (Numerical): upper limit for the value + + Returns: + Number: modified instance + """ + self._f_lte = False + self._f_lt = True + self._m_ul = value + return self + + def lte(self, value: Numerical) -> Number: + """ + Sets the upper limit for the value. The value has to be less than or equal to the specified + value. + + Args: + value (Numerical): upper limit for the value + + Returns: + Number: modified instance + """ + self._f_lt = False + self._f_lte = True + self._m_ul = value + return self + + def negative(self) -> Number: + """ + Sets the upper limit for the value. The value has to be less than 0. + + Returns: + Number: modified instance + """ + return self.lt(0) + + def not_positive(self) -> Number: + """ + Sets the upper limit for the value. The value has to be less than or equal to 0. + + Returns: + Number: modified instance + """ + return self.lte(0) + + def max(self, value: Numerical) -> Number: + """ + Sets the upper limit for the value. The value has to be less than or equal to the specified + value. + + Args: + value (Numerical): upper limit for the value + + Returns: + Number: modified instance + """ + return self.lte(value) + + def _parse(self, obj: Numerical) -> Numerical: + """ + Private function for parsing the value. This function is called by `parse(..)` and should + not be called directly by the user. + + Throws: + ValidationError: if the value could not be parsed or was invalid + + Args: + obj (Numerical): value to parse + + Returns: + Numerical: parsed destination value + """ + # cast to integer, if required + if self._f_integer: + self._m_ll = int(self._m_ll) if self._m_ll is not None else None + self._m_ul = int(self._m_ul) if self._m_ul is not None else None + + if self._f_lt and obj >= self._m_ul: + raise ValidationError(f"object has to be < '{self._m_ul}', but is '{obj!r}'") + + elif self._f_lte and obj > self._m_ul: + raise ValidationError(f"object has to be =<'{self._m_ul}', but is '{obj!r}'") + + if self._f_gt and obj <= self._m_ll: + raise ValidationError(f"object has to be > '{self._m_ll}', but is '{obj!r}'") + + elif self._f_gte and obj < self._m_ll: + raise ValidationError(f"object has to be >='{self._m_ll}', but is '{obj!r}'") + + return obj + + def parse(self, obj: Any) -> Numerical: + # python handles bool as int, so we have to check for bool first + if isinstance(obj, bool): + # fallthrough on boolean values + pass + + elif isinstance(obj, float): + if self._f_integer: + raise ValidationError(f"object has to be an integer, but is '{obj!r}'") + return float(self._parse(obj)) + + elif isinstance(obj, int): + return int(self._parse(obj)) + + raise ValidationError(f"object has to be a number, but is '{obj!r}'") + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[Numerical | None]: + if (value := parent.get(key, _NotFound)) is not _NotFound: + # if value is None, check if the value is nullable + if value is None: + if self._f_nullable: + return Some(None) + raise ValidationError(f"key '{key}' cannot be None") + # if key is found, just package `parse(..)` it into a Some + return Some(self.parse(value)) + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/src/parasite/object.py b/src/parasite/object.py new file mode 100644 index 0000000..7856db0 --- /dev/null +++ b/src/parasite/object.py @@ -0,0 +1,250 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass, field, replace +from typing import Any, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound +from parasite.variant import Variant + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Object(ParasiteType[dict[Any, Any]]): + """ + Parasite type for representing dictionary values. + + Inheritance: + ParasiteType[dict] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + _f_nullable (bool): Whether the value can be None. Default: False + _f_strict (bool): Whether the dictionary should be parsed and not allow any other keys to + exist. Default: False + _f_strip (bool): Whether the dictionary should be stripped of all keys that are not in the + _m_items (dict[K, ParasiteType]): The items of the dictionary. Default: {} + """ + # NOTE: do not move this attribute, this has to be first in the class, as it will break, reading + # items from the dictionary functionality + _m_items: dict[Any, ParasiteType] = field(default_factory = lambda: {}) + + _f_optional: bool = False + _f_nullable: bool = False + _f_strict: bool = False + _f_strip: bool = False + + def optional(self) -> Object: + """ + Set the value to be optional. + + Returns: + Object: The updated instance of the class. + """ + self._f_optional = True + return self + + def required(self) -> Object: + """ + Set the value to be required. + + Returns: + Object: The updated instance of the class. + """ + self._f_optional = False + return self + + def nullable(self) -> Object: + """ + Set the value to be nullable. + + Returns: + Object: The updated instance of the class. + """ + self._f_nullable = True + return self + + def non_nullable(self) -> Object: + """ + Set the value to be non-nullable. + + Returns: + Object: The updated instance of the class. + """ + self._f_nullable = False + return self + + def strict(self) -> Object: + """ + Set the dictionary to be strict. + + Returns: + Object: The updated instance of the class. + """ + self._f_strict = True + return self + + def strip(self) -> Object: + """ + Set the dictionary to be stripped. + + Returns: + Object: The updated instance of the class. + """ + self._f_strip = True + return self + + def extend(self, other: Object) -> Object: + """ + Extend the dictionary with another dictionary. + + Args: + other (Object): The dictionary to extend with. + + Returns: + Object: The updated instance of the class. + """ + if not isinstance(other, Object): + raise ValidationError(f"object has to be a dictionary, but is '{other!r}'") + + self._m_items.update(other._m_items) + return self + + def merge(self, other: Object) -> Object: + """ + Merge the dictionary with another dictionary. + + Args: + other (Object): The dictionary to merge with. + + Returns: + Object: The updated instance of the class. + """ + for key, value in other._m_items.items(): + + # If the key is in the dictionary, merge the values. + if key in self._m_items: + # if both src and dest are objects, merge them + if isinstance(value, Object) and isinstance(self._m_items[key], Object): + self._m_items[key].merge(value) + + # if both src and dest are arrays, merge them + elif isinstance(value, Variant) and isinstance(self._m_items[key], Variant): + for variant in value._m_variants: + self._m_items[key].add_variant(variant) + + # if dest is already a variant, add the value to it + elif isinstance(self._m_items[key], Variant): + self._m_items[key].add_variant(value) + + # else, create a new variant and add both values + else: + org = self._m_items[key] + self._m_items[key] = Variant().add_variant(org).add_variant(value) + + # If the key is not in the dictionary, add it. + else: + self._m_items[key] = value + + return self + + def pick(self, keys: list) -> Object: + """ + Pick only the keys from the dictionary. + + Args: + keys (list[K]): The keys to pick. + + Returns: + Object: New instance of the class with only the picked keys. + """ + new_obj = replace(self) + new_obj._m_items = {key: self._m_items[key] for key in keys} + return new_obj + + def pick_safe(self, keys: list) -> Object: + """ + Pick only the keys from the dictionary. + + Args: + keys (list[K]): The keys to pick. + + Returns: + Object: The updated instance of the class. + """ + new_obj = replace(self) + new_obj._m_items = {key: self._m_items[key] for key in keys if key in self._m_items} + return new_obj + + def omit(self, keys: list) -> Object: + """ + Omit the keys from the dictionary. + + Args: + keys (list[K]): The keys to omit. + + Returns: + Object: New instance of the class with the omitted keys. + """ + new_obj = replace(self) + new_obj._m_items = {key: value for key, value in self._m_items.items() if key not in keys} + return new_obj + + def add_item(self, key: Any, item: ParasiteType) -> Object: + """ + Add an item to the dictionary. + + Args: + key (K): The key of the item. + item (ParasiteType): The item to add. + + Returns: + Object: The updated instance of the class. + """ + self._m_items[key] = item + return self + + def parse(self, obj: Any) -> dict[Any, Any]: + if not isinstance(obj, dict): + raise ValidationError(f"object has to be a dictionary, but is '{obj!r}'") + + # If the dictionary should be strict, check if all keys are allowed. + if self._f_strict: + for key in obj.keys(): + if key not in self._m_items: + raise ValidationError(f"object has the '{key}', but is not allowed to") + + # If the dictionary should be stripped, strip it. + if self._f_strip: + obj = {key: value for key, value in obj.items() if key in self._m_items.keys()} + + # Parse the dictionary. + for key, item in self._m_items.items(): + item.find_and_parse(obj, key).map(lambda x: obj.update({key: x})) + + return obj + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[dict[K, Any] | None]: + if (value := parent.get(key, _NotFound)) is not _NotFound: + # If key is found, just package `parse(..)` it into a Some. + if value is not None: + return Some(self.parse(value)) + + # If value is None, check if the value is nullable. + if self._f_nullable: + return Some(None) + + # If the value is optional, return a Nil. + if self._f_optional: + return Nil + + # If the value is required, raise an error. + raise ValidationError(f"object has to be a dictionary, but is '{value!r}'") diff --git a/src/parasite/string.py b/src/parasite/string.py new file mode 100644 index 0000000..5df9ff3 --- /dev/null +++ b/src/parasite/string.py @@ -0,0 +1,498 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass +from re import Pattern +import re +from typing import Any, TypeVar +from enum import Enum + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some +import tracing + +# -- Package Imports -- +from parasite import _const +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class String(ParasiteType[str]): + """ + Parasite type for representing string values. + + Inheritance: + ParasiteType[str] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + _f_nullable (bool): Whether the value can be None. Default: False + _f_transform_before_parse (bool): Whether to transform the value before parsing. + Default: False + _f_trim (bool): Whether to trim the value. Default: False + _f_to_lower (bool): Whether to convert the value to lowercase. Default: False + _f_to_upper (bool): Whether to convert the value to uppercase. Default: False + _m_regex_t (_RegexType): Type of regex to use for validation. Default: NONE + _m_ul (int | None): Upper limit for the value. Default: None + _m_ll (int | None): Lower limit for the value. Default: None + _m_starts (str | None): String that the value must start with. Default: None + _m_ends (str | None): String that the value must end with. Default: None + _m_contains (str | None): String that the value must contain. Default: None + _m_regex (Pattern | None): Compiled regex pattern to use for validation, if _m_regex_t is + REGEX. Default: None + """ + + class _RegexType(Enum): + """ + Enum for the different types of regexes that can be used to validate a string. + """ + NONE = 0 + EMAIL = 1 + URL = 2 + UUID = 3 + CUID = 4 + CUID2 = 5 + ULID = 6 + IPV4 = 7 + IPV6 = 8 + REGEX = 9 + + # flags + _f_optional: bool = False + _f_nullable: bool = False + _f_transform_before_parse: bool = False + + # transformation flags + _f_trim: bool = False + _f_to_lower: bool = False + _f_to_upper: bool = False + + _m_regex_t: _RegexType = _RegexType.NONE + _m_ul: int | None = None + _m_ll: int | None = None + + _m_starts: str | None = None + _m_ends: str | None = None + _m_contains: str | None = None + _m_regex: Pattern | None = None + + def optional(self) -> String: + """ + Makes the value optional, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `required(..)`. + + Returns: + String: modified instance + """ + self._f_optional = True + return self + + def required(self) -> String: + """ + Makes the value required, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `optional(..)`. Default behavior. + + Returns: + String: modified instance + """ + self._f_optional = False + return self + + def nullable(self) -> String: + """ + Makes the value nullable, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `non_nullable(..)`. + + Returns: + String: modified instance + """ + self._f_nullable = True + return self + + def non_nullable(self) -> String: + """ + Makes the value non-nullable, when parsing with `find_and_parse(..)`. Has no effect on + `parse(..)`. Inverse of `nullable(..)`. Default behavior. + + Returns: + String: modified instance + """ + self._f_nullable = False + return self + + def trim(self) -> String: + """ + Trims the value. + + Returns: + String: modified instance + """ + self._f_trim = True + return self + + def to_lower(self) -> String: + """ + Converts the value to lowercase. + + Returns: + String: modified instance + """ + self._f_to_lower = True + return self + + def to_upper(self) -> String: + """ + Converts the value to uppercase. + + Returns: + String: modified instance + """ + self._f_to_upper = True + return self + + def transform_before_parse(self) -> String: + """ + Transforms the value before parsing. + + Returns: + String: modified instance + """ + self._f_transform_before_parse = True + return self + + def min(self, value: int) -> String: + """ + Sets the lower length limit for the string value. + + Args: + value (int): lower limit + + Returns: + String: modified instance + """ + self._m_ll = value + return self + + def max(self, value: int) -> String: + """ + Sets the upper length limit for the string value. + + Args: + value (int): upper limit + + Returns: + String: modified instance + """ + self._m_ul = value + return self + + def length(self, value: int) -> String: + """ + Sets the lower and upper limit for the value. + + Args: + value (int): length of the value + + Returns: + String: modified instance + """ + self._m_ll = value + self._m_ul = value + return self + + def not_empty(self) -> String: + """ + Sets the lower limit for the value. The value has to be non-empty. + + Returns: + String: modified instance + """ + return self.min(1) + + def starts_with(self, value: str) -> String: + """ + Sets the value that the string must start with. + + Args: + value (str): string that the value must start with + + Returns: + String: modified instance + """ + self._m_starts = value + return self + + def ends_with(self, value: str) -> String: + """ + Sets the value that the string must end with. + + Args: + value (str): string that the value must end with + + Returns: + String: modified instance + """ + self._m_ends = value + return self + + def contains(self, value: str) -> String: + """ + Sets the value that the string must contain. + + Args: + value (str): string that the value must contain + + Returns: + String: modified instance + """ + self._m_contains = value + return self + + def email(self) -> String: + """ + Sets the regex type to email. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.EMAIL + return self + + def url(self) -> String: + """ + Sets the regex type to URL. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.URL + return self + + def uuid(self) -> String: + """ + Sets the regex type to UUID. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.UUID + return self + + def cuid(self) -> String: + """ + Sets the regex type to CUID. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.CUID + return self + + def cuid2(self) -> String: + """ + Sets the regex type to CUID2. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.CUID2 + return self + + def ulid(self) -> String: + """ + Sets the regex type to ULID. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.ULID + return self + + def ipv4(self) -> String: + """ + Sets the regex type to IPv4. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.IPV4 + return self + + def ipv6(self) -> String: + """ + Sets the regex type to IPv6. + + Returns: + String: modified instance + """ + self._m_regex_t = self._RegexType.IPV6 + return self + + def _apply_transformations(self, value: str) -> str: + """ + Applies the transformations on the value. + + Args: + value (str): value to transform + + Returns: + str: transformed value + """ + if self._f_trim: + value = value.strip() + + if self._f_to_lower: + value = value.lower() + + if self._f_to_upper: + value = value.upper() + + return value + + def _parse_bounds(self, value: str) -> str: + """ + Parses the value with the length _constraints. + + Args: + value (str): value to parse + + Returns: + str: parsed value + """ + if self._m_ll is not None and len(value) < self._m_ll: + raise ValidationError(f"length of value '{value}' is less than {self._m_ll}") + + if self._m_ul is not None and len(value) > self._m_ul: + raise ValidationError(f"length of value '{value}' is greater than {self._m_ul}") + + return value + + def _parse_basic(self, value: str) -> str: + """ + Parses the value with the basic _constraints. + + Args: + value (str): value to parse + + Returns: + str: parsed value + """ + if self._m_starts is not None and not value.startswith(self._m_starts): + raise ValidationError(f"value '{value}' does not start with '{self._m_starts}'") + + if self._m_ends is not None and not value.endswith(self._m_ends): + raise ValidationError(f"value '{value}' does not end with '{self._m_ends}'") + + if self._m_contains is not None and self._m_contains not in value: + raise ValidationError(f"value '{value}' does not contain '{self._m_contains}'") + + return value + + def _parse_regex(self, value: str) -> str: + """ + Parses the value with the regex _constraints. + + Args: + value (str): value to parse + + Returns: + str: parsed value + """ + match self._m_regex_t: + case self._RegexType.NONE: + return value + + case self._RegexType.EMAIL: + r = re.match(_const.RE_EMAIL, value) + print(f"r: {r!r}, regex = {_const.RE_EMAIL!r}") + + if not _const.RE_EMAIL.match(value): + raise ValidationError(f"value '{value}' is not a valid email") + return value + + case self._RegexType.URL: + if not _const.RE_URL.match(value): + raise ValidationError(f"value '{value}' is not a valid URL") + return value + + case self._RegexType.UUID: + if not _const.RE_UUID.match(value): + raise ValidationError(f"value '{value}' is not a valid UUID") + return value + + case self._RegexType.CUID: + if not _const.RE_CUID.match(value): + raise ValidationError(f"value '{value}' is not a valid CUID") + return value + + case self._RegexType.CUID2: + if not _const.RE_CUID2.match(value): + raise ValidationError(f"value '{value}' is not a valid CUID2") + return value + + case self._RegexType.ULID: + if not _const.RE_ULID.match(value): + raise ValidationError(f"value '{value}' is not a valid ULID") + return value + + case self._RegexType.IPV4: + if not _const.RE_IPV4.match(value): + raise ValidationError(f"value '{value}' is not a valid IPv4") + return value + + case self._RegexType.IPV6: + if not _const.RE_IPV6.match(value): + raise ValidationError(f"value '{value}' is not a valid IPv6") + return value + + case self._RegexType.REGEX: + if not self._m_regex: + raise ValidationError("no regex pattern provided") + if not self._m_regex.match(value): + raise ValidationError(f"value '{value}' does not match the regex pattern") + return value + + case _: + pass + + tracing.warn(f"unsupported regex type {self._m_regex_t!r}") + return value + + def parse(self, obj: Any) -> str: + if not isinstance(obj, str): + raise ValidationError(f"expected a string, but got '{obj!r}'") + + if self._f_transform_before_parse: + obj = self._apply_transformations(obj) + + obj = self._parse_bounds(obj) + obj = self._parse_basic(obj) + obj = self._parse_regex(obj) + + if not self._f_transform_before_parse: + obj = self._apply_transformations(obj) + + return obj + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[str | None]: + if (value := parent.get(key, _NotFound)) is not _NotFound: + if value is not None: + # if key is found, just package `parse(..)` it into a Some + return Some(self.parse(value)) + + # if value is None, check if the value is nullable + if self._f_nullable: + return Some(None) + + raise ValidationError(f"key '{key}' cannot be None") + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/src/parasite/type.py b/src/parasite/type.py new file mode 100644 index 0000000..96672f7 --- /dev/null +++ b/src/parasite/type.py @@ -0,0 +1,108 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +# -- Library Imports -- +from rusttypes.option import Option +from rusttypes.result import Err, Ok, Result + +# -- Package Imports -- +from parasite.errors import ValidationError + +T = TypeVar("T") +"""Template type for the destination value.""" + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + +_NotFound = object() +"""Internal parasite value for not found keys.""" + + +@dataclass +class ParasiteType(ABC, Generic[T]): + """ + Abstract base class for parasite types. Parasite types are used to parse and validate values + from a dictionary or standalone values (some options are only available for dictionaries). + + Inheritance: + ABC, Generic[T] + """ + + @abstractmethod + def parse(self, obj: Any) -> T: + """ + Default method for parsing a value. This method should be overridden by subclasses. + + Throws: + ValidationError: if the value could not be parsed or was invalid + + Args: + obj (Any): value to parse + + Returns: + T: parsed destination value + """ + raise NotImplementedError + + @abstractmethod + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[T | None]: + """ + Default method for finding and parsing a value from a dictionary. This method should be + overridden by subclasses. If the key is not found, the method should return `Nil`. + + Throws: + ValidationError: if the value could not be parsed or was invalid + + Args: + parent (dict[K, Any]): dictionary to search for the key + key (K): key to search for in the dictionary + + Returns: + Option[T]: parsed destination value or `Nil` + """ + raise NotImplementedError + + def parse_safe(self, obj: Any) -> Result[T, ValidationError]: + """ + Converts the result of `parse(..)` into a `Result` type. Should be used when safe parsing is + required. Will only catch `ValidationError` exceptions!!! + + Args: + obj (Any): value to parse + + Returns: + Result[T, ValidationError]: parsed destination value or an error + """ + try: + # may throw a ValidationError exception + return Ok(self.parse(obj)) + + # handle ValidationError exceptions, if parsing fails + except ValidationError as exc: + return Err(exc) + + def find_and_parse_safe(self, parent: dict[K, Any], + key: K) -> Result[Option[T | None], ValidationError]: + """ + Converts the result of `find_and_parse(..)` into a `Result` type. Should be used when + safe parsing is required. Will only catch `ValidationError` exceptions!!! + + Args: + parent (dict[K, Any]): dictionary to search for the key + key (K): key to search for in the dictionary + + Returns: + Result[T, ValidationError]: parsed destination value or an error + """ + try: + # may throw a ValidationError exception + return Ok(self.find_and_parse(parent, key)) + + # handle ValidationError exceptions, if parsing fails + except ValidationError as exc: + return Err(exc) diff --git a/src/parasite/variant.py b/src/parasite/variant.py new file mode 100644 index 0000000..fc1867b --- /dev/null +++ b/src/parasite/variant.py @@ -0,0 +1,153 @@ +# -- Future Imports -- (Use with caution, may not work as expected in all cases) +from __future__ import annotations + +# -- STL Imports -- +from dataclasses import dataclass, field +from typing import Any, TypeVar + +# -- Library Imports -- +from rusttypes.option import Nil, Option, Some +from rusttypes.result import Result, Err, Ok + +# -- Package Imports -- +from parasite.errors import ValidationError +from parasite.type import ParasiteType, _NotFound + +K = TypeVar("K") +"""Template type for the key in a dictionary.""" + + +@dataclass +class Variant(ParasiteType[Any]): + """ + Parasite type for representing variant values. + + Inheritance: + ParasiteType[Any] + + Args: + _f_optional (bool): Whether the value is optional. Default: False + _f_nullable (bool): Whether the value can be None. Default: False + _m_variants (set[ParasiteType]): The variants of the variant. Default: {} + """ + # NOTE: do not move this attribute, this has to be first in the class, as it will break, reading + # variants from list functionality + _m_variants: list[ParasiteType] = field(default_factory = lambda: []) + + _f_optional: bool = False + _f_nullable: bool = False + + def optional(self) -> Variant: + """ + Set the value to be optional. + + Returns: + Variant: The updated instance of the class. + """ + self._f_optional = True + return self + + def required(self) -> Variant: + """ + Set the value to be required. + + Returns: + Variant: The updated instance of the class. + """ + self._f_optional = False + return self + + def nullable(self) -> Variant: + """ + Set the value to be nullable. + + Returns: + Variant: The updated instance of the class. + """ + self._f_nullable = True + return self + + def non_nullable(self) -> Variant: + """ + Set the value to be non-nullable. + + Returns: + Variant: The updated instance of the class. + """ + self._f_nullable = False + return self + + def add_variant(self, variant: ParasiteType) -> Variant: + """ + Add a variant to the variant. + + Args: + variant (ParasiteType): The variant to add. + + Returns: + Variant: The updated instance of the class. + """ + self._m_variants.append(variant) + return self + + def rm_variant(self, variant: ParasiteType) -> Variant: + """ + Remove a variant from the variant. + + Throws: + ValueError: If the variant is not found in the variant. + + Args: + variant (ParasiteType): The variant to remove. + + Returns: + Variant: The updated instance of the class. + """ + try: + self._m_variants.remove(variant) + except ValueError as exc: + raise ValueError(f"Variant {variant!r} not found in {self!r}") from exc + + return self + + def rm_variant_safe(self, variant: ParasiteType) -> Result[Variant, ValueError]: + """ + Remove a variant from the variant. + + Returns: + Optional[ParasiteType]: The removed variant. + """ + try: + self.rm_variant(variant) + return Ok(self) + + except ValueError as exc: + return Err(exc) + + def parse(self, obj: Any) -> Any: + for variant in self._m_variants: + try: + return variant.parse(obj) + + except ValidationError: + continue + + raise ValidationError(f"object has to be one of {self._m_variants!r}, but is '{obj!r}'") + + def find_and_parse(self, parent: dict[K, Any], key: K) -> Option[Any | None]: + if (value := parent.get(key, _NotFound)) is not _NotFound: + # if key is found, just package `parse(..)` it into a Some + if value is not None: + return Some(self.parse(value)) + + # if value is None, check if the value is nullable + if self._f_nullable: + return Some(None) + + raise ValidationError(f"key '{key}' cannot be None") + + # if key is not found, return Nil if optional, else raise an error + if self._f_optional: + return Nil + + raise ValidationError(f"key '{key}' not found, but is required") diff --git a/tests/test_00_parasite/test_00_never.py b/tests/test_00_parasite/test_00_never.py new file mode 100644 index 0000000..0489747 --- /dev/null +++ b/tests/test_00_parasite/test_00_never.py @@ -0,0 +1,23 @@ +from rusttypes.option import Nil +from rusttypes.result import Ok +from parasite import p + + +def test_never_default() -> None: + assert p.never().parse_safe(None).is_err() + assert p.never().parse_safe(1).is_err() + assert p.never().parse_safe(1.0).is_err() + assert p.never().parse_safe("hello").is_err() + assert p.never().parse_safe(True).is_err() + assert p.never().parse_safe(False).is_err() + assert p.never().parse_safe([]).is_err() + assert p.never().parse_safe({}).is_err() + assert p.never().parse_safe(()).is_err() + assert p.never().parse_safe(set()).is_err() + assert p.never().parse_safe(frozenset()).is_err() + assert p.never().parse_safe(object()).is_err() + + +def test_never_find() -> None: + assert p.never().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.never().find_and_parse_safe({"key": "value"}, "key").is_err() diff --git a/tests/test_00_parasite/test_01_any.py b/tests/test_00_parasite/test_01_any.py new file mode 100644 index 0000000..132e550 --- /dev/null +++ b/tests/test_00_parasite/test_01_any.py @@ -0,0 +1,28 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_any_default() -> None: + assert p.any().parse_safe(None) == Ok(None) + assert p.any().parse_safe(1) == Ok(1) + assert p.any().parse_safe(1.0) == Ok(1.0) + assert p.any().parse_safe("hello") == Ok("hello") + assert p.any().parse_safe(True) == Ok(True) + assert p.any().parse_safe(False) == Ok(False) + assert p.any().parse_safe([]) == Ok([]) + assert p.any().parse_safe({}) == Ok({}) + assert p.any().parse_safe(()) == Ok(()) + assert p.any().parse_safe(set()) == Ok(set()) + assert p.any().parse_safe(frozenset()) == Ok(frozenset()) + + o = object() + assert p.any().parse_safe(o) == Ok(o) + + +def test_any_optional() -> None: + assert p.any().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.any().optional().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + assert p.any().required().find_and_parse_safe({}, "key").is_err() + assert p.any().required().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) diff --git a/tests/test_00_parasite/test_02_null.py b/tests/test_00_parasite/test_02_null.py new file mode 100644 index 0000000..b08a63e --- /dev/null +++ b/tests/test_00_parasite/test_02_null.py @@ -0,0 +1,26 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_null_default() -> None: + assert p.null().parse_safe(None) == Ok(None) + + assert p.null().parse_safe(1).is_err() + assert p.null().parse_safe(1.0).is_err() + assert p.null().parse_safe(True).is_err() + assert p.null().parse_safe("hello").is_err() + assert p.null().parse_safe([]).is_err() + assert p.null().parse_safe({}).is_err() + assert p.null().parse_safe(()).is_err() + assert p.null().parse_safe(set()).is_err() + assert p.null().parse_safe(frozenset()).is_err() + assert p.null().parse_safe(object()).is_err() + + +def test_null_optional() -> None: + assert p.null().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.null().optional().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert p.null().required().find_and_parse_safe({}, "key").is_err() + assert p.null().required().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) diff --git a/tests/test_00_parasite/test_03_boolean.py b/tests/test_00_parasite/test_03_boolean.py new file mode 100644 index 0000000..da42de6 --- /dev/null +++ b/tests/test_00_parasite/test_03_boolean.py @@ -0,0 +1,76 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_boolean_default() -> None: + assert p.boolean().parse_safe(True) == Ok(True) + assert p.boolean().parse_safe(False) == Ok(False) + + assert p.boolean().parse_safe(None).is_err() + assert p.boolean().parse_safe(1).is_err() + assert p.boolean().parse_safe(1.0).is_err() + assert p.boolean().parse_safe("hello").is_err() + assert p.boolean().parse_safe([]).is_err() + assert p.boolean().parse_safe({}).is_err() + assert p.boolean().parse_safe(()).is_err() + assert p.boolean().parse_safe(set()).is_err() + assert p.boolean().parse_safe(frozenset()).is_err() + assert p.boolean().parse_safe(object()).is_err() + + +def test_boolean_find() -> None: + assert p.boolean().find_and_parse_safe({}, "key").is_err() + assert p.boolean().find_and_parse_safe({"key": True}, "key") == Ok(Some(True)) + + +def test_boolean_optional() -> None: + assert p.boolean().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.boolean().optional().find_and_parse_safe({"key": True}, "key") == Ok(Some(True)) + + assert p.boolean().required().find_and_parse_safe({}, "key").is_err() + assert p.boolean().required().find_and_parse_safe({"key": True}, "key") == Ok(Some(True)) + + +def test_boolean_nullable() -> None: + assert p.boolean().nullable().find_and_parse_safe({}, "key").is_err() + assert p.boolean().nullable().find_and_parse_safe({"key": True}, "key") == Ok(Some(True)) + assert p.boolean().nullable().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert p.boolean().non_nullable().find_and_parse_safe({}, "key").is_err() + assert p.boolean().non_nullable().find_and_parse_safe({"key": True}, "key") == Ok(Some(True)) + assert p.boolean().non_nullable().find_and_parse_safe({"key": None}, "key").is_err() + + +def test_boolean_leaniant() -> None: + assert p.boolean().leaniant().parse_safe("true") == Ok(True) + assert p.boolean().leaniant().parse_safe("1") == Ok(True) + assert p.boolean().leaniant().parse_safe("yes") == Ok(True) + assert p.boolean().leaniant().parse_safe("y") == Ok(True) + assert p.boolean().leaniant().parse_safe(1) == Ok(True) + assert p.boolean().leaniant().parse_safe(1.0) == Ok(True) + assert p.boolean().leaniant().parse_safe(True) == Ok(True) + + assert p.boolean().leaniant().parse_safe("false") == Ok(False) + assert p.boolean().leaniant().parse_safe("0") == Ok(False) + assert p.boolean().leaniant().parse_safe("no") == Ok(False) + assert p.boolean().leaniant().parse_safe("n") == Ok(False) + assert p.boolean().leaniant().parse_safe(0) == Ok(False) + assert p.boolean().leaniant().parse_safe(0.0) == Ok(False) + assert p.boolean().leaniant().parse_safe(float("nan")) == Ok(False) + assert p.boolean().leaniant().parse_safe(False) == Ok(False) + + re_true = r'^foo$' + re_false = r'^bar$' + assert p.boolean().leaniant(re_true, re_false).parse_safe("foo") == Ok(True) + assert p.boolean().leaniant(re_true, re_false).parse_safe("bar") == Ok(False) + assert p.boolean().leaniant(re_true, re_false).parse_safe(1) == Ok(True) + assert p.boolean().leaniant(re_true, re_false).parse_safe(0) == Ok(False) + + +def test_boolean_literal() -> None: + assert p.boolean().literal(True).parse_safe(True) == Ok(True) + assert p.boolean().literal(True).parse_safe(False).is_err() + + assert p.boolean().literal(False).parse_safe(False) == Ok(False) + assert p.boolean().literal(False).parse_safe(True).is_err() diff --git a/tests/test_00_parasite/test_04_number.py b/tests/test_00_parasite/test_04_number.py new file mode 100644 index 0000000..6119f97 --- /dev/null +++ b/tests/test_00_parasite/test_04_number.py @@ -0,0 +1,112 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_number_default() -> None: + assert p.number().parse_safe(1) == Ok(1) + assert p.number().parse_safe(1.0) == Ok(1.0) + + assert p.number().parse_safe(None).is_err() + assert p.number().parse_safe(True).is_err() + assert p.number().parse_safe(False).is_err() + assert p.number().parse_safe("hello").is_err() + assert p.number().parse_safe([]).is_err() + assert p.number().parse_safe({}).is_err() + assert p.number().parse_safe(()).is_err() + assert p.number().parse_safe(set()).is_err() + assert p.number().parse_safe(frozenset()).is_err() + assert p.number().parse_safe(object()).is_err() + + +def test_number_find() -> None: + assert p.number().find_and_parse_safe({}, "key").is_err() + assert p.number().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert p.number().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + + +def test_number_optional() -> None: + assert p.number().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.number().optional().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert p.number().optional().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + + assert p.number().required().find_and_parse_safe({}, "key").is_err() + assert p.number().required().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert p.number().required().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + + +def test_number_nullable() -> None: + assert p.number().nullable().find_and_parse_safe({}, "key").is_err() + assert p.number().nullable().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert p.number().nullable().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert p.number().nullable().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert p.number().non_nullable().find_and_parse_safe({}, "key").is_err() + assert p.number().non_nullable().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert p.number().non_nullable().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert p.number().non_nullable().find_and_parse_safe({"key": None}, "key").is_err() + + +def test_number_integer() -> None: + assert p.number().integer().parse_safe(1) == Ok(1) + assert p.number().integer().parse_safe(1.0).is_err() + + +def test_number_gt() -> None: + assert p.number().gt(1).parse_safe(2) == Ok(2) + assert p.number().gt(1).parse_safe(1).is_err() + assert p.number().gt(1).parse_safe(0).is_err() + + +def test_number_gte() -> None: + assert p.number().gte(1).parse_safe(2) == Ok(2) + assert p.number().gte(1).parse_safe(1) == Ok(1) + assert p.number().gte(1).parse_safe(0).is_err() + + +def test_number_positive() -> None: + assert p.number().positive().parse_safe(1) == Ok(1) + assert p.number().positive().parse_safe(0).is_err() + assert p.number().positive().parse_safe(-1).is_err() + + +def test_number_not_negative() -> None: + assert p.number().not_negative().parse_safe(1) == Ok(1) + assert p.number().not_negative().parse_safe(0) == Ok(0) + assert p.number().not_negative().parse_safe(-1).is_err() + + +def test_number_min() -> None: + assert p.number().min(1).parse_safe(0).is_err() + assert p.number().min(1).parse_safe(1) == Ok(1) + assert p.number().min(1).parse_safe(2) == Ok(2) + + +def test_number_lt() -> None: + assert p.number().lt(1).parse_safe(0) == Ok(0) + assert p.number().lt(1).parse_safe(1).is_err() + assert p.number().lt(1).parse_safe(2).is_err() + + +def test_number_lte() -> None: + assert p.number().lte(1).parse_safe(0) == Ok(0) + assert p.number().lte(1).parse_safe(1) == Ok(1) + assert p.number().lte(1).parse_safe(2).is_err() + + +def test_number_negative() -> None: + assert p.number().negative().parse_safe(-1) == Ok(-1) + assert p.number().negative().parse_safe(0).is_err() + assert p.number().negative().parse_safe(1).is_err() + + +def test_number_not_positive() -> None: + assert p.number().not_positive().parse_safe(-1) == Ok(-1) + assert p.number().not_positive().parse_safe(0) == Ok(0) + assert p.number().not_positive().parse_safe(1).is_err() + + +def test_number_max() -> None: + assert p.number().max(1).parse_safe(0) == Ok(0) + assert p.number().max(1).parse_safe(1) == Ok(1) + assert p.number().max(1).parse_safe(2).is_err() diff --git a/tests/test_00_parasite/test_05_string.py b/tests/test_00_parasite/test_05_string.py new file mode 100644 index 0000000..7f215d9 --- /dev/null +++ b/tests/test_00_parasite/test_05_string.py @@ -0,0 +1,178 @@ +from uuid import uuid4 +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_string_default() -> None: + assert p.string().parse_safe("hello") == Ok("hello") + + assert p.string().parse_safe(None).is_err() + assert p.string().parse_safe(1).is_err() + assert p.string().parse_safe(1.0).is_err() + assert p.string().parse_safe(True).is_err() + assert p.string().parse_safe(False).is_err() + assert p.string().parse_safe([]).is_err() + assert p.string().parse_safe({}).is_err() + assert p.string().parse_safe(()).is_err() + assert p.string().parse_safe(set()).is_err() + assert p.string().parse_safe(frozenset()).is_err() + assert p.string().parse_safe(object()).is_err() + + +def test_string_find() -> None: + assert p.string().find_and_parse_safe({}, "key").is_err() + assert p.string().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + +def test_string_optional() -> None: + assert p.string().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.string().optional().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + assert p.string().required().find_and_parse_safe({}, "key").is_err() + assert p.string().required().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + +def test_string_nullable() -> None: + assert p.string().nullable().find_and_parse_safe({}, "key").is_err() + assert p.string().nullable().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + assert p.string().nullable().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert p.string().non_nullable().find_and_parse_safe({}, "key").is_err() + assert p.string().non_nullable().find_and_parse_safe({"key": "value"}, + "key") == Ok(Some("value")) + assert p.string().non_nullable().find_and_parse_safe({"key": None}, "key").is_err() + + +def test_string_trim() -> None: + assert p.string().trim().parse_safe(" hello ") == Ok("hello") + assert p.string().trim().parse_safe("hello") == Ok("hello") + assert p.string().trim().parse_safe(" hello") == Ok("hello") + assert p.string().trim().parse_safe("hello ") == Ok("hello") + assert p.string().trim().parse_safe(" hello ") == Ok("hello") + assert p.string().trim().parse_safe(" ") == Ok("") + + +def test_string_to_lower() -> None: + assert p.string().to_lower().parse_safe("HELLO") == Ok("hello") + assert p.string().to_lower().parse_safe("hello") == Ok("hello") + assert p.string().to_lower().parse_safe("HeLLo") == Ok("hello") + assert p.string().to_lower().parse_safe("hello") == Ok("hello") + assert p.string().to_lower().parse_safe(" hello ") == Ok(" hello ") + assert p.string().to_lower().parse_safe(" ") == Ok(" ") + + +def test_string_to_upper() -> None: + assert p.string().to_upper().parse_safe("HELLO") == Ok("HELLO") + assert p.string().to_upper().parse_safe("hello") == Ok("HELLO") + assert p.string().to_upper().parse_safe("HeLLo") == Ok("HELLO") + assert p.string().to_upper().parse_safe("hello") == Ok("HELLO") + assert p.string().to_upper().parse_safe(" hello ") == Ok(" HELLO ") + assert p.string().to_upper().parse_safe(" ") == Ok(" ") + + +def test_string_min() -> None: + assert p.string().min(5).parse_safe("hello") == Ok("hello") + assert p.string().min(5).parse_safe("hello world") == Ok("hello world") + + assert p.string().min(5).parse_safe("hell").is_err() + + +def test_string_max() -> None: + assert p.string().max(5).parse_safe("hell") == Ok("hell") + assert p.string().max(5).parse_safe("hello") == Ok("hello") + + assert p.string().max(5).parse_safe("hello world").is_err() + + +def test_string_length() -> None: + assert p.string().length(5).parse_safe("hello") == Ok("hello") + + assert p.string().length(5).parse_safe("hell").is_err() + assert p.string().length(5).parse_safe("hello world").is_err() + + +def test_string_not_empty() -> None: + assert p.string().not_empty().parse_safe("hello") == Ok("hello") + assert p.string().not_empty().parse_safe("").is_err() + + +def test_transform_before_parse() -> None: + assert p.string().max(5).trim().transform_before_parse().parse_safe(" hello ") == Ok("hello") + assert p.string().max(5).trim().transform_before_parse().parse_safe("hello") == Ok("hello") + + assert p.string().max(5).trim().parse_safe(" hello ").is_err() + + +def test_string_starts_with() -> None: + assert p.string().starts_with("hello").parse_safe("hello world") == Ok("hello world") + assert p.string().starts_with("hello").parse_safe("hello") == Ok("hello") + + assert p.string().starts_with("hello").parse_safe("world").is_err() + + +def test_string_ends_with() -> None: + assert p.string().ends_with("world").parse_safe("hello world") == Ok("hello world") + assert p.string().ends_with("world").parse_safe("world") == Ok("world") + + assert p.string().ends_with("world").parse_safe("hello").is_err() + + +def test_string_contains() -> None: + assert p.string().contains("world").parse_safe("hello world") == Ok("hello world") + assert p.string().contains("world").parse_safe("world") == Ok("world") + + assert p.string().contains("world").parse_safe("hello").is_err() + + +def test_string_email() -> None: + assert p.string().email().parse_safe("test@gmail.com") == Ok("test@gmail.com") + assert p.string().email().parse_safe("hello").is_err() + + +def test_url() -> None: + assert p.string().url().parse_safe("https://www.google.com") == Ok("https://www.google.com") + assert p.string().url().parse_safe("hello").is_err() + + +def test_string_uuid() -> None: + id_ = "e99ec0df-87ca-4d56-a913-991500154108" + + assert p.string().uuid().parse_safe(id_) == Ok(id_) + assert p.string().uuid().parse_safe("hello").is_err() + + +def test_string_cuid() -> None: + id_ = "clwta4wuf000009jp98jbggmo" + + assert p.string().cuid().parse_safe(id_) == Ok(id_) + assert p.string().cuid().parse_safe("hello").is_err() + + +def test_string_cuid2() -> None: + id_ = "nv1wfjjccvmzezbu98wjj7gr" + + assert p.string().cuid2().parse_safe(id_) == Ok(id_) + assert p.string().cuid2().parse_safe("hello_").is_err() + + +def test_string_ulid() -> None: + id_ = "01HZ4TH3GR88DZRFC8CPWEKPNQ" + + assert p.string().ulid().parse_safe(id_) == Ok(id_) + assert p.string().ulid().parse_safe("hello").is_err() + + +def test_string_ipv4() -> None: + assert p.string().ipv4().parse_safe("10.0.0.1") == Ok("10.0.0.1") + assert p.string().ipv4().parse_safe("10.0.0.1.110").is_err() + assert p.string().ipv4().parse_safe("hello").is_err() + + +def test_string_ipv6() -> None: + assert p.string().ipv6().parse_safe("2001:0db8:85a3:0000:0000:8a2e:0370:7334" + ) == Ok("2001:0db8:85a3:0000:0000:8a2e:0370:7334") + assert p.string().ipv6().parse_safe( + "2001:0db8:85a3:0000:0000:8a2e:0370:7334:2001:0db8:85a3:0000:0000:8a2e:0370:7334" + ).is_err() + assert p.string().ipv6().parse_safe("hello").is_err() diff --git a/tests/test_00_parasite/test_06_variant.py b/tests/test_00_parasite/test_06_variant.py new file mode 100644 index 0000000..f0f60dc --- /dev/null +++ b/tests/test_00_parasite/test_06_variant.py @@ -0,0 +1,90 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_variant_default() -> None: + assert p.variant().parse_safe(None).is_err() + assert p.variant().parse_safe(1).is_err() + assert p.variant().parse_safe(1.0).is_err() + assert p.variant().parse_safe("hello").is_err() + assert p.variant().parse_safe(True).is_err() + assert p.variant().parse_safe(False).is_err() + assert p.variant().parse_safe([]).is_err() + assert p.variant().parse_safe({}).is_err() + assert p.variant().parse_safe(()).is_err() + assert p.variant().parse_safe(set()).is_err() + assert p.variant().parse_safe(frozenset()).is_err() + assert p.variant().parse_safe(object()).is_err() + + +def test_variant_add_variant() -> None: + v = p.variant().add_variant(p.number()).add_variant(p.string()) + + assert v.parse_safe(1) == Ok(1) + assert v.parse_safe(1.0) == Ok(1.0) + assert v.parse_safe("hello") == Ok("hello") + + assert v.parse_safe(None).is_err() + assert v.parse_safe(True).is_err() + assert v.parse_safe(False).is_err() + assert v.parse_safe([]).is_err() + assert v.parse_safe({}).is_err() + assert v.parse_safe(()).is_err() + assert v.parse_safe(set()).is_err() + assert v.parse_safe(frozenset()).is_err() + assert v.parse_safe(object()).is_err() + + +def test_variant_find() -> None: + v = p.variant().add_variant(p.number()).add_variant(p.string()) + + assert v.find_and_parse_safe({}, "key").is_err() + assert v.find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert v.find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert v.find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + +def test_variant_optional() -> None: + v = p.variant().add_variant(p.number()).add_variant(p.string()) + + assert v.optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert v.optional().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert v.optional().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert v.optional().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + assert v.required().find_and_parse_safe({}, "key").is_err() + assert v.required().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert v.required().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert v.required().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + + +def test_variant_nullable() -> None: + v = p.variant().add_variant(p.number()).add_variant(p.string()) + + assert v.nullable().find_and_parse_safe({}, "key").is_err() + assert v.nullable().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert v.nullable().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert v.nullable().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + assert v.nullable().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert v.non_nullable().find_and_parse_safe({}, "key").is_err() + assert v.non_nullable().find_and_parse_safe({"key": 1}, "key") == Ok(Some(1)) + assert v.non_nullable().find_and_parse_safe({"key": 1.0}, "key") == Ok(Some(1.0)) + assert v.non_nullable().find_and_parse_safe({"key": "value"}, "key") == Ok(Some("value")) + assert v.non_nullable().find_and_parse_safe({"key": None}, "key").is_err() + + +def test_variant_rm_variant() -> None: + v = p.variant().add_variant(p.number()).add_variant(p.string()).rm_variant(p.number()) + + assert v.parse_safe("hello") == Ok("hello") + assert v.parse_safe(1).is_err() + assert v.parse_safe(1.0).is_err() + + +def test_variant_rm_variant_safe() -> None: + v = p.variant().add_variant(p.number()).add_variant(p.string()) + + assert v.rm_variant_safe(p.number()).unwrap().parse_safe("hello") == Ok("hello") + assert v.rm_variant_safe(p.any()).is_err() diff --git a/tests/test_00_parasite/test_07_array.py b/tests/test_00_parasite/test_07_array.py new file mode 100644 index 0000000..e2de6a8 --- /dev/null +++ b/tests/test_00_parasite/test_07_array.py @@ -0,0 +1,124 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_array_default() -> None: + assert p.array().parse_safe([]) == Ok([]) + assert p.array().parse_safe([1, 2, 3]) == Ok([1, 2, 3]) + assert p.array().parse_safe([1.0, 2.0, 3.0]) == Ok([1.0, 2.0, 3.0]) + assert p.array().parse_safe(["hello", "world"]) == Ok(["hello", "world"]) + assert p.array().parse_safe([True, False]) == Ok([True, False]) + assert p.array().parse_safe([[], {}]) == Ok([[], {}]) + assert p.array().parse_safe([(), set()]) == Ok([(), set()]) + assert p.array().parse_safe([set(), frozenset()]) == Ok([set(), frozenset()]) + + assert p.array().parse_safe(None).is_err() + assert p.array().parse_safe(1).is_err() + assert p.array().parse_safe(1.0).is_err() + assert p.array().parse_safe("hello").is_err() + assert p.array().parse_safe(True).is_err() + assert p.array().parse_safe(False).is_err() + assert p.array().parse_safe({}).is_err() + assert p.array().parse_safe(()).is_err() + assert p.array().parse_safe(set()).is_err() + assert p.array().parse_safe(frozenset()).is_err() + assert p.array().parse_safe(object()).is_err() + + +def test_array_find() -> None: + assert p.array().find_and_parse_safe({}, "key").is_err() + assert p.array().find_and_parse_safe({"key": []}, "key") == Ok(Some([])) + + assert p.array().find_and_parse_safe({"key": [1, 2, 3]}, "key") == Ok(Some([1, 2, 3])) + assert p.array().find_and_parse_safe({"key": [1.0, 2.0, 3.0]}, + "key") == Ok(Some([1.0, 2.0, 3.0])) + assert p.array().find_and_parse_safe({"key": ["hello", "world"]}, + "key") == Ok(Some(["hello", "world"])) + assert p.array().find_and_parse_safe({"key": [True, False]}, "key") == Ok(Some([True, False])) + assert p.array().find_and_parse_safe({"key": [[], {}]}, "key") == Ok(Some([[], {}])) + assert p.array().find_and_parse_safe({"key": [(), set()]}, "key") == Ok(Some([(), set()])) + assert p.array().find_and_parse_safe({"key": [set(), frozenset()]}, + "key") == Ok(Some([set(), frozenset()])) + + +def test_array_optional() -> None: + assert p.array().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.array().optional().find_and_parse_safe({"key": []}, "key") == Ok(Some([])) + + assert p.array().required().find_and_parse_safe({}, "key").is_err() + assert p.array().required().find_and_parse_safe({"key": []}, "key") == Ok(Some([])) + + +def test_array_nullable() -> None: + assert p.array().nullable().find_and_parse_safe({}, "key").is_err() + assert p.array().nullable().find_and_parse_safe({"key": []}, "key") == Ok(Some([])) + assert p.array().nullable().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert p.array().non_nullable().find_and_parse_safe({}, "key").is_err() + assert p.array().non_nullable().find_and_parse_safe({"key": []}, "key") == Ok(Some([])) + assert p.array().non_nullable().find_and_parse_safe({"key": None}, "key").is_err() + + +def test_array_min() -> None: + assert p.array().min(1).parse_safe([]).is_err() + assert p.array().min(1).parse_safe([1]) == Ok([1]) + assert p.array().min(1).parse_safe([1, 2, 3]) == Ok([1, 2, 3]) + + +def test_array_max() -> None: + assert p.array().max(1).parse_safe([1, 2, 3]).is_err() + assert p.array().max(1).parse_safe([1]) == Ok([1]) + assert p.array().max(1).parse_safe([]) == Ok([]) + + +def test_array_not_empty() -> None: + assert p.array().not_empty().parse_safe([]).is_err() + assert p.array().not_empty().parse_safe([1, 2, 3]) == Ok([1, 2, 3]) + assert p.array().not_empty().parse_safe([1.0, 2.0, 3.0]) == Ok([1.0, 2.0, 3.0]) + assert p.array().not_empty().parse_safe(["hello", "world"]) == Ok(["hello", "world"]) + assert p.array().not_empty().parse_safe([True, False]) == Ok([True, False]) + assert p.array().not_empty().parse_safe([[], {}]) == Ok([[], {}]) + assert p.array().not_empty().parse_safe([(), set()]) == Ok([(), set()]) + assert p.array().not_empty().parse_safe([set(), frozenset()]) == Ok([set(), frozenset()]) + + assert p.array().not_empty().parse_safe(None).is_err() + assert p.array().not_empty().parse_safe(1).is_err() + assert p.array().not_empty().parse_safe(1.0).is_err() + assert p.array().not_empty().parse_safe("hello").is_err() + assert p.array().not_empty().parse_safe(True).is_err() + assert p.array().not_empty().parse_safe(False).is_err() + assert p.array().not_empty().parse_safe({}).is_err() + assert p.array().not_empty().parse_safe(()).is_err() + assert p.array().not_empty().parse_safe(set()).is_err() + assert p.array().not_empty().parse_safe(frozenset()).is_err() + assert p.array().not_empty().parse_safe(object()).is_err() + + +def test_array_empty() -> None: + assert p.array().empty().parse_safe([]) == Ok([]) + assert p.array().empty().parse_safe([1, 2, 3]).is_err() + assert p.array().empty().parse_safe([1.0, 2.0, 3.0]).is_err() + assert p.array().empty().parse_safe(["hello", "world"]).is_err() + assert p.array().empty().parse_safe([True, False]).is_err() + assert p.array().empty().parse_safe([[], {}]).is_err() + assert p.array().empty().parse_safe([(), set()]).is_err() + assert p.array().empty().parse_safe([set(), frozenset()]).is_err() + + +def test_array_length() -> None: + assert p.array().length(1).parse_safe([]).is_err() + assert p.array().length(1).parse_safe([1]) == Ok([1]) + + +def test_array_element() -> None: + a = p.array().element(p.number().integer()) + + assert a.parse_safe([1, 2, 3]) == Ok([1, 2, 3]) + assert a.parse_safe([1.0, 2.0, 3.0]).is_err() + assert a.parse_safe(["hello", "world"]).is_err() + assert a.parse_safe([True, False]).is_err() + assert a.parse_safe([[], {}]).is_err() + assert a.parse_safe([(), set()]).is_err() + assert a.parse_safe([set(), frozenset()]).is_err() + assert a.parse_safe([object(), object()]).is_err() diff --git a/tests/test_00_parasite/test_08_object.py b/tests/test_00_parasite/test_08_object.py new file mode 100644 index 0000000..4b5baaa --- /dev/null +++ b/tests/test_00_parasite/test_08_object.py @@ -0,0 +1,186 @@ +from rusttypes.option import Nil, Some +from rusttypes.result import Ok +from parasite import p + + +def test_object_default() -> None: + assert p.obj().parse_safe({}) == Ok({}) + assert p.obj().parse_safe({"key": "value"}) == Ok({"key": "value"}) + assert p.obj().parse_safe({"key": 1}) == Ok({"key": 1}) + assert p.obj().parse_safe({"key": 1.0}) == Ok({"key": 1.0}) + assert p.obj().parse_safe({"key": True}) == Ok({"key": True}) + assert p.obj().parse_safe({"key": []}) == Ok({"key": []}) + assert p.obj().parse_safe({"key": {}}) == Ok({"key": {}}) + assert p.obj().parse_safe({"key": ()}) == Ok({"key": ()}) + assert p.obj().parse_safe({"key": set()}) == Ok({"key": set()}) + assert p.obj().parse_safe({"key": frozenset()}) == Ok({"key": frozenset()}) + + assert p.obj().parse_safe(None).is_err() + assert p.obj().parse_safe(1).is_err() + assert p.obj().parse_safe(1.0).is_err() + assert p.obj().parse_safe("hello").is_err() + assert p.obj().parse_safe(True).is_err() + assert p.obj().parse_safe(False).is_err() + assert p.obj().parse_safe([]).is_err() + assert p.obj().parse_safe(()).is_err() + assert p.obj().parse_safe(set()).is_err() + assert p.obj().parse_safe(frozenset()).is_err() + assert p.obj().parse_safe(object()).is_err() + + +def test_object_find() -> None: + assert p.obj().find_and_parse_safe({}, "key").is_err() + assert p.obj().find_and_parse_safe({"key": {}}, "key") == Ok(Some({})) + + +def test_object_optional() -> None: + assert p.obj().optional().find_and_parse_safe({}, "key") == Ok(Nil) + assert p.obj().optional().find_and_parse_safe({"key": {}}, "key") == Ok(Some({})) + + assert p.obj().required().find_and_parse_safe({}, "key").is_err() + assert p.obj().required().find_and_parse_safe({"key": {}}, "key") == Ok(Some({})) + + +def test_object_nullable() -> None: + assert p.obj().nullable().find_and_parse_safe({}, "key").is_err() + assert p.obj().nullable().find_and_parse_safe({"key": {}}, "key") == Ok(Some({})) + assert p.obj().nullable().find_and_parse_safe({"key": None}, "key") == Ok(Some(None)) + + assert p.obj().non_nullable().find_and_parse_safe({}, "key").is_err() + assert p.obj().non_nullable().find_and_parse_safe({"key": {}}, "key") == Ok(Some({})) + assert p.obj().non_nullable().find_and_parse_safe({"key": None}, "key").is_err() + + +def test_object_init_items() -> None: + o = p.obj({"key": p.string()}) + assert o._m_items == {"key": p.string()} + + assert o.parse_safe({"key": "value"}) == Ok({"key": "value"}) + assert o.parse_safe({"key": 1}).is_err() + + +def test_object_init_items_optional() -> None: + o = p.obj({"key": p.string().optional()}) + assert o._m_items == {"key": p.string().optional()} + + assert o.parse_safe({"key": "value"}) == Ok({"key": "value"}) + assert o.parse_safe({}) == Ok({}) + assert o.parse_safe({"key": None}).is_err() + assert o.parse_safe({"key": 1}).is_err() + + +def test_object_init_items_nullable() -> None: + o = p.obj({"key": p.string().nullable()}) + assert o._m_items == {"key": p.string().nullable()} + + assert o.parse_safe({"key": "value"}) == Ok({"key": "value"}) + assert o.parse_safe({"key": None}) == Ok({"key": None}) + assert o.parse_safe({}).is_err() + assert o.parse_safe({"key": 1}).is_err() + + +def test_object_strict() -> None: + o = p.obj({"key": p.string()}).strict() + + assert o.parse_safe({"key": "value"}) == Ok({"key": "value"}) + assert o.parse_safe({"key": "value", "key2": "value2"}).is_err() + assert o.parse_safe({}).is_err() + + +def test_object_strip() -> None: + o = p.obj({"key": p.string().optional()}).strip() + + assert o.parse_safe({"key": "value"}) == Ok({"key": "value"}) + assert o.parse_safe({"key": "value", "key2": "value2"}) == Ok({"key": "value"}) + assert o.parse_safe({}) == Ok({}) + + +def test_object_extend() -> None: + o1 = p.obj({"key": p.string()}) + o2 = p.obj({"key2": p.string()}) + + o1.extend(o2) + assert o1._m_items == {"key": p.string(), "key2": p.string()} + assert o2._m_items == {"key2": p.string()} + + o1 = p.obj({"key": p.string()}) + o2 = p.obj({"key": p.number()}) + + o1.extend(o2) + assert o1._m_items == {"key": p.number()} + assert o2._m_items == {"key": p.number()} + + +def test_object_merge() -> None: + o1 = p.obj({"key": p.string()}) + o2 = p.obj({"key2": p.string()}) + + o1.merge(o2) + assert o1._m_items == {"key": p.string(), "key2": p.string()} + assert o2._m_items == {"key2": p.string()} + + o1 = p.obj({"key": p.string()}) + o2 = p.obj({"key": p.number()}) + + o1.merge(o2) + assert o1._m_items == {"key": p.variant([p.string(), p.number()])} + assert o2._m_items == {"key": p.number()} + + o1 = p.obj({"key": p.variant([p.string(), p.number()])}) + o2 = p.obj({"key": p.boolean()}) + + o1.merge(o2) + assert o1._m_items == {"key": p.variant([p.string(), p.number(), p.boolean()])} + assert o2._m_items == {"key": p.boolean()} + + o1 = p.obj({"key": p.obj({"key": p.string()})}) + o2 = p.obj({"key": p.obj({"key2": p.string()})}) + + o1.merge(o2) + assert o1._m_items == {"key": p.obj({"key": p.string(), "key2": p.string()})} + assert o2._m_items == {"key": p.obj({"key2": p.string()})} + + +def test_object_pick() -> None: + assert p.obj({ + "key": p.string(), + "key2": p.string() + }).pick(["key"]) == p.obj({"key": p.string()}) + + assert p.obj({ + "key": p.string(), + "key2": p.string() + }).pick_safe(["key"]) == p.obj({"key": p.string()}) + + try: + p.obj({"key": p.string(), "key2": p.string()}).pick(["key3"]) + assert False + except KeyError: + assert True + + assert p.obj({"key": p.string(), "key2": p.string()}).pick_safe(["key3"]) == p.obj({}) + + +def test_object_omit() -> None: + assert p.obj({ + "key": p.string(), + "key2": p.string() + }).omit(["key"]) == p.obj({"key2": p.string()}) + + assert p.obj({ + "key": p.string(), + "key2": p.string() + }).omit(["key3"]) == p.obj({ + "key": p.string(), + "key2": p.string() + }) + + +def test_object_add_item() -> None: + assert p.obj().add_item("key", p.string()) == p.obj({"key": p.string()}) + assert p.obj({ + "key": p.string() + }).add_item("key2", p.string()) == p.obj({ + "key": p.string(), + "key2": p.string() + })