jsonvv: Add grouped keys syntax 0.2.0

This commit is contained in:
daijro 2024-11-27 18:09:03 -06:00
parent 20f4aa00e9
commit cad90e30aa
7 changed files with 92 additions and 22 deletions

View file

@ -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<br/><small>Raised when a key in config<br/>isn't defined in property types</small>"]
JvvRuntimeException --> InvalidPropertyType["InvalidPropertyType<br/><small>Raised when a value doesn't<br/>match its type definition</small>"]
InvalidPropertyType --> MissingRequiredKey["MissingRequiredKey<br/><small>Raised when a required key<br/>is missing from config</small>"]
MissingRequiredKey --> MissingGroupKey["MissingGroupKey<br/><small>Raised when some keys in a<br/>property group are missing</small>"]
JvvSyntaxError --> PropertySyntaxError["PropertySyntaxError<br/><small>Raised when property type<br/>definitions have syntax errors</small>"]
@ -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.

View file

@ -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',
]

View file

@ -25,5 +25,9 @@ class MissingRequiredKey(InvalidPropertyType):
pass
class MissingGroupKey(MissingRequiredKey):
pass
class PropertySyntaxError(JvvSyntaxError):
pass

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <daijro.dev@gmail.com>"]
license = "MIT"