# Copyright (c) 2024 Harim Kang
# SPDX-License-Identifier: MIT
import json
from pathlib import Path
from typing import Any
import jsonschema
from jsonschema import Draft7Validator
from .config import Config
[docs]
class ConfigValidator:
"""Class for validating configuration files.
This class provides functionality to validate configuration data against a JSON schema.
"""
[docs]
def __init__(self, schema: dict[str, Any]):
"""Initialize validator with a schema.
Args:
schema: JSON schema dictionary to validate against
Raises:
jsonschema.exceptions.SchemaError: If the schema itself is invalid
"""
self.schema = schema
Draft7Validator.check_schema(schema)
[docs]
@classmethod
def from_file(cls, schema_path: str | Path) -> "ConfigValidator":
"""Create a validator from a schema file.
Args:
schema_path: Path to the JSON schema file
Returns:
ConfigValidator instance initialized with the schema from file
"""
with open(schema_path) as f:
schema = json.load(f)
return cls(schema)
[docs]
def validate(self, config: Config) -> list[str] | None:
"""Validate a configuration against the schema."""
try:
validator = Draft7Validator(self.schema)
validator.validate(config.to_dict())
return None
except jsonschema.exceptions.ValidationError as e:
print(f"Debug - Schema: {json.dumps(self.schema, indent=2)}") # Temporary debug
print(f"Debug - Config: {json.dumps(config.to_dict(), indent=2)}") # Temporary debug
print(f"Debug - Error: {e}") # Temporary debug
return [str(e)]
[docs]
@classmethod
def generate_schema(cls, config: Config) -> dict[str, Any]:
"""Generate a JSON schema from a configuration file."""
def _merge_types(type1: dict[str, Any], type2: dict[str, Any]) -> dict[str, Any]:
"""Merge two type definitions into one."""
if type1 == type2:
return type1
# Special handling for number types
def get_number_type(t: dict[str, Any]) -> str | None:
type_val = t.get("type")
if isinstance(type_val, str) and type_val in ("integer", "number"):
return type_val
return None
# If both are number types, use the more general one
num_type1 = get_number_type(type1)
num_type2 = get_number_type(type2)
if num_type1 and num_type2:
return {"type": "number" if "number" in (num_type1, num_type2) else "integer"}
# Extract types from anyOf if present
types1 = type1.get("anyOf", [type1])
types2 = type2.get("anyOf", [type2])
# Combine all unique types
all_types: list[dict[str, Any]] = []
for t in types1 + types2:
if t not in all_types:
all_types.append(t)
# If only one type remains, return it directly
if len(all_types) == 1:
return all_types[0]
# Otherwise, return anyOf with all types
return {"anyOf": all_types}
def _merge_multiple_types(types: list[dict[str, Any]]) -> dict[str, Any]:
"""Merge multiple type definitions into one."""
if not types:
return {}
if len(types) == 1:
return types[0]
result = types[0]
for t in types[1:]:
result = _merge_types(result, t)
return result
def _infer_type(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
# For each property, collect all types from all objects
props = {k: _infer_type(v) for k, v in value.items()}
schema = {
"type": "object",
"properties": props,
"additionalProperties": True,
}
return schema
elif isinstance(value, list):
if not value: # Empty list
return {"type": "array", "items": {}}
if isinstance(value[0], dict):
# For array of objects, create a common schema
# First, collect all unique keys and their types
key_types: dict[str, set[str]] = {}
for item in value:
if not isinstance(item, dict):
continue
for k, v in item.items():
type_def = _infer_type(v)
type_str = str(type_def)
if k not in key_types:
key_types[k] = {type_str}
else:
key_types[k].add(type_str)
# Then create merged schema for each key
obj_props: dict[str, Any] = {}
for k, types in key_types.items():
type_defs = [eval(t) for t in types]
if len(type_defs) == 1:
obj_props[k] = type_defs[0]
else:
obj_props[k] = {"anyOf": type_defs}
return {
"type": "array",
"items": {
"type": "object",
"properties": obj_props,
"additionalProperties": True,
},
}
else:
# For array of primitives, merge all types
item_types = [_infer_type(item) for item in value]
return {"type": "array", "items": _merge_multiple_types(item_types)}
elif isinstance(value, bool):
return {"type": "boolean"}
elif isinstance(value, int):
return {"type": "integer"}
elif isinstance(value, float):
return {"type": "number"}
elif isinstance(value, str):
return {"type": "string"}
elif value is None:
return {"type": "null"}
else:
return {"type": "string"}
data = config.to_dict()
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {k: _infer_type(v) for k, v in data.items()},
"additionalProperties": True,
}
return schema
[docs]
@classmethod
def from_config(cls, config: Config) -> "ConfigValidator":
"""Create a validator from an existing configuration.
Args:
config: Configuration object to generate schema from
Returns:
ConfigValidator instance with schema generated from config
"""
schema = cls.generate_schema(config)
return cls(schema)
[docs]
def save_schema(self, path: str | Path) -> None:
"""Save the current schema to a file.
Args:
path: Path where to save the schema
"""
with open(path, "w") as f:
json.dump(self.schema, f, indent=2)