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"