tissuebox 2.0.1__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.
- tissuebox/__init__.py +312 -0
- tissuebox/basic.py +548 -0
- tissuebox/helpers.py +33 -0
- tissuebox-2.0.1.dist-info/METADATA +8 -0
- tissuebox-2.0.1.dist-info/RECORD +7 -0
- tissuebox-2.0.1.dist-info/WHEEL +5 -0
- tissuebox-2.0.1.dist-info/top_level.txt +1 -0
tissuebox/__init__.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from tissuebox.basic import array, boolean, complex_number, dictionary, integer, null, numeric, string, required
|
|
2
|
+
from tissuebox.helpers import exists, kgattr, sattr
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SchemaError(BaseException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sort_unique(l):
|
|
10
|
+
l[:] = sorted(set(l))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalise(schema, start=None):
|
|
14
|
+
if start is None:
|
|
15
|
+
start = []
|
|
16
|
+
|
|
17
|
+
if type(schema) in [list, tuple, set]:
|
|
18
|
+
for s in schema:
|
|
19
|
+
normalise(s, start + [s])
|
|
20
|
+
|
|
21
|
+
if type(schema) is dict:
|
|
22
|
+
if "*" in schema and len(schema) > 1:
|
|
23
|
+
raise SchemaError(
|
|
24
|
+
"Can't normalise {} as it contains more keys {} than expected".format(start + ["*"], [k for k in schema.keys() if k != "*"])
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Track array vs dict keys
|
|
28
|
+
array_keys = set()
|
|
29
|
+
dict_keys = set()
|
|
30
|
+
|
|
31
|
+
# First detect any conflicts
|
|
32
|
+
for k in schema.keys():
|
|
33
|
+
if "." not in k:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
parts = k.split(".")
|
|
37
|
+
base_key = parts[0]
|
|
38
|
+
|
|
39
|
+
if base_key.startswith("[") and base_key.endswith("]"):
|
|
40
|
+
array_keys.add(base_key[1:-1]) # Remove brackets
|
|
41
|
+
else:
|
|
42
|
+
dict_keys.add(base_key)
|
|
43
|
+
|
|
44
|
+
# Check for conflicts
|
|
45
|
+
conflicts = array_keys.intersection(dict_keys)
|
|
46
|
+
if conflicts:
|
|
47
|
+
raise SchemaError("Ambiguous schema: '{}' is used both as array and dict pattern".format(list(conflicts)[0]))
|
|
48
|
+
|
|
49
|
+
# First pass - identify array patterns and group their fields
|
|
50
|
+
array_fields = {} # Will store array_key -> fields mapping
|
|
51
|
+
|
|
52
|
+
for k in list(schema.keys()):
|
|
53
|
+
if "." not in k:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
parts = k.split(".")
|
|
57
|
+
if not parts[0].startswith("["):
|
|
58
|
+
# Handle regular dot notation
|
|
59
|
+
try:
|
|
60
|
+
sch = schema
|
|
61
|
+
sofar = []
|
|
62
|
+
for s in parts[:-1]:
|
|
63
|
+
if s in sch:
|
|
64
|
+
sch = sch[s]
|
|
65
|
+
else:
|
|
66
|
+
sch[s] = {}
|
|
67
|
+
sch = sch[s]
|
|
68
|
+
sofar.append(s)
|
|
69
|
+
sch[parts[-1]] = schema[k]
|
|
70
|
+
del schema[k]
|
|
71
|
+
except TypeError:
|
|
72
|
+
raise SchemaError("Can't normalise {} as it conflicts with existing structure".format(k))
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
# Get the first array key
|
|
76
|
+
array_key = parts[0][1:-1] # Remove brackets
|
|
77
|
+
|
|
78
|
+
if array_key not in array_fields:
|
|
79
|
+
array_fields[array_key] = {}
|
|
80
|
+
|
|
81
|
+
# Remove the first array part and store remaining path
|
|
82
|
+
remaining_path = ".".join(parts[1:])
|
|
83
|
+
array_fields[array_key][remaining_path] = schema[k]
|
|
84
|
+
del schema[k]
|
|
85
|
+
|
|
86
|
+
# Second pass - process each array pattern
|
|
87
|
+
for array_key, fields in array_fields.items():
|
|
88
|
+
if array_key not in schema:
|
|
89
|
+
schema[array_key] = [{}]
|
|
90
|
+
|
|
91
|
+
# Group nested array fields
|
|
92
|
+
nested_arrays = {}
|
|
93
|
+
regular_fields = {}
|
|
94
|
+
|
|
95
|
+
for field_path, value in fields.items():
|
|
96
|
+
if "." not in field_path:
|
|
97
|
+
regular_fields[field_path] = value
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
parts = field_path.split(".")
|
|
101
|
+
if parts[0].startswith("["):
|
|
102
|
+
nested_key = parts[0][1:-1]
|
|
103
|
+
if nested_key not in nested_arrays:
|
|
104
|
+
nested_arrays[nested_key] = {}
|
|
105
|
+
nested_arrays[nested_key][".".join(parts[1:])] = value
|
|
106
|
+
else:
|
|
107
|
+
regular_fields[field_path] = value
|
|
108
|
+
|
|
109
|
+
# Add regular fields to array schema
|
|
110
|
+
for field, value in regular_fields.items():
|
|
111
|
+
if "." in field:
|
|
112
|
+
parts = field.split(".")
|
|
113
|
+
current = schema[array_key][0]
|
|
114
|
+
for part in parts[:-1]:
|
|
115
|
+
if part not in current:
|
|
116
|
+
current[part] = {}
|
|
117
|
+
current = current[part]
|
|
118
|
+
current[parts[-1]] = value
|
|
119
|
+
else:
|
|
120
|
+
schema[array_key][0][field] = value
|
|
121
|
+
|
|
122
|
+
# Process nested arrays recursively
|
|
123
|
+
for nested_key, nested_fields in nested_arrays.items():
|
|
124
|
+
nested_schema = {"[" + nested_key + "]." + k: v for k, v in nested_fields.items()}
|
|
125
|
+
normalised = normalise(nested_schema)
|
|
126
|
+
schema[array_key][0].update(normalised)
|
|
127
|
+
|
|
128
|
+
return schema
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
primitives = {
|
|
132
|
+
int: integer,
|
|
133
|
+
str: string,
|
|
134
|
+
bool: boolean,
|
|
135
|
+
float: numeric,
|
|
136
|
+
list: array,
|
|
137
|
+
set: array,
|
|
138
|
+
tuple: array,
|
|
139
|
+
dict: dictionary,
|
|
140
|
+
None: null,
|
|
141
|
+
complex: complex_number,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def decorate(payload):
|
|
146
|
+
# Decorate the payload, i.e if string add quotations, if list add brackets
|
|
147
|
+
if type(payload) is str:
|
|
148
|
+
return "'{}'".format(payload)
|
|
149
|
+
return payload
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def msg(schema):
|
|
153
|
+
# Returns human-friendly validation error message for schema type using .msg attribute (e.g. "must be integer", "must be null", etc.)
|
|
154
|
+
if schema is None:
|
|
155
|
+
return "null"
|
|
156
|
+
if is_primitive_value(schema):
|
|
157
|
+
return str(schema)
|
|
158
|
+
if is_primitive_type(schema):
|
|
159
|
+
schema = primitives[schema]
|
|
160
|
+
return schema.msg
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_primitive_value(schema):
|
|
164
|
+
global primitives
|
|
165
|
+
return type(schema) in primitives
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_primitive_type(schema):
|
|
169
|
+
global primitives
|
|
170
|
+
return schema in primitives
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_valid_schema(schema):
|
|
174
|
+
global primitives
|
|
175
|
+
|
|
176
|
+
# Handle collections
|
|
177
|
+
if type(schema) in (set, list, tuple):
|
|
178
|
+
return all([is_valid_schema(s) for s in schema])
|
|
179
|
+
|
|
180
|
+
# Handle dictionaries
|
|
181
|
+
if type(schema) is dict:
|
|
182
|
+
# If dict has "*", it should be the only key
|
|
183
|
+
if "*" in schema and len(schema) > 1:
|
|
184
|
+
return False
|
|
185
|
+
# Recursively validate all values in dict
|
|
186
|
+
return all(is_valid_schema(v) for v in schema.values())
|
|
187
|
+
|
|
188
|
+
# Handle primitives and tissue functions
|
|
189
|
+
if type(schema) in primitives:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
if schema in primitives:
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
if callable(schema) and getattr(schema, "msg", None):
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate(schema, payload, errors=None):
|
|
202
|
+
if errors is None:
|
|
203
|
+
errors = []
|
|
204
|
+
|
|
205
|
+
if not is_valid_schema(schema):
|
|
206
|
+
raise SchemaError("Schema is invalid, Use SchemaInspector to debug the schema")
|
|
207
|
+
|
|
208
|
+
normalise(schema)
|
|
209
|
+
|
|
210
|
+
if type(schema) is dict:
|
|
211
|
+
if type(payload) is not dict:
|
|
212
|
+
errors.append("must be dict")
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
# Handle wildcard schema
|
|
216
|
+
if "*" in schema:
|
|
217
|
+
wildcard_schema = schema["*"]
|
|
218
|
+
for key, value in payload.items():
|
|
219
|
+
E = []
|
|
220
|
+
validate(wildcard_schema, value, E)
|
|
221
|
+
for e in E:
|
|
222
|
+
errors.append("['{}']{}".format(key, e))
|
|
223
|
+
sort_unique(errors)
|
|
224
|
+
return not errors
|
|
225
|
+
|
|
226
|
+
# First check for required fields
|
|
227
|
+
for k, v in schema.items():
|
|
228
|
+
if type(k) is str:
|
|
229
|
+
validator = v[0] if type(v) is tuple else v
|
|
230
|
+
if validator == required and k not in payload:
|
|
231
|
+
errors.append("['{}'] is required".format(k))
|
|
232
|
+
|
|
233
|
+
# Then validate present fields
|
|
234
|
+
for k in schema:
|
|
235
|
+
if type(k) is str and k in payload:
|
|
236
|
+
E = []
|
|
237
|
+
validator = schema[k]
|
|
238
|
+
|
|
239
|
+
# If it's a tuple of (required, type), use the type
|
|
240
|
+
if type(validator) is tuple and validator[0] == required:
|
|
241
|
+
validator = validator[1]
|
|
242
|
+
|
|
243
|
+
# Skip validation if it's just required
|
|
244
|
+
if validator != required:
|
|
245
|
+
validate(validator, payload.get(k), E)
|
|
246
|
+
for e in E:
|
|
247
|
+
errors.append("['{}']{}".format(k, e))
|
|
248
|
+
|
|
249
|
+
sort_unique(errors)
|
|
250
|
+
return not errors
|
|
251
|
+
|
|
252
|
+
elif type(schema) is list:
|
|
253
|
+
if type(payload) is not list:
|
|
254
|
+
errors.append("{} must be list".format(payload))
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
if len(schema) > 1:
|
|
258
|
+
schema = [set(schema)]
|
|
259
|
+
|
|
260
|
+
for i, p in enumerate(payload):
|
|
261
|
+
E = []
|
|
262
|
+
validate(schema[0], p, E)
|
|
263
|
+
for e in E:
|
|
264
|
+
errors.append("[{}]{}".format(i, e))
|
|
265
|
+
return not errors
|
|
266
|
+
|
|
267
|
+
elif type(schema) is set:
|
|
268
|
+
schema = list(schema)
|
|
269
|
+
if not any([validate(s, payload) for s in schema]):
|
|
270
|
+
if len(schema) > 1:
|
|
271
|
+
labels = sorted([msg(s) for s in schema])
|
|
272
|
+
errors.append(" must be either {} or {} (but {})".format(", ".join(labels[:-1]), labels[-1], payload))
|
|
273
|
+
else:
|
|
274
|
+
errors.append("{} must be {}".format(payload, msg(schema[0])))
|
|
275
|
+
sort_unique(errors)
|
|
276
|
+
return not errors
|
|
277
|
+
|
|
278
|
+
elif type(schema) is tuple:
|
|
279
|
+
# If first element is required, validate rest
|
|
280
|
+
if len(schema) > 0 and schema[0] == required:
|
|
281
|
+
if len(schema) > 1:
|
|
282
|
+
return validate(schema[1], payload, errors)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
# Otherwise validate all
|
|
286
|
+
all_valid = True
|
|
287
|
+
for s in schema:
|
|
288
|
+
if not validate(s, payload):
|
|
289
|
+
errors.append("{} must be {}".format(payload, msg(s)))
|
|
290
|
+
all_valid = False
|
|
291
|
+
sort_unique(errors)
|
|
292
|
+
return all_valid
|
|
293
|
+
|
|
294
|
+
else:
|
|
295
|
+
if schema in primitives:
|
|
296
|
+
schema = primitives[schema]
|
|
297
|
+
|
|
298
|
+
# Just return True if schema is required
|
|
299
|
+
if schema == required:
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
result = False
|
|
303
|
+
if callable(schema):
|
|
304
|
+
result = schema(payload)
|
|
305
|
+
elif is_primitive_value(schema):
|
|
306
|
+
result = schema == payload
|
|
307
|
+
|
|
308
|
+
if not result:
|
|
309
|
+
errors.append(" must be {} (but {})".format(msg(schema), decorate(payload)))
|
|
310
|
+
|
|
311
|
+
sort_unique(errors)
|
|
312
|
+
return not errors
|
tissuebox/basic.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from ipaddress import ip_address
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Existing core type validators
|
|
9
|
+
def integer(x):
|
|
10
|
+
if isinstance(x, bool):
|
|
11
|
+
return False
|
|
12
|
+
return isinstance(x, int)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
integer.msg = "integer"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def numeric(x):
|
|
19
|
+
if isinstance(x, bool):
|
|
20
|
+
return False
|
|
21
|
+
return isinstance(x, (int, float, Decimal))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
numeric.msg = "numeric"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def complex_number(x):
|
|
28
|
+
return isinstance(x, complex)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
complex_number.msg = "complex number"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def string(x):
|
|
35
|
+
return isinstance(x, str)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
string.msg = "string"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def array(x):
|
|
42
|
+
return isinstance(x, list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
array.msg = "list"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dictionary(x):
|
|
49
|
+
return isinstance(x, dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
dictionary.msg = "dictionary"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def boolean(x):
|
|
56
|
+
return isinstance(x, bool)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
boolean.msg = "boolean"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def null(x):
|
|
63
|
+
return x is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
null.msg = "null"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Enhanced string validators
|
|
70
|
+
def length(min_len=None, max_len=None):
|
|
71
|
+
"""Validate string length is within range"""
|
|
72
|
+
|
|
73
|
+
def validator(x):
|
|
74
|
+
if not isinstance(x, str):
|
|
75
|
+
return False
|
|
76
|
+
if min_len is not None and len(x) < min_len:
|
|
77
|
+
return False
|
|
78
|
+
if max_len is not None and len(x) > max_len:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
msg_parts = []
|
|
83
|
+
if min_len is not None:
|
|
84
|
+
msg_parts.append(f"at least {min_len} characters")
|
|
85
|
+
if max_len is not None:
|
|
86
|
+
msg_parts.append(f"at most {max_len} characters")
|
|
87
|
+
|
|
88
|
+
validator.msg = f"string with {' and '.join(msg_parts)}"
|
|
89
|
+
return validator
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def pattern(regex, flags=0):
|
|
93
|
+
"""Validate string matches regex pattern"""
|
|
94
|
+
|
|
95
|
+
def validator(x):
|
|
96
|
+
if not isinstance(x, str):
|
|
97
|
+
return False
|
|
98
|
+
return bool(re.match(regex, x, flags))
|
|
99
|
+
|
|
100
|
+
validator.msg = f"string matching pattern {regex}"
|
|
101
|
+
return validator
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def alpha():
|
|
105
|
+
"""Validate string contains only letters"""
|
|
106
|
+
|
|
107
|
+
def validator(x):
|
|
108
|
+
if not isinstance(x, str):
|
|
109
|
+
return False
|
|
110
|
+
return x.isalpha()
|
|
111
|
+
|
|
112
|
+
validator.msg = "string containing only letters"
|
|
113
|
+
return validator
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def alphanumeric():
|
|
117
|
+
"""Validate string contains only letters and numbers"""
|
|
118
|
+
|
|
119
|
+
def validator(x):
|
|
120
|
+
if not isinstance(x, str):
|
|
121
|
+
return False
|
|
122
|
+
return x.isalnum()
|
|
123
|
+
|
|
124
|
+
validator.msg = "string containing only letters and numbers"
|
|
125
|
+
return validator
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def lowercase():
|
|
129
|
+
"""Validate string is lowercase"""
|
|
130
|
+
|
|
131
|
+
def validator(x):
|
|
132
|
+
if not isinstance(x, str):
|
|
133
|
+
return False
|
|
134
|
+
return x.islower()
|
|
135
|
+
|
|
136
|
+
validator.msg = "lowercase string"
|
|
137
|
+
return validator
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def uppercase():
|
|
141
|
+
"""Validate string is uppercase"""
|
|
142
|
+
|
|
143
|
+
def validator(x):
|
|
144
|
+
if not isinstance(x, str):
|
|
145
|
+
return False
|
|
146
|
+
return x.isupper()
|
|
147
|
+
|
|
148
|
+
validator.msg = "uppercase string"
|
|
149
|
+
return validator
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Number validators
|
|
153
|
+
def positive(x):
|
|
154
|
+
"""Validate number is positive"""
|
|
155
|
+
if not numeric(x):
|
|
156
|
+
return False
|
|
157
|
+
return x > 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
positive.msg = "positive number"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def negative(x):
|
|
164
|
+
"""Validate number is negative"""
|
|
165
|
+
if not numeric(x):
|
|
166
|
+
return False
|
|
167
|
+
return x < 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
negative.msg = "negative number"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def between(min_val=None, max_val=None):
|
|
174
|
+
"""Validate number is within range"""
|
|
175
|
+
|
|
176
|
+
def validator(x):
|
|
177
|
+
if not numeric(x):
|
|
178
|
+
return False
|
|
179
|
+
if min_val is not None and x < min_val:
|
|
180
|
+
return False
|
|
181
|
+
if max_val is not None and x > max_val:
|
|
182
|
+
return False
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
msg_parts = []
|
|
186
|
+
if min_val is not None:
|
|
187
|
+
msg_parts.append(f"greater than or equal to {min_val}")
|
|
188
|
+
if max_val is not None:
|
|
189
|
+
msg_parts.append(f"less than or equal to {max_val}")
|
|
190
|
+
|
|
191
|
+
validator.msg = f"number {' and '.join(msg_parts)}"
|
|
192
|
+
return validator
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def even(x):
|
|
196
|
+
"""Validate number is even"""
|
|
197
|
+
if not integer(x):
|
|
198
|
+
return False
|
|
199
|
+
return x % 2 == 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
even.msg = "even number"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def odd(x):
|
|
206
|
+
"""Validate number is odd"""
|
|
207
|
+
if not integer(x):
|
|
208
|
+
return False
|
|
209
|
+
return x % 2 != 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
odd.msg = "odd number"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Date and time validators
|
|
216
|
+
def iso_date(x):
|
|
217
|
+
"""Validate string is ISO format date"""
|
|
218
|
+
if not isinstance(x, str):
|
|
219
|
+
return False
|
|
220
|
+
try:
|
|
221
|
+
datetime.fromisoformat(x)
|
|
222
|
+
return True
|
|
223
|
+
except ValueError:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
iso_date.msg = "ISO format date string (YYYY-MM-DD)"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def future_date():
|
|
231
|
+
"""Validate date is in the future"""
|
|
232
|
+
|
|
233
|
+
def validator(x):
|
|
234
|
+
if not isinstance(x, str):
|
|
235
|
+
return False
|
|
236
|
+
try:
|
|
237
|
+
date = datetime.fromisoformat(x)
|
|
238
|
+
return date > datetime.now()
|
|
239
|
+
except ValueError:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
validator.msg = "future date"
|
|
243
|
+
return validator
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def past_date():
|
|
247
|
+
"""Validate date is in the past"""
|
|
248
|
+
|
|
249
|
+
def validator(x):
|
|
250
|
+
if not isinstance(x, str):
|
|
251
|
+
return False
|
|
252
|
+
try:
|
|
253
|
+
date = datetime.fromisoformat(x)
|
|
254
|
+
return date < datetime.now()
|
|
255
|
+
except ValueError:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
validator.msg = "past date"
|
|
259
|
+
return validator
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Array validators
|
|
263
|
+
def min_length(min_len):
|
|
264
|
+
"""Validate array has minimum length"""
|
|
265
|
+
|
|
266
|
+
def validator(x):
|
|
267
|
+
if not array(x):
|
|
268
|
+
return False
|
|
269
|
+
return len(x) >= min_len
|
|
270
|
+
|
|
271
|
+
validator.msg = f"array with at least {min_len} items"
|
|
272
|
+
return validator
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def max_length(max_len):
|
|
276
|
+
"""Validate array has maximum length"""
|
|
277
|
+
|
|
278
|
+
def validator(x):
|
|
279
|
+
if not array(x):
|
|
280
|
+
return False
|
|
281
|
+
return len(x) <= max_len
|
|
282
|
+
|
|
283
|
+
validator.msg = f"array with at most {max_len} items"
|
|
284
|
+
return validator
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# Web-related validators
|
|
288
|
+
def email(x):
|
|
289
|
+
"""Validate email address"""
|
|
290
|
+
if not isinstance(x, str):
|
|
291
|
+
return False
|
|
292
|
+
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
293
|
+
return bool(re.match(pattern, x))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
email.msg = "valid email address"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def url(x):
|
|
300
|
+
"""Validate URL"""
|
|
301
|
+
if not isinstance(x, str):
|
|
302
|
+
return False
|
|
303
|
+
try:
|
|
304
|
+
result = urlparse(x)
|
|
305
|
+
return all([result.scheme, result.netloc])
|
|
306
|
+
except ValueError:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
url.msg = "valid URL"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def ip_address_str(x):
|
|
314
|
+
"""Validate IP address (v4 or v6)"""
|
|
315
|
+
if not isinstance(x, str):
|
|
316
|
+
return False
|
|
317
|
+
try:
|
|
318
|
+
ip_address(x)
|
|
319
|
+
return True
|
|
320
|
+
except ValueError:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
ip_address_str.msg = "valid IP address"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def ipv4(x):
|
|
328
|
+
"""Validate IPv4 address"""
|
|
329
|
+
if not isinstance(x, str):
|
|
330
|
+
return False
|
|
331
|
+
try:
|
|
332
|
+
addr = ip_address(x)
|
|
333
|
+
return addr.version == 4
|
|
334
|
+
except ValueError:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
ipv4.msg = "valid IPv4 address"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def ipv6(x):
|
|
342
|
+
"""Validate IPv6 address"""
|
|
343
|
+
if not isinstance(x, str):
|
|
344
|
+
return False
|
|
345
|
+
try:
|
|
346
|
+
addr = ip_address(x)
|
|
347
|
+
return addr.version == 6
|
|
348
|
+
except ValueError:
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
ipv6.msg = "valid IPv6 address"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def hostname(x):
|
|
356
|
+
"""Validate hostname"""
|
|
357
|
+
if not isinstance(x, str):
|
|
358
|
+
return False
|
|
359
|
+
if len(x) > 255:
|
|
360
|
+
return False
|
|
361
|
+
pattern = r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
|
|
362
|
+
return bool(re.match(pattern, x))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
hostname.msg = "valid hostname"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# Specialized string validators
|
|
369
|
+
def uuid4(x):
|
|
370
|
+
"""Validate UUID v4"""
|
|
371
|
+
if not isinstance(x, str):
|
|
372
|
+
return False
|
|
373
|
+
pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
374
|
+
return bool(re.match(pattern, x, re.I))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
uuid4.msg = "valid UUID v4"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def slug(x):
|
|
381
|
+
"""Validate slug (URL-friendly string)"""
|
|
382
|
+
if not isinstance(x, str):
|
|
383
|
+
return False
|
|
384
|
+
pattern = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
385
|
+
return bool(re.match(pattern, x))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
slug.msg = "valid slug (lowercase letters, numbers, hyphens)"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def hex_color(x):
|
|
392
|
+
"""Validate hex color code"""
|
|
393
|
+
if not isinstance(x, str):
|
|
394
|
+
return False
|
|
395
|
+
pattern = r"^#(?:[0-9a-fA-F]{3}){1,2}$"
|
|
396
|
+
return bool(re.match(pattern, x))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
hex_color.msg = "valid hex color code"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def credit_card(x):
|
|
403
|
+
"""Validate credit card number using Luhn algorithm"""
|
|
404
|
+
if not isinstance(x, str):
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
# Remove spaces and hyphens
|
|
408
|
+
x = x.replace(" ", "").replace("-", "")
|
|
409
|
+
|
|
410
|
+
if not x.isdigit():
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
# Luhn algorithm
|
|
414
|
+
digits = [int(d) for d in x]
|
|
415
|
+
for i in range(len(digits) - 2, -1, -2):
|
|
416
|
+
digits[i] *= 2
|
|
417
|
+
if digits[i] > 9:
|
|
418
|
+
digits[i] -= 9
|
|
419
|
+
|
|
420
|
+
return sum(digits) % 10 == 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
credit_card.msg = "valid credit card number"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def phone(country_code=None):
|
|
427
|
+
"""Validate phone number"""
|
|
428
|
+
|
|
429
|
+
def validator(x):
|
|
430
|
+
if not isinstance(x, str):
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
# Remove common separators
|
|
434
|
+
x = re.sub(r"[\s\-\(\)]", "", x)
|
|
435
|
+
|
|
436
|
+
if country_code == "US":
|
|
437
|
+
pattern = r"^\+?1?\d{10}$"
|
|
438
|
+
else:
|
|
439
|
+
# Generic international format
|
|
440
|
+
pattern = r"^\+?[1-9]\d{1,14}$"
|
|
441
|
+
|
|
442
|
+
return bool(re.match(pattern, x))
|
|
443
|
+
|
|
444
|
+
validator.msg = "valid phone number"
|
|
445
|
+
if country_code:
|
|
446
|
+
validator.msg += f" ({country_code} format)"
|
|
447
|
+
return validator
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def strong_password(x):
|
|
451
|
+
min_length = 8
|
|
452
|
+
|
|
453
|
+
if not isinstance(x, str):
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
if len(x) < min_length:
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
# Check for at least one uppercase, lowercase, and digit
|
|
460
|
+
if not (any(c.isupper() for c in x) and any(c.islower() for c in x) and any(c.isdigit() for c in x)):
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
strong_password.msg = "password with minimum length 8, mixed case, numbers and special characters"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# Data format validators
|
|
470
|
+
def json_string(x):
|
|
471
|
+
"""Validate JSON string"""
|
|
472
|
+
if not isinstance(x, str):
|
|
473
|
+
return False
|
|
474
|
+
try:
|
|
475
|
+
import json
|
|
476
|
+
|
|
477
|
+
json.loads(x)
|
|
478
|
+
return True
|
|
479
|
+
except ValueError:
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
json_string.msg = "valid JSON string"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def base64(x):
|
|
487
|
+
"""Validate base64 string"""
|
|
488
|
+
if not isinstance(x, str):
|
|
489
|
+
return False
|
|
490
|
+
pattern = r"^[A-Za-z0-9+/]*={0,2}$"
|
|
491
|
+
return bool(re.match(pattern, x))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
base64.msg = "valid base64 string"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# Geographic validators
|
|
498
|
+
def latitude(x):
|
|
499
|
+
"""Validate latitude"""
|
|
500
|
+
if not numeric(x):
|
|
501
|
+
return False
|
|
502
|
+
return -90 <= float(x) <= 90
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
latitude.msg = "valid latitude (-90 to 90)"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def longitude(x):
|
|
509
|
+
"""Validate longitude"""
|
|
510
|
+
if not numeric(x):
|
|
511
|
+
return False
|
|
512
|
+
return -180 <= float(x) <= 180
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
longitude.msg = "valid longitude (-180 to 180)"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# Required field validator (existing)
|
|
519
|
+
def required(x):
|
|
520
|
+
"""Required field validator - always returns True as it just checks presence"""
|
|
521
|
+
return True
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
required.msg = "required"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def lt(n):
|
|
528
|
+
def lt(x):
|
|
529
|
+
return x < n
|
|
530
|
+
|
|
531
|
+
lt.msg = f"less than {n}"
|
|
532
|
+
return lt
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def gt(n):
|
|
536
|
+
def gt(x):
|
|
537
|
+
return x < n
|
|
538
|
+
|
|
539
|
+
gt.msg = f"greater than {n}"
|
|
540
|
+
return lt
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def divisible(n):
|
|
544
|
+
def divisible(x):
|
|
545
|
+
return numeric(x) and numeric(n) and x % n == 0
|
|
546
|
+
|
|
547
|
+
divisible.msg = f"multiple of {n}"
|
|
548
|
+
return divisible
|
tissuebox/helpers.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def exists(d, attrs):
|
|
5
|
+
try:
|
|
6
|
+
for at in attrs:
|
|
7
|
+
d = d[at]
|
|
8
|
+
return True
|
|
9
|
+
except (KeyError, TypeError):
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sattr(d, *attrs):
|
|
14
|
+
try:
|
|
15
|
+
for attr in attrs[:-2]:
|
|
16
|
+
if type(d.get(attr)) is not dict:
|
|
17
|
+
d[attr] = {}
|
|
18
|
+
# raise Exception("Overriding Attempt")
|
|
19
|
+
d = d[attr]
|
|
20
|
+
d[attrs[-2]] = attrs[-1]
|
|
21
|
+
except IndexError:
|
|
22
|
+
print()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def kgattr(d, sofar, *attrs):
|
|
26
|
+
# gattr but very strict, tries to go nested, upon KeyError return the sofar list.
|
|
27
|
+
for at in attrs:
|
|
28
|
+
sofar.append(at)
|
|
29
|
+
d = d[at]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def regex_in(r, l):
|
|
33
|
+
return bool(list(filter(re.compile(r).match, l)))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tissuebox/__init__.py,sha256=_J6gsxKl4FOdkVBx7uL-4BcobprQAJSgMRMgUIuki3Q,9868
|
|
2
|
+
tissuebox/basic.py,sha256=cSjEe212tFP9tmnr8Tofy5y1aY0Bo_lQhtaYYtl98Ks,10883
|
|
3
|
+
tissuebox/helpers.py,sha256=uxy4lXYgnsgUD6MbfSvPMuf0VgDuzbnPk91f3MdbxuU,715
|
|
4
|
+
tissuebox-2.0.1.dist-info/METADATA,sha256=FW61bfyNjLRMTo56S18vzlwj7UrAlAaZ88-CpyRgewg,245
|
|
5
|
+
tissuebox-2.0.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
6
|
+
tissuebox-2.0.1.dist-info/top_level.txt,sha256=5jrIChUwF7yCKAWFnbzybiDZD8HRXPKPMK84p14aj_4,10
|
|
7
|
+
tissuebox-2.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tissuebox
|