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.
@@ -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, "")