Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add typing information #475

Merged
merged 9 commits into from
Feb 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ jobs:
- { python-version: "3.9", session: "py39" }
- { python-version: "3.8", session: "py38" }
- { python-version: "3.7", session: "py37" }
- { python-version: "3.6", session: "py36" }
- { python-version: "2.7", session: "py27" }

steps:
- name: Check out the repository
Expand All @@ -31,11 +29,6 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Install tox-setuptools-version
if: ${{ matrix.session != 'py27' }}
run: |
pip install tox-setuptools-version

- name: Run tox
run: |
pip install tox
Expand Down
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
license='BSD-3-Clause',
platforms=['any'],
packages=['voluptuous'],
package_data={
'voluptuous': ['py.typed'],
},
author='Alec Thomas',
author_email='[email protected]',
classifiers=[
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = flake8,py27,py36,py37,py38,py39,py310
envlist = flake8,py37,py38,py39,py310

[flake8]
; E501: line too long (X > 79 characters)
Expand Down
26 changes: 14 additions & 12 deletions voluptuous/error.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing


class Error(Exception):
"""Base validation exception."""
Expand All @@ -17,54 +19,54 @@ class Invalid(Error):

"""

def __init__(self, message, path=None, error_message=None, error_type=None):
def __init__(self, message: str, path: typing.Optional[typing.List[str]] = 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):
def msg(self) -> str:
return self.args[0]

def __str__(self):
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):
def prepend(self, path: typing.List[str]) -> None:
self.path = path + self.path


class MultipleInvalid(Invalid):
def __init__(self, errors=None):
def __init__(self, errors: typing.Optional[typing.List[Invalid]] = None) -> None:
self.errors = errors[:] if errors else []

def __repr__(self):
def __repr__(self) -> str:
return 'MultipleInvalid(%r)' % self.errors

@property
def msg(self):
def msg(self) -> str:
return self.errors[0].msg

@property
def path(self):
def path(self) -> typing.List[str]:
return self.errors[0].path

@property
def error_message(self):
def error_message(self) -> str:
return self.errors[0].error_message

def add(self, error):
def add(self, error: Invalid) -> None:
self.errors.append(error)

def __str__(self):
def __str__(self) -> str:
return str(self.errors[0])

def prepend(self, path):
def prepend(self, path: typing.List[str]) -> None:
for error in self.errors:
error.prepend(path)

Expand Down
11 changes: 8 additions & 3 deletions voluptuous/humanize.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from voluptuous import Invalid, MultipleInvalid
from voluptuous.error import Error
from voluptuous.schema_builder import Schema
import typing


MAX_VALIDATION_ERROR_ITEM_LENGTH = 500


def _nested_getitem(data, path):
IndexT = typing.TypeVar("IndexT")


def _nested_getitem(data: typing.Dict[IndexT, typing.Any], path: typing.List[IndexT]) -> typing.Optional[typing.Any]:
for item_index in path:
try:
data = data[item_index]
Expand All @@ -16,7 +21,7 @@ def _nested_getitem(data, path):
return data


def humanize_error(data, validation_error, max_sub_error_length=MAX_VALIDATION_ERROR_ITEM_LENGTH):
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.
Expand All @@ -33,7 +38,7 @@ def humanize_error(data, validation_error, max_sub_error_length=MAX_VALIDATION_E
return '%s. Got %s' % (validation_error, offending_item_summary)


def validate_with_humanized_errors(data, schema, max_sub_error_length=MAX_VALIDATION_ERROR_ITEM_LENGTH):
def validate_with_humanized_errors(data, schema: Schema, max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH) -> typing.Any:
alecthomas marked this conversation as resolved.
Show resolved Hide resolved
try:
return schema(data)
except (Invalid, MultipleInvalid) as e:
Expand Down
Empty file added voluptuous/py.typed
Empty file.
49 changes: 30 additions & 19 deletions voluptuous/schema_builder.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import collections
import inspect
import re
Expand All @@ -7,6 +9,9 @@

import itertools
from voluptuous import error as er
from collections.abc import Generator
import typing
from voluptuous.error import Error
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error could have been used as er.Error and this import would be not needed


if sys.version_info >= (3,):
long = int
Expand Down Expand Up @@ -127,18 +132,21 @@ def __repr__(self):
UNDEFINED = Undefined()


def Self():
def Self() -> None:
raise er.SchemaError('"Self" should never be called')


def default_factory(value):
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=None, regex=None):
def raises(exc, msg: typing.Optional[str] = None, regex: typing.Optional[re.Pattern] = None) -> Generator[None, None, None]:
try:
yield
except exc as e:
Expand All @@ -148,7 +156,7 @@ def raises(exc, msg=None, regex=None):
assert re.search(regex, str(e)), '%r does not match %r' % (str(e), regex)


def Extra(_):
def Extra(_) -> None:
"""Allow keys in the data that are not present in the schema."""
raise er.SchemaError('"Extra" should never be called')

Expand All @@ -157,6 +165,8 @@ def Extra(_):
# deprecated object, so we just leave an alias here instead.
extra = Extra

Schemable = typing.Union[dict, list, type, typing.Callable]


class Schema(object):
"""A validation schema.
Expand Down Expand Up @@ -186,7 +196,7 @@ class Schema(object):
PREVENT_EXTRA: 'PREVENT_EXTRA',
}

