diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..d9786799 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = *tests* diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..08653ed8 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Add linters configuration, reformat whole code (#503) +c5189d2b6f9dfb3016f094561afcfb5584d143ad diff --git a/.github/ISSUE_TEMPLATE/contributions-only.md b/.github/ISSUE_TEMPLATE/contributions-only.md new file mode 100644 index 00000000..b3e10453 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contributions-only.md @@ -0,0 +1,17 @@ +--- +name: Contributions only +about: Contributions only +title: "(Contributions only)" +labels: '' +assignees: '' + +--- + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..71b89199 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + pull_request: + push: + branches: + - master + +jobs: + tests: + name: Tox ${{ matrix.session }} session on Python ${{ matrix.python-version }} + runs-on: "ubuntu-latest" + strategy: + fail-fast: false + matrix: + include: + - { python-version: "3.11", session: "black" } + - { python-version: "3.11", session: "flake8" } + - { python-version: "3.11", session: "mypy" } + - { python-version: "3.11", session: "isort" } + - { python-version: "3.12", session: "py312" } + - { python-version: "3.11", session: "py311" } + - { python-version: "3.10", session: "py310" } + - { python-version: "3.9", session: "py39" } + + steps: + - name: Check out the repository + uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tox + run: | + pip install tox + tox -e ${{ matrix.session }} diff --git a/.gitignore b/.gitignore index 597a8052..331b5d84 100755 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ dist .coverage .tox MANIFEST +.idea +venv +docs/_build +.vscode/ +.venv +.pytest_cache +htmlcov diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b9b14497..00000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python -python: - - "2.6" - - "2.7" - # Not quite ready for prime time... - - "3.2" - - "3.3" - - "pypy" -# command to install dependencies -#install: "pip install -r requirements.txt --use-mirrors" -# command to run tests -script: nosetests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..8f61df4f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,287 @@ +# Changelog + +## [0.16.0] + +**New**: + +* [#534](https://github.com/alecthomas/voluptuous/pull/534): Support requiring `anyOf` a list of keys + +**Fixes**: + +* [#523](https://github.com/alecthomas/voluptuous/pull/523): Allow Generators for `vol.In` +* [#524](https://github.com/alecthomas/voluptuous/pull/524): Fix bug with `Any` validator and `REMOVE_EXTRA` + +**Changes**: + +* [#530](https://github.com/alecthomas/voluptuous/pull/530): Add comprehensive tests for `humanize.py` module + +## [0.15.2] + +**Fixes**: + +* [522](https://github.com/alecthomas/voluptuous/pull/522): Fix regression with ALLOW_EXTRA and `Any` validator + +## [0.15.1] + +**Fixes**: + +* [515](https://github.com/alecthomas/voluptuous/pull/515): Fix `Remove` not removing keys that do not validate +* [516](https://github.com/alecthomas/voluptuous/pull/516): Improve validator typing to allow non-number formats for min and max +* [517](https://github.com/alecthomas/voluptuous/pull/517): Remove `Maybe` validator typing +* [518](https://github.com/alecthomas/voluptuous/pull/518): Use typing.Container for `In` validator +* [519](https://github.com/alecthomas/voluptuous/pull/519): Don't enforce type for unused description attribute +* [521](https://github.com/alecthomas/voluptuous/pull/521): Type schema attribute as `Any` + +## [0.15.0] + +**Fixes**: + +* [#512](https://github.com/alecthomas/voluptuous/pull/512): Add Any type to defaults +* [#513](https://github.com/alecthomas/voluptuous/pull/513): Only calculate hash once for Marker objects + +**Changes**: + +* [#514](https://github.com/alecthomas/voluptuous/pull/514): Remove python 3.8 support + +## [0.14.2] + +**New**: +* [#507](https://github.com/alecthomas/voluptuous/pull/507): docs: document description field of Marker + +**Fixes**: +* [#506](https://github.com/alecthomas/voluptuous/pull/506): fix: allow unsortable containers in In and NotIn validators (fixes [#451](https://github.com/alecthomas/voluptuous/issues/451)) (bug introduced in 0.12.1) +* [#488](https://github.com/alecthomas/voluptuous/pull/488): fix(typing): fix type hint for Coerce type param (bug introduced in 0.14.0) +* [#497](https://github.com/alecthomas/voluptuous/pull/497): fix(typing): allow path to be a list of strings, integers or any other hashables (bug introduced in 0.14.0) + +**Changes**: +* [#499](https://github.com/alecthomas/voluptuous/pull/499): support: drop support for python 3.7 +* [#501](https://github.com/alecthomas/voluptuous/pull/501): support: run tests on python 3.11 +* [#502](https://github.com/alecthomas/voluptuous/pull/502): support: run tests on python 3.12 +* [#495](https://github.com/alecthomas/voluptuous/pull/495): refactor: drop duplicated type checks in Schema._compile +* [#500](https://github.com/alecthomas/voluptuous/pull/500): refactor: fix few tests, use pytest.raises, extend raises helper +* [#503](https://github.com/alecthomas/voluptuous/pull/503): refactor: Add linters configuration, reformat whole code + +## [0.14.1] + +**Changes**: +* [#487](https://github.com/alecthomas/voluptuous/pull/487): Add pytest.ini and tox.ini to sdist +* [#494](https://github.com/alecthomas/voluptuous/pull/494): Add `python_requires` so package installers know requirement is >= 3.7 + +## [0.14.0] + +**Fixes**: +* [#470](https://github.com/alecthomas/voluptuous/pull/470): Fix a few code comment typos +* [#472](https://github.com/alecthomas/voluptuous/pull/472): Change to SPDX conform license string + +**New**: +* [#475](https://github.com/alecthomas/voluptuous/pull/475): Add typing information +* [#478](https://github.com/alecthomas/voluptuous/pull/478): Fix new type hint of schemas, for example for `Required('key')` +* [#486](https://github.com/alecthomas/voluptuous/pull/486): Fix new type hints and enable `mypy` +* [#479](https://github.com/alecthomas/voluptuous/pull/479): Allow error reporting on keys + +**Changes**: +* [#476](https://github.com/alecthomas/voluptuous/pull/476): Set static PyPI project description +* [#482](https://github.com/alecthomas/voluptuous/pull/482): Remove Travis build status badge + +## [0.13.1] + +**Fixes**: + +- [#439](https://github.com/alecthomas/voluptuous/pull/454): Ignore `Enum` if it is unavailable +- [#456](https://github.com/alecthomas/voluptuous/pull/456): Fix email regex match for Python 2.7 + +**New**: + +- [#457](https://github.com/alecthomas/voluptuous/pull/457): Enable github actions +- [#462](https://github.com/alecthomas/voluptuous/pull/462): Convert codebase to adhere to `flake8` W504 (PEP 8) +- [#459](https://github.com/alecthomas/voluptuous/pull/459): Enable `flake8` in github actions +- [#464](https://github.com/alecthomas/voluptuous/pull/464): `pytest` migration + enable Python 3.10 + +## [0.13.0] + +**Changes**: + +- [#450](https://github.com/alecthomas/voluptuous/pull/450): Display valid `Enum` values in `Coerce` + +## [0.12.2] + +**Fixes**: + +- [#439](https://github.com/alecthomas/voluptuous/issues/439): Revert Breaking `Maybe` change in 0.12.1 +- [#447](https://github.com/alecthomas/voluptuous/issues/447): Fix Email Regex to not match on extra characters + +## [0.12.1] + +**Changes**: +- [#435](https://github.com/alecthomas/voluptuous/pull/435): Extended a few tests (`Required` and `In`) +- [#425](https://github.com/alecthomas/voluptuous/pull/425): Improve error message for `In` and `NotIn` +- [#436](https://github.com/alecthomas/voluptuous/pull/436): Add sorted() for `In` and `NotIn` + fix tests +- [#437](https://github.com/alecthomas/voluptuous/pull/437): Grouped `Maybe` tests plus added another `Range` test +- [#438](https://github.com/alecthomas/voluptuous/pull/438): Extend tests for `Schema` with empty list or dict + +**New**: +- [#433](https://github.com/alecthomas/voluptuous/pull/433): Add Python 3.9 support + +**Fixes**: +- [#431](https://github.com/alecthomas/voluptuous/pull/431): Fixed typos + made spelling more consistent +- [#411](https://github.com/alecthomas/voluptuous/pull/411): Ensure `Maybe` propagates error information +- [#434](https://github.com/alecthomas/voluptuous/pull/434): Remove value enumeration when validating empty list + +## [0.12.0] + +**Changes**: +- n/a + +**New**: +- [#368](https://github.com/alecthomas/voluptuous/pull/368): Allow a discriminant field in validators + +**Fixes**: +- [#420](https://github.com/alecthomas/voluptuous/pull/420): Fixed issue with 'required' not being set properly and added test +- [#414](https://github.com/alecthomas/voluptuous/pull/414): Handle incomparable values in Range +- [#427](https://github.com/alecthomas/voluptuous/pull/427): Added additional tests for Range, Clamp and Length + catch TypeError exceptions + +## [0.11.7] + +**Changes**: + +- [#378](https://github.com/alecthomas/voluptuous/pull/378): Allow `extend()` of a `Schema` to return a subclass of a `Schema` as well. + +**New**: + +- [#364](https://github.com/alecthomas/voluptuous/pull/364): Accept `description` for `Inclusive` instances. +- [#373](https://github.com/alecthomas/voluptuous/pull/373): Accept `msg` for `Maybe` instances. +- [#382](https://github.com/alecthomas/voluptuous/pull/382): Added support for default values in `Inclusive` instances. + +**Fixes**: + +- [#371](https://github.com/alecthomas/voluptuous/pull/371): Fixed `DeprecationWarning` related to `collections.Mapping`. +- [#377](https://github.com/alecthomas/voluptuous/pull/377): Preserve Unicode strings when passed to utility functions (e.g., `Lower()`, `Upper()`). +- [#380](https://github.com/alecthomas/voluptuous/pull/380): Fixed regression with `Any` and `required` flag. + +## [0.11.5] + +- Fixed issue with opening README file in `setup.py`. + +## [0.11.4] + +- Removed use of `pypandoc` as Markdown is now supported by `setup()`. + +## [0.11.3] and [0.11.2] + +**Changes**: + +- [#349](https://github.com/alecthomas/voluptuous/pull/349): Support Python 3.7. +- [#343](https://github.com/alecthomas/voluptuous/pull/343): Drop support for Python 3.3. + +**New**: + +- [#342](https://github.com/alecthomas/voluptuous/pull/342): Add support for sets and frozensets. + +**Fixes**: + +- [#332](https://github.com/alecthomas/voluptuous/pull/332): Fix Python 3.x compatibility for setup.py when `pypandoc` is installed. +- [#348](https://github.com/alecthomas/voluptuous/pull/348): Include path in `AnyInvalid` errors. +- [#351](https://github.com/alecthomas/voluptuous/pull/351): Fix `Date` behaviour when a custom format is specified. + +## [0.11.1] and [0.11.0] + +**Changes**: + +- [#293](https://github.com/alecthomas/voluptuous/pull/293): Support Python 3.6. +- [#294](https://github.com/alecthomas/voluptuous/pull/294): Drop support for Python 2.6, 3.1 and 3.2. +- [#318](https://github.com/alecthomas/voluptuous/pull/318): Allow to use nested schema and allow any validator to be compiled. +- [#324](https://github.com/alecthomas/voluptuous/pull/324): + Default values MUST now pass validation just as any regular value. This is a backward incompatible change if a schema uses default values that don't pass validation against the specified schema. +- [#328](https://github.com/alecthomas/voluptuous/pull/328): + Modify `__lt__` in Marker class to allow comparison with non Marker objects, such as str and int. + +**New**: + +- [#307](https://github.com/alecthomas/voluptuous/pull/307): Add description field to `Marker` instances. +- [#311](https://github.com/alecthomas/voluptuous/pull/311): Add `Schema.infer` method for basic schema inference. +- [#314](https://github.com/alecthomas/voluptuous/pull/314): Add `SomeOf` validator. + +**Fixes**: + +- [#279](https://github.com/alecthomas/voluptuous/pull/279): + Treat Python 2 old-style classes like types when validating. +- [#280](https://github.com/alecthomas/voluptuous/pull/280): Make + `IsDir()`, `IsFile()` and `PathExists()` consistent between different Python versions. +- [#290](https://github.com/alecthomas/voluptuous/pull/290): Use absolute imports to avoid import conflicts. +- [#291](https://github.com/alecthomas/voluptuous/pull/291): Fix `Coerce` validator to catch `decimal.InvalidOperation`. +- [#298](https://github.com/alecthomas/voluptuous/pull/298): Make `Schema([])` usage consistent with `Schema({})`. +- [#303](https://github.com/alecthomas/voluptuous/pull/303): Allow partial validation when using validate decorator. +- [#316](https://github.com/alecthomas/voluptuous/pull/316): Make `Schema.__eq__` deterministic. +- [#319](https://github.com/alecthomas/voluptuous/pull/319): Replace implementation of `Maybe(s)` with `Any(None, s)` to allow it to be compiled. + +## [0.10.5] + +- [#278](https://github.com/alecthomas/voluptuous/pull/278): Unicode +translation to python 2 issue fixed. + +## [0.10.2] + +**Changes**: + +- [#195](https://github.com/alecthomas/voluptuous/pull/195): + `Range` raises `RangeInvalid` when testing `math.nan`. +- [#215](https://github.com/alecthomas/voluptuous/pull/215): + `{}` and `[]` now always evaluate as is, instead of as any dict or any list. + To specify a free-form list, use `list` instead of `[]`. To specify a + free-form dict, use `dict` instead of `Schema({}, extra=ALLOW_EXTRA)`. +- [#224](https://github.com/alecthomas/voluptuous/pull/224): + Change the encoding of keys in error messages from Unicode to UTF-8. + +**New**: + +- [#185](https://github.com/alecthomas/voluptuous/pull/185): + Add argument validation decorator. +- [#199](https://github.com/alecthomas/voluptuous/pull/199): + Add `Unordered`. +- [#200](https://github.com/alecthomas/voluptuous/pull/200): + Add `Equal`. +- [#207](https://github.com/alecthomas/voluptuous/pull/207): + Add `Number`. +- [#210](https://github.com/alecthomas/voluptuous/pull/210): + Add `Schema` equality check. +- [#212](https://github.com/alecthomas/voluptuous/pull/212): + Add `coveralls`. +- [#227](https://github.com/alecthomas/voluptuous/pull/227): + Improve `Marker` management in `Schema`. +- [#232](https://github.com/alecthomas/voluptuous/pull/232): + Add `Maybe`. +- [#234](https://github.com/alecthomas/voluptuous/pull/234): + Add `Date`. +- [#236](https://github.com/alecthomas/voluptuous/pull/236), [#237](https://github.com/alecthomas/voluptuous/pull/237), and [#238](https://github.com/alecthomas/voluptuous/pull/238): + Add script for updating `gh-pages`. +- [#256](https://github.com/alecthomas/voluptuous/pull/256): + Add support for `OrderedDict` validation. +- [#258](https://github.com/alecthomas/voluptuous/pull/258): + Add `Contains`. + +**Fixes**: + +- [#197](https://github.com/alecthomas/voluptuous/pull/197): + `ExactSequence` checks sequences are the same length. +- [#201](https://github.com/alecthomas/voluptuous/pull/201): + Empty lists are evaluated as is. +- [#205](https://github.com/alecthomas/voluptuous/pull/205): + Filepath validators correctly handle `None`. +- [#206](https://github.com/alecthomas/voluptuous/pull/206): + Handle non-subscriptable types in `humanize_error`. +- [#231](https://github.com/alecthomas/voluptuous/pull/231): + Validate `namedtuple` as a `tuple`. +- [#235](https://github.com/alecthomas/voluptuous/pull/235): + Update docstring. +- [#249](https://github.com/alecthomas/voluptuous/pull/249): + Update documentation. +- [#262](https://github.com/alecthomas/voluptuous/pull/262): + Fix a performance issue of exponential complexity where all of the dict keys were matched against all keys in the schema. + This resulted in O(n*m) complexity where n is the number of keys in the dict being validated and m is the number of keys in the schema. + The fix ensures that each key in the dict is matched against the relevant schema keys only. It now works in O(n). +- [#266](https://github.com/alecthomas/voluptuous/pull/266): + Remove setuptools as a dependency. + +## 0.9.3 (2016-08-03) + +Changelog not kept for 0.9.3 and earlier releases. diff --git a/MANIFEST.in b/MANIFEST.in index f03451d5..cfd67171 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,6 @@ include *.md include COPYING +include voluptuous/tests/*.py +include voluptuous/tests/*.md +include pyproject.toml +include tox.ini diff --git a/README.md b/README.md index 60fc0715..3c571e84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ + +# CONTRIBUTIONS ONLY + +**What does this mean?** I do not have time to fix issues myself. The only way fixes or new features will be added is by people submitting PRs. + +**Current status:** Voluptuous is largely feature stable. There hasn't been a need to add new features in a while, but there are some bugs that should be fixed. + +**Why?** I no longer use Voluptuous personally (in fact I no longer regularly write Python code). Rather than leave the project in a limbo of people filing issues and wondering why they're not being worked on, I believe this notice will more clearly set expectations. + # Voluptuous is a Python data validation library -[![Build Status](https://travis-ci.org/alecthomas/voluptuous.png)](https://travis-ci.org/alecthomas/voluptuous) [![Stories in Ready](https://badge.waffle.io/alecthomas/voluptuous.png?label=ready&title=Ready)](https://waffle.io/alecthomas/voluptuous) +[![image](https://img.shields.io/pypi/v/voluptuous.svg)](https://python.org/pypi/voluptuous) +[![image](https://img.shields.io/pypi/l/voluptuous.svg)](https://python.org/pypi/voluptuous) +[![image](https://img.shields.io/pypi/pyversions/voluptuous.svg)](https://python.org/pypi/voluptuous) +[![Test status](https://github.com/alecthomas/voluptuous/actions/workflows/tests.yml/badge.svg)](https://github.com/alecthomas/voluptuous/actions/workflows/tests.yml) +[![Coverage status](https://coveralls.io/repos/github/alecthomas/voluptuous/badge.svg?branch=master)](https://coveralls.io/github/alecthomas/voluptuous?branch=master) +[![Gitter chat](https://badges.gitter.im/alecthomas.svg)](https://gitter.im/alecthomas/Lobby) Voluptuous, *despite* the name, is a Python data validation library. It is primarily intended for validating data coming into Python as JSON, @@ -8,9 +22,9 @@ YAML, etc. It has three goals: -1. Simplicity. -2. Support for complex data structures. -3. Provide useful error messages. +1. Simplicity. +2. Support for complex data structures. +3. Provide useful error messages. ## Contact @@ -23,13 +37,53 @@ You can also contact me directly via [email](mailto:alec@swapoff.org) or To file a bug, create a [new issue](https://github.com/alecthomas/voluptuous/issues/new) on GitHub with a short example of how to replicate the issue. +## Documentation + +The documentation is provided [here](http://alecthomas.github.io/voluptuous/). + +## Contribution to Documentation + +Documentation is built using `Sphinx`. You can install it by + + pip install -r requirements.txt + +For building `sphinx-apidoc` from scratch you need to set PYTHONPATH to `voluptuous/voluptuous` repository. + +The documentation is provided [here.](http://alecthomas.github.io/voluptuous/) + +## Changelog + +See [CHANGELOG.md](https://github.com/alecthomas/voluptuous/blob/master/CHANGELOG.md). + +## Why use Voluptuous over another validation library? + +**Validators are simple callables:** +No need to subclass anything, just use a function. + +**Errors are simple exceptions:** +A validator can just `raise Invalid(msg)` and expect the user to get +useful messages. + +**Schemas are basic Python data structures:** +Should your data be a dictionary of integer keys to strings? +`{int: str}` does what you expect. List of integers, floats or +strings? `[int, float, str]`. + +**Designed from the ground up for validating more than just forms:** +Nested data structures are treated in the same way as any other +type. Need a list of dictionaries? `[{}]` + +**Consistency:** +Types in the schema are checked as types. Values are compared as +values. Callables are called to validate. Simple. + ## Show me an example -Twitter's [user search API](https://dev.twitter.com/docs/api/1/get/users/search) accepts +Twitter's [user search API](https://dev.twitter.com/rest/reference/get/users/search) accepts query URLs like: -``` -$ curl 'http://api.twitter.com/1/users/search.json?q=python&per_page=20&page=1 +```bash +$ curl 'https://api.twitter.com/1.1/users/search.json?q=python&per_page=20&page=1' ``` To validate this we might use a schema like: @@ -41,7 +95,6 @@ To validate this we might use a schema like: ... 'per_page': int, ... 'page': int, ... }) - ``` This schema very succinctly and roughly describes the data required by @@ -58,7 +111,6 @@ schema will need to be more thoroughly defined: ... Required('per_page', default=5): All(int, Range(min=1, max=20)), ... 'page': All(int, Range(min=0)), ... }) - ``` This schema fully enforces the interface defined in Twitter's @@ -75,7 +127,6 @@ documentation, and goes a little further for completeness. ... exc = e >>> str(exc) == "required key not provided @ data['q']" True - ``` ...must be a string: @@ -88,7 +139,6 @@ True ... exc = e >>> str(exc) == "expected str for dictionary value @ data['q']" True - ``` ...and must be at least one character in length: @@ -103,7 +153,6 @@ True True >>> schema({'q': '#topic'}) == {'q': '#topic', 'per_page': 5} True - ``` "per\_page" is a positive integer no greater than 20: @@ -123,7 +172,6 @@ True ... exc = e >>> str(exc) == "value must be at least 1 for dictionary value @ data['per_page']" True - ``` "page" is an integer \>= 0: @@ -138,7 +186,6 @@ True "expected int for dictionary value @ data['per_page']" >>> schema({'q': '#topic', 'page': 1}) == {'q': '#topic', 'page': 1, 'per_page': 5} True - ``` ## Defining schemas @@ -158,7 +205,6 @@ Literals in the schema are matched using normal equality checks: >>> schema = Schema('a string') >>> schema('a string') 'a string' - ``` ### Types @@ -177,7 +223,24 @@ is an instance of the type: ... exc = e >>> str(exc) == "expected int" True +``` + +### URLs +URLs in the schema are matched by using `urlparse` library. + +```pycon +>>> from voluptuous import Url +>>> schema = Schema(Url()) +>>> schema('http://w3.org') +'http://w3.org' +>>> try: +... schema('one') +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "expected a URL" +True ``` ### Lists @@ -193,7 +256,78 @@ in the schema list is compared to each value in the input data: [1, 1, 1] >>> schema(['a', 1, 'string', 1, 'string']) ['a', 1, 'string', 1, 'string'] +``` + +However, an empty list (`[]`) is treated as is. If you want to specify a list that can +contain anything, specify it as `list`: + +```pycon +>>> schema = Schema([]) +>>> try: +... schema([1]) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "not a valid value @ data[1]" +True +>>> schema([]) +[] +>>> schema = Schema(list) +>>> schema([]) +[] +>>> schema([1, 2]) +[1, 2] +``` + +### Sets and frozensets + +Sets and frozensets are treated as a set of valid values. Each element +in the schema set is compared to each value in the input data: + +```pycon +>>> schema = Schema({42}) +>>> schema({42}) == {42} +True +>>> try: +... schema({43}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "invalid value in set" +True +>>> schema = Schema({int}) +>>> schema({1, 2, 3}) == {1, 2, 3} +True +>>> schema = Schema({int, str}) +>>> schema({1, 2, 'abc'}) == {1, 2, 'abc'} +True +>>> schema = Schema(frozenset([int])) +>>> try: +... schema({3}) +... raise AssertionError('Invalid not raised') +... except Invalid as e: +... exc = e +>>> str(exc) == 'expected a frozenset' +True +``` +However, an empty set (`set()`) is treated as is. If you want to specify a set +that can contain anything, specify it as `set`: + +```pycon +>>> schema = Schema(set()) +>>> try: +... schema({1}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "invalid value in set" +True +>>> schema(set()) == set() +True +>>> schema = Schema(set) +>>> schema({1, 2}) == {1, 2} +True ``` ### Validation functions @@ -213,7 +347,6 @@ validator: >>> from datetime import datetime >>> def Date(fmt='%Y-%m-%d'): ... return lambda v: datetime.strptime(v, fmt) - ``` ```pycon @@ -227,7 +360,6 @@ datetime.datetime(2013, 3, 3, 0, 0) ... exc = e >>> str(exc) == "not a valid value" True - ``` In addition to simply determining if a value is valid, validators may @@ -248,7 +380,6 @@ def Coerce(type, msg=None): except ValueError: raise Invalid(msg or ('expected %s' % type.__name__)) return f - ``` This example also shows a common idiom where an optional human-readable @@ -264,7 +395,6 @@ key-value pair in the corresponding data dictionary: >>> schema = Schema({1: 'one', 2: 'two'}) >>> schema({1: 'one'}) {1: 'one'} - ``` #### Extra dictionary keys @@ -281,17 +411,27 @@ trigger exceptions: ... exc = e >>> str(exc) == "extra keys not allowed @ data[1]" True - ``` -This behaviour can be altered on a per-schema basis with -`Schema(..., extra=True)`: +This behaviour can be altered on a per-schema basis. To allow +additional keys use +`Schema(..., extra=ALLOW_EXTRA)`: ```pycon ->>> schema = Schema({2: 3}, extra=True) +>>> from voluptuous import ALLOW_EXTRA +>>> schema = Schema({2: 3}, extra=ALLOW_EXTRA) >>> schema({1: 2, 2: 3}) {1: 2, 2: 3} +``` + +To remove additional keys use +`Schema(..., extra=REMOVE_EXTRA)`: +```pycon +>>> from voluptuous import REMOVE_EXTRA +>>> schema = Schema({2: 3}, extra=REMOVE_EXTRA) +>>> schema({1: 2, 2: 3}) +{2: 3} ``` It can also be overridden per-dictionary by using the catch-all marker @@ -302,7 +442,6 @@ token `extra` as a key: >>> schema = Schema({1: {Extra: object}}) >>> schema({1: {'foo': 'bar'}}) {1: {'foo': 'bar'}} - ``` #### Required dictionary keys @@ -313,7 +452,6 @@ By default, keys in the schema are not required to be in the data: >>> schema = Schema({1: 2, 3: 4}) >>> schema({3: 4}) {3: 4} - ``` Similarly to how extra\_ keys work, this behaviour can be overridden @@ -328,7 +466,6 @@ per-schema: ... exc = e >>> str(exc) == "required key not provided @ data[1]" True - ``` And per-key, with the marker token `Required(key)`: @@ -344,7 +481,6 @@ And per-key, with the marker token `Required(key)`: True >>> schema({1: 2}) {1: 2} - ``` #### Optional dictionary keys @@ -371,15 +507,40 @@ True ... exc = e >>> str(exc) == "extra keys not allowed @ data[4]" True - ``` ```pycon >>> schema({1: 2, 3: 4}) {1: 2, 3: 4} +``` + +### Recursive / nested schema + +You can use `voluptuous.Self` to define a nested schema: + +```pycon +>>> from voluptuous import Schema, Self +>>> recursive = Schema({"more": Self, "value": int}) +>>> recursive({"more": {"value": 42}, "value": 41}) == {'more': {'value': 42}, 'value': 41} +True +``` + +### Extending an existing Schema +Often it comes handy to have a base `Schema` that is extended with more +requirements. In that case you can use `Schema.extend` to create a new +`Schema`: + +```pycon +>>> from voluptuous import Schema +>>> person = Schema({'name': str}) +>>> person_with_age = person.extend({'age': int}) +>>> sorted(list(person_with_age.schema.keys())) +['age', 'name'] ``` +The original `Schema` remains unchanged. + ### Objects Each key-value pair in a schema dictionary is validated against each @@ -396,7 +557,19 @@ attribute-value pair in the corresponding object: >>> schema = Schema(Object({'q': 'one'}, cls=Structure)) >>> schema(Structure(q='one')) +``` + +### Allow None values + +To allow value to be None as well, use Any: + +```pycon +>>> from voluptuous import Any +>>> schema = Schema(Any(None, int)) +>>> schema(None) +>>> schema(5) +5 ``` ## Error reporting @@ -412,7 +585,6 @@ exception. This is especially useful when you want to catch `Invalid` exceptions and give some feedback to the user, for instance in the context of an HTTP API. - ```pycon >>> def validate_email(email): ... """Validate email.""" @@ -433,7 +605,6 @@ an HTTP API. 'This email is invalid.' >>> exc.error_message 'This email is invalid.' - ``` The `path` attribute is used during error reporting, but also during matching @@ -448,7 +619,6 @@ To illustrate this, here is an example schema: ```pycon >>> schema = Schema([[2, 3], 6]) - ``` Each value in the top-level list is matched depth-first in-order. Given @@ -463,9 +633,8 @@ backtracking is attempted: ... raise AssertionError('MultipleInvalid not raised') ... except MultipleInvalid as e: ... exc = e ->>> str(exc) == "invalid list value @ data[0][0]" +>>> str(exc) == "not a valid value @ data[0][0]" True - ``` If we pass the data `[6]`, the `6` is not a list type and so will not @@ -475,37 +644,65 @@ to the second element in the schema, and succeed: ```pycon >>> schema([6]) [6] - ``` -## Running tests. +## Multi-field validation -Voluptuous is using nosetests: +Validation rules that involve multiple fields can be implemented as +custom validators. It's recommended to use `All()` to do a two-pass +validation - the first pass checking the basic structure of the data, +and only after that, the second pass applying your cross-field +validator: - $ nosetests +```python +def passwords_must_match(passwords): + if passwords['password'] != passwords['password_again']: + raise Invalid('passwords must match') + return passwords +schema = Schema(All( + # First "pass" for field types + {'password': str, 'password_again': str}, + # Follow up the first "pass" with your multi-field rules + passwords_must_match +)) -## Why use Voluptuous over another validation library? +# valid +schema({'password': '123', 'password_again': '123'}) -**Validators are simple callables** -: No need to subclass anything, just use a function. +# raises MultipleInvalid: passwords must match +schema({'password': '123', 'password_again': 'and now for something completely different'}) -**Errors are simple exceptions.** -: A validator can just `raise Invalid(msg)` and expect the user to get -useful messages. +``` -**Schemas are basic Python data structures.** -: Should your data be a dictionary of integer keys to strings? -`{int: str}` does what you expect. List of integers, floats or -strings? `[int, float, str]`. +With this structure, your multi-field validator will run with +pre-validated data from the first "pass" and so will not have to do +its own type checking on its inputs. -**Designed from the ground up for validating more than just forms.** -: Nested data structures are treated in the same way as any other -type. Need a list of dictionaries? `[{}]` +The flipside is that if the first "pass" of validation fails, your +cross-field validator will not run: -**Consistency.** -: Types in the schema are checked as types. Values are compared as -values. Callables are called to validate. Simple. +```python +# raises Invalid because password_again is not a string +# passwords_must_match() will not run because first-pass validation already failed +schema({'password': '123', 'password_again': 1337}) +``` + +## Running tests + +Voluptuous is using `pytest`: + +```bash +$ pip install pytest +$ pytest +``` + +To also include a coverage report: + +```bash +$ pip install pytest pytest-cov coverage>=3.0 +$ pytest --cov=voluptuous voluptuous/tests/ +``` ## Other libraries and inspirations @@ -514,5 +711,10 @@ Voluptuous is heavily inspired by [jsonvalidator](http://code.google.com/p/jsonvalidator/) and [json\_schema](http://blog.sendapatch.se/category/json_schema.html). +[pytest-voluptuous](https://github.com/F-Secure/pytest-voluptuous) is a +[pytest](https://github.com/pytest-dev/pytest) plugin that helps in +using voluptuous validators in `assert`s. + I greatly prefer the light-weight style promoted by these libraries to the complexity of libraries like FormEncode. + diff --git a/bin/.python3-3.12.3.pkg b/bin/.python3-3.12.3.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.python3-3.12.3.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 00000000..e889550b --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 00000000..fe28214d --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 00000000..7fef7692 --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 00000000..e69de29b diff --git a/bin/pip b/bin/pip new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/pip @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/pip3 b/bin/pip3 new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/pip3 @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/pip3.12 b/bin/pip3.12 new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/pip3.12 @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/pydoc3 b/bin/pydoc3 new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/pydoc3 @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/pydoc3.12 b/bin/pydoc3.12 new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/pydoc3.12 @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/python b/bin/python new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/python @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/python3 b/bin/python3 new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/python3 @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/python3-config b/bin/python3-config new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/python3-config @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/python3.12 b/bin/python3.12 new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/python3.12 @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/bin/python3.12-config b/bin/python3.12-config new file mode 120000 index 00000000..0289f6d3 --- /dev/null +++ b/bin/python3.12-config @@ -0,0 +1 @@ +.python3-3.12.3.pkg \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2fd32941 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.black] +target-version = ["py39", "py310", "py311", "py312"] +skip-string-normalization = true + +[tool.isort] +skip_gitignore = true +profile = "black" +multi_line_output = 5 + +[tool.mypy] +python_version = "3.9" + +warn_unused_ignores = true + +[tool.pytest.ini_options] +python_files = "tests.py" +testpaths = "voluptuous/tests" +addopts = "--doctest-glob=*.md -v" diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 00000000..897864b8 --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,11 @@ +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + extends: [ + "config:recommended", + ":semanticCommits", + ":semanticCommitTypeAll(chore)", + ":semanticCommitScope(deps)", + "group:allNonMajor", + "schedule:earlyMondays", // Run once a week. + ], +} diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index adcebe26..00000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[nosetests] -doctest-extension = md -with-doctest = 1 -where = . diff --git a/setup.py b/setup.py index eb685671..a889e41a 100644 --- a/setup.py +++ b/setup.py @@ -1,54 +1,42 @@ -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - +import io import sys -import os -import atexit + +from setuptools import setup + sys.path.insert(0, '.') version = __import__('voluptuous').__version__ -try: - import pypandoc - long_description = pypandoc.convert('README.md', 'rst') - with open('README.rst', 'w') as f: - f.write(long_description) - atexit.register(lambda: os.unlink('README.rst')) -except ImportError: - print('WARNING: Could not locate pandoc, using Markdown long_description.') - with open('README.md') as f: - long_description = f.read() -description = long_description.splitlines()[0].strip() +with io.open('README.md', encoding='utf-8') as f: + long_description = f.read() setup( name='voluptuous', - url='http://github.com/alecthomas/voluptuous', - download_url='http://pypi.python.org/pypi/voluptuous', + url='https://github.com/alecthomas/voluptuous', + download_url='https://pypi.python.org/pypi/voluptuous', version=version, - description=description, + description='Python data validation library', long_description=long_description, - license='BSD', + long_description_content_type='text/markdown', + license='BSD-3-Clause', platforms=['any'], - py_modules=['voluptuous'], + packages=['voluptuous'], + package_data={ + 'voluptuous': ['py.typed'], + }, author='Alec Thomas', author_email='alec@swapoff.org', + python_requires=">=3.9", classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.1', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - ], - install_requires=[ - 'setuptools >= 0.6b1', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], ) diff --git a/tests.py b/tests.py deleted file mode 100644 index 9f72f0c5..00000000 --- a/tests.py +++ /dev/null @@ -1,113 +0,0 @@ -from nose.tools import assert_equal - -import voluptuous -from voluptuous import ( - Schema, Required, Extra, Invalid, In, Remove, Literal, - MultipleInvalid, LiteralInvalid -) - - -def test_required(): - """Verify that Required works.""" - schema = Schema({Required('q'): 1}) - # Can't use nose's raises (because we need to access the raised - # exception, nor assert_raises which fails with Python 2.6.9. - try: - schema({}) - except Invalid as e: - assert_equal(str(e), "required key not provided @ data['q']") - else: - assert False, "Did not raise Invalid" - - -def test_extra_with_required(): - """Verify that Required does not break Extra.""" - schema = Schema({Required('toaster'): str, Extra: object}) - r = schema({'toaster': 'blue', 'another_valid_key': 'another_valid_value'}) - assert_equal( - r, {'toaster': 'blue', 'another_valid_key': 'another_valid_value'}) - - -def test_iterate_candidates(): - """Verify that the order for iterating over mapping candidates is right.""" - schema = { - "toaster": str, - Extra: object, - } - # toaster should be first. - assert_equal(voluptuous._iterate_mapping_candidates(schema)[0][0], - 'toaster') - - -def test_in(): - """Verify that In works.""" - schema = Schema({"color": In(frozenset(["blue", "red", "yellow"]))}) - schema({"color": "blue"}) - - -def test_remove(): - """Verify that Remove works.""" - # remove dict keys - schema = Schema({"weight": int, - Remove("color"): str, - Remove("amount"): int}) - out_ = schema({"weight": 10, "color": "red", "amount": 1}) - assert "color" not in out_ and "amount" not in out_ - - # remove keys by type - schema = Schema({"weight": float, - "amount": int, - # remvove str keys with int values - Remove(str): int, - # keep str keys with str values - str: str}) - out_ = schema({"weight": 73.4, - "condition": "new", - "amount": 5, - "left": 2}) - # amount should stay since it's defined - # other string keys with int values will be removed - assert "amount" in out_ and "left" not in out_ - # string keys with string values will stay - assert "condition" in out_ - - # remove value from list - schema = Schema([Remove(1), int]) - out_ = schema([1, 2, 3, 4, 1, 5, 6, 1, 1, 1]) - assert_equal(out_, [2, 3, 4, 5, 6]) - - # remove values from list by type - schema = Schema([1.0, Remove(float), int]) - out_ = schema([1, 2, 1.0, 2.0, 3.0, 4]) - assert_equal(out_, [1, 2, 1.0, 4]) - - -def test_extra_empty_errors(): - schema = Schema({'a': {Extra: object}}, required=True) - schema({'a': {}}) - - -def test_literal(): - """ test with Literal """ - - schema = Schema([Literal({"a": 1}), Literal({"b": 1})]) - schema([{"a": 1}]) - schema([{"b": 1}]) - schema([{"a": 1}, {"b": 1}]) - - try: - schema([{"c": 1}]) - except Invalid as e: - assert_equal(str(e), 'invalid list value @ data[0]') - else: - assert False, "Did not raise Invalid" - - schema = Schema(Literal({"a": 1})) - try: - schema({"b": 1}) - except MultipleInvalid as e: - assert_equal(str(e), "{'b': 1} not match for {'a': 1}") - assert_equal(len(e.errors), 1) - assert_equal(type(e.errors[0]), LiteralInvalid) - else: - assert False, "Did not raise Invalid" diff --git a/tox.ini b/tox.ini index dcc0800d..acb505dc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,31 +1,40 @@ [tox] -envlist = flake8,py26,py27 +envlist = flake8,black,py39,py310,py311,py312 [flake8] ; E501: line too long (X > 79 characters) -ignore = E501 +; E203, E704: black-related ignores (see https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#flake8) +extend-ignore = E203, E501, E704 exclude = .tox,.venv,build,*.egg [testenv] distribute = True sitepackages = False deps = - nose - nose-cover3 + pytest + pytest-cov coverage>=3.0 commands = - nosetests \ - --with-coverage3 \ - --cover3-package=voluptuous \ - --cover3-branch \ - --verbose + pytest \ + --cov=voluptuous \ + voluptuous/tests/ [testenv:flake8] deps = flake8 -commands = flake8 +commands = flake8 --doctests setup.py voluptuous -[testenv:py26] -basepython = python2.6 +[testenv:mypy] +deps = + mypy + pytest +commands = mypy voluptuous + +[testenv:black] +deps = + black +commands = black --check . -[testenv:py27] -basepython = python2.7 +[testenv:isort] +deps = + isort +commands = isort --check . diff --git a/voluptuous.py b/voluptuous.py deleted file mode 100644 index 0e0b11f2..00000000 --- a/voluptuous.py +++ /dev/null @@ -1,1647 +0,0 @@ -# encoding: utf-8 -# -# Copyright (C) 2010-2013 Alec Thomas -# All rights reserved. -# -# This software is licensed as described in the file COPYING, which -# you should have received as part of this distribution. -# -# Author: Alec Thomas - -"""Schema validation for Python data structures. - -Given eg. a nested data structure like this: - - { - 'exclude': ['Users', 'Uptime'], - 'include': [], - 'set': { - 'snmp_community': 'public', - 'snmp_timeout': 15, - 'snmp_version': '2c', - }, - 'targets': { - 'localhost': { - 'exclude': ['Uptime'], - 'features': { - 'Uptime': { - 'retries': 3, - }, - 'Users': { - 'snmp_community': 'monkey', - 'snmp_port': 15, - }, - }, - 'include': ['Users'], - 'set': { - 'snmp_community': 'monkeys', - }, - }, - }, - } - -A schema like this: - - >>> settings = { - ... 'snmp_community': str, - ... 'retries': int, - ... 'snmp_version': All(Coerce(str), Any('3', '2c', '1')), - ... } - >>> features = ['Ping', 'Uptime', 'Http'] - >>> schema = Schema({ - ... 'exclude': features, - ... 'include': features, - ... 'set': settings, - ... 'targets': { - ... 'exclude': features, - ... 'include': features, - ... 'features': { - ... str: settings, - ... }, - ... }, - ... }) - -Validate like so: - - >>> schema({ - ... 'set': { - ... 'snmp_community': 'public', - ... 'snmp_version': '2c', - ... }, - ... 'targets': { - ... 'exclude': ['Ping'], - ... 'features': { - ... 'Uptime': {'retries': 3}, - ... 'Users': {'snmp_community': 'monkey'}, - ... }, - ... }, - ... }) == { - ... 'set': {'snmp_version': '2c', 'snmp_community': 'public'}, - ... 'targets': { - ... 'exclude': ['Ping'], - ... 'features': {'Uptime': {'retries': 3}, - ... 'Users': {'snmp_community': 'monkey'}}}} - True -""" -import datetime -import os -import re -import sys -import inspect -from contextlib import contextmanager -from functools import wraps - - -if sys.version_info >= (3,): - import urllib.parse as urlparse - long = int - unicode = str - basestring = str - ifilter = filter - iteritems = dict.items -else: - from itertools import ifilter - import urlparse - iteritems = dict.iteritems - - -__author__ = 'Alec Thomas ' -__version__ = '0.8.7' - - -@contextmanager -def raises(exc, msg=None): - try: - yield - except exc as e: - if msg is not None: - assert str(e) == msg, '%r != %r' % (str(e), msg) - - -class Undefined(object): - def __nonzero__(self): - return False - - def __repr__(self): - return '...' - - -UNDEFINED = Undefined() - - -def default_factory(value): - if value is UNDEFINED or callable(value): - return value - return lambda: value - - -# options for extra keys -PREVENT_EXTRA = 0 # any extra key not in schema will raise an error -ALLOW_EXTRA = 1 # extra keys not in schema will be included in output -REMOVE_EXTRA = 2 # extra keys not in schema will be excluded from output - - -class Error(Exception): - """Base validation exception.""" - - -class SchemaError(Error): - """An error was encountered in the schema.""" - - -class Invalid(Error): - """The data was invalid. - - :attr msg: The error message. - :attr path: The path to the error, as a list of keys in the source data. - :attr error_message: The actual error message that was raised, as a - string. - - """ - - def __init__(self, message, path=None, error_message=None, error_type=None): - Error.__init__(self, message) - self.path = path or [] - self.error_message = error_message or message - self.error_type = error_type - - @property - def msg(self): - return self.args[0] - - def __str__(self): - path = ' @ data[%s]' % ']['.join(map(repr, self.path)) \ - if self.path else '' - output = Exception.__str__(self) - if self.error_type: - output += ' for ' + self.error_type - return output + path - - -class MultipleInvalid(Invalid): - def __init__(self, errors=None): - self.errors = errors[:] if errors else [] - - def __repr__(self): - return 'MultipleInvalid(%r)' % self.errors - - @property - def msg(self): - return self.errors[0].msg - - @property - def path(self): - return self.errors[0].path - - @property - def error_message(self): - return self.errors[0].error_message - - def add(self, error): - self.errors.append(error) - - def __str__(self): - return str(self.errors[0]) - - -class RequiredFieldInvalid(Invalid): - """Required field was missing.""" - - -class ObjectInvalid(Invalid): - """The value we found was not an object.""" - - -class DictInvalid(Invalid): - """The value found was not a dict.""" - - -class ExclusiveInvalid(Invalid): - """More than one value found in exclusion group.""" - - -class InclusiveInvalid(Invalid): - """Not all values found in inclusion group.""" - - -class SequenceItemInvalid(Invalid): - """One of the values found in a sequence was invalid.""" - - -class SequenceTypeInvalid(Invalid): - """The type found is not a sequence type.""" - - -class TypeInvalid(Invalid): - """The value was not of required type.""" - - -class ValueInvalid(Invalid): - """The value was found invalid by evaluation function.""" - - -class ScalarInvalid(Invalid): - """Scalars did not match.""" - - -class CoerceInvalid(Invalid): - """Impossible to coerce value to type.""" - - -class AnyInvalid(Invalid): - """The value did not pass any validator.""" - - -class AllInvalid(Invalid): - """The value did not pass all validators.""" - - -class MatchInvalid(Invalid): - """The value does not match the given regular expression.""" - - -class RangeInvalid(Invalid): - """The value is not in given range.""" - - -class TrueInvalid(Invalid): - """The value is not True.""" - - -class FalseInvalid(Invalid): - """The value is not False.""" - - -class BooleanInvalid(Invalid): - """The value is not a boolean.""" - - -class UrlInvalid(Invalid): - """The value is not a url.""" - - -class FileInvalid(Invalid): - """The value is not a file.""" - - -class DirInvalid(Invalid): - """The value is not a directory.""" - - -class PathInvalid(Invalid): - """The value is not a path.""" - - -class LiteralInvalid(Invalid): - """The litteral values do not match.""" - - -class Schema(object): - """A validation schema. - - The schema is a Python tree-like structure where nodes are pattern - matched against corresponding trees of values. - - Nodes can be values, in which case a direct comparison is used, types, - in which case an isinstance() check is performed, or callables, which will - validate and optionally convert the value. - """ - - def __init__(self, schema, required=False, extra=PREVENT_EXTRA): - """Create a new Schema. - - :param schema: Validation schema. See :module:`voluptuous` for details. - :param required: Keys defined in the schema must be in the data. - :param extra: Specify how extra keys in the data are treated: - - :const:`~voluptuous.PREVENT_EXTRA`: to disallow any undefined - extra keys (raise ``Invalid``). - - :const:`~voluptuous.ALLOW_EXTRA`: to include undefined extra - keys in the output. - - :const:`~voluptuous.REMOVE_EXTRA`: to exclude undefined extra keys - from the output. - - Any value other than the above defaults to - :const:`~voluptuous.PREVENT_EXTRA` - """ - self.schema = schema - self.required = required - self.extra = int(extra) # ensure the value is an integer - self._compiled = self._compile(schema) - - def __call__(self, data): - """Validate data against this schema.""" - try: - return self._compiled([], data) - except MultipleInvalid: - raise - except Invalid as e: - raise MultipleInvalid([e]) - # return self.validate([], self.schema, data) - - def _compile(self, schema): - if schema is Extra: - return lambda _, v: v - if isinstance(schema, Object): - return self._compile_object(schema) - if isinstance(schema, dict): - return self._compile_dict(schema) - elif isinstance(schema, list): - return self._compile_list(schema) - elif isinstance(schema, tuple): - return self._compile_tuple(schema) - type_ = type(schema) - if type_ is type: - type_ = schema - if type_ in (int, long, str, unicode, float, complex, object, - list, dict, type(None)) or callable(schema): - return _compile_scalar(schema) - raise SchemaError('unsupported schema data type %r' % - type(schema).__name__) - - def _compile_mapping(self, schema, invalid_msg=None): - """Create validator for given mapping.""" - invalid_msg = invalid_msg or 'mapping value' - - # Keys that may be required - all_required_keys = set(key for key in schema - if key is not Extra - and ((self.required and not isinstance(key, (Optional, Remove))) - or isinstance(key, Required))) - - # Keys that may have defaults - all_default_keys = set(key for key in schema - if isinstance(key, Required) - or isinstance(key, Optional)) - - _compiled_schema = {} - for skey, svalue in iteritems(schema): - new_key = self._compile(skey) - new_value = self._compile(svalue) - _compiled_schema[skey] = (new_key, new_value) - - candidates = list(_iterate_mapping_candidates(_compiled_schema)) - - def validate_mapping(path, iterable, out): - required_keys = all_required_keys.copy() - # keeps track of all default keys that haven't been filled - default_keys = all_default_keys.copy() - error = None - errors = [] - for key, value in iterable: - key_path = path + [key] - remove_key = False - - # compare each given key/value against all compiled key/values - # schema key, (compiled key, compiled value) - for skey, (ckey, cvalue) in candidates: - try: - new_key = ckey(key_path, key) - except Invalid as e: - if len(e.path) > len(key_path): - raise - if not error or len(e.path) > len(error.path): - error = e - continue - # Backtracking is not performed once a key is selected, so if - # the value is invalid we immediately throw an exception. - exception_errors = [] - # check if the key is marked for removal - is_remove = new_key is Remove - try: - cval = cvalue(key_path, value) - # include if it's not marked for removal - if not is_remove: - out[new_key] = cval - else: - remove_key = True - continue - except MultipleInvalid as e: - exception_errors.extend(e.errors) - except Invalid as e: - exception_errors.append(e) - - if exception_errors: - if is_remove or remove_key: - continue - for err in exception_errors: - if len(err.path) > len(key_path): - errors.append(err) - else: - err.error_type = invalid_msg - errors.append(err) - # If there is a validation error for a required - # key, this means that the key was provided. - # Discard the required key so it does not - # create an additional, noisy exception. - required_keys.discard(skey) - break - - # Key and value okay, mark any Required() fields as found. - required_keys.discard(skey) - - # No need for a default if it was filled - default_keys.discard(skey) - - break - else: - if remove_key: - # remove key - continue - elif self.extra == ALLOW_EXTRA: - out[key] = value - elif self.extra != REMOVE_EXTRA: - errors.append(Invalid('extra keys not allowed', key_path)) - # else REMOVE_EXTRA: ignore the key so it's removed from output - - # set defaults for any that can have defaults - for key in default_keys: - if key.default != UNDEFINED: # if the user provides a default with the node - out[key.schema] = key.default() - if key in required_keys: - required_keys.discard(key) - - # for any required keys left that weren't found and don't have defaults: - for key in required_keys: - msg = key.msg if hasattr(key, 'msg') and key.msg else 'required key not provided' - errors.append(RequiredFieldInvalid(msg, path + [key])) - if errors: - raise MultipleInvalid(errors) - - return out - - return validate_mapping - - def _compile_object(self, schema): - """Validate an object. - - Has the same behavior as dictionary validator but work with object - attributes. - - For example: - - >>> class Structure(object): - ... def __init__(self, one=None, three=None): - ... self.one = one - ... self.three = three - ... - >>> validate = Schema(Object({'one': 'two', 'three': 'four'}, cls=Structure)) - >>> with raises(MultipleInvalid, "not a valid value for object value @ data['one']"): - ... validate(Structure(one='three')) - - """ - base_validate = self._compile_mapping( - schema, invalid_msg='object value') - - def validate_object(path, data): - if (schema.cls is not UNDEFINED - and not isinstance(data, schema.cls)): - raise ObjectInvalid('expected a {0!r}'.format(schema.cls), path) - iterable = _iterate_object(data) - iterable = ifilter(lambda item: item[1] is not None, iterable) - out = base_validate(path, iterable, {}) - return type(data)(**out) - - return validate_object - - def _compile_dict(self, schema): - """Validate a dictionary. - - A dictionary schema can contain a set of values, or at most one - validator function/type. - - A dictionary schema will only validate a dictionary: - - >>> validate = Schema({}) - >>> with raises(MultipleInvalid, 'expected a dictionary'): - ... validate([]) - - An invalid dictionary value: - - >>> validate = Schema({'one': 'two', 'three': 'four'}) - >>> with raises(MultipleInvalid, "not a valid value for dictionary value @ data['one']"): - ... validate({'one': 'three'}) - - An invalid key: - - >>> with raises(MultipleInvalid, "extra keys not allowed @ data['two']"): - ... validate({'two': 'three'}) - - - Validation function, in this case the "int" type: - - >>> validate = Schema({'one': 'two', 'three': 'four', int: str}) - - Valid integer input: - - >>> validate({10: 'twenty'}) - {10: 'twenty'} - - By default, a "type" in the schema (in this case "int") will be used - purely to validate that the corresponding value is of that type. It - will not Coerce the value: - - >>> with raises(MultipleInvalid, "extra keys not allowed @ data['10']"): - ... validate({'10': 'twenty'}) - - Wrap them in the Coerce() function to achieve this: - - >>> validate = Schema({'one': 'two', 'three': 'four', - ... Coerce(int): str}) - >>> validate({'10': 'twenty'}) - {10: 'twenty'} - - Custom message for required key - - >>> validate = Schema({Required('one', 'required'): 'two'}) - >>> with raises(MultipleInvalid, "required @ data['one']"): - ... validate({}) - - (This is to avoid unexpected surprises.) - - Multiple errors for nested field in a dict: - - >>> validate = Schema({ - ... 'adict': { - ... 'strfield': str, - ... 'intfield': int - ... } - ... }) - >>> try: - ... validate({ - ... 'adict': { - ... 'strfield': 123, - ... 'intfield': 'one' - ... } - ... }) - ... except MultipleInvalid as e: - ... print(sorted(str(i) for i in e.errors)) # doctest: +NORMALIZE_WHITESPACE - ["expected int for dictionary value @ data['adict']['intfield']", - "expected str for dictionary value @ data['adict']['strfield']"] - - """ - base_validate = self._compile_mapping( - schema, invalid_msg='dictionary value') - - groups_of_exclusion = {} - groups_of_inclusion = {} - for node in schema: - if isinstance(node, Exclusive): - g = groups_of_exclusion.setdefault(node.group_of_exclusion, []) - g.append(node) - elif isinstance(node, Inclusive): - g = groups_of_inclusion.setdefault(node.group_of_inclusion, []) - g.append(node) - - def validate_dict(path, data): - if not isinstance(data, dict): - raise DictInvalid('expected a dictionary', path) - - errors = [] - for label, group in groups_of_exclusion.items(): - exists = False - for exclusive in group: - if exclusive.schema in data: - if exists: - msg = exclusive.msg if hasattr(exclusive, 'msg') and exclusive.msg else \ - "two or more values in the same group of exclusion '%s'" % label - errors.append(ExclusiveInvalid(msg, path)) - break - exists = True - - if errors: - raise MultipleInvalid(errors) - - for label, group in groups_of_inclusion.items(): - included = [node.schema in data for node in group] - if any(included) and not all(included): - msg = None - for g in group: - if hasattr(g, 'msg') and g.msg: - msg = g.msg - break - if msg is None: - msg = ("some but not all values in the same group of " - "inclusion '%s'") % label - errors.append(InclusiveInvalid(msg, path)) - break - - if errors: - raise MultipleInvalid(errors) - - out = {} - return base_validate(path, iteritems(data), out) - - return validate_dict - - def _compile_sequence(self, schema, seq_type): - """Validate a sequence type. - - This is a sequence of valid values or validators tried in order. - - >>> validator = Schema(['one', 'two', int]) - >>> validator(['one']) - ['one'] - >>> with raises(MultipleInvalid, 'invalid list value @ data[0]'): - ... validator([3.5]) - >>> validator([1]) - [1] - """ - _compiled = [self._compile(s) for s in schema] - seq_type_name = seq_type.__name__ - - def validate_sequence(path, data): - if not isinstance(data, seq_type): - raise SequenceTypeInvalid('expected a %s' % seq_type_name, path) - - # Empty seq schema, allow any data. - if not schema: - return data - - out = [] - invalid = None - errors = [] - index_path = UNDEFINED - for i, value in enumerate(data): - index_path = path + [i] - invalid = None - for validate in _compiled: - try: - cval = validate(index_path, value) - if cval is not Remove: # do not include Remove values - out.append(cval) - break - except Invalid as e: - if len(e.path) > len(index_path): - raise - invalid = e - else: - if len(invalid.path) <= len(index_path): - invalid = SequenceItemInvalid('invalid %s value' % seq_type_name, index_path) - errors.append(invalid) - if errors: - raise MultipleInvalid(errors) - return type(data)(out) - return validate_sequence - - def _compile_tuple(self, schema): - """Validate a tuple. - - A tuple is a sequence of valid values or validators tried in order. - - >>> validator = Schema(('one', 'two', int)) - >>> validator(('one',)) - ('one',) - >>> with raises(MultipleInvalid, 'invalid tuple value @ data[0]'): - ... validator((3.5,)) - >>> validator((1,)) - (1,) - """ - return self._compile_sequence(schema, tuple) - - def _compile_list(self, schema): - """Validate a list. - - A list is a sequence of valid values or validators tried in order. - - >>> validator = Schema(['one', 'two', int]) - >>> validator(['one']) - ['one'] - >>> with raises(MultipleInvalid, 'invalid list value @ data[0]'): - ... validator([3.5]) - >>> validator([1]) - [1] - """ - return self._compile_sequence(schema, list) - - -def _compile_scalar(schema): - """A scalar value. - - The schema can either be a value or a type. - - >>> _compile_scalar(int)([], 1) - 1 - >>> with raises(Invalid, 'expected float'): - ... _compile_scalar(float)([], '1') - - Callables have - >>> _compile_scalar(lambda v: float(v))([], '1') - 1.0 - - As a convenience, ValueError's are trapped: - - >>> with raises(Invalid, 'not a valid value'): - ... _compile_scalar(lambda v: float(v))([], 'a') - """ - if isinstance(schema, type): - def validate_instance(path, data): - if isinstance(data, schema): - return data - else: - msg = 'expected %s' % schema.__name__ - raise TypeInvalid(msg, path) - return validate_instance - - if callable(schema): - def validate_callable(path, data): - try: - return schema(data) - except ValueError as e: - raise ValueInvalid('not a valid value', path) - except MultipleInvalid as e: - for error in e.errors: - error.path = path + error.path - raise - except Invalid as e: - e.path = path + e.path - raise - return validate_callable - - def validate_value(path, data): - if data != schema: - raise ScalarInvalid('not a valid value', path) - return data - - return validate_value - - -def _compile_itemsort(): - '''return sort function of mappings''' - def is_extra(key_): - return key_ is Extra - - def is_remove(key_): - return isinstance(key_, Remove) - - def is_marker(key_): - return isinstance(key_, Marker) - - def is_type(key_): - return inspect.isclass(key_) - - def is_callable(key_): - return callable(key_) - - # priority list for map sorting (in order of checking) - # We want Extra to match last, because it's a catch-all. On the other hand, - # Remove markers should match first (since invalid values will not - # raise an Error, instead the validator will check if other schemas match - # the same value). - priority = [(1, is_remove), # Remove highest priority after values - (2, is_marker), # then other Markers - (4, is_type), # types/classes lowest before Extra - (3, is_callable), # callables after markers - (5, is_extra)] # Extra lowest priority - - def item_priority(item_): - key_ = item_[0] - for i, check_ in priority: - if check_(key_): - return i - # values have hightest priorities - return 0 - - return item_priority - -_sort_item = _compile_itemsort() - - -def _iterate_mapping_candidates(schema): - """Iterate over schema in a meaningful order.""" - # Without this, Extra might appear first in the iterator, and fail to - # validate a key even though it's a Required that has its own validation, - # generating a false positive. - return sorted(iteritems(schema), key=_sort_item) - - -def _iterate_object(obj): - """Return iterator over object attributes. Respect objects with - defined __slots__. - - """ - d = {} - try: - d = vars(obj) - except TypeError: - # maybe we have named tuple here? - if hasattr(obj, '_asdict'): - d = obj._asdict() - for item in iteritems(d): - yield item - try: - slots = obj.__slots__ - except AttributeError: - pass - else: - for key in slots: - if key != '__dict__': - yield (key, getattr(obj, key)) - raise StopIteration() - - -class Object(dict): - """Indicate that we should work with attributes, not keys.""" - - def __init__(self, schema, cls=UNDEFINED): - self.cls = cls - super(Object, self).__init__(schema) - - -class Marker(object): - """Mark nodes for special treatment.""" - - def __init__(self, schema, msg=None): - self.schema = schema - self._schema = Schema(schema) - self.msg = msg - - def __call__(self, v): - try: - return self._schema(v) - except Invalid as e: - if not self.msg or len(e.path) > 1: - raise - raise Invalid(self.msg) - - def __str__(self): - return str(self.schema) - - def __repr__(self): - return repr(self.schema) - - -class Optional(Marker): - """Mark a node in the schema as optional, and optionally provide a default - - >>> schema = Schema({Optional('key'): str}) - >>> schema({}) - {} - >>> schema = Schema({Optional('key', default='value'): str}) - >>> schema({}) - {'key': 'value'} - >>> schema = Schema({Optional('key', default=list): list}) - >>> schema({}) - {'key': []} - - If 'required' flag is set for an entire schema, optional keys aren't required - - >>> schema = Schema({ - ... Optional('key'): str, - ... 'key2': str - ... }, required=True) - >>> schema({'key2':'value'}) - {'key2': 'value'} - """ - def __init__(self, schema, msg=None, default=UNDEFINED): - super(Optional, self).__init__(schema, msg=msg) - self.default = default_factory(default) - - -class Exclusive(Optional): - """Mark a node in the schema as exclusive. - - Exclusive keys inherited from Optional: - - >>> schema = Schema({Exclusive('alpha', 'angles'): int, Exclusive('beta', 'angles'): int}) - >>> schema({'alpha': 30}) - {'alpha': 30} - - Keys inside a same group of exclusion cannot be together, it only makes sense for dictionaries: - - >>> with raises(MultipleInvalid, "two or more values in the same group of exclusion 'angles'"): - ... schema({'alpha': 30, 'beta': 45}) - - For example, API can provides multiple types of authentication, but only one works in the same time: - - >>> msg = 'Please, use only one type of authentication at the same time.' - >>> schema = Schema({ - ... Exclusive('classic', 'auth', msg=msg):{ - ... Required('email'): basestring, - ... Required('password'): basestring - ... }, - ... Exclusive('internal', 'auth', msg=msg):{ - ... Required('secret_key'): basestring - ... }, - ... Exclusive('social', 'auth', msg=msg):{ - ... Required('social_network'): basestring, - ... Required('token'): basestring - ... } - ... }) - - >>> with raises(MultipleInvalid, "Please, use only one type of authentication at the same time."): - ... schema({'classic': {'email': 'foo@example.com', 'password': 'bar'}, - ... 'social': {'social_network': 'barfoo', 'token': 'tEMp'}}) - """ - def __init__(self, schema, group_of_exclusion, msg=None): - super(Exclusive, self).__init__(schema, msg=msg) - self.group_of_exclusion = group_of_exclusion - - -class Inclusive(Optional): - """ Mark a node in the schema as inclusive. - - Exclusive keys inherited from Optional: - - >>> schema = Schema({ - ... Inclusive('filename', 'file'): str, - ... Inclusive('mimetype', 'file'): str - ... }) - >>> data = {'filename': 'dog.jpg', 'mimetype': 'image/jpeg'} - >>> data == schema(data) - True - - Keys inside a same group of inclusive must exist together, it only makes sense for dictionaries: - - >>> with raises(MultipleInvalid, "some but not all values in the same group of inclusion 'file'"): - ... schema({'filename': 'dog.jpg'}) - - If none of the keys in the group are present, it is accepted: - - >>> schema({}) - {} - - For example, API can return 'height' and 'width' together, but not separately. - - >>> msg = 'Height and width must exist together' - >>> schema = Schema({ - ... Inclusive('height', 'size', msg=msg): int, - ... Inclusive('width', 'size', msg=msg): int - ... }) - - >>> with raises(MultipleInvalid, msg): - ... schema({'height': 100}) - - >>> with raises(MultipleInvalid, msg): - ... schema({'width': 100}) - - >>> data = {'height': 100, 'width': 100} - >>> data == schema(data) - True - """ - - def __init__(self, schema, group_of_inclusion, msg=None): - super(Inclusive, self).__init__(schema, msg=msg) - self.group_of_inclusion = group_of_inclusion - - -class Required(Marker): - """Mark a node in the schema as being required, and optionally provide a default value. - - >>> schema = Schema({Required('key'): str}) - >>> with raises(MultipleInvalid, "required key not provided @ data['key']"): - ... schema({}) - - >>> schema = Schema({Required('key', default='value'): str}) - >>> schema({}) - {'key': 'value'} - >>> schema = Schema({Required('key', default=list): list}) - >>> schema({}) - {'key': []} - """ - def __init__(self, schema, msg=None, default=UNDEFINED): - super(Required, self).__init__(schema, msg=msg) - self.default = default_factory(default) - - -class Remove(Marker): - """Mark a node in the schema to be removed and excluded from the validated - output. Keys that fail validation will not raise ``Invalid``. Instead, these - keys will be treated as extras. - - >>> schema = Schema({str: int, Remove(int): str}) - >>> with raises(MultipleInvalid, "extra keys not allowed @ data[1]"): - ... schema({'keep': 1, 1: 1.0}) - >>> schema({1: 'red', 'red': 1, 2: 'green'}) - {'red': 1} - >>> schema = Schema([int, Remove(float), Extra]) - >>> schema([1, 2, 3, 4.0, 5, 6.0, '7']) - [1, 2, 3, 5, '7'] - """ - def __call__(self, v): - super(Remove, self).__call__(v) - return self.__class__ - - -def Extra(_): - """Allow keys in the data that are not present in the schema.""" - raise SchemaError('"Extra" should never be called') - - -# As extra() is never called there's no way to catch references to the -# deprecated object, so we just leave an alias here instead. -extra = Extra - - -def Msg(schema, msg, cls=None): - """Report a user-friendly message if a schema fails to validate. - - >>> validate = Schema( - ... Msg(['one', 'two', int], - ... 'should be one of "one", "two" or an integer')) - >>> with raises(MultipleInvalid, 'should be one of "one", "two" or an integer'): - ... validate(['three']) - - Messages are only applied to invalid direct descendants of the schema: - - >>> validate = Schema(Msg([['one', 'two', int]], 'not okay!')) - >>> with raises(MultipleInvalid, 'invalid list value @ data[0][0]'): - ... validate([['three']]) - - The type which is thrown can be overridden but needs to be a subclass of Invalid - - >>> with raises(SchemaError, 'Msg can only use subclases of Invalid as custom class'): - ... validate = Schema(Msg([int], 'should be int', cls=KeyError)) - - If you do use a subclass of Invalid, that error will be thrown (wrapped in a MultipleInvalid) - - >>> validate = Schema(Msg([['one', 'two', int]], 'not okay!', cls=RangeInvalid)) - >>> try: - ... validate(['three']) - ... except MultipleInvalid as e: - ... assert isinstance(e.errors[0], RangeInvalid) - """ - - schema = Schema(schema) - - if cls and not issubclass(cls, Invalid): - raise SchemaError("Msg can only use subclases of Invalid as custom class") - - @wraps(Msg) - def f(v): - try: - return schema(v) - except Invalid as e: - if len(e.path) > 1: - raise e - else: - raise (cls or Invalid)(msg) - return f - - -def message(default=None, cls=None): - """Convenience decorator to allow functions to provide a message. - - Set a default message: - - >>> @message('not an integer') - ... def isint(v): - ... return int(v) - - >>> validate = Schema(isint()) - >>> with raises(MultipleInvalid, 'not an integer'): - ... validate('a') - - The message can be overridden on a per validator basis: - - >>> validate = Schema(isint('bad')) - >>> with raises(MultipleInvalid, 'bad'): - ... validate('a') - - The class thrown too: - - >>> class IntegerInvalid(Invalid): pass - >>> validate = Schema(isint('bad', clsoverride=IntegerInvalid)) - >>> try: - ... validate('a') - ... except MultipleInvalid as e: - ... assert isinstance(e.errors[0], IntegerInvalid) - """ - if cls and not issubclass(cls, Invalid): - raise SchemaError("message can only use subclases of Invalid as custom class") - - def decorator(f): - @wraps(f) - def check(msg=None, clsoverride=None): - @wraps(f) - def wrapper(*args, **kwargs): - try: - return f(*args, **kwargs) - except ValueError: - raise (clsoverride or cls or ValueInvalid)(msg or default or 'invalid value') - return wrapper - return check - return decorator - - -def truth(f): - """Convenience decorator to convert truth functions into validators. - - >>> @truth - ... def isdir(v): - ... return os.path.isdir(v) - >>> validate = Schema(isdir) - >>> validate('/') - '/' - >>> with raises(MultipleInvalid, 'not a valid value'): - ... validate('/notavaliddir') - """ - @wraps(f) - def check(v): - t = f(v) - if not t: - raise ValueError - return v - return check - - -def Coerce(type, msg=None): - """Coerce a value to a type. - - If the type constructor throws a ValueError or TypeError, the value - will be marked as Invalid. - - - Default behavior: - - >>> validate = Schema(Coerce(int)) - >>> with raises(MultipleInvalid, 'expected int'): - ... validate(None) - >>> with raises(MultipleInvalid, 'expected int'): - ... validate('foo') - - With custom message: - - >>> validate = Schema(Coerce(int, "moo")) - >>> with raises(MultipleInvalid, 'moo'): - ... validate('foo') - """ - @wraps(Coerce) - def f(v): - try: - return type(v) - except (ValueError, TypeError): - raise CoerceInvalid(msg or ('expected %s' % type.__name__)) - return f - - -@message('value was not true', cls=TrueInvalid) -@truth -def IsTrue(v): - """Assert that a value is true, in the Python sense. - - >>> validate = Schema(IsTrue()) - - "In the Python sense" means that implicitly false values, such as empty - lists, dictionaries, etc. are treated as "false": - - >>> with raises(MultipleInvalid, "value was not true"): - ... validate([]) - >>> validate([1]) - [1] - >>> with raises(MultipleInvalid, "value was not true"): - ... validate(False) - - ...and so on. - - >>> try: - ... validate([]) - ... except MultipleInvalid as e: - ... assert isinstance(e.errors[0], TrueInvalid) - """ - return v - - -@message('value was not false', cls=FalseInvalid) -def IsFalse(v): - """Assert that a value is false, in the Python sense. - - (see :func:`IsTrue` for more detail) - - >>> validate = Schema(IsFalse()) - >>> validate([]) - [] - >>> with raises(MultipleInvalid, "value was not false"): - ... validate(True) - - >>> try: - ... validate(True) - ... except MultipleInvalid as e: - ... assert isinstance(e.errors[0], FalseInvalid) - """ - if v: - raise ValueError - return v - - -@message('expected boolean', cls=BooleanInvalid) -def Boolean(v): - """Convert human-readable boolean values to a bool. - - Accepted values are 1, true, yes, on, enable, and their negatives. - Non-string values are cast to bool. - - >>> validate = Schema(Boolean()) - >>> validate(True) - True - >>> validate("1") - True - >>> validate("0") - False - >>> with raises(MultipleInvalid, "expected boolean"): - ... validate('moo') - >>> try: - ... validate('moo') - ... except MultipleInvalid as e: - ... assert isinstance(e.errors[0], BooleanInvalid) - """ - if isinstance(v, basestring): - v = v.lower() - if v in ('1', 'true', 'yes', 'on', 'enable'): - return True - if v in ('0', 'false', 'no', 'off', 'disable'): - return False - raise ValueError - return bool(v) - - -def Any(*validators, **kwargs): - """Use the first validated value. - - :param msg: Message to deliver to user if validation fails. - :param kwargs: All other keyword arguments are passed to the sub-Schema constructors. - :returns: Return value of the first validator that passes. - - >>> validate = Schema(Any('true', 'false', - ... All(Any(int, bool), Coerce(bool)))) - >>> validate('true') - 'true' - >>> validate(1) - True - >>> with raises(MultipleInvalid, "not a valid value"): - ... validate('moo') - - msg argument is used - - >>> validate = Schema(Any(1, 2, 3, msg="Expected 1 2 or 3")) - >>> validate(1) - 1 - >>> with raises(MultipleInvalid, "Expected 1 2 or 3"): - ... validate(4) - """ - msg = kwargs.pop('msg', None) - schemas = [Schema(val, **kwargs) for val in validators] - - @wraps(Any) - def f(v): - error = None - for schema in schemas: - try: - return schema(v) - except Invalid as e: - if error is None or len(e.path) > len(error.path): - error = e - else: - if error: - raise error if msg is None else AnyInvalid(msg) - raise AnyInvalid(msg or 'no valid value found') - return f - - -def All(*validators, **kwargs): - """Value must pass all validators. - - The output of each validator is passed as input to the next. - - :param msg: Message to deliver to user if validation fails. - :param kwargs: All other keyword arguments are passed to the sub-Schema constructors. - - >>> validate = Schema(All('10', Coerce(int))) - >>> validate('10') - 10 - """ - msg = kwargs.pop('msg', None) - schemas = [Schema(val, **kwargs) for val in validators] - - def f(v): - try: - for schema in schemas: - v = schema(v) - except Invalid as e: - raise e if msg is None else AllInvalid(msg) - return v - return f - - -def Match(pattern, msg=None): - """Value must be a string that matches the regular expression. - - >>> validate = Schema(Match(r'^0x[A-F0-9]+$')) - >>> validate('0x123EF4') - '0x123EF4' - >>> with raises(MultipleInvalid, "does not match regular expression"): - ... validate('123EF4') - - >>> with raises(MultipleInvalid, 'expected string or buffer'): - ... validate(123) - - Pattern may also be a _compiled regular expression: - - >>> validate = Schema(Match(re.compile(r'0x[A-F0-9]+', re.I))) - >>> validate('0x123ef4') - '0x123ef4' - """ - if isinstance(pattern, basestring): - pattern = re.compile(pattern) - - def f(v): - try: - match = pattern.match(v) - except TypeError: - raise MatchInvalid("expected string or buffer") - if not match: - raise MatchInvalid(msg or 'does not match regular expression') - return v - return f - - -def Replace(pattern, substitution, msg=None): - """Regex substitution. - - >>> validate = Schema(All(Replace('you', 'I'), - ... Replace('hello', 'goodbye'))) - >>> validate('you say hello') - 'I say goodbye' - """ - if isinstance(pattern, basestring): - pattern = re.compile(pattern) - - def f(v): - return pattern.sub(substitution, v) - return f - - -@message('expected a URL', cls=UrlInvalid) -def Url(v): - """Verify that the value is a URL. - - >>> s = Schema(Url()) - >>> with raises(MultipleInvalid, 'expected a URL'): - ... s(1) - >>> s('http://w3.org') - 'http://w3.org' - """ - try: - urlparse.urlparse(v) - return v - except: - raise ValueError - - -@message('not a file', cls=FileInvalid) -@truth -def IsFile(v): - """Verify the file exists. - - >>> os.path.basename(IsFile()(__file__)).startswith('voluptuous.py') - True - >>> with raises(FileInvalid, 'not a file'): - ... IsFile()("random_filename_goes_here.py") - """ - return os.path.isfile(v) - - -@message('not a directory', cls=DirInvalid) -@truth -def IsDir(v): - """Verify the directory exists. - - >>> IsDir()('/') - '/' - """ - return os.path.isdir(v) - - -@message('path does not exist', cls=PathInvalid) -@truth -def PathExists(v): - """Verify the path exists, regardless of its type. - - >>> os.path.basename(PathExists()(__file__)).startswith('voluptuous.py') - True - >>> with raises(Invalid, 'path does not exist'): - ... PathExists()("random_filename_goes_here.py") - """ - return os.path.exists(v) - - -def Range(min=None, max=None, min_included=True, max_included=True, msg=None): - """Limit a value to a range. - - Either min or max may be omitted. - Either min or max can be excluded from the range of accepted values. - - :raises Invalid: If the value is outside the range. - - >>> s = Schema(Range(min=1, max=10, min_included=False)) - >>> s(5) - 5 - >>> s(10) - 10 - >>> with raises(MultipleInvalid, 'value must be at most 10'): - ... s(20) - >>> with raises(MultipleInvalid, 'value must be higher than 1'): - ... s(1) - >>> with raises(MultipleInvalid, 'value must be lower than 10'): - ... Schema(Range(max=10, max_included=False))(20) - """ - @wraps(Range) - def f(v): - if min_included: - if min is not None and v < min: - raise RangeInvalid(msg or 'value must be at least %s' % min) - else: - if min is not None and v <= min: - raise RangeInvalid(msg or 'value must be higher than %s' % min) - if max_included: - if max is not None and v > max: - raise RangeInvalid(msg or 'value must be at most %s' % max) - else: - if max is not None and v >= max: - raise RangeInvalid(msg or 'value must be lower than %s' % max) - return v - return f - - -def Clamp(min=None, max=None, msg=None): - """Clamp a value to a range. - - Either min or max may be omitted. - >>> s = Schema(Clamp(min=0, max=1)) - >>> s(0.5) - 0.5 - >>> s(5) - 1 - >>> s(-1) - 0 - - """ - @wraps(Clamp) - def f(v): - if min is not None and v < min: - v = min - if max is not None and v > max: - v = max - return v - return f - - -class LengthInvalid(Invalid): - pass - - -def Length(min=None, max=None, msg=None): - """The length of a value must be in a certain range.""" - @wraps(Length) - def f(v): - if min is not None and len(v) < min: - raise LengthInvalid(msg or 'length of value must be at least %s' % min) - if max is not None and len(v) > max: - raise LengthInvalid(msg or 'length of value must be at most %s' % max) - return v - return f - - -class DatetimeInvalid(Invalid): - """The value is not a formatted datetime string.""" - - -def Datetime(format=None, msg=None): - """Validate that the value matches the datetime format.""" - @wraps(Datetime) - def f(v): - check_format = format or '%Y-%m-%dT%H:%M:%S.%fZ' - try: - datetime.datetime.strptime(v, check_format) - except (TypeError, ValueError): - raise DatetimeInvalid(msg or 'value does not match expected format %s' % check_format) - return v - return f - - -class InInvalid(Invalid): - pass - - -def In(container, msg=None): - """Validate that a value is in a collection.""" - @wraps(In) - def validator(value): - if value not in container: - raise InInvalid(msg or 'value is not allowed') - return value - return validator - - -def Lower(v): - """Transform a string to lower case. - - >>> s = Schema(Lower) - >>> s('HI') - 'hi' - """ - return str(v).lower() - - -def Upper(v): - """Transform a string to upper case. - - >>> s = Schema(Upper) - >>> s('hi') - 'HI' - """ - return str(v).upper() - - -def Capitalize(v): - """Capitalise a string. - - >>> s = Schema(Capitalize) - >>> s('hello world') - 'Hello world' - """ - return str(v).capitalize() - - -def Title(v): - """Title case a string. - - >>> s = Schema(Title) - >>> s('hello world') - 'Hello World' - """ - return str(v).title() - - -def DefaultTo(default_value, msg=None): - """Sets a value to default_value if none provided. - - >>> s = Schema(DefaultTo(42)) - >>> s(None) - 42 - >>> s = Schema(DefaultTo(list)) - >>> s(None) - [] - """ - default_value = default_factory(default_value) - - @wraps(DefaultTo) - def f(v): - if v is None: - v = default_value() - return v - return f - - -class ExactSequenceInvalid(Invalid): - pass - - -def ExactSequence(validators, **kwargs): - """Matches each element in a sequence against the corresponding element in - the validators. - - :param msg: Message to deliver to user if validation fails. - :param kwargs: All other keyword arguments are passed to the sub-Schema - constructors. - - >>> from voluptuous import * - >>> validate = Schema(ExactSequence([str, int, list, list])) - >>> validate(['hourly_report', 10, [], []]) - ['hourly_report', 10, [], []] - """ - msg = kwargs.pop('msg', None) - schemas = [Schema(val, **kwargs) for val in validators] - - def f(v): - if not isinstance(v, (list, tuple)): - raise ExactSequenceInvalid(msg) - try: - for i, schema in enumerate(schemas): - v[i] = schema(v[i]) - except Invalid as e: - raise e if msg is None else ExactSequenceInvalid(msg) - return v - return f - - -class Literal(object): - def __init__(self, lit): - self.lit = lit - - def __call__(self, value, msg=None): - if self.lit != value: - raise LiteralInvalid( - msg or '%s not match for %s' % (value, self.lit) - ) - else: - return self.lit - - def __str__(self): - return str(self.lit) - - def __repr__(self): - return repr(self.lit) - - -if __name__ == '__main__': - import doctest - doctest.testmod() diff --git a/voluptuous/__init__.py b/voluptuous/__init__.py new file mode 100644 index 00000000..e1980474 --- /dev/null +++ b/voluptuous/__init__.py @@ -0,0 +1,88 @@ +"""Schema validation for Python data structures. + +Given eg. a nested data structure like this: + + { + 'exclude': ['Users', 'Uptime'], + 'include': [], + 'set': { + 'snmp_community': 'public', + 'snmp_timeout': 15, + 'snmp_version': '2c', + }, + 'targets': { + 'localhost': { + 'exclude': ['Uptime'], + 'features': { + 'Uptime': { + 'retries': 3, + }, + 'Users': { + 'snmp_community': 'monkey', + 'snmp_port': 15, + }, + }, + 'include': ['Users'], + 'set': { + 'snmp_community': 'monkeys', + }, + }, + }, + } + +A schema like this: + + >>> settings = { + ... 'snmp_community': str, + ... 'retries': int, + ... 'snmp_version': All(Coerce(str), Any('3', '2c', '1')), + ... } + >>> features = ['Ping', 'Uptime', 'Http'] + >>> schema = Schema({ + ... 'exclude': features, + ... 'include': features, + ... 'set': settings, + ... 'targets': { + ... 'exclude': features, + ... 'include': features, + ... 'features': { + ... str: settings, + ... }, + ... }, + ... }) + +Validate like so: + + >>> schema({ + ... 'set': { + ... 'snmp_community': 'public', + ... 'snmp_version': '2c', + ... }, + ... 'targets': { + ... 'exclude': ['Ping'], + ... 'features': { + ... 'Uptime': {'retries': 3}, + ... 'Users': {'snmp_community': 'monkey'}, + ... }, + ... }, + ... }) == { + ... 'set': {'snmp_version': '2c', 'snmp_community': 'public'}, + ... 'targets': { + ... 'exclude': ['Ping'], + ... 'features': {'Uptime': {'retries': 3}, + ... 'Users': {'snmp_community': 'monkey'}}}} + True +""" + +# flake8: noqa +# fmt: off +from voluptuous.schema_builder import * +from voluptuous.util import * +from voluptuous.validators import * + +from voluptuous.error import * # isort: skip + +# fmt: on + +__version__ = '0.16.0' +__author__ = 'alecthomas' diff --git a/voluptuous/error.py b/voluptuous/error.py new file mode 100644 index 00000000..9dab9435 --- /dev/null +++ b/voluptuous/error.py @@ -0,0 +1,219 @@ +# fmt: off +import typing + +# fmt: on + + +class Error(Exception): + """Base validation exception.""" + + +class SchemaError(Error): + """An error was encountered in the schema.""" + + +class Invalid(Error): + """The data was invalid. + + :attr msg: The error message. + :attr path: The path to the error, as a list of keys in the source data. + :attr error_message: The actual error message that was raised, as a + string. + + """ + + def __init__( + self, + message: str, + path: typing.Optional[typing.List[typing.Hashable]] = None, + error_message: typing.Optional[str] = None, + error_type: typing.Optional[str] = None, + ) -> None: + Error.__init__(self, message) + self._path = path or [] + self._error_message = error_message or message + self.error_type = error_type + + @property + def msg(self) -> str: + return self.args[0] + + @property + def path(self) -> typing.List[typing.Hashable]: + return self._path + + @property + def error_message(self) -> str: + return self._error_message + + def __str__(self) -> str: + path = ' @ data[%s]' % ']['.join(map(repr, self.path)) if self.path else '' + output = Exception.__str__(self) + if self.error_type: + output += ' for ' + self.error_type + return output + path + + def prepend(self, path: typing.List[typing.Hashable]) -> None: + self._path = path + self.path + + +class MultipleInvalid(Invalid): + def __init__(self, errors: typing.Optional[typing.List[Invalid]] = None) -> None: + self.errors = errors[:] if errors else [] + + def __repr__(self) -> str: + return 'MultipleInvalid(%r)' % self.errors + + @property + def msg(self) -> str: + return self.errors[0].msg + + @property + def path(self) -> typing.List[typing.Hashable]: + return self.errors[0].path + + @property + def error_message(self) -> str: + return self.errors[0].error_message + + def add(self, error: Invalid) -> None: + self.errors.append(error) + + def __str__(self) -> str: + return str(self.errors[0]) + + def prepend(self, path: typing.List[typing.Hashable]) -> None: + for error in self.errors: + error.prepend(path) + + +class RequiredFieldInvalid(Invalid): + """Required field was missing.""" + + +class ObjectInvalid(Invalid): + """The value we found was not an object.""" + + +class DictInvalid(Invalid): + """The value found was not a dict.""" + + +class ExclusiveInvalid(Invalid): + """More than one value found in exclusion group.""" + + +class InclusiveInvalid(Invalid): + """Not all values found in inclusion group.""" + + +class SequenceTypeInvalid(Invalid): + """The type found is not a sequence type.""" + + +class TypeInvalid(Invalid): + """The value was not of required type.""" + + +class ValueInvalid(Invalid): + """The value was found invalid by evaluation function.""" + + +class ContainsInvalid(Invalid): + """List does not contain item""" + + +class ScalarInvalid(Invalid): + """Scalars did not match.""" + + +class CoerceInvalid(Invalid): + """Impossible to coerce value to type.""" + + +class AnyInvalid(Invalid): + """The value did not pass any validator.""" + + +class AllInvalid(Invalid): + """The value did not pass all validators.""" + + +class MatchInvalid(Invalid): + """The value does not match the given regular expression.""" + + +class RangeInvalid(Invalid): + """The value is not in given range.""" + + +class TrueInvalid(Invalid): + """The value is not True.""" + + +class FalseInvalid(Invalid): + """The value is not False.""" + + +class BooleanInvalid(Invalid): + """The value is not a boolean.""" + + +class UrlInvalid(Invalid): + """The value is not a URL.""" + + +class EmailInvalid(Invalid): + """The value is not an email address.""" + + +class FileInvalid(Invalid): + """The value is not a file.""" + + +class DirInvalid(Invalid): + """The value is not a directory.""" + + +class PathInvalid(Invalid): + """The value is not a path.""" + + +class LiteralInvalid(Invalid): + """The literal values do not match.""" + + +class LengthInvalid(Invalid): + pass + + +class DatetimeInvalid(Invalid): + """The value is not a formatted datetime string.""" + + +class DateInvalid(Invalid): + """The value is not a formatted date string.""" + + +class InInvalid(Invalid): + pass + + +class NotInInvalid(Invalid): + pass + + +class ExactSequenceInvalid(Invalid): + pass + + +class NotEnoughValid(Invalid): + """The value did not pass enough validations.""" + + pass + + +class TooManyValid(Invalid): + """The value passed more than expected validations.""" + + pass diff --git a/voluptuous/humanize.py b/voluptuous/humanize.py new file mode 100644 index 00000000..eabfd027 --- /dev/null +++ b/voluptuous/humanize.py @@ -0,0 +1,57 @@ +# fmt: off +import typing + +from voluptuous import Invalid, MultipleInvalid +from voluptuous.error import Error +from voluptuous.schema_builder import Schema + +# fmt: on + +MAX_VALIDATION_ERROR_ITEM_LENGTH = 500 + + +def _nested_getitem( + data: typing.Any, path: typing.List[typing.Hashable] +) -> typing.Optional[typing.Any]: + for item_index in path: + try: + data = data[item_index] + except (KeyError, IndexError, TypeError): + # The index is not present in the dictionary, list or other + # indexable or data is not subscriptable + return None + return data + + +def humanize_error( + data, + validation_error: Invalid, + max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH, +) -> str: + """Provide a more helpful + complete validation error message than that provided automatically + Invalid and MultipleInvalid do not include the offending value in error messages, + and MultipleInvalid.__str__ only provides the first error. + """ + if isinstance(validation_error, MultipleInvalid): + return '\n'.join( + sorted( + humanize_error(data, sub_error, max_sub_error_length) + for sub_error in validation_error.errors + ) + ) + else: + offending_item_summary = repr(_nested_getitem(data, validation_error.path)) + if len(offending_item_summary) > max_sub_error_length: + offending_item_summary = ( + offending_item_summary[: max_sub_error_length - 3] + '...' + ) + return '%s. Got %s' % (validation_error, offending_item_summary) + + +def validate_with_humanized_errors( + data, schema: Schema, max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH +) -> typing.Any: + try: + return schema(data) + except (Invalid, MultipleInvalid) as e: + raise Error(humanize_error(data, e, max_sub_error_length)) diff --git a/voluptuous/py.typed b/voluptuous/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py new file mode 100644 index 00000000..895c6e98 --- /dev/null +++ b/voluptuous/schema_builder.py @@ -0,0 +1,1376 @@ +# fmt: off +from __future__ import annotations + +import collections +import inspect +import itertools +import re +import sys +import typing +from collections.abc import Generator +from contextlib import contextmanager +from functools import cache, wraps + +from voluptuous import error as er +from voluptuous.error import Error + +# fmt: on + +# options for extra keys +PREVENT_EXTRA = 0 # any extra key not in schema will raise an error +ALLOW_EXTRA = 1 # extra keys not in schema will be included in output +REMOVE_EXTRA = 2 # extra keys not in schema will be excluded from output + + +def _isnamedtuple(obj): + return isinstance(obj, tuple) and hasattr(obj, '_fields') + + +class Undefined(object): + def __nonzero__(self): + return False + + def __repr__(self): + return '...' + + +UNDEFINED = Undefined() + + +def Self() -> None: + raise er.SchemaError('"Self" should never be called') + + +DefaultFactory = typing.Union[Undefined, typing.Callable[[], typing.Any]] + + +def default_factory(value) -> DefaultFactory: + if value is UNDEFINED or callable(value): + return value + return lambda: value + + +@contextmanager +def raises( + exc, msg: typing.Optional[str] = None, regex: typing.Optional[re.Pattern] = None +) -> Generator[None, None, None]: + try: + yield + except exc as e: + if msg is not None: + assert str(e) == msg, '%r != %r' % (str(e), msg) + if regex is not None: + assert re.search(regex, str(e)), '%r does not match %r' % (str(e), regex) + else: + raise AssertionError(f"Did not raise exception {exc.__name__}") + + +def Extra(_) -> None: + """Allow keys in the data that are not present in the schema.""" + raise er.SchemaError('"Extra" should never be called') + + +# As extra() is never called there's no way to catch references to the +# deprecated object, so we just leave an alias here instead. +extra = Extra + +primitive_types = (bool, bytes, int, str, float, complex) + +# fmt: off +Schemable = typing.Union[ + 'Schema', 'Object', + collections.abc.Mapping, + list, tuple, frozenset, set, + bool, bytes, int, str, float, complex, + type, object, dict, None, typing.Callable +] +# fmt: on + + +class Schema(object): + """A validation schema. + + The schema is a Python tree-like structure where nodes are pattern + matched against corresponding trees of values. + + Nodes can be values, in which case a direct comparison is used, types, + in which case an isinstance() check is performed, or callables, which will + validate and optionally convert the value. + + We can equate schemas also. + + For Example: + + >>> v = Schema({Required('a'): str}) + >>> v1 = Schema({Required('a'): str}) + >>> v2 = Schema({Required('b'): str}) + >>> assert v == v1 + >>> assert v != v2 + + """ + + _extra_to_name = { + REMOVE_EXTRA: 'REMOVE_EXTRA', + ALLOW_EXTRA: 'ALLOW_EXTRA', + PREVENT_EXTRA: 'PREVENT_EXTRA', + } + + def __init__( + self, schema: Schemable, required: bool = False, extra: int = PREVENT_EXTRA + ) -> None: + """Create a new Schema. + + :param schema: Validation schema. See :module:`voluptuous` for details. + :param required: Keys defined in the schema must be in the data. + :param extra: Specify how extra keys in the data are treated: + - :const:`~voluptuous.PREVENT_EXTRA`: to disallow any undefined + extra keys (raise ``Invalid``). + - :const:`~voluptuous.ALLOW_EXTRA`: to include undefined extra + keys in the output. + - :const:`~voluptuous.REMOVE_EXTRA`: to exclude undefined extra keys + from the output. + - Any value other than the above defaults to + :const:`~voluptuous.PREVENT_EXTRA` + """ + self.schema: typing.Any = schema + self.required = required + self.extra = int(extra) # ensure the value is an integer + self._compiled = self._compile(schema) + + @classmethod + def infer(cls, data, **kwargs) -> Schema: + """Create a Schema from concrete data (e.g. an API response). + + For example, this will take a dict like: + + { + 'foo': 1, + 'bar': { + 'a': True, + 'b': False + }, + 'baz': ['purple', 'monkey', 'dishwasher'] + } + + And return a Schema: + + { + 'foo': int, + 'bar': { + 'a': bool, + 'b': bool + }, + 'baz': [str] + } + + Note: only very basic inference is supported. + """ + + def value_to_schema_type(value): + if isinstance(value, dict): + if len(value) == 0: + return dict + return {k: value_to_schema_type(v) for k, v in value.items()} + if isinstance(value, list): + if len(value) == 0: + return list + else: + return [value_to_schema_type(v) for v in value] + return type(value) + + return cls(value_to_schema_type(data), **kwargs) + + def __eq__(self, other): + if not isinstance(other, Schema): + return False + return other.schema == self.schema + + def __ne__(self, other): + return not (self == other) + + def __str__(self): + return str(self.schema) + + def __repr__(self): + return "" % ( + self.schema, + self._extra_to_name.get(self.extra, '??'), + self.required, + id(self), + ) + + def __call__(self, data): + """Validate data against this schema.""" + try: + return self._compiled([], data) + except er.MultipleInvalid: + raise + except er.Invalid as e: + raise er.MultipleInvalid([e]) + # return self.validate([], self.schema, data) + + def _compile(self, schema): + if schema is Extra: + return lambda _, v: v + if schema is Self: + return lambda p, v: self._compiled(p, v) + elif hasattr(schema, "__voluptuous_compile__"): + return schema.__voluptuous_compile__(self) + if isinstance(schema, Object): + return self._compile_object(schema) + if isinstance(schema, collections.abc.Mapping): + return self._compile_dict(schema) + elif isinstance(schema, list): + return self._compile_list(schema) + elif isinstance(schema, tuple): + return self._compile_tuple(schema) + elif isinstance(schema, (frozenset, set)): + return self._compile_set(schema) + type_ = type(schema) + if inspect.isclass(schema): + type_ = schema + if type_ in (*primitive_types, object, type(None)) or callable(schema): + return _compile_scalar(schema) + raise er.SchemaError('unsupported schema data type %r' % type(schema).__name__) + + def _compile_mapping(self, schema, invalid_msg=None): + """Create validator for given mapping.""" + invalid_msg = invalid_msg or 'mapping value' + + # Keys that may be required + all_required_keys = set( + key + for key in schema + if key is not Extra + and ( + (self.required and not isinstance(key, (Optional, Remove))) + or isinstance(key, Required) + ) + ) + + # Complex required keys that need special validation + complex_required_keys = set( + key + for key in all_required_keys + if isinstance(key, Required) and key.is_complex_key + ) + + # Keys that may have defaults + all_default_keys = set( + key + for key in schema + if isinstance(key, Required) or isinstance(key, Optional) + ) + + _compiled_schema = {} + for skey, svalue in schema.items(): + new_key = self._compile(skey) + new_value = self._compile(svalue) + _compiled_schema[skey] = (new_key, new_value) + + candidates = list(_iterate_mapping_candidates(_compiled_schema)) + + # After we have the list of candidates in the correct order, we want to apply some optimization so that each + # key in the data being validated will be matched against the relevant schema keys only. + # No point in matching against different keys + additional_candidates = [] + candidates_by_key = {} + for skey, (ckey, cvalue) in candidates: + if type(skey) in primitive_types: + candidates_by_key.setdefault(skey, []).append((skey, (ckey, cvalue))) + elif isinstance(skey, Marker) and type(skey.schema) in primitive_types: + candidates_by_key.setdefault(skey.schema, []).append( + (skey, (ckey, cvalue)) + ) + else: + # These are wildcards such as 'int', 'str', 'Remove' and others which should be applied to all keys + additional_candidates.append((skey, (ckey, cvalue))) + + def validate_mapping(path, iterable, out): + required_keys = all_required_keys.copy() + + # Build a map of all provided key-value pairs. + # The type(out) is used to retain ordering in case a ordered + # map type is provided as input. + key_value_map = type(out)() + for key, value in iterable: + key_value_map[key] = value + + # Insert default values for non-existing keys. + for key in all_default_keys: + if ( + not isinstance(key.default, Undefined) + and key.schema not in key_value_map + ): + # A default value has been specified for this missing + # key, insert it. + key_value_map[key.schema] = key.default() + + errors = [] + + # Check complex required keys - at least one candidate key must be present + for complex_key in complex_required_keys: + if not any( + candidate in key_value_map + for candidate in complex_key.candidate_keys + ): + msg = ( + complex_key.msg + if hasattr(complex_key, 'msg') and complex_key.msg + else f'at least one of {complex_key.candidate_keys} is required' + ) + errors.append(er.RequiredFieldInvalid(msg, path + [complex_key])) + else: + # If at least one candidate key is present, mark this complex requirement as satisfied + required_keys.discard(complex_key) + for key, value in key_value_map.items(): + key_path = path + [key] + remove_key = False + + # Optimization. Validate against the matching key first, then fallback to the rest + relevant_candidates = itertools.chain( + candidates_by_key.get(key, []), additional_candidates + ) + + # compare each given key/value against all compiled key/values + # schema key, (compiled key, compiled value) + error = None + for skey, (ckey, cvalue) in relevant_candidates: + try: + new_key = ckey(key_path, key) + except er.Invalid as e: + if len(e.path) > len(key_path): + raise + if not error or len(e.path) > len(error.path): + error = e + continue + # Backtracking is not performed once a key is selected, so if + # the value is invalid we immediately throw an exception. + exception_errors = [] + # check if the key is marked for removal + is_remove = new_key is Remove + try: + cval = cvalue(key_path, value) + # include if it's not marked for removal + if not is_remove: + out[new_key] = cval + else: + remove_key = True + continue + except er.MultipleInvalid as e: + exception_errors.extend(e.errors) + except er.Invalid as e: + exception_errors.append(e) + + if exception_errors: + if is_remove or remove_key: + continue + for err in exception_errors: + if len(err.path) <= len(key_path): + err.error_type = invalid_msg + errors.append(err) + # If there is a validation error for a required + # key, this means that the key was provided. + # Discard the required key so it does not + # create an additional, noisy exception. + required_keys.discard(skey) + break + + # Key and value okay, mark as found in case it was + # a Required() field. + required_keys.discard(skey) + + break + else: + if remove_key: + # remove key + continue + elif self.extra == ALLOW_EXTRA: + out[key] = value + elif self.extra == REMOVE_EXTRA: + # ignore the key so it's removed from output + continue + elif error: + errors.append(error) + else: + errors.append(er.Invalid('extra keys not allowed', key_path)) + + # for any required keys left that weren't found and don't have defaults: + for key in required_keys: + msg = ( + key.msg + if hasattr(key, 'msg') and key.msg + else 'required key not provided' + ) + errors.append(er.RequiredFieldInvalid(msg, path + [key])) + if errors: + raise er.MultipleInvalid(errors) + + return out + + return validate_mapping + + def _compile_object(self, schema): + """Validate an object. + + Has the same behavior as dictionary validator but work with object + attributes. + + For example: + + >>> class Structure(object): + ... def __init__(self, one=None, three=None): + ... self.one = one + ... self.three = three + ... + >>> validate = Schema(Object({'one': 'two', 'three': 'four'}, cls=Structure)) + >>> with raises(er.MultipleInvalid, "not a valid value for object value @ data['one']"): + ... validate(Structure(one='three')) + + """ + base_validate = self._compile_mapping(schema, invalid_msg='object value') + + def validate_object(path, data): + if schema.cls is not UNDEFINED and not isinstance(data, schema.cls): + raise er.ObjectInvalid('expected a {0!r}'.format(schema.cls), path) + iterable = _iterate_object(data) + iterable = filter(lambda item: item[1] is not None, iterable) + out = base_validate(path, iterable, {}) + return type(data)(**out) + + return validate_object + + def _compile_dict(self, schema): + """Validate a dictionary. + + A dictionary schema can contain a set of values, or at most one + validator function/type. + + A dictionary schema will only validate a dictionary: + + >>> validate = Schema({}) + >>> with raises(er.MultipleInvalid, 'expected a dictionary'): + ... validate([]) + + An invalid dictionary value: + + >>> validate = Schema({'one': 'two', 'three': 'four'}) + >>> with raises(er.MultipleInvalid, "not a valid value for dictionary value @ data['one']"): + ... validate({'one': 'three'}) + + An invalid key: + + >>> with raises(er.MultipleInvalid, "extra keys not allowed @ data['two']"): + ... validate({'two': 'three'}) + + + Validation function, in this case the "int" type: + + >>> validate = Schema({'one': 'two', 'three': 'four', int: str}) + + Valid integer input: + + >>> validate({10: 'twenty'}) + {10: 'twenty'} + + By default, a "type" in the schema (in this case "int") will be used + purely to validate that the corresponding value is of that type. It + will not Coerce the value: + + >>> with raises(er.MultipleInvalid, "extra keys not allowed @ data['10']"): + ... validate({'10': 'twenty'}) + + Wrap them in the Coerce() function to achieve this: + >>> from voluptuous import Coerce + >>> validate = Schema({'one': 'two', 'three': 'four', + ... Coerce(int): str}) + >>> validate({'10': 'twenty'}) + {10: 'twenty'} + + Custom message for required key + + >>> validate = Schema({Required('one', 'required'): 'two'}) + >>> with raises(er.MultipleInvalid, "required @ data['one']"): + ... validate({}) + + (This is to avoid unexpected surprises.) + + Multiple errors for nested field in a dict: + + >>> validate = Schema({ + ... 'adict': { + ... 'strfield': str, + ... 'intfield': int + ... } + ... }) + >>> try: + ... validate({ + ... 'adict': { + ... 'strfield': 123, + ... 'intfield': 'one' + ... } + ... }) + ... except er.MultipleInvalid as e: + ... print(sorted(str(i) for i in e.errors)) # doctest: +NORMALIZE_WHITESPACE + ["expected int for dictionary value @ data['adict']['intfield']", + "expected str for dictionary value @ data['adict']['strfield']"] + + """ + base_validate = self._compile_mapping(schema, invalid_msg='dictionary value') + + groups_of_exclusion = {} + groups_of_inclusion = {} + for node in schema: + if isinstance(node, Exclusive): + g = groups_of_exclusion.setdefault(node.group_of_exclusion, []) + g.append(node) + elif isinstance(node, Inclusive): + g = groups_of_inclusion.setdefault(node.group_of_inclusion, []) + g.append(node) + + def validate_dict(path, data): + if not isinstance(data, dict): + raise er.DictInvalid('expected a dictionary', path) + + errors = [] + for label, group in groups_of_exclusion.items(): + exists = False + for exclusive in group: + if exclusive.schema in data: + if exists: + msg = ( + exclusive.msg + if hasattr(exclusive, 'msg') and exclusive.msg + else "two or more values in the same group of exclusion '%s'" + % label + ) + next_path = path + [VirtualPathComponent(label)] + errors.append(er.ExclusiveInvalid(msg, next_path)) + break + exists = True + + if errors: + raise er.MultipleInvalid(errors) + + for label, group in groups_of_inclusion.items(): + included = [node.schema in data for node in group] + if any(included) and not all(included): + msg = ( + "some but not all values in the same group of inclusion '%s'" + % label + ) + for g in group: + if hasattr(g, 'msg') and g.msg: + msg = g.msg + break + next_path = path + [VirtualPathComponent(label)] + errors.append(er.InclusiveInvalid(msg, next_path)) + break + + if errors: + raise er.MultipleInvalid(errors) + + out = data.__class__() + return base_validate(path, data.items(), out) + + return validate_dict + + def _compile_sequence(self, schema, seq_type): + """Validate a sequence type. + + This is a sequence of valid values or validators tried in order. + + >>> validator = Schema(['one', 'two', int]) + >>> validator(['one']) + ['one'] + >>> with raises(er.MultipleInvalid, 'expected int @ data[0]'): + ... validator([3.5]) + >>> validator([1]) + [1] + """ + _compiled = [self._compile(s) for s in schema] + seq_type_name = seq_type.__name__ + + def validate_sequence(path, data): + if not isinstance(data, seq_type): + raise er.SequenceTypeInvalid('expected a %s' % seq_type_name, path) + + # Empty seq schema, reject any data. + if not schema: + if data: + raise er.MultipleInvalid( + [er.ValueInvalid('not a valid value', path if path else data)] + ) + return data + + out = [] + invalid = None + errors = [] + index_path = UNDEFINED + for i, value in enumerate(data): + index_path = path + [i] + invalid = None + for validate in _compiled: + try: + cval = validate(index_path, value) + if cval is not Remove: # do not include Remove values + out.append(cval) + break + except er.Invalid as e: + if len(e.path) > len(index_path): + raise + invalid = e + else: + errors.append(invalid) + if errors: + raise er.MultipleInvalid(errors) + + if _isnamedtuple(data): + return type(data)(*out) + else: + return type(data)(out) + + return validate_sequence + + def _compile_tuple(self, schema): + """Validate a tuple. + + A tuple is a sequence of valid values or validators tried in order. + + >>> validator = Schema(('one', 'two', int)) + >>> validator(('one',)) + ('one',) + >>> with raises(er.MultipleInvalid, 'expected int @ data[0]'): + ... validator((3.5,)) + >>> validator((1,)) + (1,) + """ + return self._compile_sequence(schema, tuple) + + def _compile_list(self, schema): + """Validate a list. + + A list is a sequence of valid values or validators tried in order. + + >>> validator = Schema(['one', 'two', int]) + >>> validator(['one']) + ['one'] + >>> with raises(er.MultipleInvalid, 'expected int @ data[0]'): + ... validator([3.5]) + >>> validator([1]) + [1] + """ + return self._compile_sequence(schema, list) + + def _compile_set(self, schema): + """Validate a set. + + A set is an unordered collection of unique elements. + + >>> validator = Schema({int}) + >>> validator(set([42])) == set([42]) + True + >>> with raises(er.Invalid, 'expected a set'): + ... validator(42) + >>> with raises(er.MultipleInvalid, 'invalid value in set'): + ... validator(set(['a'])) + """ + type_ = type(schema) + type_name = type_.__name__ + + def validate_set(path, data): + if not isinstance(data, type_): + raise er.Invalid('expected a %s' % type_name, path) + + _compiled = [self._compile(s) for s in schema] + errors = [] + for value in data: + for validate in _compiled: + try: + validate(path, value) + break + except er.Invalid: + pass + else: + invalid = er.Invalid('invalid value in %s' % type_name, path) + errors.append(invalid) + + if errors: + raise er.MultipleInvalid(errors) + + return data + + return validate_set + + def extend( + self, + schema: Schemable, + required: typing.Optional[bool] = None, + extra: typing.Optional[int] = None, + ) -> Schema: + """Create a new `Schema` by merging this and the provided `schema`. + + Neither this `Schema` nor the provided `schema` are modified. The + resulting `Schema` inherits the `required` and `extra` parameters of + this, unless overridden. + + Both schemas must be dictionary-based. + + :param schema: dictionary to extend this `Schema` with + :param required: if set, overrides `required` of this `Schema` + :param extra: if set, overrides `extra` of this `Schema` + """ + + assert isinstance(self.schema, dict) and isinstance( + schema, dict + ), 'Both schemas must be dictionary-based' + + result = self.schema.copy() + + # returns the key that may have been passed as an argument to Marker constructor + def key_literal(key): + return key.schema if isinstance(key, Marker) else key + + # build a map that takes the key literals to the needed objects + # literal -> Required|Optional|literal + result_key_map = dict((key_literal(key), key) for key in result) + + # for each item in the extension schema, replace duplicates + # or add new keys + for key, value in schema.items(): + # if the key is already in the dictionary, we need to replace it + # transform key to literal before checking presence + if key_literal(key) in result_key_map: + result_key = result_key_map[key_literal(key)] + result_value = result[result_key] + + # if both are dictionaries, we need to extend recursively + # create the new extended sub schema, then remove the old key and add the new one + if isinstance(result_value, dict) and isinstance(value, dict): + new_value = Schema(result_value).extend(value).schema + del result[result_key] + result[key] = new_value + # one or the other or both are not sub-schemas, simple replacement is fine + # remove old key and add new one + else: + del result[result_key] + result[key] = value + + # key is new and can simply be added + else: + result[key] = value + + # recompile and send old object + result_cls = type(self) + result_required = required if required is not None else self.required + result_extra = extra if extra is not None else self.extra + return result_cls(result, required=result_required, extra=result_extra) + + +def _compile_scalar(schema): + """A scalar value. + + The schema can either be a value or a type. + + >>> _compile_scalar(int)([], 1) + 1 + >>> with raises(er.Invalid, 'expected float'): + ... _compile_scalar(float)([], '1') + + Callables have + >>> _compile_scalar(lambda v: float(v))([], '1') + 1.0 + + As a convenience, ValueError's are trapped: + + >>> with raises(er.Invalid, 'not a valid value'): + ... _compile_scalar(lambda v: float(v))([], 'a') + """ + if inspect.isclass(schema): + + def validate_instance(path, data): + if isinstance(data, schema): + return data + else: + msg = 'expected %s' % schema.__name__ + raise er.TypeInvalid(msg, path) + + return validate_instance + + if callable(schema): + + def validate_callable(path, data): + try: + return schema(data) + except ValueError: + raise er.ValueInvalid('not a valid value', path) + except er.Invalid as e: + e.prepend(path) + raise + + return validate_callable + + def validate_value(path, data): + if data != schema: + raise er.ScalarInvalid('not a valid value', path) + return data + + return validate_value + + +def _compile_itemsort(): + '''return sort function of mappings''' + + def is_extra(key_): + return key_ is Extra + + def is_remove(key_): + return isinstance(key_, Remove) + + def is_marker(key_): + return isinstance(key_, Marker) + + def is_type(key_): + return inspect.isclass(key_) + + def is_callable(key_): + return callable(key_) + + # priority list for map sorting (in order of checking) + # We want Extra to match last, because it's a catch-all. On the other hand, + # Remove markers should match first (since invalid values will not + # raise an Error, instead the validator will check if other schemas match + # the same value). + priority = [ + (1, is_remove), # Remove highest priority after values + (2, is_marker), # then other Markers + (4, is_type), # types/classes lowest before Extra + (3, is_callable), # callables after markers + (5, is_extra), # Extra lowest priority + ] + + def item_priority(item_): + key_ = item_[0] + for i, check_ in priority: + if check_(key_): + return i + # values have highest priorities + return 0 + + return item_priority + + +_sort_item = _compile_itemsort() + + +def _iterate_mapping_candidates(schema): + """Iterate over schema in a meaningful order.""" + # Without this, Extra might appear first in the iterator, and fail to + # validate a key even though it's a Required that has its own validation, + # generating a false positive. + return sorted(schema.items(), key=_sort_item) + + +def _iterate_object(obj): + """Return iterator over object attributes. Respect objects with + defined __slots__. + + """ + d = {} + try: + d = vars(obj) + except TypeError: + # maybe we have named tuple here? + if hasattr(obj, '_asdict'): + d = obj._asdict() + for item in d.items(): + yield item + try: + slots = obj.__slots__ + except AttributeError: + pass + else: + for key in slots: + if key != '__dict__': + yield (key, getattr(obj, key)) + + +class Msg(object): + """Report a user-friendly message if a schema fails to validate. + + >>> validate = Schema( + ... Msg(['one', 'two', int], + ... 'should be one of "one", "two" or an integer')) + >>> with raises(er.MultipleInvalid, 'should be one of "one", "two" or an integer'): + ... validate(['three']) + + Messages are only applied to invalid direct descendants of the schema: + + >>> validate = Schema(Msg([['one', 'two', int]], 'not okay!')) + >>> with raises(er.MultipleInvalid, 'expected int @ data[0][0]'): + ... validate([['three']]) + + The type which is thrown can be overridden but needs to be a subclass of Invalid + + >>> with raises(er.SchemaError, 'Msg can only use subclases of Invalid as custom class'): + ... validate = Schema(Msg([int], 'should be int', cls=KeyError)) + + If you do use a subclass of Invalid, that error will be thrown (wrapped in a MultipleInvalid) + + >>> validate = Schema(Msg([['one', 'two', int]], 'not okay!', cls=er.RangeInvalid)) + >>> try: + ... validate(['three']) + ... except er.MultipleInvalid as e: + ... assert isinstance(e.errors[0], er.RangeInvalid) + """ + + def __init__( + self, + schema: Schemable, + msg: str, + cls: typing.Optional[typing.Type[Error]] = None, + ) -> None: + if cls and not issubclass(cls, er.Invalid): + raise er.SchemaError( + "Msg can only use subclases of Invalid as custom class" + ) + self._schema = schema + self.schema = Schema(schema) + self.msg = msg + self.cls = cls + + def __call__(self, v): + try: + return self.schema(v) + except er.Invalid as e: + if len(e.path) > 1: + raise e + else: + raise (self.cls or er.Invalid)(self.msg) + + def __repr__(self): + return 'Msg(%s, %s, cls=%s)' % (self._schema, self.msg, self.cls) + + +class Object(dict): + """Indicate that we should work with attributes, not keys.""" + + def __init__(self, schema: typing.Any, cls: object = UNDEFINED) -> None: + self.cls = cls + super(Object, self).__init__(schema) + + +class VirtualPathComponent(str): + def __str__(self): + return '<' + self + '>' + + def __repr__(self): + return self.__str__() + + +class Marker(object): + """Mark nodes for special treatment. + + `description` is an optional field, unused by Voluptuous itself, but can be + introspected by any external tool, for example to generate schema documentation. + """ + + __slots__ = ('schema', '_schema', 'msg', 'description', '__hash__') + + def __init__( + self, + schema_: Schemable, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + ) -> None: + self.schema: typing.Any = schema_ + self._schema = Schema(schema_) + self.msg = msg + self.description = description + self.__hash__ = cache(lambda: hash(schema_)) # type: ignore[method-assign] + + def __call__(self, v): + try: + return self._schema(v) + except er.Invalid as e: + if not self.msg or len(e.path) > 1: + raise + raise er.Invalid(self.msg) + + def __str__(self): + return str(self.schema) + + def __repr__(self): + return repr(self.schema) + + def __lt__(self, other): + if isinstance(other, Marker): + return self.schema < other.schema + return self.schema < other + + def __eq__(self, other): + return self.schema == other + + def __ne__(self, other): + return not (self.schema == other) + + +class Optional(Marker): + """Mark a node in the schema as optional, and optionally provide a default + + >>> schema = Schema({Optional('key'): str}) + >>> schema({}) + {} + >>> schema = Schema({Optional('key', default='value'): str}) + >>> schema({}) + {'key': 'value'} + >>> schema = Schema({Optional('key', default=list): list}) + >>> schema({}) + {'key': []} + + If 'required' flag is set for an entire schema, optional keys aren't required + + >>> schema = Schema({ + ... Optional('key'): str, + ... 'key2': str + ... }, required=True) + >>> schema({'key2':'value'}) + {'key2': 'value'} + """ + + def __init__( + self, + schema: Schemable, + msg: typing.Optional[str] = None, + default: typing.Any = UNDEFINED, + description: typing.Any | None = None, + ) -> None: + super(Optional, self).__init__(schema, msg=msg, description=description) + self.default = default_factory(default) + + +class Exclusive(Optional): + """Mark a node in the schema as exclusive. + + Exclusive keys inherited from Optional: + + >>> schema = Schema({Exclusive('alpha', 'angles'): int, Exclusive('beta', 'angles'): int}) + >>> schema({'alpha': 30}) + {'alpha': 30} + + Keys inside a same group of exclusion cannot be together, it only makes sense for dictionaries: + + >>> with raises(er.MultipleInvalid, "two or more values in the same group of exclusion 'angles' @ data[]"): + ... schema({'alpha': 30, 'beta': 45}) + + For example, API can provides multiple types of authentication, but only one works in the same time: + + >>> msg = 'Please, use only one type of authentication at the same time.' + >>> schema = Schema({ + ... Exclusive('classic', 'auth', msg=msg):{ + ... Required('email'): str, + ... Required('password'): str + ... }, + ... Exclusive('internal', 'auth', msg=msg):{ + ... Required('secret_key'): str + ... }, + ... Exclusive('social', 'auth', msg=msg):{ + ... Required('social_network'): str, + ... Required('token'): str + ... } + ... }) + + >>> with raises(er.MultipleInvalid, "Please, use only one type of authentication at the same time. @ data[]"): + ... schema({'classic': {'email': 'foo@example.com', 'password': 'bar'}, + ... 'social': {'social_network': 'barfoo', 'token': 'tEMp'}}) + """ + + def __init__( + self, + schema: Schemable, + group_of_exclusion: str, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + ) -> None: + super(Exclusive, self).__init__(schema, msg=msg, description=description) + self.group_of_exclusion = group_of_exclusion + + +class Inclusive(Optional): + """Mark a node in the schema as inclusive. + + Inclusive keys inherited from Optional: + + >>> schema = Schema({ + ... Inclusive('filename', 'file'): str, + ... Inclusive('mimetype', 'file'): str + ... }) + >>> data = {'filename': 'dog.jpg', 'mimetype': 'image/jpeg'} + >>> data == schema(data) + True + + Keys inside a same group of inclusive must exist together, it only makes sense for dictionaries: + + >>> with raises(er.MultipleInvalid, "some but not all values in the same group of inclusion 'file' @ data[]"): + ... schema({'filename': 'dog.jpg'}) + + If none of the keys in the group are present, it is accepted: + + >>> schema({}) + {} + + For example, API can return 'height' and 'width' together, but not separately. + + >>> msg = "Height and width must exist together" + >>> schema = Schema({ + ... Inclusive('height', 'size', msg=msg): int, + ... Inclusive('width', 'size', msg=msg): int + ... }) + + >>> with raises(er.MultipleInvalid, msg + " @ data[]"): + ... schema({'height': 100}) + + >>> with raises(er.MultipleInvalid, msg + " @ data[]"): + ... schema({'width': 100}) + + >>> data = {'height': 100, 'width': 100} + >>> data == schema(data) + True + """ + + def __init__( + self, + schema: Schemable, + group_of_inclusion: str, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + default: typing.Any = UNDEFINED, + ) -> None: + super(Inclusive, self).__init__( + schema, msg=msg, default=default, description=description + ) + self.group_of_inclusion = group_of_inclusion + + +class Required(Marker): + """Mark a node in the schema as being required, and optionally provide a default value. + + >>> schema = Schema({Required('key'): str}) + >>> with raises(er.MultipleInvalid, "required key not provided @ data['key']"): + ... schema({}) + + >>> schema = Schema({Required('key', default='value'): str}) + >>> schema({}) + {'key': 'value'} + >>> schema = Schema({Required('key', default=list): list}) + >>> schema({}) + {'key': []} + + Complex key validation - at least one of the specified keys must be present: + + >>> from voluptuous.validators import Any + >>> schema = Schema({Required(Any('color', 'temperature', 'brightness')): str}) + >>> schema({'color': 'red'}) # Valid - has color + {'color': 'red'} + >>> schema({'temperature': 'warm'}) # Valid - has temperature + {'temperature': 'warm'} + >>> schema({'color': 'blue', 'brightness': 'high'}) # Valid - has multiple + {'color': 'blue', 'brightness': 'high'} + """ + + def __init__( + self, + schema: Schemable, + msg: typing.Optional[str] = None, + default: typing.Any = UNDEFINED, + description: typing.Any | None = None, + ) -> None: + super(Required, self).__init__(schema, msg=msg, description=description) + self.default = default_factory(default) + self.is_complex_key = self._is_complex_key_validator(schema) + self.candidate_keys = ( + self._extract_candidate_keys(schema) if self.is_complex_key else None + ) + + def _is_complex_key_validator(self, schema): + """Check if schema is a validator that can match multiple keys.""" + # Import here to avoid circular imports + from voluptuous.validators import Any + + return isinstance(schema, Any) + + def _extract_candidate_keys(self, schema): + """Extract possible keys from validators like Any("key1", "key2", "key3").""" + # Import here to avoid circular imports + from voluptuous.validators import Any + + if isinstance(schema, Any): + # Extract literal values (strings, ints, etc.) from Any validators + return [ + v + for v in schema.validators + if isinstance(v, (str, int, float, bool, type(None))) + ] + return [] + + +class Remove(Marker): + """Mark a node in the schema to be removed and excluded from the validated + output. Keys that fail validation will not raise ``Invalid``. Instead, these + keys will be treated as extras. + + >>> schema = Schema({str: int, Remove(int): str}) + >>> with raises(er.MultipleInvalid, "extra keys not allowed @ data[1]"): + ... schema({'keep': 1, 1: 1.0}) + >>> schema({1: 'red', 'red': 1, 2: 'green'}) + {'red': 1} + >>> schema = Schema([int, Remove(float), Extra]) + >>> schema([1, 2, 3, 4.0, 5, 6.0, '7']) + [1, 2, 3, 5, '7'] + """ + + def __init__( + self, + schema_: Schemable, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + ) -> None: + super().__init__(schema_, msg, description) + self.__hash__ = cache(lambda: object.__hash__(self)) # type: ignore[method-assign] + + def __call__(self, schema: Schemable): + super(Remove, self).__call__(schema) + return self.__class__ + + def __repr__(self): + return "Remove(%r)" % (self.schema,) + + +def message( + default: typing.Optional[str] = None, + cls: typing.Optional[typing.Type[Error]] = None, +) -> typing.Callable: + """Convenience decorator to allow functions to provide a message. + + Set a default message: + + >>> @message('not an integer') + ... def isint(v): + ... return int(v) + + >>> validate = Schema(isint()) + >>> with raises(er.MultipleInvalid, 'not an integer'): + ... validate('a') + + The message can be overridden on a per validator basis: + + >>> validate = Schema(isint('bad')) + >>> with raises(er.MultipleInvalid, 'bad'): + ... validate('a') + + The class thrown too: + + >>> class IntegerInvalid(er.Invalid): pass + >>> validate = Schema(isint('bad', clsoverride=IntegerInvalid)) + >>> try: + ... validate('a') + ... except er.MultipleInvalid as e: + ... assert isinstance(e.errors[0], IntegerInvalid) + """ + if cls and not issubclass(cls, er.Invalid): + raise er.SchemaError( + "message can only use subclases of Invalid as custom class" + ) + + def decorator(f): + @wraps(f) + def check(msg=None, clsoverride=None): + @wraps(f) + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except ValueError: + raise (clsoverride or cls or er.ValueInvalid)( + msg or default or 'invalid value' + ) + + return wrapper + + return check + + return decorator + + +def _args_to_dict(func, args): + """Returns argument names as values as key-value pairs.""" + if sys.version_info >= (3, 0): + arg_count = func.__code__.co_argcount + arg_names = func.__code__.co_varnames[:arg_count] + else: + arg_count = func.func_code.co_argcount + arg_names = func.func_code.co_varnames[:arg_count] + + arg_value_list = list(args) + arguments = dict( + (arg_name, arg_value_list[i]) + for i, arg_name in enumerate(arg_names) + if i < len(arg_value_list) + ) + return arguments + + +def _merge_args_with_kwargs(args_dict, kwargs_dict): + """Merge args with kwargs.""" + ret = args_dict.copy() + ret.update(kwargs_dict) + return ret + + +def validate(*a, **kw) -> typing.Callable: + """Decorator for validating arguments of a function against a given schema. + + Set restrictions for arguments: + + >>> @validate(arg1=int, arg2=int) + ... def foo(arg1, arg2): + ... return arg1 * arg2 + + Set restriction for returned value: + + >>> @validate(arg=int, __return__=int) + ... def bar(arg1): + ... return arg1 * 2 + + """ + RETURNS_KEY = '__return__' + + def validate_schema_decorator(func): + returns_defined = False + returns = None + + schema_args_dict = _args_to_dict(func, a) + schema_arguments = _merge_args_with_kwargs(schema_args_dict, kw) + + if RETURNS_KEY in schema_arguments: + returns_defined = True + returns = schema_arguments[RETURNS_KEY] + del schema_arguments[RETURNS_KEY] + + input_schema = ( + Schema(schema_arguments, extra=ALLOW_EXTRA) + if len(schema_arguments) != 0 + else lambda x: x + ) + output_schema = Schema(returns) if returns_defined else lambda x: x + + @wraps(func) + def func_wrapper(*args, **kwargs): + args_dict = _args_to_dict(func, args) + arguments = _merge_args_with_kwargs(args_dict, kwargs) + validated_arguments = input_schema(arguments) + output = func(**validated_arguments) + return output_schema(output) + + return func_wrapper + + return validate_schema_decorator diff --git a/voluptuous/tests/__init__.py b/voluptuous/tests/__init__.py new file mode 100644 index 00000000..f29719c7 --- /dev/null +++ b/voluptuous/tests/__init__.py @@ -0,0 +1 @@ +__author__ = 'tusharmakkar08' diff --git a/tests.md b/voluptuous/tests/tests.md similarity index 95% rename from tests.md rename to voluptuous/tests/tests.md index f098c1b4..5ba97ab6 100644 --- a/tests.md +++ b/voluptuous/tests/tests.md @@ -16,7 +16,7 @@ value: ... raise AssertionError('MultipleInvalid not raised') ... except MultipleInvalid as e: ... exc = e - >>> str(exc) == 'invalid list value @ data[1]' + >>> str(exc) == 'expected a dictionary @ data[1]' True It should also be accurate for nested values: @@ -35,7 +35,7 @@ It should also be accurate for nested values: ... except MultipleInvalid as e: ... exc = e >>> str(exc) - "invalid list value @ data[0]['four'][0]" + "not a valid value @ data[0]['four'][0]" >>> try: ... schema([{'six': {'seven': 'nine'}}]) @@ -116,9 +116,9 @@ Multiple errors are reported: ... schema([1, 2, 3]) ... except MultipleInvalid as e: ... print([str(i) for i in e.errors]) # doctest: +NORMALIZE_WHITESPACE - ['invalid list value @ data[0]', - 'invalid list value @ data[1]', - 'invalid list value @ data[2]'] + ['expected a list @ data[0]', + 'expected a list @ data[1]', + 'expected a list @ data[2]'] Required fields in dictionary which are invalid should not have required : @@ -266,3 +266,8 @@ Ensure that subclasses of Invalid of are raised as is. ... exc = e >>> exc.errors[0].__class__.__name__ 'SpecialInvalid' + +Ensure that Optional('Classification') < 'Name' will return True instead of throwing an AttributeError + + >>> Optional('Classification') < 'Name' + True diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py new file mode 100644 index 00000000..a3dd219f --- /dev/null +++ b/voluptuous/tests/tests.py @@ -0,0 +1,2176 @@ +# fmt: off +import collections +import copy +import os +import sys +from enum import Enum + +import pytest + +from voluptuous import ( + ALLOW_EXTRA, + PREVENT_EXTRA, + REMOVE_EXTRA, + All, + AllInvalid, + Any, + Clamp, + Coerce, + Contains, + ContainsInvalid, + Date, + Datetime, + Email, + EmailInvalid, + Equal, + ExactSequence, + Exclusive, + Extra, + FqdnUrl, + In, + Inclusive, + InInvalid, + Invalid, + IsDir, + IsFile, + Length, + Literal, + LiteralInvalid, + Marker, + Match, + MatchInvalid, + Maybe, + MultipleInvalid, + NotIn, + NotInInvalid, + Number, + Object, + Optional, + PathExists, + Range, + Remove, + Replace, + Required, + Schema, + Self, + SomeOf, + TooManyValid, + TypeInvalid, + Union, + Unordered, + Url, + UrlInvalid, + raises, + validate, +) +from voluptuous.humanize import humanize_error +from voluptuous.util import Capitalize, Lower, Strip, Title, Upper + +# fmt: on + + +def test_new_required_test(): + schema = Schema( + { + 'my_key': All(int, Range(1, 20)), + }, + required=True, + ) + assert schema.required + + +def test_exact_sequence(): + schema = Schema(ExactSequence([int, int])) + with raises(Invalid): + schema([1, 2, 3]) + assert schema([1, 2]) == [1, 2] + + +def test_required(): + """Verify that Required works.""" + schema = Schema({Required('q'): int}) + schema({"q": 123}) + with raises(Invalid, "required key not provided @ data['q']"): + schema({}) + + +def test_extra_with_required(): + """Verify that Required does not break Extra.""" + schema = Schema({Required('toaster'): str, Extra: object}) + r = schema({'toaster': 'blue', 'another_valid_key': 'another_valid_value'}) + assert r == {'toaster': 'blue', 'another_valid_key': 'another_valid_value'} + + +def test_iterate_candidates(): + """Verify that the order for iterating over mapping candidates is right.""" + schema = { + "toaster": str, + Extra: object, + } + # toaster should be first. + from voluptuous.schema_builder import _iterate_mapping_candidates + + assert _iterate_mapping_candidates(schema)[0][0] == 'toaster' + + +def test_in(): + """Verify that In works.""" + schema = Schema({"color": In(frozenset(["red", "blue", "yellow"]))}) + schema({"color": "blue"}) + with pytest.raises( + MultipleInvalid, + match=r"value must be one of \['blue', 'red', 'yellow'\] for dictionary value @ data\['color'\]", + ) as ctx: + schema({"color": "orange"}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], InInvalid) + + +def test_in_unsortable_container(): + """Verify that In works with unsortable container.""" + schema = Schema({"type": In((int, str, float))}) + schema({"type": float}) + with pytest.raises( + MultipleInvalid, + match=( + r"value must be one of \[, , \] for dictionary value " + r"@ data\['type'\]" + ), + ) as ctx: + schema({"type": 42}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], InInvalid) + + +def test_not_in(): + """Verify that NotIn works.""" + schema = Schema({"color": NotIn(frozenset(["red", "blue", "yellow"]))}) + schema({"color": "orange"}) + with pytest.raises( + MultipleInvalid, + match=( + r"value must not be one of \['blue', 'red', 'yellow'\] for dictionary " + r"value @ data\['color'\]" + ), + ) as ctx: + schema({"color": "blue"}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], NotInInvalid) + + +def test_not_in_unsortable_container(): + """Verify that NotIn works with unsortable container.""" + schema = Schema({"type": NotIn((int, str, float))}) + schema({"type": 42}) + with pytest.raises( + MultipleInvalid, + match=( + r"value must not be one of \[, , " + r"\] for dictionary value @ data\['type'\]" + ), + ) as ctx: + schema({"type": str}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], NotInInvalid) + + +def test_contains(): + """Verify contains validation method.""" + schema = Schema({'color': Contains('red')}) + schema({'color': ['blue', 'red', 'yellow']}) + with pytest.raises( + MultipleInvalid, + match=r"value is not allowed for dictionary value @ data\['color'\]", + ) as ctx: + schema({'color': ['blue', 'yellow']}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], ContainsInvalid) + + +def test_remove(): + """Verify that Remove works.""" + # remove dict keys + schema = Schema({"weight": int, Remove("color"): str, Remove("amount"): int}) + out_ = schema({"weight": 10, "color": "red", "amount": 1}) + assert "color" not in out_ and "amount" not in out_ + + # remove keys by type + schema = Schema( + { + "weight": float, + "amount": int, + # remove str keys with int values + Remove(str): int, + # keep str keys with str values + str: str, + } + ) + out_ = schema({"weight": 73.4, "condition": "new", "amount": 5, "left": 2}) + # amount should stay since it's defined + # other string keys with int values will be removed + assert "amount" in out_ and "left" not in out_ + # string keys with string values will stay + assert "condition" in out_ + + # remove value from list + schema = Schema([Remove(1), int]) + out_ = schema([1, 2, 3, 4, 1, 5, 6, 1, 1, 1]) + assert out_ == [2, 3, 4, 5, 6] + + # remove values from list by type + schema = Schema([1.0, Remove(float), int]) + out_ = schema([1, 2, 1.0, 2.0, 3.0, 4]) + assert out_ == [1, 2, 1.0, 4] + + +def test_remove_with_error(): + def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise Invalid("Key does not start with .") + return key + + def does_not_start_with_dot(key: str) -> str: + """Check if key does not start with dot.""" + if key.startswith("."): + raise Invalid("Key starts with .") + return key + + schema = Schema( + { + Remove(All(str, starts_with_dot)): object, + does_not_start_with_dot: Any(None), + } + ) + out_ = schema({".remove": None, "ok": None}) + assert ".remove" not in out_ and "ok" in out_ + + +def test_extra_empty_errors(): + schema = Schema({'a': {Extra: object}}, required=True) + schema({'a': {}}) + + +def test_literal(): + """Test with Literal""" + + schema = Schema([Literal({"a": 1}), Literal({"b": 1})]) + schema([{"a": 1}]) + schema([{"b": 1}]) + schema([{"a": 1}, {"b": 1}]) + + with pytest.raises( + MultipleInvalid, match=r"\{'c': 1\} not match for \{'b': 1\} @ data\[0\]" + ) as ctx: + schema([{"c": 1}]) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], LiteralInvalid) + + schema = Schema(Literal({"a": 1})) + with pytest.raises( + MultipleInvalid, match=r"\{'b': 1\} not match for \{'a': 1\}" + ) as ctx: + schema({"b": 1}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], LiteralInvalid) + + +def test_class(): + class C1: + pass + + schema = Schema(C1) + schema(C1()) + + with pytest.raises(MultipleInvalid, match=r"expected C1") as ctx: + schema(None) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], TypeInvalid) + + +def test_email_validation(): + """Test with valid email address""" + schema = Schema({"email": Email()}) + out_ = schema({"email": "example@example.com"}) + + assert 'example@example.com"', out_.get("url") + + +def test_email_validation_with_none(): + """Test with invalid None email address""" + schema = Schema({"email": Email()}) + with pytest.raises( + MultipleInvalid, + match=r"expected an email address for dictionary value @ data\['email'\]", + ) as ctx: + schema({"email": None}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], EmailInvalid) + + +def test_email_validation_with_empty_string(): + """Test with empty string email address""" + schema = Schema({"email": Email()}) + with pytest.raises( + MultipleInvalid, + match=r"expected an email address for dictionary value @ data\['email'\]", + ) as ctx: + schema({"email": ''}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], EmailInvalid) + + +def test_email_validation_without_host(): + """Test with empty host name in email address""" + schema = Schema({"email": Email()}) + with pytest.raises( + MultipleInvalid, + match=r"expected an email address for dictionary value @ data\['email'\]", + ) as ctx: + schema({"email": 'a@.com'}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], EmailInvalid) + + +@pytest.mark.parametrize( + 'input_value', ['john@voluptuous.com>', 'john!@voluptuous.org!@($*!'] +) +def test_email_validation_with_bad_data(input_value: str): + """Test with bad data in email address""" + schema = Schema({"email": Email()}) + with pytest.raises( + MultipleInvalid, + match=r"expected an email address for dictionary value @ data\['email'\]", + ) as ctx: + schema({"email": input_value}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], EmailInvalid) + + +def test_fqdn_url_validation(): + """Test with valid fully qualified domain name URL""" + schema = Schema({"url": FqdnUrl()}) + out_ = schema({"url": "http://example.com/"}) + + assert 'http://example.com/', out_.get("url") + + +@pytest.mark.parametrize( + 'input_value', + [ + pytest.param("http://localhost/", id="without domain name"), + pytest.param(None, id="None"), + pytest.param("", id="empty string"), + pytest.param("http://", id="empty host"), + ], +) +def test_fqdn_url_validation_with_bad_data(input_value): + schema = Schema({"url": FqdnUrl()}) + with pytest.raises( + MultipleInvalid, + match=r"expected a fully qualified domain name URL for dictionary value @ data\['url'\]", + ) as ctx: + schema({"url": input_value}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], UrlInvalid) + + +def test_url_validation(): + """Test with valid URL""" + schema = Schema({"url": Url()}) + out_ = schema({"url": "http://example.com/"}) + + assert 'http://example.com/', out_.get("url") + + +@pytest.mark.parametrize( + 'input_value', + [ + pytest.param(None, id="None"), + pytest.param("", id="empty string"), + pytest.param("http://", id="empty host"), + ], +) +def test_url_validation_with_bad_data(input_value): + schema = Schema({"url": Url()}) + with pytest.raises( + MultipleInvalid, match=r"expected a URL for dictionary value @ data\['url'\]" + ) as ctx: + schema({"url": input_value}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], UrlInvalid) + + +def test_copy_dict_undefined(): + """Test with a copied dictionary""" + fields = {Required("foo"): int} + copied_fields = copy.deepcopy(fields) + + schema = Schema(copied_fields) + + # This used to raise a `TypeError` because the instance of `Undefined` + # was a copy, so object comparison would not work correctly. + try: + schema({"foo": "bar"}) + except Exception as e: + assert isinstance(e, MultipleInvalid) + + +def test_sorting(): + """Expect alphabetic sorting""" + foo = Required('foo') + bar = Required('bar') + items = [foo, bar] + expected = [bar, foo] + result = sorted(items) + assert result == expected + + +def test_schema_extend(): + """Verify that Schema.extend copies schema keys from both.""" + + base = Schema({'a': int}, required=True) + extension = {'b': str} + extended = base.extend(extension) + + assert base.schema == {'a': int} + assert extension == {'b': str} + assert extended.schema == {'a': int, 'b': str} + assert extended.required == base.required + assert extended.extra == base.extra + assert isinstance(extended, Schema) + + +def test_schema_extend_overrides(): + """Verify that Schema.extend can override required/extra parameters.""" + base = Schema({'a': int}, required=True) + extended = base.extend({'b': str}, required=False, extra=ALLOW_EXTRA) + + assert base.required is True + assert base.extra == PREVENT_EXTRA + assert extended.required is False + assert extended.extra == ALLOW_EXTRA + + +def test_schema_extend_key_swap(): + """Verify that Schema.extend can replace keys, even when different markers are used""" + base = Schema({Optional('a'): int}) + extension = {Required('a'): int} + extended = base.extend(extension) + + assert len(base.schema) == 1 + assert isinstance(list(base.schema)[0], Optional) + assert len(extended.schema) == 1 + assert list(extended.schema)[0] + + +def test_subschema_extension(): + """Verify that Schema.extend adds and replaces keys in a subschema""" + base = Schema({'a': {'b': int, 'c': float}}) + extension = {'d': str, 'a': {'b': str, 'e': int}} + extended = base.extend(extension) + + assert base.schema == {'a': {'b': int, 'c': float}} + assert extension == {'d': str, 'a': {'b': str, 'e': int}} + assert extended.schema == {'a': {'b': str, 'c': float, 'e': int}, 'd': str} + + +def test_schema_extend_handles_schema_subclass(): + """Verify that Schema.extend handles a subclass of Schema""" + + class S(Schema): + pass + + base = S({Required('a'): int}) + extension = {Optional('b'): str} + extended = base.extend(extension) + + expected_schema = {Required('a'): int, Optional('b'): str} + assert extended.schema == expected_schema + assert isinstance(extended, S) + + +def test_equality(): + assert Schema('foo') == Schema('foo') + + assert Schema(['foo', 'bar', 'baz']) == Schema(['foo', 'bar', 'baz']) + + # Ensure two Schemas w/ two equivalent dicts initialized in a different + # order are considered equal. + dict_a = {} + dict_a['foo'] = 1 + dict_a['bar'] = 2 + dict_a['baz'] = 3 + + dict_b = {} + dict_b['baz'] = 3 + dict_b['bar'] = 2 + dict_b['foo'] = 1 + + assert Schema(dict_a) == Schema(dict_b) + + +def test_equality_negative(): + """Verify that Schema objects are not equal to string representations""" + assert not Schema('foo') == 'foo' + + assert not Schema(['foo', 'bar']) == "['foo', 'bar']" + assert not Schema(['foo', 'bar']) == Schema("['foo', 'bar']") + + assert not Schema({'foo': 1, 'bar': 2}) == "{'foo': 1, 'bar': 2}" + assert not Schema({'foo': 1, 'bar': 2}) == Schema("{'foo': 1, 'bar': 2}") + + +def test_inequality(): + assert Schema('foo') != 'foo' + + assert Schema(['foo', 'bar']) != "['foo', 'bar']" + assert Schema(['foo', 'bar']) != Schema("['foo', 'bar']") + + assert Schema({'foo': 1, 'bar': 2}) != "{'foo': 1, 'bar': 2}" + assert Schema({'foo': 1, 'bar': 2}) != Schema("{'foo': 1, 'bar': 2}") + + +def test_inequality_negative(): + assert not Schema('foo') != Schema('foo') + + assert not Schema(['foo', 'bar', 'baz']) != Schema(['foo', 'bar', 'baz']) + + # Ensure two Schemas w/ two equivalent dicts initialized in a different + # order are considered equal. + dict_a = {} + dict_a['foo'] = 1 + dict_a['bar'] = 2 + dict_a['baz'] = 3 + + dict_b = {} + dict_b['baz'] = 3 + dict_b['bar'] = 2 + dict_b['foo'] = 1 + + assert not Schema(dict_a) != Schema(dict_b) + + +def test_repr(): + """Verify that __repr__ returns valid Python expressions""" + match = Match('a pattern', msg='message') + replace = Replace('you', 'I', msg='you and I') + range_ = Range( + min=0, max=42, min_included=False, max_included=False, msg='number not in range' + ) + coerce_ = Coerce(int, msg="moo") + all_ = All('10', Coerce(int), msg='all msg') + maybe_int = Maybe(int) + + assert repr(match) == "Match('a pattern', msg='message')" + assert repr(replace) == "Replace('you', 'I', msg='you and I')" + assert ( + repr(range_) + == "Range(min=0, max=42, min_included=False, max_included=False, msg='number not in range')" + ) + assert repr(coerce_) == "Coerce(int, msg='moo')" + assert repr(all_) == "All('10', Coerce(int, msg=None), msg='all msg')" + assert repr(maybe_int) == "Any(None, %s, msg=None)" % str(int) + + +def test_list_validation_messages(): + """Make sure useful error messages are available""" + + def is_even(value): + if value % 2: + raise Invalid('%i is not even' % value) + return value + + schema = Schema(dict(even_numbers=[All(int, is_even)])) + + with pytest.raises( + MultipleInvalid, match=r"3 is not even @ data\['even_numbers'\]\[0\]" + ) as ctx: + schema(dict(even_numbers=[3])) + + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], Invalid) + assert str(ctx.value.errors[0]) == "3 is not even @ data['even_numbers'][0]" + + +def test_nested_multiple_validation_errors(): + """Make sure useful error messages are available""" + + def is_even(value): + if value % 2: + raise Invalid('%i is not even' % value) + return value + + schema = Schema(dict(even_numbers=All([All(int, is_even)], Length(min=1)))) + + with pytest.raises( + MultipleInvalid, match=r"3 is not even @ data\['even_numbers'\]\[0\]" + ) as ctx: + schema(dict(even_numbers=[3])) + + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], Invalid) + assert str(ctx.value.errors[0]) == "3 is not even @ data['even_numbers'][0]" + + +def test_humanize_error(): + data = {'a': 'not an int', 'b': [123]} + schema = Schema({'a': int, 'b': [str]}) + with pytest.raises(MultipleInvalid) as ctx: + schema(data) + assert len(ctx.value.errors) == 2 + assert humanize_error(data, ctx.value) == ( + "expected int for dictionary value @ data['a']. Got 'not an int'\nexpected str @ data['b'][0]. Got 123" + ) + + +def test_fix_157(): + s = Schema(All([Any('one', 'two', 'three')]), Length(min=1)) + assert ['one'] == s(['one']) + with pytest.raises(MultipleInvalid): + s(['four']) + + +def test_range_inside(): + s = Schema(Range(min=0, max=10)) + assert 5 == s(5) + + +def test_range_outside(): + s = Schema(Range(min=0, max=10)) + with pytest.raises(MultipleInvalid): + s(12) + with pytest.raises(MultipleInvalid): + s(-1) + + +def test_range_no_upper_limit(): + s = Schema(Range(min=0)) + assert 123 == s(123) + with pytest.raises(MultipleInvalid): + s(-1) + + +def test_range_no_lower_limit(): + s = Schema(Range(max=10)) + assert -1 == s(-1) + with pytest.raises(MultipleInvalid): + s(123) + + +def test_range_excludes_nan(): + s = Schema(Range(min=0, max=10)) + pytest.raises(MultipleInvalid, s, float('nan')) + + +def test_range_excludes_none(): + s = Schema(Range(min=0, max=10)) + pytest.raises(MultipleInvalid, s, None) + + +def test_range_excludes_string(): + s = Schema(Range(min=0, max=10)) + with pytest.raises(MultipleInvalid): + s("abc") + + +def test_range_excludes_unordered_object(): + class MyObject(object): + pass + + s = Schema(Range(min=0, max=10)) + pytest.raises(MultipleInvalid, s, MyObject()) + + +def test_clamp_inside(): + s = Schema(Clamp(min=1, max=10)) + assert 5 == s(5) + + +def test_clamp_above(): + s = Schema(Clamp(min=1, max=10)) + assert 10 == s(12) + + +def test_clamp_below(): + s = Schema(Clamp(min=1, max=10)) + assert 1 == s(-3) + + +def test_clamp_invalid(): + s = Schema(Clamp(min=1, max=10)) + if sys.version_info.major >= 3: + with pytest.raises(MultipleInvalid): + s(None) + with pytest.raises(MultipleInvalid): + s("abc") + else: + assert 1 == s(None) + + +def test_length_ok(): + v1 = ['a', 'b', 'c'] + s = Schema(Length(min=1, max=10)) + assert v1 == s(v1) + v2 = "abcde" + assert v2 == s(v2) + + +def test_length_too_short(): + v1 = [] + s = Schema(Length(min=1, max=10)) + with pytest.raises(MultipleInvalid): + s(v1) + with pytest.raises(MultipleInvalid): + v2 = '' + s(v2) + + +def test_length_too_long(): + v = ['a', 'b', 'c'] + s = Schema(Length(min=0, max=2)) + with pytest.raises(MultipleInvalid): + s(v) + + +def test_length_invalid(): + v = None + s = Schema(Length(min=0, max=2)) + with pytest.raises(MultipleInvalid): + s(v) + + +def test_equal(): + s = Schema(Equal(1)) + s(1) + pytest.raises(Invalid, s, 2) + s = Schema(Equal('foo')) + s('foo') + pytest.raises(Invalid, s, 'bar') + s = Schema(Equal([1, 2])) + s([1, 2]) + pytest.raises(Invalid, s, []) + pytest.raises(Invalid, s, [1, 2, 3]) + # Evaluates exactly, not through validators + s = Schema(Equal(str)) + pytest.raises(Invalid, s, 'foo') + + +def test_unordered(): + # Any order is OK + s = Schema(Unordered([2, 1])) + s([2, 1]) + s([1, 2]) + # Amount of errors is OK + pytest.raises(Invalid, s, [2, 0]) + pytest.raises(MultipleInvalid, s, [0, 0]) + # Different length is NOK + pytest.raises(Invalid, s, [1]) + pytest.raises(Invalid, s, [1, 2, 0]) + pytest.raises(MultipleInvalid, s, [1, 2, 0, 0]) + # Other type than list or tuple is NOK + pytest.raises(Invalid, s, 'foo') + pytest.raises(Invalid, s, 10) + # Validators are evaluated through as schemas + s = Schema(Unordered([int, str])) + s([1, '2']) + s(['1', 2]) + s = Schema(Unordered([{'foo': int}, []])) + s([{'foo': 3}, []]) + # Most accurate validators must be positioned on left + s = Schema(Unordered([int, 3])) + pytest.raises(Invalid, s, [3, 2]) + s = Schema(Unordered([3, int])) + s([3, 2]) + + +def test_maybe(): + s = Schema(Maybe(int)) + assert s(1) == 1 + assert s(None) is None + pytest.raises(Invalid, s, 'foo') + + s = Schema(Maybe({str: Coerce(int)})) + assert s({'foo': '100'}) == {'foo': 100} + assert s(None) is None + pytest.raises(Invalid, s, {'foo': 'bar'}) + + +def test_maybe_accepts_msg(): + s = Schema(Maybe(int, msg='int or None expected')) + with raises(MultipleInvalid, 'int or None expected'): + assert s([]) + + +def test_maybe_returns_default_error(): + schema = Schema(Maybe(Range(1, 2))) + + # The following should be valid + schema(None) + schema(1) + schema(2) + + try: + # Should trigger a MultipleInvalid exception + schema(3) + except MultipleInvalid as e: + assert str(e) == "not a valid value" + else: + assert False, "Did not raise correct Invalid" + + +def test_schema_empty_list(): + s = Schema([]) + s([]) + + try: + s([123]) + except MultipleInvalid as e: + assert str(e) == "not a valid value @ data[123]" + else: + assert False, "Did not raise correct Invalid" + + try: + s({'var': 123}) + except MultipleInvalid as e: + assert str(e) == "expected a list" + else: + assert False, "Did not raise correct Invalid" + + +def test_schema_empty_dict(): + s = Schema({}) + s({}) + + try: + s({'var': 123}) + except MultipleInvalid as e: + assert str(e) == "extra keys not allowed @ data['var']" + else: + assert False, "Did not raise correct Invalid" + + try: + s([123]) + except MultipleInvalid as e: + assert str(e) == "expected a dictionary" + else: + assert False, "Did not raise correct Invalid" + + +def test_schema_empty_dict_key(): + """https://github.com/alecthomas/voluptuous/pull/434""" + s = Schema({'var': []}) + s({'var': []}) + + try: + s({'var': [123]}) + except MultipleInvalid as e: + assert str(e) == "not a valid value for dictionary value @ data['var']" + else: + assert False, "Did not raise correct Invalid" + + +def test_schema_decorator_match_with_args(): + @validate(int) + def fn(arg): + return arg + + fn(1) + + +def test_schema_decorator_unmatch_with_args(): + @validate(int) + def fn(arg): + return arg + + pytest.raises(Invalid, fn, 1.0) + + +def test_schema_decorator_match_with_kwargs(): + @validate(arg=int) + def fn(arg): + return arg + + fn(1) + + +def test_schema_decorator_unmatch_with_kwargs(): + @validate(arg=int) + def fn(arg): + return arg + + pytest.raises(Invalid, fn, 1.0) + + +def test_schema_decorator_match_return_with_args(): + @validate(int, __return__=int) + def fn(arg): + return arg + + fn(1) + + +def test_schema_decorator_unmatch_return_with_args(): + @validate(int, __return__=int) + def fn(arg): + return "hello" + + pytest.raises(Invalid, fn, 1) + + +def test_schema_decorator_match_return_with_kwargs(): + @validate(arg=int, __return__=int) + def fn(arg): + return arg + + fn(1) + + +def test_schema_decorator_unmatch_return_with_kwargs(): + @validate(arg=int, __return__=int) + def fn(arg): + return "hello" + + pytest.raises(Invalid, fn, 1) + + +def test_schema_decorator_return_only_match(): + @validate(__return__=int) + def fn(arg): + return arg + + fn(1) + + +def test_schema_decorator_return_only_unmatch(): + @validate(__return__=int) + def fn(arg): + return "hello" + + pytest.raises(Invalid, fn, 1) + + +def test_schema_decorator_partial_match_called_with_args(): + @validate(arg1=int) + def fn(arg1, arg2): + return arg1 + + fn(1, "foo") + + +def test_schema_decorator_partial_unmatch_called_with_args(): + @validate(arg1=int) + def fn(arg1, arg2): + return arg1 + + pytest.raises(Invalid, fn, "bar", "foo") + + +def test_schema_decorator_partial_match_called_with_kwargs(): + @validate(arg2=int) + def fn(arg1, arg2): + return arg1 + + fn(arg1="foo", arg2=1) + + +def test_schema_decorator_partial_unmatch_called_with_kwargs(): + @validate(arg2=int) + def fn(arg1, arg2): + return arg1 + + pytest.raises(Invalid, fn, arg1=1, arg2="foo") + + +def test_number_validation_with_string(): + """Test with Number with string""" + schema = Schema({"number": Number(precision=6, scale=2)}) + try: + schema({"number": 'teststr'}) + except MultipleInvalid as e: + assert ( + str(e) + == "Value must be a number enclosed with string for dictionary value @ data['number']" + ) + else: + assert False, "Did not raise Invalid for String" + + +def test_number_validation_with_invalid_precision_invalid_scale(): + """Test with Number with invalid precision and scale""" + schema = Schema({"number": Number(precision=6, scale=2)}) + try: + schema({"number": '123456.712'}) + except MultipleInvalid as e: + assert ( + str(e) + == "Precision must be equal to 6, and Scale must be equal to 2 for dictionary value @ data['number']" + ) + else: + assert False, "Did not raise Invalid for String" + + +def test_number_validation_with_valid_precision_scale_yield_decimal_true(): + """Test with Number with valid precision and scale""" + schema = Schema({"number": Number(precision=6, scale=2, yield_decimal=True)}) + out_ = schema({"number": '1234.00'}) + assert float(out_.get("number")) == 1234.00 + + +def test_number_when_precision_scale_none_yield_decimal_true(): + """Test with Number with no precision and scale""" + schema = Schema({"number": Number(yield_decimal=True)}) + out_ = schema({"number": '12345678901234'}) + assert out_.get("number") == 12345678901234 + + +def test_number_when_precision_none_n_valid_scale_case1_yield_decimal_true(): + """Test with Number with no precision and valid scale case 1""" + schema = Schema({"number": Number(scale=2, yield_decimal=True)}) + out_ = schema({"number": '123456789.34'}) + assert float(out_.get("number")) == 123456789.34 + + +def test_number_when_precision_none_n_valid_scale_case2_yield_decimal_true(): + """Test with Number with no precision and valid scale case 2 with zero in decimal part""" + schema = Schema({"number": Number(scale=2, yield_decimal=True)}) + out_ = schema({"number": '123456789012.00'}) + assert float(out_.get("number")) == 123456789012.00 + + +def test_number_when_precision_none_n_invalid_scale_yield_decimal_true(): + """Test with Number with no precision and invalid scale""" + schema = Schema({"number": Number(scale=2, yield_decimal=True)}) + try: + schema({"number": '12345678901.234'}) + except MultipleInvalid as e: + assert ( + str(e) == "Scale must be equal to 2 for dictionary value @ data['number']" + ) + else: + assert False, "Did not raise Invalid for String" + + +def test_number_when_valid_precision_n_scale_none_yield_decimal_true(): + """Test with Number with no precision and valid scale""" + schema = Schema({"number": Number(precision=14, yield_decimal=True)}) + out_ = schema({"number": '1234567.8901234'}) + assert float(out_.get("number")) == 1234567.8901234 + + +def test_number_when_invalid_precision_n_scale_none_yield_decimal_true(): + """Test with Number with no precision and invalid scale""" + schema = Schema({"number": Number(precision=14, yield_decimal=True)}) + try: + schema({"number": '12345674.8901234'}) + except MultipleInvalid as e: + assert ( + str(e) + == "Precision must be equal to 14 for dictionary value @ data['number']" + ) + else: + assert False, "Did not raise Invalid for String" + + +def test_number_validation_with_valid_precision_scale_yield_decimal_false(): + """Test with Number with valid precision, scale and no yield_decimal""" + schema = Schema({"number": Number(precision=6, scale=2, yield_decimal=False)}) + out_ = schema({"number": '1234.00'}) + assert out_.get("number") == '1234.00' + + +def test_named_tuples_validate_as_tuples(): + NT = collections.namedtuple('NT', ['a', 'b']) + nt = NT(1, 2) + t = (1, 2) + + Schema((int, int))(nt) + Schema((int, int))(t) + Schema(NT(int, int))(nt) + Schema(NT(int, int))(t) + + +def test_datetime(): + schema = Schema({"datetime": Datetime()}) + schema({"datetime": "2016-10-24T14:01:57.102152Z"}) + pytest.raises(MultipleInvalid, schema, {"datetime": "2016-10-24T14:01:57"}) + + +def test_date(): + schema = Schema({"date": Date()}) + schema({"date": "2016-10-24"}) + pytest.raises(MultipleInvalid, schema, {"date": "2016-10-24Z"}) + + +def test_date_custom_format(): + schema = Schema({"date": Date("%Y%m%d")}) + schema({"date": "20161024"}) + pytest.raises(MultipleInvalid, schema, {"date": "2016-10-24"}) + + +def test_ordered_dict(): + if not hasattr(collections, 'OrderedDict'): + # collections.OrderedDict was added in Python2.7; only run if present + return + schema = Schema({Number(): Number()}) # x, y pairs (for interpolation or something) + data = collections.OrderedDict( + [ + (5.0, 3.7), + (24.0, 8.7), + (43.0, 1.5), + (62.0, 2.1), + (71.5, 6.7), + (90.5, 4.1), + (109.0, 3.9), + ] + ) + out = schema(data) + assert isinstance(out, collections.OrderedDict), 'Collection is no longer ordered' + assert data.keys() == out.keys(), 'Order is not consistent' + + +def test_marker_hashable(): + """Verify that you can get schema keys, even if markers were used""" + definition = { + Required('x'): int, + Optional('y'): float, + Remove('j'): int, + Remove(int): str, + int: int, + } + assert definition.get('x') == int + assert definition.get('y') == float + assert Required('x') == Required('x') + assert Required('x') != Required('y') + assert hash(Required('x').schema) == hash(Required('x')) + # Remove markers are not hashable + assert definition.get('j') is None + + +def test_schema_infer(): + schema = Schema.infer({'str': 'foo', 'bool': True, 'int': 42, 'float': 3.14}) + assert schema == Schema( + { + Required('str'): str, + Required('bool'): bool, + Required('int'): int, + Required('float'): float, + } + ) + + +def test_schema_infer_dict(): + schema = Schema.infer({'a': {'b': {'c': 'foo'}}}) + + assert schema == Schema({Required('a'): {Required('b'): {Required('c'): str}}}) + + +def test_schema_infer_list(): + schema = Schema.infer({'list': ['foo', True, 42, 3.14]}) + + assert schema == Schema({Required('list'): [str, bool, int, float]}) + + +def test_schema_infer_scalar(): + assert Schema.infer('foo') == Schema(str) + assert Schema.infer(True) == Schema(bool) + assert Schema.infer(42) == Schema(int) + assert Schema.infer(3.14) == Schema(float) + assert Schema.infer({}) == Schema(dict) + assert Schema.infer([]) == Schema(list) + + +def test_schema_infer_accepts_kwargs(): + schema = Schema.infer({'str': 'foo', 'bool': True}, required=False, extra=True) + + # Subset of schema should be acceptable thanks to required=False. + schema({'bool': False}) + + # Keys that are in schema should still match required types. + try: + schema({'str': 42}) + except Invalid: + pass + else: + assert False, 'Did not raise Invalid for Number' + + # Extra fields should be acceptable thanks to extra=True. + schema({'str': 'bar', 'int': 42}) + + +def test_validation_performance(): + """ + This test comes to make sure the validation complexity of dictionaries is done in a linear time. + To achieve this a custom marker is used in the scheme that counts each time it is evaluated. + By doing so we can determine if the validation is done in linear complexity. + Prior to issue https://github.com/alecthomas/voluptuous/issues/259 this was exponential. + """ + num_of_keys = 1000 + + schema_dict = {} + data = {} + data_extra_keys = {} + + counter = [0] + + class CounterMarker(Marker): + def __call__(self, *args, **kwargs): + counter[0] += 1 + return super(CounterMarker, self).__call__(*args, **kwargs) + + for i in range(num_of_keys): + schema_dict[CounterMarker(str(i))] = str + data[str(i)] = str(i) + data_extra_keys[str(i * 2)] = str( + i + ) # half of the keys are present, and half aren't + + schema = Schema(schema_dict, extra=ALLOW_EXTRA) + + schema(data) + + assert counter[0] <= num_of_keys, "Validation complexity is not linear! %s > %s" % ( + counter[0], + num_of_keys, + ) + + counter[0] = 0 # reset counter + schema(data_extra_keys) + + assert counter[0] <= num_of_keys, "Validation complexity is not linear! %s > %s" % ( + counter[0], + num_of_keys, + ) + + +def test_IsDir(): + schema = Schema(IsDir()) + pytest.raises(MultipleInvalid, schema, 3) + schema(os.path.dirname(os.path.abspath(__file__))) + + +def test_IsFile(): + schema = Schema(IsFile()) + pytest.raises(MultipleInvalid, schema, 3) + schema(os.path.abspath(__file__)) + + +def test_PathExists(): + schema = Schema(PathExists()) + pytest.raises(MultipleInvalid, schema, 3) + schema(os.path.abspath(__file__)) + + +def test_description(): + marker = Marker(Schema(str), description='Hello') + assert marker.description == 'Hello' + + optional = Optional('key', description='Hello') + assert optional.description == 'Hello' + + exclusive = Exclusive('alpha', 'angles', description='Hello') + assert exclusive.description == 'Hello' + + inclusive = Inclusive('alpha', 'angles', description='Hello') + assert inclusive.description == 'Hello' + + required = Required('key', description='Hello') + assert required.description == 'Hello' + + +def test_SomeOf_min_validation(): + validator = All( + Length(min=8), + SomeOf( + min_valid=3, + validators=[ + Match(r'.*[A-Z]', 'no uppercase letters'), + Match(r'.*[a-z]', 'no lowercase letters'), + Match(r'.*[0-9]', 'no numbers'), + Match(r'.*[$@$!%*#?&^:;/<,>|{}()\-\'._+=]', 'no symbols'), + ], + ), + ) + + validator('ffe532A1!') + with raises(MultipleInvalid, 'length of value must be at least 8'): + validator('a') + + with raises(MultipleInvalid, 'no uppercase letters, no lowercase letters'): + validator('1232!#4111') + + with raises(MultipleInvalid, 'no lowercase letters, no symbols'): + validator('3A34SDEF5') + + +def test_SomeOf_max_validation(): + validator = SomeOf( + max_valid=2, + validators=[ + Match(r'.*[A-Z]', 'no uppercase letters'), + Match(r'.*[a-z]', 'no lowercase letters'), + Match(r'.*[0-9]', 'no numbers'), + ], + msg='max validation test failed', + ) + + validator('Aa') + with raises(TooManyValid, 'max validation test failed'): + validator('Aa1') + + +def test_self_validation(): + schema = Schema({"number": int, "follow": Self}) + with raises(MultipleInvalid): + schema({"number": "abc"}) + + with raises(MultipleInvalid): + schema({"follow": {"number": '123456.712'}}) + + schema({"follow": {"number": 123456}}) + schema({"follow": {"follow": {"number": 123456}}}) + + +def test_any_error_has_path(): + """https://github.com/alecthomas/voluptuous/issues/347""" + s = Schema({Optional('q'): int, Required('q2'): Any(int, msg='toto')}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'q': 'str', 'q2': 'tata'}) + + assert ( + ctx.value.errors[0].path == ['q'] and ctx.value.errors[1].path == ['q2'] + ) or (ctx.value.errors[1].path == ['q'] and ctx.value.errors[0].path == ['q2']) + + +def test_all_error_has_path(): + """https://github.com/alecthomas/voluptuous/issues/347""" + s = Schema( + { + Optional('q'): int, + Required('q2'): All([str, Length(min=10)], msg='toto'), + } + ) + with pytest.raises(MultipleInvalid) as ctx: + s({'q': 'str', 'q2': 12}) + + assert len(ctx.value.errors) == 2 + assert ( + isinstance(ctx.value.errors[0], TypeInvalid) + and isinstance(ctx.value.errors[1], AllInvalid) + ) or ( + isinstance(ctx.value.errors[1], TypeInvalid) + and isinstance(ctx.value.errors[0], AllInvalid) + ) + assert ( + ctx.value.errors[0].path == ['q'] and ctx.value.errors[1].path == ['q2'] + ) or (ctx.value.errors[1].path == ['q'] and ctx.value.errors[0].path == ['q2']) + + +def test_match_error_has_path(): + """https://github.com/alecthomas/voluptuous/issues/347""" + s = Schema( + { + Required('q2'): Match("a"), + } + ) + with pytest.raises(MultipleInvalid) as ctx: + s({'q2': 12}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], MatchInvalid) + assert ctx.value.errors[0].path == ['q2'] + + +def test_path_with_string(): + """Most common dict use with strings as keys""" + s = Schema({'string_key': int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'string_key': 'str'}) + assert ctx.value.errors[0].path == ['string_key'] + + +def test_path_with_list_index(): + """Position of the offending list index included in path as int""" + s = Schema({'string_key': [int]}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'string_key': [123, 'should be int']}) + assert ctx.value.errors[0].path == ['string_key', 1] + + +def test_path_with_tuple_index(): + """Position of the offending tuple index included in path as int""" + s = Schema({'string_key': (int,)}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'string_key': (123, 'should be int')}) + assert ctx.value.errors[0].path == ['string_key', 1] + + +def test_path_with_integer_dict_key(): + """Not obvious case with dict having not strings, but integers as keys""" + s = Schema({1337: int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({1337: 'should be int'}) + assert ctx.value.errors[0].path == [1337] + + +def test_path_with_float_dict_key(): + """Not obvious case with dict having not strings, but floats as keys""" + s = Schema({13.37: int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({13.37: 'should be int'}) + assert ctx.value.errors[0].path == [13.37] + + +def test_path_with_tuple_dict_key(): + """Not obvious case with dict having not strings, but tuples as keys""" + s = Schema({('fancy', 'key'): int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({('fancy', 'key'): 'should be int'}) + assert ctx.value.errors[0].path == [('fancy', 'key')] + + +def test_path_with_arbitrary_hashable_dict_key(): + """Not obvious case with dict having not strings, but arbitrary hashable objects as keys""" + + class HashableObjectWhichWillBeKeyInDict: + def __hash__(self): + return 1337 # dummy hash, used only for illustration + + s = Schema({HashableObjectWhichWillBeKeyInDict: [int]}) + + hashable_obj_provided_in_input = HashableObjectWhichWillBeKeyInDict() + + with pytest.raises(MultipleInvalid) as ctx: + s({hashable_obj_provided_in_input: [0, 1, 'should be int']}) + assert ctx.value.errors[0].path == [hashable_obj_provided_in_input, 2] + + +def test_self_any(): + schema = Schema({"number": int, "follow": Any(Self, "stop")}) + with pytest.raises(MultipleInvalid) as ctx: + schema({"number": "abc"}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], TypeInvalid) + + with raises(MultipleInvalid): + schema({"follow": {"number": '123456.712'}}) + + schema({"follow": {"number": 123456}}) + schema({"follow": {"follow": {"number": 123456}}}) + schema({"follow": {"follow": {"number": 123456, "follow": "stop"}}}) + + +def test_self_all(): + schema = Schema( + { + "number": int, + "follow": All(Self, Schema({"extra_number": int}, extra=ALLOW_EXTRA)), + }, + extra=ALLOW_EXTRA, + ) + with pytest.raises(MultipleInvalid) as ctx: + schema({"number": "abc"}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], TypeInvalid) + + with pytest.raises(MultipleInvalid) as ctx: + schema({"follow": {"number": '123456.712'}}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], TypeInvalid) + + schema({"follow": {"number": 123456}}) + schema({"follow": {"follow": {"number": 123456}}}) + schema({"follow": {"number": 123456, "extra_number": 123}}) + + with pytest.raises(MultipleInvalid) as ctx: + schema({"follow": {"number": 123456, "extra_number": "123"}}) + assert len(ctx.value.errors) == 1 + assert isinstance(ctx.value.errors[0], TypeInvalid) + + +def test_SomeOf_on_bounds_assertion(): + with raises( + AssertionError, + 'when using "SomeOf" you should specify at least one of min_valid and max_valid', + ): + SomeOf(validators=[]) + + +def test_comparing_voluptuous_object_to_str(): + assert Optional('Classification') < 'Name' + + +def test_set_of_integers(): + schema = Schema({int}) + with raises(Invalid, 'expected a set'): + schema(42) + with raises(Invalid, 'expected a set'): + schema(frozenset([42])) + + schema(set()) + schema(set([42])) + schema(set([42, 43, 44])) + with pytest.raises(MultipleInvalid, match="invalid value in set") as ctx: + schema(set(['abc'])) + assert len(ctx.value.errors) == 1 + + +def test_frozenset_of_integers(): + schema = Schema(frozenset([int])) + with raises(Invalid, 'expected a frozenset'): + schema(42) + with raises(Invalid, 'expected a frozenset'): + schema(set([42])) + + schema(frozenset()) + schema(frozenset([42])) + schema(frozenset([42, 43, 44])) + + with pytest.raises(MultipleInvalid, match="invalid value in frozenset") as ctx: + schema(frozenset(['abc'])) + assert len(ctx.value.errors) == 1 + + +def test_set_of_integers_and_strings(): + schema = Schema({int, str}) + with raises(Invalid, 'expected a set'): + schema(42) + + schema(set()) + schema(set([42])) + schema(set(['abc'])) + schema(set([42, 'abc'])) + + with pytest.raises(MultipleInvalid, match="invalid value in set") as ctx: + schema(set([None])) + assert len(ctx.value.errors) == 1 + + +def test_frozenset_of_integers_and_strings(): + schema = Schema(frozenset([int, str])) + with raises(Invalid, 'expected a frozenset'): + schema(42) + + schema(frozenset()) + schema(frozenset([42])) + schema(frozenset(['abc'])) + schema(frozenset([42, 'abc'])) + + with pytest.raises(MultipleInvalid, match="invalid value in frozenset") as ctx: + schema(frozenset([None])) + assert len(ctx.value.errors) == 1 + + +def test_lower_util_handles_various_inputs(): + assert Lower(3) == "3" + assert Lower(u"3") == u"3" + assert Lower(b'\xe2\x98\x83'.decode("UTF-8")) == b'\xe2\x98\x83'.decode("UTF-8") + assert Lower(u"A") == u"a" + + +def test_upper_util_handles_various_inputs(): + assert Upper(3) == "3" + assert Upper(u"3") == u"3" + assert Upper(b'\xe2\x98\x83'.decode("UTF-8")) == b'\xe2\x98\x83'.decode("UTF-8") + assert Upper(u"a") == u"A" + + +def test_capitalize_util_handles_various_inputs(): + assert Capitalize(3) == "3" + assert Capitalize(u"3") == u"3" + assert Capitalize(b'\xe2\x98\x83'.decode("UTF-8")) == b'\xe2\x98\x83'.decode( + "UTF-8" + ) + assert Capitalize(u"aaa aaa") == u"Aaa aaa" + + +def test_title_util_handles_various_inputs(): + assert Title(3) == "3" + assert Title(u"3") == u"3" + assert Title(b'\xe2\x98\x83'.decode("UTF-8")) == b'\xe2\x98\x83'.decode("UTF-8") + assert Title(u"aaa aaa") == u"Aaa Aaa" + + +def test_strip_util_handles_various_inputs(): + assert Strip(3) == "3" + assert Strip(u"3") == u"3" + assert Strip(b'\xe2\x98\x83'.decode("UTF-8")) == b'\xe2\x98\x83'.decode("UTF-8") + assert Strip(u" aaa ") == u"aaa" + + +def test_any_required(): + schema = Schema(Any({'a': int}, {'b': str}, required=True)) + + with raises(MultipleInvalid, "required key not provided @ data['a']"): + schema({}) + + +def test_any_required_with_subschema(): + schema = Schema( + Any({'a': Any(float, int)}, {'b': int}, {'c': {'aa': int}}, required=True) + ) + + with raises(MultipleInvalid, "required key not provided @ data['a']"): + schema({}) + + +def test_inclusive(): + schema = Schema( + { + Inclusive('x', 'stuff'): int, + Inclusive('y', 'stuff'): int, + } + ) + + r = schema({}) + assert r == {} + + r = schema({'x': 1, 'y': 2}) + assert r == {'x': 1, 'y': 2} + + with raises( + MultipleInvalid, + "some but not all values in the same group of inclusion 'stuff' @ data[]", + ): + schema({'x': 1}) + + +def test_inclusive_defaults(): + schema = Schema( + { + Inclusive('x', 'stuff', default=3): int, + Inclusive('y', 'stuff', default=4): int, + } + ) + + r = schema({}) + assert r == {'x': 3, 'y': 4} + + with raises( + MultipleInvalid, + "some but not all values in the same group of inclusion 'stuff' @ data[]", + ): + r = schema({'x': 1}) + + +def test_exclusive(): + schema = Schema( + { + Exclusive('x', 'stuff'): int, + Exclusive('y', 'stuff'): int, + } + ) + + r = schema({}) + assert r == {} + + r = schema({'x': 1}) + assert r == {'x': 1} + + with raises( + MultipleInvalid, + "two or more values in the same group of exclusion 'stuff' @ data[]", + ): + r = schema({'x': 1, 'y': 2}) + + +def test_any_with_discriminant(): + schema = Schema( + { + 'implementation': Union( + { + 'type': 'A', + 'a-value': str, + }, + { + 'type': 'B', + 'b-value': int, + }, + { + 'type': 'C', + 'c-value': bool, + }, + discriminant=lambda value, alternatives: filter( + lambda v: v['type'] == value['type'], alternatives + ), + ) + } + ) + with raises( + MultipleInvalid, + "expected bool for dictionary value @ data['implementation']['c-value']", + ): + schema({'implementation': {'type': 'C', 'c-value': None}}) + + +def test_key1(): + def as_int(a): + return int(a) + + schema = Schema({as_int: str}) + with pytest.raises(MultipleInvalid) as ctx: + schema( + { + '1': 'one', + 'two': '2', + '3': 'three', + 'four': '4', + } + ) + + assert len(ctx.value.errors) == 2 + assert str(ctx.value.errors[0]) == "not a valid value @ data['two']" + assert str(ctx.value.errors[1]) == "not a valid value @ data['four']" + + +def test_key2(): + def as_int(a): + try: + return int(a) + except ValueError: + raise Invalid('expecting a number') + + schema = Schema({as_int: str}) + with pytest.raises(MultipleInvalid) as ctx: + schema( + { + '1': 'one', + 'two': '2', + '3': 'three', + 'four': '4', + } + ) + assert len(ctx.value.errors) == 2 + assert str(ctx.value.errors[0]) == "expecting a number @ data['two']" + assert str(ctx.value.errors[1]) == "expecting a number @ data['four']" + + +def test_any_with_extra_allow(): + schema = Schema( + { + Any("name", "area"): str, + "domain": str, + }, + extra=ALLOW_EXTRA, + ) + + result = schema( + { + "name": "one", + "domain": "two", + "additional_key": "extra", + } + ) + + assert result == { + "name": "one", + "domain": "two", + "additional_key": "extra", + } + + +def test_any_with_extra_remove(): + schema = Schema( + { + Any("name", "area"): str, + "domain": str, + }, + extra=REMOVE_EXTRA, + ) + + result = schema( + { + "name": "one", + "domain": "two", + "additional_key": "extra", + } + ) + + assert result == { + "name": "one", + "domain": "two", + } + + +def test_any_with_extra_prevent(): + schema = Schema( + { + Any("name", "area"): str, + "domain": str, + }, + extra=PREVENT_EXTRA, + ) + + with pytest.raises(MultipleInvalid) as ctx: + schema( + { + "name": "one", + "domain": "two", + "additional_key": "extra", + } + ) + + assert len(ctx.value.errors) == 1 + assert str(ctx.value.errors[0]) == "not a valid value @ data['additional_key']" + + +def test_any_with_extra_none(): + schema = Schema( + { + Any("name", "area"): str, + "domain": str, + }, + ) + + with pytest.raises(MultipleInvalid) as ctx: + schema( + { + "name": "one", + "domain": "two", + "additional_key": "extra", + } + ) + + assert len(ctx.value.errors) == 1 + assert str(ctx.value.errors[0]) == "not a valid value @ data['additional_key']" + + +def test_coerce_enum(): + """Test Coerce Enum""" + + class Choice(Enum): + Easy = 1 + Medium = 2 + Hard = 3 + + class StringChoice(str, Enum): + Easy = "easy" + Medium = "medium" + Hard = "hard" + + schema = Schema(Coerce(Choice)) + string_schema = Schema(Coerce(StringChoice)) + + # Valid value + assert schema(1) == Choice.Easy + assert string_schema("easy") == StringChoice.Easy + + # Invalid value + with raises(Invalid, "expected Choice or one of 1, 2, 3"): + schema(4) + + with raises(Invalid, "expected StringChoice or one of 'easy', 'medium', 'hard'"): + string_schema("hello") + + +class MyValueClass(object): + def __init__(self, value=None): + self.value = value + + +def test_object(): + s = Schema(Object({'value': 1}), required=True) + s(MyValueClass(value=1)) + pytest.raises(MultipleInvalid, s, MyValueClass(value=2)) + pytest.raises(MultipleInvalid, s, 345) + + +def test_exception(): + s = Schema(None) + with pytest.raises(MultipleInvalid) as ctx: + s(123) + + invalid_scalar_excp_repr = "ScalarInvalid('not a valid value')" + assert repr(ctx.value) == f"MultipleInvalid([{invalid_scalar_excp_repr}])" + assert str(ctx.value.msg) == "not a valid value" + assert str(ctx.value.error_message) == "not a valid value" + assert str(ctx.value.errors) == f"[{invalid_scalar_excp_repr}]" + ctx.value.add("Test Error") + assert str(ctx.value.errors) == f"[{invalid_scalar_excp_repr}, 'Test Error']" + + +# Additional tests for humanize.py module to improve coverage +def test_humanize_error_with_nested_getitem_keyerror(): + """Test _nested_getitem with KeyError (line 19-22).""" + from voluptuous.humanize import _nested_getitem + + # Test KeyError handling + data = {'a': {'b': 1}} + path = ['a', 'c'] # 'c' doesn't exist in {'b': 1} + result = _nested_getitem(data, path) + assert result is None + + +def test_humanize_error_with_nested_getitem_indexerror(): + """Test _nested_getitem with IndexError (line 19-22).""" + from voluptuous.humanize import _nested_getitem + + # Test IndexError handling + data = {'a': [1, 2, 3]} + path = ['a', 5] # Index 5 doesn't exist in [1, 2, 3] + result = _nested_getitem(data, path) + assert result is None + + +def test_humanize_error_with_nested_getitem_typeerror(): + """Test _nested_getitem with TypeError (line 19-22).""" + from voluptuous.humanize import _nested_getitem + + # Test TypeError handling - data is not subscriptable + data = 42 # int is not subscriptable + path = ['a'] + result = _nested_getitem(data, path) + assert result is None + + +def test_humanize_error_with_long_error_message(): + """Test humanize_error with long error message that gets truncated (line 45).""" + from voluptuous.humanize import MAX_VALIDATION_ERROR_ITEM_LENGTH, humanize_error + + # Create a very long string that will be truncated + long_string = "x" * (MAX_VALIDATION_ERROR_ITEM_LENGTH + 10) + data = {'a': long_string} + schema = Schema({'a': int}) + + with pytest.raises(MultipleInvalid) as ctx: + schema(data) + + error_message = humanize_error(data, ctx.value, max_sub_error_length=50) + assert "..." in error_message + assert len(error_message.split("Got ")[1]) <= 53 # 50 + 3 for "..." + + +def test_validate_with_humanized_errors_success(): + """Test validate_with_humanized_errors with successful validation (line 54-57).""" + from voluptuous.humanize import validate_with_humanized_errors + + schema = Schema({'a': int, 'b': str}) + data = {'a': 42, 'b': 'hello'} + + result = validate_with_humanized_errors(data, schema) + assert result == data + + +def test_validate_with_humanized_errors_failure(): + """Test validate_with_humanized_errors with validation failure (line 54-57).""" + from voluptuous.humanize import Error, validate_with_humanized_errors + + schema = Schema({'a': int, 'b': str}) + data = {'a': 'not an int', 'b': 123} + + with pytest.raises(Error) as ctx: + validate_with_humanized_errors(data, schema) + + error_message = str(ctx.value) + assert "expected int for dictionary value @ data['a']" in error_message + assert "expected str for dictionary value @ data['b']" in error_message + assert "Got 'not an int'" in error_message + assert "Got 123" in error_message + + +def test_validate_with_humanized_errors_custom_max_length(): + """Test validate_with_humanized_errors with custom max_sub_error_length.""" + from voluptuous.humanize import Error, validate_with_humanized_errors + + schema = Schema({'a': int}) + data = {'a': 'not an int'} + + with pytest.raises(Error) as ctx: + validate_with_humanized_errors(data, schema, max_sub_error_length=10) + + error_message = str(ctx.value) + assert "..." in error_message # Should be truncated + + +def test_humanize_error_with_multiple_invalid(): + """Test humanize_error with MultipleInvalid containing multiple errors.""" + from voluptuous.humanize import humanize_error + + schema = Schema({'a': int, 'b': str, 'c': [int]}) + data = {'a': 'not an int', 'b': 123, 'c': ['not an int']} + + with pytest.raises(MultipleInvalid) as ctx: + schema(data) + + error_message = humanize_error(data, ctx.value) + # Should contain all three error messages + assert "expected int for dictionary value @ data['a']" in error_message + assert "expected str for dictionary value @ data['b']" in error_message + assert "expected int @ data['c'][0]" in error_message + + +def test_humanize_error_with_single_invalid(): + """Test humanize_error with single Invalid error.""" + from voluptuous.humanize import humanize_error + + schema = Schema({'a': int}) + data = {'a': 'not an int'} + + with pytest.raises(MultipleInvalid) as ctx: + schema(data) + + error_message = humanize_error(data, ctx.value) + assert "expected int for dictionary value @ data['a']" in error_message + assert "Got 'not an int'" in error_message + + +def test_humanize_error_with_none_data(): + """Test humanize_error with None data.""" + from voluptuous.humanize import _nested_getitem, humanize_error + + # Test _nested_getitem with None data + result = _nested_getitem(None, ['a']) + assert result is None + + # Test humanize_error with None data + schema = Schema({'a': int}) + data = None + + with pytest.raises(MultipleInvalid) as ctx: + schema(data) + + error_message = humanize_error(data, ctx.value) + assert "expected a dictionary" in error_message + + +def test_required_complex_key_any(): + """Test Required with Any validator for multiple possible keys""" + schema = Schema( + {Required(Any("color", "temperature", "brightness")): str, "device_id": str} + ) + + # Should pass - defines one of the required keys + result = schema({"color": "red", "device_id": "light1"}) + assert result == {"color": "red", "device_id": "light1"} + + # Should pass - defines several of the required keys + result = schema({"color": "blue", "brightness": "50%", "device_id": "light1"}) + assert result == {"color": "blue", "brightness": "50%", "device_id": "light1"} + + # Should fail - has none of the required keys + with pytest.raises(MultipleInvalid) as ctx: + schema({"device_id": "light1"}) + + error_msg = str(ctx.value) + assert ( + "at least one of ['color', 'temperature', 'brightness'] is required" + in error_msg + ) + + +def test_required_complex_key_custom_message(): + """Test Required with Any validator and custom error message""" + schema = Schema( + { + Required( + Any("color", "temperature", "brightness"), + msg="Please specify a lighting attribute", + ): str, + "device_id": str, + } + ) + + # Should pass + schema({"color": "red", "device_id": "light1"}) + + # Should fail with custom message + with pytest.raises(MultipleInvalid) as ctx: + schema({"device_id": "light1"}) + + error_msg = str(ctx.value) + assert "Please specify a lighting attribute" in error_msg + + +def test_required_complex_key_mixed_types(): + """Test Required with Any validator containing mixed key types""" + schema = Schema({Required(Any("string_key", 123, 45.6)): str, "other": int}) + + # Should work with string key + result = schema({"string_key": "value", "other": 1}) + assert result == {"string_key": "value", "other": 1} + + # Should work with int key + result = schema({123: "value", "other": 1}) + assert result == {123: "value", "other": 1} + + # Should work with float key + result = schema({45.6: "value", "other": 1}) + assert result == {45.6: "value", "other": 1} + + # Should fail with none present + with pytest.raises(MultipleInvalid) as ctx: + schema({"other": 1}) + + error_msg = str(ctx.value) + assert "at least one of ['string_key', 123, 45.6] is required" in error_msg + + +def test_required_complex_key_multiple_complex_requirements(): + """Test multiple Required complex keys in same schema""" + schema = Schema( + { + Required(Any("color", "hue")): str, + Required(Any("brightness", "intensity")): str, + "device": str, + } + ) + + # Should pass with one from each group + result = schema({"color": "red", "brightness": "high", "device": "light"}) + assert result == {"color": "red", "brightness": "high", "device": "light"} + + # Should fail if missing on any group + with pytest.raises(MultipleInvalid) as ctx: + schema({"brightness": "high", "device": "light"}) + + error_msg = str(ctx.value) + assert "at least one of ['color', 'hue'] is required" in error_msg + + +def test_required_complex_key_value_validation(): + """Test that value validation still works with complex required keys""" + schema = Schema({Required(Any("color", "temperature")): str, "device": str}) + + # Should pass with valid string value + result = schema({"color": "red", "device": "light"}) + assert result == {"color": "red", "device": "light"} + + # Should fail with invalid value type + with pytest.raises(MultipleInvalid) as ctx: + schema({"color": 123, "device": "light"}) # color should be str, not int + + error_msg = str(ctx.value) + assert "expected str" in error_msg + + +def test_complex_required_keys_with_specific_value_validation(): + """Test complex required keys combined with specific value validation for brightness range.""" + schema = Schema( + { + Required(Any('color', 'temperature', 'brightness')): object, + 'brightness': All( + Coerce(int), Range(min=0, max=100) + ), # Additional validation for brightness specifically + 'device_id': str, + } + ) + + # Valid - color provided, no brightness validation needed + result = schema({'color': 'red', 'device_id': 'light1'}) + assert result == {'color': 'red', 'device_id': 'light1'} + + # Invalid - brightness provided but out of range (255 > 100) + # Should NOT get "required field missing" error, but should get range error + with pytest.raises(MultipleInvalid) as exc_info: + schema({'brightness': '255', 'device_id': 'light1'}) + + # Verify it's a range error, not a missing required field error + error_msg = str(exc_info.value) + assert "required" not in error_msg.lower() # No "required field missing" error + assert "value must be at most 100" in error_msg # Range validation error diff --git a/voluptuous/util.py b/voluptuous/util.py new file mode 100644 index 00000000..0bf93022 --- /dev/null +++ b/voluptuous/util.py @@ -0,0 +1,149 @@ +# F401: "imported but unused" +# fmt: off +import typing + +from voluptuous import validators # noqa: F401 +from voluptuous.error import Invalid, LiteralInvalid, TypeInvalid # noqa: F401 +from voluptuous.schema_builder import DefaultFactory # noqa: F401 +from voluptuous.schema_builder import Schema, default_factory, raises # noqa: F401 + +# fmt: on + +__author__ = 'tusharmakkar08' + + +def Lower(v: str) -> str: + """Transform a string to lower case. + + >>> s = Schema(Lower) + >>> s('HI') + 'hi' + """ + return str(v).lower() + + +def Upper(v: str) -> str: + """Transform a string to upper case. + + >>> s = Schema(Upper) + >>> s('hi') + 'HI' + """ + return str(v).upper() + + +def Capitalize(v: str) -> str: + """Capitalise a string. + + >>> s = Schema(Capitalize) + >>> s('hello world') + 'Hello world' + """ + return str(v).capitalize() + + +def Title(v: str) -> str: + """Title case a string. + + >>> s = Schema(Title) + >>> s('hello world') + 'Hello World' + """ + return str(v).title() + + +def Strip(v: str) -> str: + """Strip whitespace from a string. + + >>> s = Schema(Strip) + >>> s(' hello world ') + 'hello world' + """ + return str(v).strip() + + +class DefaultTo(object): + """Sets a value to default_value if none provided. + + >>> s = Schema(DefaultTo(42)) + >>> s(None) + 42 + >>> s = Schema(DefaultTo(list)) + >>> s(None) + [] + """ + + def __init__(self, default_value, msg: typing.Optional[str] = None) -> None: + self.default_value = default_factory(default_value) + self.msg = msg + + def __call__(self, v): + if v is None: + v = self.default_value() + return v + + def __repr__(self): + return 'DefaultTo(%s)' % (self.default_value(),) + + +class SetTo(object): + """Set a value, ignoring any previous value. + + >>> s = Schema(validators.Any(int, SetTo(42))) + >>> s(2) + 2 + >>> s("foo") + 42 + """ + + def __init__(self, value) -> None: + self.value = default_factory(value) + + def __call__(self, v): + return self.value() + + def __repr__(self): + return 'SetTo(%s)' % (self.value(),) + + +class Set(object): + """Convert a list into a set. + + >>> s = Schema(Set()) + >>> s([]) == set([]) + True + >>> s([1, 2]) == set([1, 2]) + True + >>> with raises(Invalid, regex="^cannot be presented as set: "): + ... s([set([1, 2]), set([3, 4])]) + """ + + def __init__(self, msg: typing.Optional[str] = None) -> None: + self.msg = msg + + def __call__(self, v): + try: + set_v = set(v) + except Exception as e: + raise TypeInvalid(self.msg or 'cannot be presented as set: {0}'.format(e)) + return set_v + + def __repr__(self): + return 'Set()' + + +class Literal(object): + def __init__(self, lit) -> None: + self.lit = lit + + def __call__(self, value, msg: typing.Optional[str] = None): + if self.lit != value: + raise LiteralInvalid(msg or '%s not match for %s' % (value, self.lit)) + else: + return self.lit + + def __str__(self): + return str(self.lit) + + def __repr__(self): + return repr(self.lit) diff --git a/voluptuous/validators.py b/voluptuous/validators.py new file mode 100644 index 00000000..da754156 --- /dev/null +++ b/voluptuous/validators.py @@ -0,0 +1,1270 @@ +# fmt: off +from __future__ import annotations + +import datetime +import os +import re +import sys +import typing +from decimal import Decimal, InvalidOperation +from functools import wraps + +from voluptuous.error import ( + AllInvalid, + AnyInvalid, + BooleanInvalid, + CoerceInvalid, + ContainsInvalid, + DateInvalid, + DatetimeInvalid, + DirInvalid, + EmailInvalid, + ExactSequenceInvalid, + FalseInvalid, + FileInvalid, + InInvalid, + Invalid, + LengthInvalid, + MatchInvalid, + MultipleInvalid, + NotEnoughValid, + NotInInvalid, + PathInvalid, + RangeInvalid, + TooManyValid, + TrueInvalid, + TypeInvalid, + UrlInvalid, +) + +# F401: flake8 complains about 'raises' not being used, but it is used in doctests +from voluptuous.schema_builder import Schema, Schemable, message, raises # noqa: F401 + +if typing.TYPE_CHECKING: + from _typeshed import SupportsAllComparisons + +# fmt: on + + +Enum: typing.Union[type, None] +try: + from enum import Enum +except ImportError: + Enum = None + + +if sys.version_info >= (3,): + import urllib.parse as urlparse + + basestring = str +else: + import urlparse + +# Taken from https://github.com/kvesteri/validators/blob/master/validators/email.py +# fmt: off +USER_REGEX = re.compile( + # start anchor, because fullmatch is not available in python 2.7 + "(?:" + # dot-atom + r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+" + r"(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*$" + # quoted-string + r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|' + r"""\\[\001-\011\013\014\016-\177])*"$)""" + # end anchor, because fullmatch is not available in python 2.7 + r")\Z", + re.IGNORECASE, +) +DOMAIN_REGEX = re.compile( + # start anchor, because fullmatch is not available in python 2.7 + "(?:" + # domain + r'(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+' + # tld + r'(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?$)' + # literal form, ipv4 address (SMTP 4.1.3) + r'|^\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)' + r'(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\]$' + # end anchor, because fullmatch is not available in python 2.7 + r")\Z", + re.IGNORECASE, +) +# fmt: on + +__author__ = 'tusharmakkar08' + + +def truth(f: typing.Callable) -> typing.Callable: + """Convenience decorator to convert truth functions into validators. + + >>> @truth + ... def isdir(v): + ... return os.path.isdir(v) + >>> validate = Schema(isdir) + >>> validate('/') + '/' + >>> with raises(MultipleInvalid, 'not a valid value'): + ... validate('/notavaliddir') + """ + + @wraps(f) + def check(v): + t = f(v) + if not t: + raise ValueError + return v + + return check + + +class Coerce(object): + """Coerce a value to a type. + + If the type constructor throws a ValueError or TypeError, the value + will be marked as Invalid. + + Default behavior: + + >>> validate = Schema(Coerce(int)) + >>> with raises(MultipleInvalid, 'expected int'): + ... validate(None) + >>> with raises(MultipleInvalid, 'expected int'): + ... validate('foo') + + With custom message: + + >>> validate = Schema(Coerce(int, "moo")) + >>> with raises(MultipleInvalid, 'moo'): + ... validate('foo') + """ + + def __init__( + self, + type: typing.Union[type, typing.Callable], + msg: typing.Optional[str] = None, + ) -> None: + self.type = type + self.msg = msg + self.type_name = type.__name__ + + def __call__(self, v): + try: + return self.type(v) + except (ValueError, TypeError, InvalidOperation): + msg = self.msg or ('expected %s' % self.type_name) + if not self.msg and Enum and issubclass(self.type, Enum): + msg += " or one of %s" % str([e.value for e in self.type])[1:-1] + raise CoerceInvalid(msg) + + def __repr__(self): + return 'Coerce(%s, msg=%r)' % (self.type_name, self.msg) + + +@message('value was not true', cls=TrueInvalid) +@truth +def IsTrue(v): + """Assert that a value is true, in the Python sense. + + >>> validate = Schema(IsTrue()) + + "In the Python sense" means that implicitly false values, such as empty + lists, dictionaries, etc. are treated as "false": + + >>> with raises(MultipleInvalid, "value was not true"): + ... validate([]) + >>> validate([1]) + [1] + >>> with raises(MultipleInvalid, "value was not true"): + ... validate(False) + + ...and so on. + + >>> try: + ... validate([]) + ... except MultipleInvalid as e: + ... assert isinstance(e.errors[0], TrueInvalid) + """ + return v + + +@message('value was not false', cls=FalseInvalid) +def IsFalse(v): + """Assert that a value is false, in the Python sense. + + (see :func:`IsTrue` for more detail) + + >>> validate = Schema(IsFalse()) + >>> validate([]) + [] + >>> with raises(MultipleInvalid, "value was not false"): + ... validate(True) + + >>> try: + ... validate(True) + ... except MultipleInvalid as e: + ... assert isinstance(e.errors[0], FalseInvalid) + """ + if v: + raise ValueError + return v + + +@message('expected boolean', cls=BooleanInvalid) +def Boolean(v): + """Convert human-readable boolean values to a bool. + + Accepted values are 1, true, yes, on, enable, and their negatives. + Non-string values are cast to bool. + + >>> validate = Schema(Boolean()) + >>> validate(True) + True + >>> validate("1") + True + >>> validate("0") + False + >>> with raises(MultipleInvalid, "expected boolean"): + ... validate('moo') + >>> try: + ... validate('moo') + ... except MultipleInvalid as e: + ... assert isinstance(e.errors[0], BooleanInvalid) + """ + if isinstance(v, basestring): + v = v.lower() + if v in ('1', 'true', 'yes', 'on', 'enable'): + return True + if v in ('0', 'false', 'no', 'off', 'disable'): + return False + raise ValueError + return bool(v) + + +class _WithSubValidators(object): + """Base class for validators that use sub-validators. + + Special class to use as a parent class for validators using sub-validators. + This class provides the `__voluptuous_compile__` method so the + sub-validators are compiled by the parent `Schema`. + """ + + def __init__( + self, *validators, msg=None, required=False, discriminant=None, **kwargs + ) -> None: + self.validators = validators + self.msg = msg + self.required = required + self.discriminant = discriminant + + def __voluptuous_compile__(self, schema: Schema) -> typing.Callable: + self._compiled = [] + old_required = schema.required + self.schema = schema + for v in self.validators: + schema.required = self.required + self._compiled.append(schema._compile(v)) + schema.required = old_required + return self._run + + def _run(self, path: typing.List[typing.Hashable], value): + if self.discriminant is not None: + self._compiled = [ + self.schema._compile(v) + for v in self.discriminant(value, self.validators) + ] + + return self._exec(self._compiled, value, path) + + def __call__(self, v): + return self._exec((Schema(val) for val in self.validators), v) + + def __repr__(self): + return '%s(%s, msg=%r)' % ( + self.__class__.__name__, + ", ".join(repr(v) for v in self.validators), + self.msg, + ) + + def _exec( + self, + funcs: typing.Iterable, + v, + path: typing.Optional[typing.List[typing.Hashable]] = None, + ): + raise NotImplementedError() + + +class Any(_WithSubValidators): + """Use the first validated value. + + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + :returns: Return value of the first validator that passes. + + >>> validate = Schema(Any('true', 'false', + ... All(Any(int, bool), Coerce(bool)))) + >>> validate('true') + 'true' + >>> validate(1) + True + >>> with raises(MultipleInvalid, "not a valid value"): + ... validate('moo') + + msg argument is used + + >>> validate = Schema(Any(1, 2, 3, msg="Expected 1 2 or 3")) + >>> validate(1) + 1 + >>> with raises(MultipleInvalid, "Expected 1 2 or 3"): + ... validate(4) + """ + + def _exec(self, funcs, v, path=None): + error = None + for func in funcs: + try: + if path is None: + return func(v) + else: + return func(path, v) + except Invalid as e: + if error is None or len(e.path) > len(error.path): + error = e + else: + if error: + raise error if self.msg is None else AnyInvalid(self.msg, path=path) + raise AnyInvalid(self.msg or 'no valid value found', path=path) + + +# Convenience alias +Or = Any + + +class Union(_WithSubValidators): + """Use the first validated value among those selected by discriminant. + + :param msg: Message to deliver to user if validation fails. + :param discriminant(value, validators): Returns the filtered list of validators based on the value. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + :returns: Return value of the first validator that passes. + + >>> validate = Schema(Union({'type':'a', 'a_val':'1'},{'type':'b', 'b_val':'2'}, + ... discriminant=lambda val, alt: filter( + ... lambda v : v['type'] == val['type'] , alt))) + >>> validate({'type':'a', 'a_val':'1'}) == {'type':'a', 'a_val':'1'} + True + >>> with raises(MultipleInvalid, "not a valid value for dictionary value @ data['b_val']"): + ... validate({'type':'b', 'b_val':'5'}) + + ```discriminant({'type':'b', 'a_val':'5'}, [{'type':'a', 'a_val':'1'},{'type':'b', 'b_val':'2'}])``` is invoked + + Without the discriminant, the exception would be "extra keys not allowed @ data['b_val']" + """ + + def _exec(self, funcs, v, path=None): + error = None + for func in funcs: + try: + if path is None: + return func(v) + else: + return func(path, v) + except Invalid as e: + if error is None or len(e.path) > len(error.path): + error = e + else: + if error: + raise error if self.msg is None else AnyInvalid(self.msg, path=path) + raise AnyInvalid(self.msg or 'no valid value found', path=path) + + +# Convenience alias +Switch = Union + + +class All(_WithSubValidators): + """Value must pass all validators. + + The output of each validator is passed as input to the next. + + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + + >>> validate = Schema(All('10', Coerce(int))) + >>> validate('10') + 10 + """ + + def _exec(self, funcs, v, path=None): + try: + for func in funcs: + if path is None: + v = func(v) + else: + v = func(path, v) + except Invalid as e: + raise e if self.msg is None else AllInvalid(self.msg, path=path) + return v + + +# Convenience alias +And = All + + +class Match(object): + """Value must be a string that matches the regular expression. + + >>> validate = Schema(Match(r'^0x[A-F0-9]+$')) + >>> validate('0x123EF4') + '0x123EF4' + >>> with raises(MultipleInvalid, 'does not match regular expression ^0x[A-F0-9]+$'): + ... validate('123EF4') + + >>> with raises(MultipleInvalid, 'expected string or buffer'): + ... validate(123) + + Pattern may also be a compiled regular expression: + + >>> validate = Schema(Match(re.compile(r'0x[A-F0-9]+', re.I))) + >>> validate('0x123ef4') + '0x123ef4' + """ + + def __init__( + self, pattern: typing.Union[re.Pattern, str], msg: typing.Optional[str] = None + ) -> None: + if isinstance(pattern, basestring): + pattern = re.compile(pattern) + self.pattern = pattern + self.msg = msg + + def __call__(self, v): + try: + match = self.pattern.match(v) + except TypeError: + raise MatchInvalid("expected string or buffer") + if not match: + raise MatchInvalid( + self.msg + or 'does not match regular expression {}'.format(self.pattern.pattern) + ) + return v + + def __repr__(self): + return 'Match(%r, msg=%r)' % (self.pattern.pattern, self.msg) + + +class Replace(object): + """Regex substitution. + + >>> validate = Schema(All(Replace('you', 'I'), + ... Replace('hello', 'goodbye'))) + >>> validate('you say hello') + 'I say goodbye' + """ + + def __init__( + self, + pattern: typing.Union[re.Pattern, str], + substitution: str, + msg: typing.Optional[str] = None, + ) -> None: + if isinstance(pattern, basestring): + pattern = re.compile(pattern) + self.pattern = pattern + self.substitution = substitution + self.msg = msg + + def __call__(self, v): + return self.pattern.sub(self.substitution, v) + + def __repr__(self): + return 'Replace(%r, %r, msg=%r)' % ( + self.pattern.pattern, + self.substitution, + self.msg, + ) + + +def _url_validation(v: str) -> urlparse.ParseResult: + parsed = urlparse.urlparse(v) + if not parsed.scheme or not parsed.netloc: + raise UrlInvalid("must have a URL scheme and host") + return parsed + + +@message('expected an email address', cls=EmailInvalid) +def Email(v): + """Verify that the value is an email address or not. + + >>> s = Schema(Email()) + >>> with raises(MultipleInvalid, 'expected an email address'): + ... s("a.com") + >>> with raises(MultipleInvalid, 'expected an email address'): + ... s("a@.com") + >>> with raises(MultipleInvalid, 'expected an email address'): + ... s("a@.com") + >>> s('t@x.com') + 't@x.com' + """ + try: + if not v or "@" not in v: + raise EmailInvalid("Invalid email address") + user_part, domain_part = v.rsplit('@', 1) + + if not (USER_REGEX.match(user_part) and DOMAIN_REGEX.match(domain_part)): + raise EmailInvalid("Invalid email address") + return v + except: # noqa: E722 + raise ValueError + + +@message('expected a fully qualified domain name URL', cls=UrlInvalid) +def FqdnUrl(v): + """Verify that the value is a fully qualified domain name URL. + + >>> s = Schema(FqdnUrl()) + >>> with raises(MultipleInvalid, 'expected a fully qualified domain name URL'): + ... s("http://localhost/") + >>> s('http://w3.org') + 'http://w3.org' + """ + try: + parsed_url = _url_validation(v) + if "." not in parsed_url.netloc: + raise UrlInvalid("must have a domain name in URL") + return v + except: # noqa: E722 + raise ValueError + + +@message('expected a URL', cls=UrlInvalid) +def Url(v): + """Verify that the value is a URL. + + >>> s = Schema(Url()) + >>> with raises(MultipleInvalid, 'expected a URL'): + ... s(1) + >>> s('http://w3.org') + 'http://w3.org' + """ + try: + _url_validation(v) + return v + except: # noqa: E722 + raise ValueError + + +@message('Not a file', cls=FileInvalid) +@truth +def IsFile(v): + """Verify the file exists. + + >>> os.path.basename(IsFile()(__file__)).startswith('validators.py') + True + >>> with raises(FileInvalid, 'Not a file'): + ... IsFile()("random_filename_goes_here.py") + >>> with raises(FileInvalid, 'Not a file'): + ... IsFile()(None) + """ + try: + if v: + v = str(v) + return os.path.isfile(v) + else: + raise FileInvalid('Not a file') + except TypeError: + raise FileInvalid('Not a file') + + +@message('Not a directory', cls=DirInvalid) +@truth +def IsDir(v): + """Verify the directory exists. + + >>> IsDir()('/') + '/' + >>> with raises(DirInvalid, 'Not a directory'): + ... IsDir()(None) + """ + try: + if v: + v = str(v) + return os.path.isdir(v) + else: + raise DirInvalid("Not a directory") + except TypeError: + raise DirInvalid("Not a directory") + + +@message('path does not exist', cls=PathInvalid) +@truth +def PathExists(v): + """Verify the path exists, regardless of its type. + + >>> os.path.basename(PathExists()(__file__)).startswith('validators.py') + True + >>> with raises(Invalid, 'path does not exist'): + ... PathExists()("random_filename_goes_here.py") + >>> with raises(PathInvalid, 'Not a Path'): + ... PathExists()(None) + """ + try: + if v: + v = str(v) + return os.path.exists(v) + else: + raise PathInvalid("Not a Path") + except TypeError: + raise PathInvalid("Not a Path") + + +def Maybe(validator: Schemable, msg: typing.Optional[str] = None): + """Validate that the object matches given validator or is None. + + :raises Invalid: If the value does not match the given validator and is not + None. + + >>> s = Schema(Maybe(int)) + >>> s(10) + 10 + >>> with raises(Invalid): + ... s("string") + + """ + return Any(None, validator, msg=msg) + + +class Range(object): + """Limit a value to a range. + + Either min or max may be omitted. + Either min or max can be excluded from the range of accepted values. + + :raises Invalid: If the value is outside the range. + + >>> s = Schema(Range(min=1, max=10, min_included=False)) + >>> s(5) + 5 + >>> s(10) + 10 + >>> with raises(MultipleInvalid, 'value must be at most 10'): + ... s(20) + >>> with raises(MultipleInvalid, 'value must be higher than 1'): + ... s(1) + >>> with raises(MultipleInvalid, 'value must be lower than 10'): + ... Schema(Range(max=10, max_included=False))(20) + """ + + def __init__( + self, + min: SupportsAllComparisons | None = None, + max: SupportsAllComparisons | None = None, + min_included: bool = True, + max_included: bool = True, + msg: typing.Optional[str] = None, + ) -> None: + self.min = min + self.max = max + self.min_included = min_included + self.max_included = max_included + self.msg = msg + + def __call__(self, v): + try: + if self.min_included: + if self.min is not None and not v >= self.min: + raise RangeInvalid( + self.msg or 'value must be at least %s' % self.min + ) + else: + if self.min is not None and not v > self.min: + raise RangeInvalid( + self.msg or 'value must be higher than %s' % self.min + ) + if self.max_included: + if self.max is not None and not v <= self.max: + raise RangeInvalid( + self.msg or 'value must be at most %s' % self.max + ) + else: + if self.max is not None and not v < self.max: + raise RangeInvalid( + self.msg or 'value must be lower than %s' % self.max + ) + + return v + + # Objects that lack a partial ordering, e.g. None or strings will raise TypeError + except TypeError: + raise RangeInvalid( + self.msg or 'invalid value or type (must have a partial ordering)' + ) + + def __repr__(self): + return 'Range(min=%r, max=%r, min_included=%r, max_included=%r, msg=%r)' % ( + self.min, + self.max, + self.min_included, + self.max_included, + self.msg, + ) + + +class Clamp(object): + """Clamp a value to a range. + + Either min or max may be omitted. + + >>> s = Schema(Clamp(min=0, max=1)) + >>> s(0.5) + 0.5 + >>> s(5) + 1 + >>> s(-1) + 0 + """ + + def __init__( + self, + min: SupportsAllComparisons | None = None, + max: SupportsAllComparisons | None = None, + msg: typing.Optional[str] = None, + ) -> None: + self.min = min + self.max = max + self.msg = msg + + def __call__(self, v): + try: + if self.min is not None and v < self.min: + v = self.min + if self.max is not None and v > self.max: + v = self.max + return v + + # Objects that lack a partial ordering, e.g. None or strings will raise TypeError + except TypeError: + raise RangeInvalid( + self.msg or 'invalid value or type (must have a partial ordering)' + ) + + def __repr__(self): + return 'Clamp(min=%s, max=%s)' % (self.min, self.max) + + +class Length(object): + """The length of a value must be in a certain range.""" + + def __init__( + self, + min: SupportsAllComparisons | None = None, + max: SupportsAllComparisons | None = None, + msg: typing.Optional[str] = None, + ) -> None: + self.min = min + self.max = max + self.msg = msg + + def __call__(self, v): + try: + if self.min is not None and len(v) < self.min: + raise LengthInvalid( + self.msg or 'length of value must be at least %s' % self.min + ) + if self.max is not None and len(v) > self.max: + raise LengthInvalid( + self.msg or 'length of value must be at most %s' % self.max + ) + return v + + # Objects that have no length e.g. None or strings will raise TypeError + except TypeError: + raise RangeInvalid(self.msg or 'invalid value or type') + + def __repr__(self): + return 'Length(min=%s, max=%s)' % (self.min, self.max) + + +class Datetime(object): + """Validate that the value matches the datetime format.""" + + DEFAULT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' + + def __init__( + self, format: typing.Optional[str] = None, msg: typing.Optional[str] = None + ) -> None: + self.format = format or self.DEFAULT_FORMAT + self.msg = msg + + def __call__(self, v): + try: + datetime.datetime.strptime(v, self.format) + except (TypeError, ValueError): + raise DatetimeInvalid( + self.msg or 'value does not match expected format %s' % self.format + ) + return v + + def __repr__(self): + return 'Datetime(format=%s)' % self.format + + +class Date(Datetime): + """Validate that the value matches the date format.""" + + DEFAULT_FORMAT = '%Y-%m-%d' + + def __call__(self, v): + try: + datetime.datetime.strptime(v, self.format) + except (TypeError, ValueError): + raise DateInvalid( + self.msg or 'value does not match expected format %s' % self.format + ) + return v + + def __repr__(self): + return 'Date(format=%s)' % self.format + + +class In(object): + """Validate that a value is in a collection.""" + + def __init__( + self, + container: typing.Container | typing.Iterable, + msg: typing.Optional[str] = None, + ) -> None: + self.container = container + self.msg = msg + + def __call__(self, v): + try: + check = v not in self.container + except TypeError: + check = True + if check: + try: + raise InInvalid( + self.msg or f'value must be one of {sorted(self.container)}' + ) + except TypeError: + raise InInvalid( + self.msg + or f'value must be one of {sorted(self.container, key=str)}' + ) + return v + + def __repr__(self): + return 'In(%s)' % (self.container,) + + +class NotIn(object): + """Validate that a value is not in a collection.""" + + def __init__( + self, container: typing.Iterable, msg: typing.Optional[str] = None + ) -> None: + self.container = container + self.msg = msg + + def __call__(self, v): + try: + check = v in self.container + except TypeError: + check = True + if check: + try: + raise NotInInvalid( + self.msg or f'value must not be one of {sorted(self.container)}' + ) + except TypeError: + raise NotInInvalid( + self.msg + or f'value must not be one of {sorted(self.container, key=str)}' + ) + return v + + def __repr__(self): + return 'NotIn(%s)' % (self.container,) + + +class Contains(object): + """Validate that the given schema element is in the sequence being validated. + + >>> s = Contains(1) + >>> s([3, 2, 1]) + [3, 2, 1] + >>> with raises(ContainsInvalid, 'value is not allowed'): + ... s([3, 2]) + """ + + def __init__(self, item, msg: typing.Optional[str] = None) -> None: + self.item = item + self.msg = msg + + def __call__(self, v): + try: + check = self.item not in v + except TypeError: + check = True + if check: + raise ContainsInvalid(self.msg or 'value is not allowed') + return v + + def __repr__(self): + return 'Contains(%s)' % (self.item,) + + +class ExactSequence(object): + """Matches each element in a sequence against the corresponding element in + the validators. + + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema + constructors. + + >>> from voluptuous import Schema, ExactSequence + >>> validate = Schema(ExactSequence([str, int, list, list])) + >>> validate(['hourly_report', 10, [], []]) + ['hourly_report', 10, [], []] + >>> validate(('hourly_report', 10, [], [])) + ('hourly_report', 10, [], []) + """ + + def __init__( + self, + validators: typing.Iterable[Schemable], + msg: typing.Optional[str] = None, + **kwargs, + ) -> None: + self.validators = validators + self.msg = msg + self._schemas = [Schema(val, **kwargs) for val in validators] + + def __call__(self, v): + if not isinstance(v, (list, tuple)) or len(v) != len(self._schemas): + raise ExactSequenceInvalid(self.msg) + try: + v = type(v)(schema(x) for x, schema in zip(v, self._schemas)) + except Invalid as e: + raise e if self.msg is None else ExactSequenceInvalid(self.msg) + return v + + def __repr__(self): + return 'ExactSequence([%s])' % ", ".join(repr(v) for v in self.validators) + + +class Unique(object): + """Ensure an iterable does not contain duplicate items. + + Only iterables convertible to a set are supported (native types and + objects with correct __eq__). + + JSON does not support set, so they need to be presented as arrays. + Unique allows ensuring that such array does not contain dupes. + + >>> s = Schema(Unique()) + >>> s([]) + [] + >>> s([1, 2]) + [1, 2] + >>> with raises(Invalid, 'contains duplicate items: [1]'): + ... s([1, 1, 2]) + >>> with raises(Invalid, "contains duplicate items: ['one']"): + ... s(['one', 'two', 'one']) + >>> with raises(Invalid, regex="^contains unhashable elements: "): + ... s([set([1, 2]), set([3, 4])]) + >>> s('abc') + 'abc' + >>> with raises(Invalid, regex="^contains duplicate items: "): + ... s('aabbc') + """ + + def __init__(self, msg: typing.Optional[str] = None) -> None: + self.msg = msg + + def __call__(self, v): + try: + set_v = set(v) + except TypeError as e: + raise TypeInvalid(self.msg or 'contains unhashable elements: {0}'.format(e)) + if len(set_v) != len(v): + seen = set() + dupes = list(set(x for x in v if x in seen or seen.add(x))) + raise Invalid(self.msg or 'contains duplicate items: {0}'.format(dupes)) + return v + + def __repr__(self): + return 'Unique()' + + +class Equal(object): + """Ensure that value matches target. + + >>> s = Schema(Equal(1)) + >>> s(1) + 1 + >>> with raises(Invalid): + ... s(2) + + Validators are not supported, match must be exact: + + >>> s = Schema(Equal(str)) + >>> with raises(Invalid): + ... s('foo') + """ + + def __init__(self, target, msg: typing.Optional[str] = None) -> None: + self.target = target + self.msg = msg + + def __call__(self, v): + if v != self.target: + raise Invalid( + self.msg + or 'Values are not equal: value:{} != target:{}'.format(v, self.target) + ) + return v + + def __repr__(self): + return 'Equal({})'.format(self.target) + + +class Unordered(object): + """Ensures sequence contains values in unspecified order. + + >>> s = Schema(Unordered([2, 1])) + >>> s([2, 1]) + [2, 1] + >>> s([1, 2]) + [1, 2] + >>> s = Schema(Unordered([str, int])) + >>> s(['foo', 1]) + ['foo', 1] + >>> s([1, 'foo']) + [1, 'foo'] + """ + + def __init__( + self, + validators: typing.Iterable[Schemable], + msg: typing.Optional[str] = None, + **kwargs, + ) -> None: + self.validators = validators + self.msg = msg + self._schemas = [Schema(val, **kwargs) for val in validators] + + def __call__(self, v): + if not isinstance(v, (list, tuple)): + raise Invalid(self.msg or 'Value {} is not sequence!'.format(v)) + + if len(v) != len(self._schemas): + raise Invalid( + self.msg + or 'List lengths differ, value:{} != target:{}'.format( + len(v), len(self._schemas) + ) + ) + + consumed = set() + missing = [] + for index, value in enumerate(v): + found = False + for i, s in enumerate(self._schemas): + if i in consumed: + continue + try: + s(value) + except Invalid: + pass + else: + found = True + consumed.add(i) + break + if not found: + missing.append((index, value)) + + if len(missing) == 1: + el = missing[0] + raise Invalid( + self.msg + or 'Element #{} ({}) is not valid against any validator'.format( + el[0], el[1] + ) + ) + elif missing: + raise MultipleInvalid( + [ + Invalid( + self.msg + or 'Element #{} ({}) is not valid against any validator'.format( + el[0], el[1] + ) + ) + for el in missing + ] + ) + return v + + def __repr__(self): + return 'Unordered([{}])'.format(", ".join(repr(v) for v in self.validators)) + + +class Number(object): + """ + Verify the number of digits that are present in the number(Precision), + and the decimal places(Scale). + + :raises Invalid: If the value does not match the provided Precision and Scale. + + >>> schema = Schema(Number(precision=6, scale=2)) + >>> schema('1234.01') + '1234.01' + >>> schema = Schema(Number(precision=6, scale=2, yield_decimal=True)) + >>> schema('1234.01') + Decimal('1234.01') + """ + + def __init__( + self, + precision: typing.Optional[int] = None, + scale: typing.Optional[int] = None, + msg: typing.Optional[str] = None, + yield_decimal: bool = False, + ) -> None: + self.precision = precision + self.scale = scale + self.msg = msg + self.yield_decimal = yield_decimal + + def __call__(self, v): + """ + :param v: is a number enclosed with string + :return: Decimal number + """ + precision, scale, decimal_num = self._get_precision_scale(v) + + if ( + self.precision is not None + and self.scale is not None + and precision != self.precision + and scale != self.scale + ): + raise Invalid( + self.msg + or "Precision must be equal to %s, and Scale must be equal to %s" + % (self.precision, self.scale) + ) + else: + if self.precision is not None and precision != self.precision: + raise Invalid( + self.msg or "Precision must be equal to %s" % self.precision + ) + + if self.scale is not None and scale != self.scale: + raise Invalid(self.msg or "Scale must be equal to %s" % self.scale) + + if self.yield_decimal: + return decimal_num + else: + return v + + def __repr__(self): + return 'Number(precision=%s, scale=%s, msg=%s)' % ( + self.precision, + self.scale, + self.msg, + ) + + def _get_precision_scale(self, number) -> typing.Tuple[int, int, Decimal]: + """ + :param number: + :return: tuple(precision, scale, decimal_number) + """ + try: + decimal_num = Decimal(number) + except InvalidOperation: + raise Invalid(self.msg or 'Value must be a number enclosed with string') + + exp = decimal_num.as_tuple().exponent + if isinstance(exp, int): + return (len(decimal_num.as_tuple().digits), -exp, decimal_num) + else: + # TODO: handle infinity and NaN + # raise Invalid(self.msg or 'Value has no precision') + raise TypeError("infinity and NaN have no precision") + + +class SomeOf(_WithSubValidators): + """Value must pass at least some validations, determined by the given parameter. + Optionally, number of passed validations can be capped. + + The output of each validator is passed as input to the next. + + :param min_valid: Minimum number of valid schemas. + :param validators: List of schemas or validators to match input against. + :param max_valid: Maximum number of valid schemas. + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + + :raises NotEnoughValid: If the minimum number of validations isn't met. + :raises TooManyValid: If the maximum number of validations is exceeded. + + >>> validate = Schema(SomeOf(min_valid=2, validators=[Range(1, 5), Any(float, int), 6.6])) + >>> validate(6.6) + 6.6 + >>> validate(3) + 3 + >>> with raises(MultipleInvalid, 'value must be at most 5, not a valid value'): + ... validate(6.2) + """ + + def __init__( + self, + validators: typing.List[Schemable], + min_valid: typing.Optional[int] = None, + max_valid: typing.Optional[int] = None, + **kwargs, + ) -> None: + assert min_valid is not None or max_valid is not None, ( + 'when using "%s" you should specify at least one of min_valid and max_valid' + % (type(self).__name__,) + ) + self.min_valid = min_valid or 0 + self.max_valid = max_valid or len(validators) + super(SomeOf, self).__init__(*validators, **kwargs) + + def _exec(self, funcs, v, path=None): + errors = [] + funcs = list(funcs) + for func in funcs: + try: + if path is None: + v = func(v) + else: + v = func(path, v) + except Invalid as e: + errors.append(e) + + passed_count = len(funcs) - len(errors) + if self.min_valid <= passed_count <= self.max_valid: + return v + + msg = self.msg + if not msg: + msg = ', '.join(map(str, errors)) + + if passed_count > self.max_valid: + raise TooManyValid(msg) + raise NotEnoughValid(msg) + + def __repr__(self): + return 'SomeOf(min_valid=%s, validators=[%s], max_valid=%s, msg=%r)' % ( + self.min_valid, + ", ".join(repr(v) for v in self.validators), + self.max_valid, + self.msg, + )