From 200ec0a95ba52e22cb1649ba865f36daeb552f88 Mon Sep 17 00:00:00 2001 From: Edward-K1 Date: Sat, 7 Mar 2026 13:26:26 +0300 Subject: [PATCH] add top-level dict support for mirroring data --- tests/test_functions.py | 4 +- tests/test_nested_shorthand.py | 217 +++++++++++++++++++++++++++++++++ validatedata/validatedata.py | 21 +++- validatedata/validator.py | 32 +++-- 4 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 tests/test_nested_shorthand.py diff --git a/tests/test_functions.py b/tests/test_functions.py index 5213a18..6308b2c 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -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: diff --git a/tests/test_nested_shorthand.py b/tests/test_nested_shorthand.py new file mode 100644 index 0000000..f2e37c2 --- /dev/null +++ b/tests/test_nested_shorthand.py @@ -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')) diff --git a/validatedata/validatedata.py b/validatedata/validatedata.py index a7157c2..ff86cd0 100644 --- a/validatedata/validatedata.py +++ b/validatedata/validatedata.py @@ -275,7 +275,24 @@ def validate_data( 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 @@ -477,7 +494,7 @@ def expand_rule(rule: str | dict[str, Any] | list[str | dict[str, Any]]) -> list 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): diff --git a/validatedata/validator.py b/validatedata/validator.py index 7656392..1309358 100644 --- a/validatedata/validator.py +++ b/validatedata/validator.py @@ -303,11 +303,20 @@ def _has_nested_rules(rules): ) 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 @@ -418,9 +427,14 @@ def handle_nested_dict(value, current_rules, path): 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): @@ -481,9 +495,9 @@ def handle_nested_list(value, current_rules, path): 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 @@ -523,9 +537,9 @@ def handle_nested_list(value, current_rules, path): 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