diff --git a/bundles/org.openhab.automation.jsscripting/README.md b/bundles/org.openhab.automation.jsscripting/README.md
index 6dd0fbb33d03c..100249e958a1d 100644
--- a/bundles/org.openhab.automation.jsscripting/README.md
+++ b/bundles/org.openhab.automation.jsscripting/README.md
@@ -1,6 +1,6 @@
# JavaScript Scripting
-This add-on provides support for JavaScript (ECMAScript 2024+) that can be used as a scripting language within automation rules.
+This add-on provides support for JavaScript (ECMAScript 2025+) that can be used as a scripting language within automation rules.
It is based on [GraalJS](https://www.graalvm.org/javascript/) from the [GraalVM project](https://www.graalvm.org/).
Also included is [openhab-js](https://github.com/openhab/openhab-js/), a fairly high-level ES6 library to support automation in openHAB. It provides convenient access
diff --git a/bundles/org.openhab.automation.jsscripting/pom.xml b/bundles/org.openhab.automation.jsscripting/pom.xml
index 06d93bb1f617e..db3230cd13516 100644
--- a/bundles/org.openhab.automation.jsscripting/pom.xml
+++ b/bundles/org.openhab.automation.jsscripting/pom.xml
@@ -16,7 +16,7 @@
- 24.2.1
+ v22.17.1
openhab@5.14.0
@@ -25,7 +25,7 @@
org.openhab.osgiify
org.graalvm.polyglot.polyglot
- ${graaljs.version}
+ ${graalvm.version}
provided
@@ -33,7 +33,7 @@
org.openhab.osgiify
org.graalvm.js.js-scriptengine
- ${graaljs.version}
+ ${graalvm.version}
provided
@@ -47,7 +47,7 @@
frontend-maven-plugin
1.15.4
- v22.13.1
+ ${node.version}
target/js
@@ -65,7 +65,7 @@
- install ${ohjs.version} webpack@^5.101.3 webpack-cli@^5.1.4 --prefix .
+ install ${ohjs.version} webpack@^5.101.3 webpack-cli@^6.0.1 --prefix .
diff --git a/bundles/org.openhab.automation.jsscripting/src/main/feature/feature.xml b/bundles/org.openhab.automation.jsscripting/src/main/feature/feature.xml
index e08d5eea6720c..ecbdd3b3e03c1 100644
--- a/bundles/org.openhab.automation.jsscripting/src/main/feature/feature.xml
+++ b/bundles/org.openhab.automation.jsscripting/src/main/feature/feature.xml
@@ -5,19 +5,19 @@
openhab-runtime-base
- mvn:org.openhab.osgiify/org.graalvm.js.js-language/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.js.js-scriptengine/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.regex.regex/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.polyglot.polyglot/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-api/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.2.1
+ mvn:org.openhab.osgiify/org.graalvm.js.js-language/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.js.js-scriptengine/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.regex.regex/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.polyglot.polyglot/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.collections/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.word/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-api/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/25.0.1
mvn:org.openhab.addons.bundles/org.openhab.automation.jsscripting/${project.version}
diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
index a0a8c840227ca..7d58d28506e4e 100644
--- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
+++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
@@ -249,8 +249,8 @@ public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException
// enable Nashorn compat mode as openhab-js relies on accessors, see
// https://github.com/oracle/graaljs/blob/master/docs/user/NashornMigrationGuide.md#accessors
.option("js.nashorn-compat", "true") //
- // if Nashorn compat mode is enabled, it will enforce ES5 compatibility, we want ECMA2024
- .option("js.ecmascript-version", "2024") //
+ // if Nashorn compat mode is enabled, it will enforce ES5 compatibility, we want ECMA2025
+ .option("js.ecmascript-version", "2025") //
// enable CommonJS module support
.option("js.commonjs-require", "true"));
}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE b/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE
index 54479cdbefd42..a061e74789b83 100644
--- a/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE
@@ -18,3 +18,7 @@ Parts of this code (src/main/python/) have been forked.
* License: Apache License 2.0
* Project: https://www.home-assistant.io/
* Source: https://github.com/home-assistant/core
+
+Parts of this code (src/main/python/voluptuous/) have been forked:
+* License: BSD 3-Clause
+* Source: https://github.com/alecthomas/voluptuous
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml b/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml
index 514d5fdce1e6b..ebab6e560d3c1 100644
--- a/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml
@@ -15,7 +15,6 @@
openHAB Add-ons :: Bundles :: MQTT HomeAssistant Convention
- 24.2.1
bin/python3
@@ -38,7 +37,7 @@
org.openhab.osgiify
org.graalvm.polyglot.polyglot
- ${graalpy.version}
+ ${graalvm.version}
provided
@@ -49,7 +48,7 @@
org.graalvm.polyglot
python-community
- ${graalpy.version}
+ ${graalvm.version}
pom
provided
@@ -57,7 +56,7 @@
org.openhab.osgiify
org.graalvm.python.python-embedding
- ${graalpy.version}
+ ${graalvm.version}
provided
@@ -131,7 +130,7 @@
org.graalvm.python
graalpy-maven-plugin
- ${graalpy.version}
+ ${graalvm.version}
install-python-packages
@@ -144,7 +143,6 @@
awesomeversion==24.6.0
Jinja2==3.1.6
python-slugify==8.0.4
- voluptuous==0.15.2
@@ -160,7 +158,6 @@
awesomeversion==24.6.0
Jinja2==3.1.6
python-slugify==8.0.4
- voluptuous==0.15.2
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml
index cc23d45f8bbdf..4121ef5cbba4f 100644
--- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml
@@ -7,24 +7,24 @@
openhab-runtime-base
openhab-transport-mqtt
openhab.tp-commons-net
- mvn:org.openhab.osgiify/org.graalvm.llvm.llvm-api/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.polyglot.polyglot/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.python.python-embedding/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.python.python-language/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.python.python-resources/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.regex.regex/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.json/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.tools.profiler-tool/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-api/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-nfi/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.2.1
+ mvn:org.openhab.osgiify/org.graalvm.llvm.llvm-api/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.polyglot.polyglot/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.python.python-embedding/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.python.python-language/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.python.python-resources/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.regex.regex/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.collections/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.word/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.json/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.tools.profiler-tool/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-api/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-nfi/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/25.0.1
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homeassistant/${project.version}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/__init__.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/__init__.py
new file mode 100644
index 0000000000000..2891840e7e30a
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/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.15.2'
+__author__ = 'alecthomas'
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/error.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/error.py
new file mode 100644
index 0000000000000..086afafcefd41
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/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
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/humanize.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/humanize.py
new file mode 100644
index 0000000000000..2fee63c6bdd9f
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/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))
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/schema_builder.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/schema_builder.py
new file mode 100644
index 0000000000000..79001bfe3ec20
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/schema_builder.py
@@ -0,0 +1,1321 @@
+# 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)
+ )
+ )
+
+ # 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 = []
+ 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
+
+ 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)
+
+ def __hash__(self):
+ if not hasattr(self, '_hash'):
+ self._hash = hash(self.schema)
+ return self._hash
+
+
+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': []}
+ """
+
+ 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)
+
+
+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
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/util.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/util.py
new file mode 100644
index 0000000000000..5be35a0d5c74b
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/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)
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/validators.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/voluptuous/validators.py
new file mode 100644
index 0000000000000..f207536aac247
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/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,
+ )
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.tado/README.md b/bundles/org.openhab.binding.tado/README.md
index c29aaacb61fb8..f0355143f9eb7 100644
--- a/bundles/org.openhab.binding.tado/README.md
+++ b/bundles/org.openhab.binding.tado/README.md
@@ -14,11 +14,12 @@ The binding will automatically discover this thing and place it in the Inbox.
It must be authenticated before it will actually go online.
Authenticatation is done online via the OAuth Device Code Grant Flow (RFC-8628) authentication process via the link provided at `http://[openhab-ip-address]:8080/tado`.
-| Parameter | Optional | Description |
-|---------------|----------|-------------------------------------------------------------------------------|
-| `rfcWithUser` | yes | Determines if the user name is included in the oAuth RFC-8628 authentication. |
-| `username` | yes | Selects the tado° account to be used if there is more than one account. |
-| `homeId` | yes | Selects the Home Id to use in case of more than one home per account. |
+| Parameter | Optional | Description | Default |
+|---------------|----------|-------------------------------------------------------------------------------|---------|
+| `rfcWithUser` | yes | Determines if the user name is included in the oAuth RFC-8628 authentication. | |
+| `tadoApiUrl` | no | Selects the URL to be used for API calls (it's possible to use a proxy) |`https://my.tado.com/api/v2`|
+| `username` | yes | Selects the tado° account to be used if there is more than one account. | |
+| `homeId` | yes | Selects the Home Id to use in case of more than one home per account. | |
The `rfcWithUser` and `username` settings are only needed if you have more than one tado° account.
The `rfcWithUser` setting makes the binding use a different authentication token for each respective account `username`.
diff --git a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/api/HomeApiFactory.java b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/api/HomeApiFactory.java
index ca0e00df0e2e2..8b0ac3949aed0 100644
--- a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/api/HomeApiFactory.java
+++ b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/api/HomeApiFactory.java
@@ -29,9 +29,9 @@
@NonNullByDefault
public class HomeApiFactory {
- public HomeApi create(OAuthClientService oAuthClientService) {
+ public HomeApi create(OAuthClientService oAuthClientService, String baseUrl) {
Gson gson = GsonBuilderFactory.defaultGsonBuilder().create();
OAuthorizerV2 authorizer = new OAuthorizerV2(oAuthClientService);
- return new HomeApi(gson, authorizer);
+ return new HomeApi(gson, authorizer, baseUrl);
}
}
diff --git a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/config/TadoHomeConfig.java b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/config/TadoHomeConfig.java
index 74625989dc9a6..6f3d09917b77a 100644
--- a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/config/TadoHomeConfig.java
+++ b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/config/TadoHomeConfig.java
@@ -25,4 +25,5 @@ public class TadoHomeConfig {
public @Nullable String username;
public @Nullable Boolean rfcWithUser;
public @Nullable Integer homeId;
+ public String tadoApiUrl = "https://my.tado.com/api/v2";
}
diff --git a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/handler/TadoHomeHandler.java b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/handler/TadoHomeHandler.java
index e7d41578c3b49..b2649e0144500 100644
--- a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/handler/TadoHomeHandler.java
+++ b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/internal/handler/TadoHomeHandler.java
@@ -101,7 +101,7 @@ public void initialize() {
OAuthClientService oAuthClientService = tadoHandlerFactory.subscribeOAuthClientService(this, user);
oAuthClientService.addAccessTokenRefreshListener(this);
- this.api = new HomeApiFactory().create(oAuthClientService);
+ this.api = new HomeApiFactory().create(oAuthClientService, configuration.tadoApiUrl);
this.oAuthClientService = oAuthClientService;
logger.trace("initialize() api v2 created");
confPendingText = CONF_PENDING_OAUTH_CREDS.formatted(TadoAuthenticationServlet.PATH,
diff --git a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/swagger/codegen/api/client/HomeApi.java b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/swagger/codegen/api/client/HomeApi.java
index ee9c4b550fed3..7c0f7ccf8a65d 100644
--- a/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/swagger/codegen/api/client/HomeApi.java
+++ b/bundles/org.openhab.binding.tado/src/main/java/org/openhab/binding/tado/swagger/codegen/api/client/HomeApi.java
@@ -49,15 +49,17 @@
public class HomeApi {
private static final HttpClient CLIENT = new HttpClient(new SslContextFactory.Client());
- private String baseUrl = "https://my.tado.com/api/v2";
+ private String baseUrl;
+
private int timeout = 5000;
private Gson gson;
private OAuthorizerV2 authorizer;
- public HomeApi(Gson gson, OAuthorizerV2 authorizer) {
+ public HomeApi(Gson gson, OAuthorizerV2 authorizer, String baseUrl) {
this.gson = gson;
this.authorizer = authorizer;
+ this.baseUrl = baseUrl;
}
public void deleteZoneOverlay(Long homeId, Long zoneId) throws IOException, ApiException {
diff --git a/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/i18n/tado.properties b/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/i18n/tado.properties
index 84f3367254ed9..b92f267268a2c 100644
--- a/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/i18n/tado.properties
+++ b/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/i18n/tado.properties
@@ -22,6 +22,8 @@ thing-type.config.tado.home.homeId.label = Home Id
thing-type.config.tado.home.homeId.description = Selects the Home Id to be used if there is more than one home per account.
thing-type.config.tado.home.rfcWithUser.label = RFC-8628 with User
thing-type.config.tado.home.rfcWithUser.description = Determines if the user name is included in the oAuth RFC-8628 authentication
+thing-type.config.tado.home.tadoApiUrl.label = Selects the URL to be used for API calls (it's possible to use a proxy)
+thing-type.config.tado.home.tadoApiUrl.description = The base URL used for all API requests. Can be changed to use a proxy.
thing-type.config.tado.home.username.label = User Name
thing-type.config.tado.home.username.description = Selects the tado° account to be used if there is more than one account.
thing-type.config.tado.mobiledevice.id.label = Mobile Device Id
diff --git a/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/thing/thing-types.xml
index 2d21666cdc508..870eb70c7553f 100644
--- a/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/thing/thing-types.xml
+++ b/bundles/org.openhab.binding.tado/src/main/resources/OH-INF/thing/thing-types.xml
@@ -26,6 +26,13 @@
true
+
+
+ he base URL used for all API requests. Can be changed to use a proxy.
+ https://my.tado.com/api/v2
+ true
+
+
Selects the tado° account to be used if there is more than one account.
diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml
index c58fc49c67aaf..bedc90943e096 100644
--- a/features/openhab-addons/src/main/resources/footer.xml
+++ b/features/openhab-addons/src/main/resources/footer.xml
@@ -24,24 +24,24 @@
openhab-runtime-base
openhab-transport-mqtt
openhab.tp-commons-net
- mvn:org.openhab.osgiify/org.graalvm.llvm.llvm-api/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.polyglot.polyglot/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.python.python-embedding/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.python.python-language/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.python.python-resources/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.regex.regex/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.json/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.tools.profiler-tool/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-api/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-nfi/24.2.1
- mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.2.1
+ mvn:org.openhab.osgiify/org.graalvm.llvm.llvm-api/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.polyglot.polyglot/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.python.python-embedding/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.python.python-language/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.python.python-resources/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.regex.regex/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.collections/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.sdk.word/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.json/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.tools.profiler-tool/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-api/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-nfi/25.0.1
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/25.0.1
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.awtrixlight/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.espmilighthub/${project.version}