Add commands and formatters modules
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.8) (push) Has been cancelled
CI / test (3.9) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build-package (push) Has been cancelled
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.8) (push) Has been cancelled
CI / test (3.9) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build-package (push) Has been cancelled
This commit is contained in:
259
configforge/commands/schema.py
Normal file
259
configforge/commands/schema.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""Interactive schema generator command for ConfigForge."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from configforge.exceptions import (
|
||||||
|
FileOperationError,
|
||||||
|
InvalidConfigFormatError,
|
||||||
|
SchemaGenerationError,
|
||||||
|
)
|
||||||
|
from configforge.formatters import JSONHandler, YAMLHandler
|
||||||
|
from configforge.utils.file_parser import detect_format
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("schema")
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(),
|
||||||
|
help="Output file path for the generated schema",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"-f",
|
||||||
|
type=click.Choice(["json", "yaml"]),
|
||||||
|
default="json",
|
||||||
|
help="Output format for the generated schema",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--from-config",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Generate schema from an existing config file",
|
||||||
|
)
|
||||||
|
def schema(
|
||||||
|
output: Optional[str],
|
||||||
|
format: str,
|
||||||
|
from_config: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Interactively generate a JSON Schema from configuration."""
|
||||||
|
try:
|
||||||
|
if from_config:
|
||||||
|
schema_dict = _generate_from_config(from_config)
|
||||||
|
else:
|
||||||
|
schema_dict = _run_interactive_wizard()
|
||||||
|
|
||||||
|
schema_output = _format_schema(schema_dict, format)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
_write_schema(output, schema_output, format)
|
||||||
|
click.echo(f"Generated schema -> {output}")
|
||||||
|
else:
|
||||||
|
click.echo(schema_output)
|
||||||
|
|
||||||
|
except (InvalidConfigFormatError, FileOperationError, SchemaGenerationError) as e:
|
||||||
|
click.echo(f"Error: {e.message}", err=True)
|
||||||
|
click.get_current_context().exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_from_config(config_file: str) -> Dict[str, Any]:
|
||||||
|
"""Generate a schema from an existing configuration file."""
|
||||||
|
format_type = detect_format(config_file)
|
||||||
|
|
||||||
|
if format_type == "json":
|
||||||
|
data = JSONHandler.read(config_file)
|
||||||
|
elif format_type in ("yaml", "yml"):
|
||||||
|
data = YAMLHandler.read(config_file)
|
||||||
|
else:
|
||||||
|
raise InvalidConfigFormatError(
|
||||||
|
f"Unsupported format: {format_type}. Use JSON or YAML."
|
||||||
|
)
|
||||||
|
|
||||||
|
return _data_to_schema(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _data_to_schema(data: Any, name: str = "") -> Dict[str, Any]:
|
||||||
|
"""Convert sample data to JSON Schema."""
|
||||||
|
if data is None:
|
||||||
|
return {"type": "null", "description": "Null value"}
|
||||||
|
|
||||||
|
if isinstance(data, bool):
|
||||||
|
return {"type": "boolean", "description": "Boolean value"}
|
||||||
|
|
||||||
|
if isinstance(data, (int, float)):
|
||||||
|
schema: Dict[str, Any] = {"type": "number", "description": "Number value"}
|
||||||
|
if isinstance(data, int):
|
||||||
|
schema["type"] = "integer"
|
||||||
|
return schema
|
||||||
|
|
||||||
|
if isinstance(data, str):
|
||||||
|
return {"type": "string", "description": "String value"}
|
||||||
|
|
||||||
|
if isinstance(data, list):
|
||||||
|
if not data:
|
||||||
|
return {"type": "array", "items": {"type": "any"}, "description": "Array of values"}
|
||||||
|
|
||||||
|
all_schemas = [_data_to_schema(item) for item in data]
|
||||||
|
merged = _merge_schemas(all_schemas)
|
||||||
|
|
||||||
|
return {"type": "array", "items": merged, "description": "Array of values"}
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
properties: Dict[str, Any] = {}
|
||||||
|
required: List[str] = []
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
properties[key] = _data_to_schema(value, key)
|
||||||
|
required.append(key)
|
||||||
|
|
||||||
|
schema: Dict[str, Any] = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required,
|
||||||
|
"additionalProperties": False,
|
||||||
|
"description": "Configuration object"
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
return {"type": "any", "description": "Any value"}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_schemas(schemas: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Merge multiple schemas into one."""
|
||||||
|
if not schemas:
|
||||||
|
return {"type": "any"}
|
||||||
|
|
||||||
|
if len(schemas) == 1:
|
||||||
|
return schemas[0]
|
||||||
|
|
||||||
|
types = set()
|
||||||
|
for schema in schemas:
|
||||||
|
schema_type = schema.get("type")
|
||||||
|
if schema_type:
|
||||||
|
types.add(schema_type)
|
||||||
|
|
||||||
|
if len(types) > 1:
|
||||||
|
return {"type": " | ".join(sorted(types)), "description": "Union of multiple types"}
|
||||||
|
|
||||||
|
common_type = types.pop() if types else "any"
|
||||||
|
|
||||||
|
if common_type == "object":
|
||||||
|
all_props: Dict[str, Any] = {}
|
||||||
|
all_required = set()
|
||||||
|
|
||||||
|
for schema in schemas:
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
all_props.update(props)
|
||||||
|
required = schema.get("required", [])
|
||||||
|
all_required.update(required)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": all_props,
|
||||||
|
"required": list(all_required),
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"type": common_type}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_interactive_wizard() -> Dict[str, Any]:
|
||||||
|
"""Run the interactive schema wizard."""
|
||||||
|
click.echo("ConfigForge Schema Wizard")
|
||||||
|
click.echo("=" * 40)
|
||||||
|
|
||||||
|
schema_name = click.prompt("Schema name", default="Config")
|
||||||
|
schema_description = click.prompt("Schema description", default="")
|
||||||
|
|
||||||
|
properties: Dict[str, Any] = {}
|
||||||
|
required: List[str] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
click.echo("")
|
||||||
|
add_property = click.confirm("Add a property?")
|
||||||
|
if not add_property:
|
||||||
|
break
|
||||||
|
|
||||||
|
prop_name = click.prompt("Property name")
|
||||||
|
prop_type = click.prompt(
|
||||||
|
"Property type",
|
||||||
|
type=click.Choice(["string", "integer", "number", "boolean", "array", "object"]),
|
||||||
|
default="string"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_required = click.confirm("Is this property required?")
|
||||||
|
if is_required:
|
||||||
|
required.append(prop_name)
|
||||||
|
|
||||||
|
prop_description = click.prompt("Property description", default="")
|
||||||
|
|
||||||
|
prop_schema: Dict[str, Any] = {
|
||||||
|
"type": prop_type,
|
||||||
|
"description": prop_description
|
||||||
|
}
|
||||||
|
|
||||||
|
if prop_type == "string":
|
||||||
|
if click.confirm("Add string constraints?"):
|
||||||
|
min_length = click.prompt(" Min length (0 for none)", type=int, default=0)
|
||||||
|
if min_length > 0:
|
||||||
|
prop_schema["minLength"] = min_length
|
||||||
|
max_length = click.prompt(" Max length (0 for none)", type=int, default=0)
|
||||||
|
if max_length > 0:
|
||||||
|
prop_schema["maxLength"] = max_length
|
||||||
|
|
||||||
|
elif prop_type in ("integer", "number"):
|
||||||
|
if click.confirm("Add numeric constraints?"):
|
||||||
|
minimum = click.prompt(" Minimum value", type=float, default=None)
|
||||||
|
if minimum is not None:
|
||||||
|
prop_schema["minimum"] = minimum
|
||||||
|
maximum = click.prompt(" Maximum value", type=float, default=None)
|
||||||
|
if maximum is not None:
|
||||||
|
prop_schema["maximum"] = maximum
|
||||||
|
|
||||||
|
elif prop_type == "array":
|
||||||
|
if click.confirm("Add array constraints?"):
|
||||||
|
min_items = click.prompt(" Min items", type=int, default=0)
|
||||||
|
if min_items > 0:
|
||||||
|
prop_schema["minItems"] = min_items
|
||||||
|
max_items = click.prompt(" Max items", type=int, default=0)
|
||||||
|
if max_items > 0:
|
||||||
|
prop_schema["maxItems"] = max_items
|
||||||
|
|
||||||
|
properties[prop_name] = prop_schema
|
||||||
|
|
||||||
|
schema: Dict[str, Any] = {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": schema_name,
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required,
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if schema_description:
|
||||||
|
schema["description"] = schema_description
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _format_schema(schema: Dict[str, Any], format: str) -> str:
|
||||||
|
"""Format schema to output string."""
|
||||||
|
if format == "json":
|
||||||
|
return JSONHandler.dumps(schema, indent=2)
|
||||||
|
else:
|
||||||
|
return YAMLHandler.dumps(schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_schema(filepath: str, content: str, format: str) -> None:
|
||||||
|
"""Write schema to file."""
|
||||||
|
try:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
except PermissionError:
|
||||||
|
raise FileOperationError(f"Permission denied: {filepath}", filepath=filepath)
|
||||||
|
except OSError as e:
|
||||||
|
raise FileOperationError(f"Failed to write {filepath}: {str(e)}", filepath=filepath)
|
||||||
Reference in New Issue
Block a user