diff --git a/jsonvv/README.md b/jsonvv/README.md index d4874f2..8427a50 100644 --- a/jsonvv/README.md +++ b/jsonvv/README.md @@ -76,7 +76,7 @@ validator = { "name,type": "str - str[Traveling]", # Non-traveling types # If hour(s) is specified, require days have >0 hours "/hours?/": { - "*/.*day/": "int[>0]" + "*/day$/": "int[>0]" } } } @@ -110,6 +110,7 @@ else: - [Regex patterns](#regex-patterns) - [Lists of possible values](#lists-of-possible-values) - [Required fields (`*`)](#required-fields-) + - [Grouping keys (`$`)](#grouping-keys-) - [Supported Types](#supported-types) - [String (`str`)](#string-str) - [Integer (`int`)](#integer-int) @@ -160,7 +161,7 @@ To specify a list of keys, use a comma-separated string. "/k[ey]{2}1/,key2": "type" ``` -To escape a comma, use `_`. +To escape a comma, use `!`. ### Required fields (`*`) @@ -173,6 +174,20 @@ Fields marked with `*` are required. The validation will fail without them. "*/key\d+/": "type" ``` +### Grouping keys (`$`) + +Fields that end with `$group_name` are grouped together. If one of the keys is set, all of the keys in the group must also be set as well. + +**Syntax:** + +```python +"isEnabled$group1": "bool" +"value$group1": "int[>0]" +``` + +This will require both `value` is set if and only if `isEnabled` is set. + + --- ## Supported Types @@ -621,6 +636,7 @@ graph TD JvvRuntimeException --> UnknownProperty["UnknownProperty
Raised when a key in config
isn't defined in property types
"] JvvRuntimeException --> InvalidPropertyType["InvalidPropertyType
Raised when a value doesn't
match its type definition
"] InvalidPropertyType --> MissingRequiredKey["MissingRequiredKey
Raised when a required key
is missing from config
"] + MissingRequiredKey --> MissingGroupKey["MissingGroupKey
Raised when some keys in a
property group are missing
"] JvvSyntaxError --> PropertySyntaxError["PropertySyntaxError
Raised when property type
definitions have syntax errors
"] @@ -634,7 +650,7 @@ graph TD class JvvException jvv; class JvvRuntimeException,JvvSyntaxError runtime; class PropertySyntaxError syntax; - class UnknownProperty,InvalidPropertyType,MissingRequiredKey error; + class UnknownProperty,InvalidPropertyType,MissingRequiredKey,MissingGroupKey error; ``` --- @@ -704,3 +720,8 @@ graph TD - **Subtracting Domains** (`-`): Value must match `typeA` but not `typeB`. - Syntax: `"typeA - typeB"` - Example: `"int - int[13]"` (any integer except 13) + +### Escaping Characters + +- `!`: Escapes commas, slashes, and other jsonvv characters within strings. +- `\`: Escapes within a regex pattern. \ No newline at end of file diff --git a/jsonvv/jsonvv/__init__.py b/jsonvv/jsonvv/__init__.py index ced6e1e..c63e435 100644 --- a/jsonvv/jsonvv/__init__.py +++ b/jsonvv/jsonvv/__init__.py @@ -7,7 +7,7 @@ from .exceptions import ( PropertySyntaxError, UnknownProperty, ) -from .validator import JsonValidator, validate_config +from .validator import JsonValidator __all__ = [ 'JvvRuntimeException', @@ -18,5 +18,4 @@ __all__ = [ 'InvalidPropertyType', 'UnknownProperty', 'MissingRequiredKey', - 'validate_config', ] diff --git a/jsonvv/jsonvv/exceptions.py b/jsonvv/jsonvv/exceptions.py index d5c3e27..9001923 100644 --- a/jsonvv/jsonvv/exceptions.py +++ b/jsonvv/jsonvv/exceptions.py @@ -25,5 +25,9 @@ class MissingRequiredKey(InvalidPropertyType): pass +class MissingGroupKey(MissingRequiredKey): + pass + + class PropertySyntaxError(JvvSyntaxError): pass diff --git a/jsonvv/jsonvv/parser.py b/jsonvv/jsonvv/parser.py index cc9e31b..a862bdd 100644 --- a/jsonvv/jsonvv/parser.py +++ b/jsonvv/jsonvv/parser.py @@ -187,7 +187,7 @@ class Parser: # Only consume alphanumeric and underscore characters while self.pos < self.length and ( - self.type_str[self.pos].isalnum() or self.type_str[self.pos] in '_@' + self.type_str[self.pos].isalnum() or self.type_str[self.pos] in '!@' ): self.pos += 1 diff --git a/jsonvv/jsonvv/strings.py b/jsonvv/jsonvv/strings.py index 3fabe5e..56d202b 100644 --- a/jsonvv/jsonvv/strings.py +++ b/jsonvv/jsonvv/strings.py @@ -14,12 +14,12 @@ class StringValidator: i = 0 while i < len(p): - if p[i] == '/' and (i == 0 or p[i - 1] != '_'): + if p[i] == '/' and (i == 0 or p[i - 1] != '!'): in_regex = not in_regex current.append(p[i]) elif p[i] == ',' and not in_regex: # Check if comma is escaped - if i > 0 and p[i - 1] == '_': + if i > 0 and p[i - 1] == '!': current.append(',') else: # End of pattern @@ -36,23 +36,22 @@ class StringValidator: return result def _is_regex_pattern(self, p: str) -> bool: - is_regex = p.startswith('/') and p.endswith('/') and not p.endswith('_/') + is_regex = p.startswith('/') and p.endswith('/') and not p.endswith('!/') return is_regex def _clean_literal_pattern(self, p: str) -> str: - cleaned = re.sub(r'_[^_]', lambda m: m.group(0)[-1], p) - return cleaned + return re.sub(r'!(.)', r'\1', p) def validate(self, value: str) -> bool: for p in self.patterns: + p = self._clean_literal_pattern(p) if self._is_regex_pattern(p): regex = p[1:-1] match = bool(re.match(regex, value)) if match: return True else: - cleaned = self._clean_literal_pattern(p) - match = value == cleaned + match = value == p if match: return True diff --git a/jsonvv/jsonvv/validator.py b/jsonvv/jsonvv/validator.py index 63fc022..72906e5 100644 --- a/jsonvv/jsonvv/validator.py +++ b/jsonvv/jsonvv/validator.py @@ -1,6 +1,11 @@ -from typing import Any, Dict +from typing import Any, Dict, List -from .exceptions import MissingRequiredKey, PropertySyntaxError, UnknownProperty +from .exceptions import ( + MissingGroupKey, + MissingRequiredKey, + PropertySyntaxError, + UnknownProperty, +) from .parser import parse_type_def from .strings import string_validator from .types import Type @@ -12,10 +17,15 @@ class JsonValidator: # Create a registry for reference types and parsed type definitions self.type_registry = {} self.parsed_types = {} + # Track property groups + self.groups: Dict[str, List[str]] = {} # Validate and pre-parse all type definitions self.parse_types(property_types) def validate(self, config_map): + # First validate groups + self.validate_groups(config_map) + # Then validate the rest validate_config(config_map, self.property_types, self.type_registry, self.parsed_types) def parse_types(self, property_types: Dict[str, Any], path: str = ""): @@ -37,6 +47,13 @@ class JsonValidator: f"Invalid key '{current_path}': '*' must be followed by a property name" ) + # Register group dependencies + if (idx := key.rfind('$')) != -1: + base_key, group = key[:idx], key[idx + 1 :] + if group not in self.groups: + self.groups[group] = [] + self.groups[group].append(base_key) + if isinstance(value, dict): # Recursively validate and parse nested dictionaries self.parse_types(value, current_path) @@ -53,6 +70,32 @@ class JsonValidator: f"Invalid type definition for '{current_path}': must be a string or dictionary" ) + def validate_groups(self, config_map: Dict[str, Any]) -> None: + """Validates that grouped properties are all present or all absent.""" + group_presence: Dict[str, bool] = {} + + # Check which groups have any properties present + for group, props in self.groups.items(): + group_presence[group] = any(prop in config_map for prop in props) + + # Validate group completeness + for group, is_present in group_presence.items(): + props = self.groups[group] + if is_present: + # If any property in group exists, all must exist + missing = [prop for prop in props if prop not in config_map] + if missing: + raise MissingGroupKey( + f"Incomplete property group ${group}: missing {', '.join(missing)}" + ) + else: + # If no property in group exists, none should exist + present = [prop for prop in props if prop in config_map] + if present: + raise MissingGroupKey( + f"Incomplete property group ${group}: found {', '.join(present)} but missing {', '.join(set(props) - set(present))}" + ) + def validate_config( config_map: Dict[str, Any], @@ -75,8 +118,11 @@ def validate_config( type_def = None current_path = f"{path}.{key}" if path else key - if key in property_types: - type_def = property_types[key] + # Strip group suffix for type lookup + lookup_key = key.split('$')[0] if '$' in key else key + + if lookup_key in property_types: + type_def = property_types[lookup_key] # If the value is a dict and type_def is also a dict, recurse with new scope if isinstance(value, dict) and isinstance(type_def, dict): @@ -85,15 +131,16 @@ def validate_config( ) continue - elif '*' + key in property_types: - type_def = property_types['*' + key] - required_props[key] = True + elif '*' + lookup_key in property_types: + type_def = property_types['*' + lookup_key] + required_props[lookup_key] = True else: # Check pattern matches for pattern, pattern_type in property_types.items(): if pattern.startswith('@') or pattern.startswith('*'): continue - if string_validator(key, pattern): + pattern_base = pattern.split('$')[0] if '$' in pattern else pattern + if string_validator(lookup_key, pattern_base): type_def = pattern_type current_path = f"{path}.{pattern}" if path else pattern break diff --git a/jsonvv/pyproject.toml b/jsonvv/pyproject.toml index ac130c3..9fb31c7 100644 --- a/jsonvv/pyproject.toml +++ b/jsonvv/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "jsonvv" -version = "0.1.0" +version = "0.2.0" description = "JSON value validator" authors = ["daijro "] license = "MIT"