def __init__(self, schema, required=False, 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.
Expand All @@ -207,7 +217,7 @@ def __init__(self, schema, required=False, extra=PREVENT_EXTRA):
self._compiled = self._compile(schema)

@classmethod
def infer(cls, data, **kwargs):
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:
Expand Down Expand Up @@ -723,7 +733,7 @@ def validate_set(path, data):

return validate_set

def extend(self, schema, required=None, extra=None):
def extend(self, schema: dict, 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
Expand All @@ -738,6 +748,7 @@ def extend(self, schema, required=None, extra=None):
"""

assert type(self.schema) == dict and type(schema) == dict, 'Both schemas must be dictionary-based'
assert isinstance(self.schema, dict)

result = self.schema.copy()

Expand Down Expand Up @@ -936,7 +947,7 @@ class Msg(object):
... assert isinstance(e.errors[0], er.RangeInvalid)
"""

def __init__(self, schema, msg, cls=None):
def __init__(self, schema: dict, 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")
Expand All @@ -961,7 +972,7 @@ def __repr__(self):
class Object(dict):
"""Indicate that we should work with attributes, not keys."""

def __init__(self, schema, cls=UNDEFINED):
def __init__(self, schema, cls: object = UNDEFINED) -> None:
self.cls = cls
super(Object, self).__init__(schema)

Expand All @@ -977,7 +988,7 @@ def __repr__(self):
class Marker(object):
"""Mark nodes for special treatment."""

def __init__(self, schema_, msg=None, description=None):
def __init__(self, schema_: dict, msg: typing.Optional[str] = None, description: typing.Optional[str] = None) -> None:
self.schema = schema_
self._schema = Schema(schema_)
self.msg = msg
Expand Down Expand Up @@ -1009,7 +1020,7 @@ def __eq__(self, other):
return self.schema == other

def __ne__(self, other):
return not(self.schema == other)
return not (self.schema == other)


class Optional(Marker):
Expand All @@ -1035,7 +1046,7 @@ class Optional(Marker):
{'key2': 'value'}
"""

def __init__(self, schema, msg=None, default=UNDEFINED, description=None):
def __init__(self, schema: dict, msg: typing.Optional[str] = None, default=UNDEFINED, description: typing.Optional[str] = None) -> None:
super(Optional, self).__init__(schema, msg=msg,
description=description)
self.default = default_factory(default)
Expand Down Expand Up @@ -1077,7 +1088,7 @@ class Exclusive(Optional):
... 'social': {'social_network': 'barfoo', 'token': 'tEMp'}})
"""

def __init__(self, schema, group_of_exclusion, msg=None, description=None):
def __init__(self, schema: dict, group_of_exclusion: str, msg: typing.Optional[str] = None, description: typing.Optional[str] = None) -> None:
super(Exclusive, self).__init__(schema, msg=msg,
description=description)
self.group_of_exclusion = group_of_exclusion
Expand Down Expand Up @@ -1125,8 +1136,8 @@ class Inclusive(Optional):
True
"""

def __init__(self, schema, group_of_inclusion,
msg=None, description=None, default=UNDEFINED):
def __init__(self, schema: dict, group_of_inclusion: str,
msg: typing.Optional[str] = None, description: typing.Optional[str] = None, default=UNDEFINED) -> None:
super(Inclusive, self).__init__(schema, msg=msg,
default=default,
description=description)
Expand All @@ -1148,7 +1159,7 @@ class Required(Marker):
{'key': []}
"""

def __init__(self, schema, msg=None, default=UNDEFINED, description=None):
def __init__(self, schema: dict, msg: typing.Optional[str] = None, default=UNDEFINED, description: typing.Optional[str] = None) -> None:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? Required can also take other scalar types e.g. the example in its docstring: schema = Schema({Required('key'): str})

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I don't think it is. Want to send a PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I've overlooked that one. Commit coming up

super(Required, self).__init__(schema, msg=msg,
description=description)
self.default = default_factory(default)
Expand All @@ -1169,7 +1180,7 @@ class Remove(Marker):
[1, 2, 3, 5, '7']
"""

def __call__(self, v):
def __call__(self, v: object):
super(Remove, self).__call__(v)
return self.__class__

Expand All @@ -1180,7 +1191,7 @@ def __hash__(self):
return object.__hash__(self)


def message(default=None, cls=None):
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:
Expand Down Expand Up @@ -1251,7 +1262,7 @@ def _merge_args_with_kwargs(args_dict, kwargs_dict):
return ret


def validate(*a, **kw):
def validate(*a, **kw) -> typing.Callable:
"""Decorator for validating arguments of a function against a given schema.

Set restrictions for arguments:
Expand Down
27 changes: 14 additions & 13 deletions voluptuous/tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
import collections
import copy

try:
from enum import Enum
except ImportError:
Enum = None
import os
import sys

import pytest
from voluptuous.util import Capitalize, Lower, Strip, Title, Upper, u
from voluptuous.humanize import humanize_error
from voluptuous import (ALLOW_EXTRA, PREVENT_EXTRA, All, Any, Clamp, Coerce,
Contains, Date, Datetime, Email, Equal, ExactSequence,
Exclusive, Extra, FqdnUrl, In, Inclusive, Invalid,
Expand All @@ -17,8 +8,18 @@
Optional, PathExists, Range, Remove, Replace, Required,
Schema, Self, SomeOf, TooManyValid, TypeInvalid, Union,
Unordered, Url, raises, validate)
from voluptuous.humanize import humanize_error
from voluptuous.util import Capitalize, Lower, Strip, Title, Upper, u
import pytest
import sys
import os
import collections
import copy
import typing

Enum: typing.Union[type, None]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, huh, I've never seen this syntax before, TIL.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually you won't have to do this because the type can be inferred from context, but here it assumed it's always a type due to the import and then complains about the = None in the except ModuleError, so you'll have to be a bit more explicit :)

try:
from enum import Enum
except ImportError:
Enum = None


def test_new_required_test():
Expand Down
Loading