Skip to content
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
4 changes: 3 additions & 1 deletion tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ def test_expand_rule_invalid_type_raises(self):
expand_rule('notavalidtype')

def test_expand_rule_too_short_raises(self):
# Single-char strings are always invalid; the guard requires len >= 2
# so that valid 2-char types like 'ip' are not incorrectly rejected.
with self.assertRaises(ValueError):
expand_rule('ab')
expand_rule('a')

def test_type_decorator(self):
class User:
Expand Down
217 changes: 217 additions & 0 deletions tests/test_nested_shorthand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""
Tests for the nested dict shorthand feature.

A bare dict whose values are field rules (rather than a single rule dict
with a 'type' key) is treated as a shorthand for the canonical
{'type': 'dict', 'fields': {...}} form, mirroring the shape of the data.

Covers: bare field map, keys wrapper, valid/invalid data, error paths,
and mutate=True data reconstruction.
"""


from validatedata import validate_data
from .base import BaseTest


# ---------------------------------------------------------------------------
# Bare field map (no 'keys' wrapper)
# ---------------------------------------------------------------------------

class TestNestedShorthandBareMap(BaseTest):

def test_valid_data_passes(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1.0.0'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
)
self.assertTrue(result.ok)

def test_invalid_semver_fails(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
)
self.assertFalse(result.ok)

def test_invalid_semver_error_path(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
)
self.assertTrue(any('app.version' in e for e in result.errors))

def test_invalid_field_in_nested_dict(self):
result = validate_data(
data={'app': {'name': 'ab', 'version': '1.0.0'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
)
self.assertFalse(result.ok)

def test_invalid_field_error_path(self):
result = validate_data(
data={'app': {'name': 'ab', 'version': '1.0.0'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
)
self.assertTrue(any('app.name' in e for e in result.errors))

def test_multiple_nested_dicts_both_valid(self):
result = validate_data(
data={
'app': {'name': 'QuickScript', 'version': '1.0.0'},
'database': {'host': '127.0.0.1', 'port': 5432},
},
rule={
'app': {'name': 'str|min:3', 'version': 'semver'},
'database': {'host': 'ip', 'port': 'int|between:1,65535'},
},
)
self.assertTrue(result.ok)

def test_multiple_nested_dicts_one_invalid(self):
result = validate_data(
data={
'app': {'name': 'QuickScript', 'version': '1'},
'database': {'host': '127.0.0.1', 'port': 5432},
},
rule={
'app': {'name': 'str|min:3', 'version': 'semver'},
'database': {'host': 'ip', 'port': 'int|between:1,65535'},
},
)
self.assertFalse(result.ok)

def test_multiple_nested_dicts_error_only_on_failing_field(self):
result = validate_data(
data={
'app': {'name': 'QuickScript', 'version': '1'},
'database': {'host': '127.0.0.1', 'port': 5432},
},
rule={
'app': {'name': 'str|min:3', 'version': 'semver'},
'database': {'host': 'ip', 'port': 'int|between:1,65535'},
},
)
self.assertTrue(any('app.version' in e for e in result.errors))
self.assertFalse(any('database' in e for e in result.errors))


# ---------------------------------------------------------------------------
# Keys wrapper form
# ---------------------------------------------------------------------------

class TestNestedShorthandKeysWrapper(BaseTest):

def test_valid_data_passes(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1.0.0'}},
rule={'keys': {'app': {'name': 'str|min:3', 'version': 'semver'}}},
)
self.assertTrue(result.ok)

def test_invalid_semver_fails(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1'}},
rule={'keys': {'app': {'name': 'str|min:3', 'version': 'semver'}}},
)
self.assertFalse(result.ok)

def test_invalid_semver_error_path(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1'}},
rule={'keys': {'app': {'name': 'str|min:3', 'version': 'semver'}}},
)
self.assertTrue(any('app.version' in e for e in result.errors))

def test_mixed_flat_and_nested_rules(self):
"""A keys dict can mix flat string rules and nested dict shorthand."""
result = validate_data(
data={
'owner': 'alice',
'app': {'name': 'QuickScript', 'version': '1.0.0'},
},
rule={'keys': {
'owner': 'str|min:3',
'app': {'name': 'str|min:3', 'version': 'semver'},
}},
)
self.assertTrue(result.ok)

def test_mixed_flat_and_nested_flat_field_invalid(self):
result = validate_data(
data={
'owner': 'al',
'app': {'name': 'QuickScript', 'version': '1.0.0'},
},
rule={'keys': {
'owner': 'str|min:3',
'app': {'name': 'str|min:3', 'version': 'semver'},
}},
)
self.assertFalse(result.ok)


# ---------------------------------------------------------------------------
# mutate=True — data reconstruction
# ---------------------------------------------------------------------------

class TestNestedShorthandMutate(BaseTest):

def test_mutate_valid_data_reconstructs_nested_dict(self):
result = validate_data(
data={
'app': {'name': 'QuickScript', 'version': '1.0.0'},
'database': {'host': '127.0.0.1', 'port': 5432},
},
rule={
'app': {'name': 'str|min:3', 'version': 'semver'},
'database': {'host': 'ip', 'port': 'int|between:1,65535'},
},
mutate=True,
)
self.assertTrue(result.ok)
self.assertEqual(result.data, [
{'name': 'QuickScript', 'version': '1.0.0'},
{'host': '127.0.0.1', 'port': 5432},
])

def test_mutate_preserves_nested_dict_structure(self):
"""result.data must be a list of dicts, not a flat list of leaf values."""
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1.0.0'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
mutate=True,
)
self.assertEqual(len(result.data), 1)
self.assertIsInstance(result.data[0], dict)
self.assertIn('name', result.data[0])
self.assertIn('version', result.data[0])

def test_mutate_with_transform_in_nested_field(self):
"""Transforms on nested fields should be reflected in the reconstructed dict."""
result = validate_data(
data={'app': {'name': ' quickscript ', 'version': '1.0.0'}},
rule={'app': {'name': 'str|strip|min:3', 'version': 'semver'}},
mutate=True,
)
self.assertTrue(result.ok)
self.assertEqual(result.data[0]['name'], 'quickscript')

def test_mutate_invalid_data_has_no_data_key(self):
"""When validation fails, result.data should still be present but reflect input."""
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
mutate=True,
)
self.assertFalse(result.ok)
# data is present even on failure — it should still be a list of dicts
self.assertIsInstance(result.data[0], dict)

def test_mutate_false_has_no_data_attribute(self):
result = validate_data(
data={'app': {'name': 'QuickScript', 'version': '1.0.0'}},
rule={'app': {'name': 'str|min:3', 'version': 'semver'}},
mutate=False,
)
self.assertFalse(hasattr(result, 'data'))
21 changes: 19 additions & 2 deletions validatedata/validatedata.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@
return decorator


def validate_data(

Check failure on line 248 in validatedata/validatedata.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Edward-K1_validatedata&issues=AZzH886EgorC8QBZiO-K&open=AZzH886EgorC8QBZiO-K&pullRequest=17
data: str | list[Any] | tuple[Any, ...] | dict[str, Any],
rule: str | dict[str, Any] | list[str | dict[str, Any]],
raise_exceptions: bool = False,
Expand Down Expand Up @@ -275,7 +275,24 @@
field_map = expanded_rule['keys'] if 'keys' in expanded_rule else expanded_rule
for key in field_map:
rule = field_map[key]
dict_rules.append(expand_rule(rule)[0] if isinstance(rule, str) else rule)
if isinstance(rule, str):
dict_rules.append(expand_rule(rule)[0])
elif (
isinstance(rule, dict)
and 'type' not in rule
and 'fields' not in rule
and 'items' not in rule
):
# Shorthand nested field map e.g. {'name': 'str|min:3', 'version': 'semver'}
# Convert to canonical nested form so the validator recurses into sub-fields.
dict_rules.append({
'fields': {
k: (expand_rule(v)[0] if isinstance(v, str) else v)
for k, v in rule.items()
}
})
else:
dict_rules.append(rule)
ordered_data[key] = data.get(key, EMPTY)

expanded_rule = dict_rules
Expand Down Expand Up @@ -477,7 +494,7 @@
if not isinstance(rule, (str, tuple, list, dict)):
raise TypeError('Validation rule(s) must be of type: str, tuple, list, or dict')

if len(str(rule)) < 3:
if len(str(rule)) < 2:
raise ValueError(f'Invalid rule {rule}')

def expand_rule_string(rule):
Expand Down
32 changes: 23 additions & 9 deletions validatedata/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@
)


def _has_nested_rules(rules):

Check failure on line 298 in validatedata/validator.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Edward-K1_validatedata&issues=AZzH888KgorC8QBZiO-L&open=AZzH888KgorC8QBZiO-L&pullRequest=17
"""Detect whether any rules contain nested field definitions."""
if isinstance(rules, list):
return any(
Expand All @@ -303,11 +303,20 @@
)
if isinstance(rules, dict):
if 'keys' in rules:
return any(
isinstance(v, dict) and ('fields' in v or 'items' in v)
for v in rules['keys'].values()
)
return 'fields' in rules or 'items' in rules
def _is_nested_value(v):
if not isinstance(v, dict):
return False
if 'fields' in v or 'items' in v:
return True
# Shorthand: plain dict without 'type' whose values are field rules
return 'type' not in v and any(isinstance(fv, (str, dict)) for fv in v.values())
return any(_is_nested_value(v) for v in rules['keys'].values())
if 'fields' in rules or 'items' in rules:
return True
# Shorthand nested: a plain dict whose values are field rules (not a single rule dict)
if 'type' not in rules:
return any(isinstance(v, (str, dict)) for v in rules.values())
return False
return False


Expand Down Expand Up @@ -418,9 +427,14 @@
return value, True
nested_rules = list(fields.values())
nested_data = OrderedDict((k, value.get(k)) for k in fields.keys())
start = len(self.transformed_data)
nested_result = self.validate_object(
nested_data, nested_rules, {}, parent_path=path
)
if self.mutate:
sub_values = self.transformed_data[start:]
del self.transformed_data[start:]
return dict(zip(fields.keys(), sub_values)), nested_result.ok
return value, nested_result.ok

def handle_nested_list(value, current_rules, path):
Expand Down Expand Up @@ -481,9 +495,9 @@
continue

if current_rules.get('fields'):
handle_nested_dict(transformed_value, current_rules, path)
mutated_value, _ = handle_nested_dict(transformed_value, current_rules, path)
self.transformed_data.append(
transformed_value if self.mutate else value
mutated_value if self.mutate else value
)
continue

Expand Down Expand Up @@ -523,9 +537,9 @@
transformed_value = apply_transform(value, current_rules)

if current_rules.get('fields'):
handle_nested_dict(transformed_value, current_rules, path)
mutated_value, _ = handle_nested_dict(transformed_value, current_rules, path)
self.transformed_data.append(
transformed_value if self.mutate else value
mutated_value if self.mutate else value
)
continue

Expand Down