super-easy-validator-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- super_easy_validator_python/__init__.py +55 -0
- super_easy_validator_python/checks.py +525 -0
- super_easy_validator_python/codes.py +143 -0
- super_easy_validator_python/formats.py +316 -0
- super_easy_validator_python/paths.py +162 -0
- super_easy_validator_python/py.typed +0 -0
- super_easy_validator_python/rules.py +205 -0
- super_easy_validator_python/types.py +136 -0
- super_easy_validator_python/validator.py +685 -0
- super_easy_validator_python-0.1.0.dist-info/METADATA +280 -0
- super_easy_validator_python-0.1.0.dist-info/RECORD +14 -0
- super_easy_validator_python-0.1.0.dist-info/WHEEL +5 -0
- super_easy_validator_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- super_easy_validator_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The rule vocabulary, and helpers for reading a rule's tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .types import GROUP_ATLEAST, GROUP_ATMOST, OP_AND, OP_OR, OP_SWITCH, InvalidRuleError
|
|
8
|
+
|
|
9
|
+
BARE_RULES = frozenset(
|
|
10
|
+
{
|
|
11
|
+
"optional",
|
|
12
|
+
"nullable",
|
|
13
|
+
"string",
|
|
14
|
+
"number",
|
|
15
|
+
"boolean",
|
|
16
|
+
"array",
|
|
17
|
+
"object",
|
|
18
|
+
"email",
|
|
19
|
+
"url",
|
|
20
|
+
"domain",
|
|
21
|
+
"name",
|
|
22
|
+
"fullname",
|
|
23
|
+
"username",
|
|
24
|
+
"alpha",
|
|
25
|
+
"alphanumeric",
|
|
26
|
+
"phone",
|
|
27
|
+
"phonecode",
|
|
28
|
+
"objectid",
|
|
29
|
+
"uuid",
|
|
30
|
+
"date",
|
|
31
|
+
"dateonly",
|
|
32
|
+
"time",
|
|
33
|
+
"lower",
|
|
34
|
+
"upper",
|
|
35
|
+
"ip",
|
|
36
|
+
"int",
|
|
37
|
+
"positive",
|
|
38
|
+
"negative",
|
|
39
|
+
"natural",
|
|
40
|
+
"whole",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
ARGUMENT_RULES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"equal",
|
|
47
|
+
"size",
|
|
48
|
+
"min",
|
|
49
|
+
"max",
|
|
50
|
+
"regex",
|
|
51
|
+
"decimalsize",
|
|
52
|
+
"decimalmin",
|
|
53
|
+
"decimalmax",
|
|
54
|
+
"enums",
|
|
55
|
+
"field",
|
|
56
|
+
"error",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
DATA_TYPES = frozenset({"string", "number", "boolean", "array", "object"})
|
|
61
|
+
|
|
62
|
+
STRING_FORMATS = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"email",
|
|
65
|
+
"url",
|
|
66
|
+
"domain",
|
|
67
|
+
"name",
|
|
68
|
+
"fullname",
|
|
69
|
+
"username",
|
|
70
|
+
"alpha",
|
|
71
|
+
"alphanumeric",
|
|
72
|
+
"phone",
|
|
73
|
+
"phonecode",
|
|
74
|
+
"objectid",
|
|
75
|
+
"uuid",
|
|
76
|
+
"date",
|
|
77
|
+
"dateonly",
|
|
78
|
+
"time",
|
|
79
|
+
"lower",
|
|
80
|
+
"upper",
|
|
81
|
+
"ip",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
NUMBER_TYPES = frozenset({"int", "positive", "negative", "natural", "whole"})
|
|
86
|
+
|
|
87
|
+
#: Tokens that put later tokens into a numeric context.
|
|
88
|
+
NUMERIC_CONTEXT = frozenset({"number", "positive", "negative", "int", "whole", "natural"})
|
|
89
|
+
|
|
90
|
+
#: Rules dropped from this port, reported with an explanation rather than a
|
|
91
|
+
#: bare "unknown rule".
|
|
92
|
+
DROPPED_RULES = {
|
|
93
|
+
"symbol": "'symbol' has no Python equivalent and is not supported",
|
|
94
|
+
"bigint": "'bigint' is not needed: Python integers are already arbitrary precision, so use 'int'",
|
|
95
|
+
"mongoid": "'mongoid' is not supported; use 'objectid'",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
OPERATORS = frozenset({OP_OR, OP_AND, OP_SWITCH})
|
|
99
|
+
GROUP_KEYS = frozenset({GROUP_ATLEAST, GROUP_ATMOST})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_operator_key(key: str) -> bool:
|
|
103
|
+
return key in OPERATORS
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_group_key(key: str) -> bool:
|
|
107
|
+
return key in GROUP_KEYS
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def split_arg(token: str) -> Optional[tuple[str, str]]:
|
|
111
|
+
"""Split "min:5" into ("min", "5"). Returns None for a bare rule."""
|
|
112
|
+
i = token.find(":")
|
|
113
|
+
if i <= 0:
|
|
114
|
+
return None
|
|
115
|
+
return token[:i], token[i + 1 :]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def assert_known_token(token: str, field: str) -> None:
|
|
119
|
+
"""Reject an unknown rule, so a typo surfaces on first run."""
|
|
120
|
+
if token.startswith("arrayof:"):
|
|
121
|
+
inner = token[len("arrayof:") :]
|
|
122
|
+
if not inner:
|
|
123
|
+
raise InvalidRuleError(field, "has an invalid rule: 'arrayof:' needs a rule after the colon")
|
|
124
|
+
assert_known_token(inner, field)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if token in BARE_RULES:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if token in DROPPED_RULES:
|
|
131
|
+
raise InvalidRuleError(field, f"has an unknown rule: {DROPPED_RULES[token]}")
|
|
132
|
+
|
|
133
|
+
parts = split_arg(token)
|
|
134
|
+
if parts is not None:
|
|
135
|
+
prefix, _ = parts
|
|
136
|
+
if prefix in ARGUMENT_RULES:
|
|
137
|
+
return
|
|
138
|
+
raise InvalidRuleError(field, f"has an unknown rule: '{prefix}:'")
|
|
139
|
+
|
|
140
|
+
raise InvalidRuleError(field, f"has an unknown rule: '{token}'")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def assert_valid_tokens(tokens: list[str], field: str) -> None:
|
|
144
|
+
for token in tokens:
|
|
145
|
+
if not isinstance(token, str):
|
|
146
|
+
raise InvalidRuleError(field, "has an invalid rule: every rule in the list must be a string")
|
|
147
|
+
if token == "":
|
|
148
|
+
continue
|
|
149
|
+
assert_known_token(token, field)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def tokenize(value: Any) -> Optional[list[str]]:
|
|
153
|
+
"""Split a rule value into tokens, or None if it is not a token rule."""
|
|
154
|
+
if isinstance(value, str):
|
|
155
|
+
return value.split("|")
|
|
156
|
+
if isinstance(value, list) and value and all(isinstance(e, str) for e in value):
|
|
157
|
+
return list(value)
|
|
158
|
+
if isinstance(value, list) and not value:
|
|
159
|
+
return None
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def find_prefixed(tokens: list[str], prefix: str) -> Optional[str]:
|
|
164
|
+
for t in tokens:
|
|
165
|
+
if t.startswith(prefix):
|
|
166
|
+
return t[len(prefix) :]
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def custom_field(tokens: list[str], key: str) -> Optional[str]:
|
|
171
|
+
"""A field: override, suppressed for keys ending in an array index."""
|
|
172
|
+
from .paths import ends_with_index
|
|
173
|
+
|
|
174
|
+
if ends_with_index(key):
|
|
175
|
+
return None
|
|
176
|
+
return find_prefixed(tokens, "field:")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def custom_error(tokens: list[str]) -> Optional[str]:
|
|
180
|
+
return find_prefixed(tokens, "error:")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def group_size(tokens: list[str]) -> int:
|
|
184
|
+
"""The size: threshold in a $atleast/$atmost rule, defaulting to 1."""
|
|
185
|
+
raw = find_prefixed(tokens, "size:")
|
|
186
|
+
if raw is None:
|
|
187
|
+
return 1
|
|
188
|
+
try:
|
|
189
|
+
return int(raw)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def has_token(tokens: list[str], want: str) -> bool:
|
|
195
|
+
return want in tokens
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def numeric_context(previous: list[str]) -> bool:
|
|
199
|
+
"""Whether an earlier token makes this a numeric comparison."""
|
|
200
|
+
return any(t in NUMERIC_CONTEXT for t in previous)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def string_context(previous: list[str]) -> bool:
|
|
204
|
+
"""Whether an earlier token selects the "numeric string" behaviour."""
|
|
205
|
+
return "string" in previous or "arrayof:string" in previous
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Public types.
|
|
2
|
+
|
|
3
|
+
Rules are a plain dict with string keys. A rule value may be:
|
|
4
|
+
|
|
5
|
+
str a pipe-separated rule, e.g. "optional|email"
|
|
6
|
+
list[str] the same rules, for patterns containing a literal "|"
|
|
7
|
+
dict a nested object rule, or an operator node
|
|
8
|
+
[dict] a single-element list, for arrays of objects
|
|
9
|
+
callable a custom rule
|
|
10
|
+
|
|
11
|
+
An operator node is a rule value whose single key is "$or", "$and" or
|
|
12
|
+
"$switch". Because rules are plain dicts, a whole rule tree can be loaded from
|
|
13
|
+
JSON or built at runtime.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Callable, Optional
|
|
19
|
+
|
|
20
|
+
Rules = dict[str, Any]
|
|
21
|
+
Data = dict[str, Any]
|
|
22
|
+
|
|
23
|
+
#: A custom rule receives the value and its parent, and returns None to pass
|
|
24
|
+
#: or a dict with "message" and "code" to fail.
|
|
25
|
+
CustomRule = Callable[[Any, Any], Optional[dict[str, str]]]
|
|
26
|
+
|
|
27
|
+
#: One validation failure: {"field": ..., "message": ..., "code": ...}
|
|
28
|
+
Detail = dict[str, str]
|
|
29
|
+
|
|
30
|
+
OP_OR = "$or"
|
|
31
|
+
OP_AND = "$and"
|
|
32
|
+
OP_SWITCH = "$switch"
|
|
33
|
+
|
|
34
|
+
GROUP_ATLEAST = "$atleast"
|
|
35
|
+
GROUP_ATMOST = "$atmost"
|
|
36
|
+
|
|
37
|
+
BRANCH_CASE = "case"
|
|
38
|
+
BRANCH_THEN = "then"
|
|
39
|
+
BRANCH_DEFAULT = "default"
|
|
40
|
+
|
|
41
|
+
QUOTE_NONE = "none"
|
|
42
|
+
QUOTE_SINGLE = "single-quotes"
|
|
43
|
+
QUOTE_DOUBLE = "double-quotes"
|
|
44
|
+
QUOTE_BACKTICK = "backtick"
|
|
45
|
+
|
|
46
|
+
_QUOTE_CHARS = {
|
|
47
|
+
QUOTE_NONE: "",
|
|
48
|
+
QUOTE_SINGLE: "'",
|
|
49
|
+
QUOTE_DOUBLE: '"',
|
|
50
|
+
QUOTE_BACKTICK: "`",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Result:
|
|
55
|
+
"""What validate() returns.
|
|
56
|
+
|
|
57
|
+
``errors`` and ``details`` are both None when everything passed, so
|
|
58
|
+
``if result.errors:`` is the idiom. When validation fails they are the
|
|
59
|
+
same length and share indexes: ``errors[i] == details[i]["message"]``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("errors", "details")
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
errors: Optional[list[str]] = None,
|
|
67
|
+
details: Optional[list[Detail]] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.errors = errors
|
|
70
|
+
self.details = details
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def valid(self) -> bool:
|
|
74
|
+
"""True when nothing failed."""
|
|
75
|
+
return not self.errors
|
|
76
|
+
|
|
77
|
+
def raise_for_errors(self) -> None:
|
|
78
|
+
"""Raise ValidationError if anything failed, for callers who prefer
|
|
79
|
+
exceptions to checking a flag."""
|
|
80
|
+
if self.errors:
|
|
81
|
+
raise ValidationError(self.errors, self.details or [])
|
|
82
|
+
|
|
83
|
+
def __bool__(self) -> bool:
|
|
84
|
+
return self.valid
|
|
85
|
+
|
|
86
|
+
def __iter__(self):
|
|
87
|
+
"""Allow ``errors, details = validate(...)``."""
|
|
88
|
+
yield self.errors
|
|
89
|
+
yield self.details
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
if not self.errors:
|
|
93
|
+
return "Result(valid=True)"
|
|
94
|
+
return f"Result(errors={self.errors!r})"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ValidationError(Exception):
|
|
98
|
+
"""Raised by Result.raise_for_errors()."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, errors: list[str], details: list[Detail]) -> None:
|
|
101
|
+
super().__init__("; ".join(errors))
|
|
102
|
+
self.errors = errors
|
|
103
|
+
self.details = details
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InvalidRuleError(Exception):
|
|
107
|
+
"""A malformed rule: a mistake in your rules, not in the data.
|
|
108
|
+
|
|
109
|
+
Raised rather than returned, because it signals a programming error that
|
|
110
|
+
no amount of different input would fix.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, field: str, reason: str) -> None:
|
|
114
|
+
super().__init__(f"[super-easy-validator] '{field}' {reason}")
|
|
115
|
+
self.field = field
|
|
116
|
+
self.reason = reason
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Config:
|
|
120
|
+
"""Validation options. The defaults match the npm package."""
|
|
121
|
+
|
|
122
|
+
__slots__ = ("quotes", "strict", "array_indexing_check")
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
quotes: str = QUOTE_NONE,
|
|
127
|
+
strict: bool = False,
|
|
128
|
+
array_indexing_check: bool = True,
|
|
129
|
+
) -> None:
|
|
130
|
+
self.quotes = quotes
|
|
131
|
+
self.strict = strict
|
|
132
|
+
self.array_indexing_check = array_indexing_check
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def quote_char(self) -> str:
|
|
136
|
+
return _QUOTE_CHARS.get(self.quotes, "")
|