341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""CLI interface for Config Converter."""
|
|
|
|
import glob
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import click
|
|
|
|
from config_converter.converters import BaseConverter, JsonConverter, YamlConverter, TomlConverter, IniConverter
|
|
from config_converter.generators import TypeScriptGenerator
|
|
from config_converter.validators import SchemaInferrer, SchemaValidator
|
|
from config_converter.utils import OutputFormatter
|
|
|
|
|
|
@click.group()
|
|
@click.option("--color/--no-color", default=True, help="Enable/disable colorized output")
|
|
@click.pass_context
|
|
def main(ctx: click.Context, color: bool) -> None:
|
|
"""Config Converter CLI - Convert configuration files between formats."""
|
|
ctx.ensure_object(dict)
|
|
ctx.obj["color"] = color
|
|
ctx.obj["formatter"] = OutputFormatter(use_color=color)
|
|
|
|
|
|
@main.command("convert")
|
|
@click.argument("source", type=click.Path(exists=True))
|
|
@click.option("--from", "-f", "from_format", type=str, help="Source format (auto-detected if not specified)")
|
|
@click.option("--to", "-t", "to_format", required=True, type=click.Choice(["json", "yaml", "toml", "ini"]), help="Target format")
|
|
@click.option("--output", "-o", "output_path", type=click.Path(), help="Output file path (stdout if not specified)")
|
|
@click.option("--overwrite", "-w", is_flag=True, help="Overwrite output file if it exists")
|
|
@click.pass_context
|
|
def convert(
|
|
ctx: click.Context,
|
|
source: str,
|
|
from_format: Optional[str],
|
|
to_format: str,
|
|
output_path: Optional[str],
|
|
overwrite: bool,
|
|
) -> None:
|
|
"""Convert a configuration file from one format to another."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
|
|
source_path = Path(source)
|
|
|
|
if from_format is None:
|
|
from_format = BaseConverter.guess_format(source_path)
|
|
if from_format is None:
|
|
formatter.print_error(f"Could not detect format from extension: {source_path.suffix}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
source_converter = BaseConverter.get_converter(from_format)
|
|
target_converter = BaseConverter.get_converter(to_format)
|
|
except Exception as e:
|
|
formatter.print_error(str(e))
|
|
sys.exit(1)
|
|
|
|
try:
|
|
data = source_converter.read(source_path)
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to read {source}: {e}")
|
|
sys.exit(1)
|
|
|
|
if output_path:
|
|
output_file = Path(output_path)
|
|
if output_file.exists() and not overwrite:
|
|
formatter.print_error(f"Output file already exists: {output_path}")
|
|
sys.exit(1)
|
|
try:
|
|
target_converter.write(data, output_file)
|
|
formatter.print_success(f"Converted {source} ({from_format}) → {output_path} ({to_format})")
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to write {output_path}: {e}")
|
|
sys.exit(1)
|
|
else:
|
|
try:
|
|
output = target_converter.format(data)
|
|
click.echo(output)
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to format output: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command("batch")
|
|
@click.argument("pattern", type=str)
|
|
@click.option("--from", "-f", "from_format", type=str, help="Source format (auto-detected if not specified)")
|
|
@click.option("--to", "-t", "to_format", required=True, type=click.Choice(["json", "yaml", "toml", "ini"]), help="Target format")
|
|
@click.option("--output-dir", "-o", "output_dir", type=click.Path(), help="Output directory for converted files")
|
|
@click.option("--overwrite", "-w", is_flag=True, help="Overwrite output files if they exist")
|
|
@click.pass_context
|
|
def batch_convert(
|
|
ctx: click.Context,
|
|
pattern: str,
|
|
from_format: Optional[str],
|
|
to_format: str,
|
|
output_dir: Optional[str],
|
|
overwrite: bool,
|
|
) -> None:
|
|
"""Convert multiple files matching a glob pattern."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
|
|
files = glob.glob(pattern)
|
|
if not files:
|
|
formatter.print_error(f"No files found matching pattern: {pattern}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
target_converter = BaseConverter.get_converter(to_format)
|
|
except Exception as e:
|
|
formatter.print_error(str(e))
|
|
sys.exit(1)
|
|
|
|
if output_dir:
|
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
success_count = 0
|
|
failed_count = 0
|
|
failed_files: List[str] = []
|
|
|
|
for file_path in files:
|
|
source_path = Path(file_path)
|
|
|
|
try:
|
|
if from_format is None:
|
|
fmt = BaseConverter.guess_format(source_path)
|
|
if fmt is None:
|
|
failed_count += 1
|
|
failed_files.append(file_path)
|
|
continue
|
|
else:
|
|
fmt = from_format
|
|
|
|
converter = BaseConverter.get_converter(fmt)
|
|
data = converter.read(source_path)
|
|
|
|
if output_dir:
|
|
output_path = Path(output_dir) / f"{source_path.stem}.{to_format}"
|
|
if output_path.exists() and not overwrite:
|
|
failed_count += 1
|
|
failed_files.append(file_path)
|
|
continue
|
|
converter.write(data, output_path)
|
|
else:
|
|
formatter.print_error(f"Output directory required for batch conversion")
|
|
failed_count += 1
|
|
failed_files.append(file_path)
|
|
continue
|
|
|
|
success_count += 1
|
|
|
|
except Exception as e:
|
|
failed_count += 1
|
|
failed_files.append(file_path)
|
|
|
|
formatter.print_info(f"Batch conversion complete: {success_count} succeeded, {failed_count} failed")
|
|
|
|
if failed_files:
|
|
formatter.print_warning("Failed files:")
|
|
for f in failed_files:
|
|
formatter.print_warning(f" - {f}")
|
|
|
|
|
|
@main.command("infer")
|
|
@click.argument("source", type=click.Path(exists=True))
|
|
@click.option("--format", "-f", "fmt", type=str, help="Input format (auto-detected if not specified)")
|
|
@click.option("--table", "-t", is_flag=True, help="Display as table")
|
|
@click.option("--tree", is_flag=True, help="Display as tree")
|
|
@click.pass_context
|
|
def infer_schema(
|
|
ctx: click.Context,
|
|
source: str,
|
|
fmt: Optional[str],
|
|
table: bool,
|
|
tree: bool,
|
|
) -> None:
|
|
"""Infer schema from a configuration file."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
source_path = Path(source)
|
|
|
|
if fmt is None:
|
|
fmt = BaseConverter.guess_format(source_path)
|
|
if fmt is None:
|
|
formatter.print_error(f"Could not detect format from extension: {source_path.suffix}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
converter = BaseConverter.get_converter(fmt)
|
|
data = converter.read(source_path)
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to read {source}: {e}")
|
|
sys.exit(1)
|
|
|
|
inferrer = SchemaInferrer()
|
|
schema = inferrer.infer(data)
|
|
schema_dict = schema.to_dict()
|
|
|
|
if table:
|
|
formatter.print_schema_table(schema_dict)
|
|
elif tree:
|
|
formatter.print_schema_tree(schema_dict)
|
|
else:
|
|
import json
|
|
click.echo(json.dumps(schema_dict, indent=2))
|
|
|
|
|
|
@main.command("validate")
|
|
@click.argument("source", type=click.Path(exists=True))
|
|
@click.option("--format", "-f", "fmt", type=str, help="Input format (auto-detected if not specified)")
|
|
@click.pass_context
|
|
def validate(
|
|
ctx: click.Context,
|
|
source: str,
|
|
fmt: Optional[str],
|
|
) -> None:
|
|
"""Validate a configuration file against its inferred schema."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
source_path = Path(source)
|
|
|
|
if fmt is None:
|
|
fmt = BaseConverter.guess_format(source_path)
|
|
if fmt is None:
|
|
formatter.print_error(f"Could not detect format from extension: {source_path.suffix}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
converter = BaseConverter.get_converter(fmt)
|
|
data = converter.read(source_path)
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to read {source}: {e}")
|
|
sys.exit(1)
|
|
|
|
inferrer = SchemaInferrer()
|
|
schema = inferrer.infer(data)
|
|
|
|
validator = SchemaValidator(schema)
|
|
is_valid, errors = validator.validate(data)
|
|
|
|
if is_valid:
|
|
formatter.print_success(f"Validation passed: {source}")
|
|
else:
|
|
formatter.print_error(f"Validation failed: {source}")
|
|
for error in errors:
|
|
formatter.print_error(f" - {error}")
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command("generate-ts")
|
|
@click.argument("source", type=click.Path(exists=True))
|
|
@click.option("--format", "-f", "fmt", type=str, help="Input format (auto-detected if not specified)")
|
|
@click.option("--interface", "-i", "interface_name", default="Config", help="Name for the generated interface")
|
|
@click.option("--output", "-o", "output_path", type=click.Path(), help="Output file path (stdout if not specified)")
|
|
@click.pass_context
|
|
def generate_typescript(
|
|
ctx: click.Context,
|
|
source: str,
|
|
fmt: Optional[str],
|
|
interface_name: str,
|
|
output_path: Optional[str],
|
|
) -> None:
|
|
"""Generate TypeScript interfaces from a configuration file."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
source_path = Path(source)
|
|
|
|
if fmt is None:
|
|
fmt = BaseConverter.guess_format(source_path)
|
|
if fmt is None:
|
|
formatter.print_error(f"Could not detect format from extension: {source_path.suffix}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
converter = BaseConverter.get_converter(fmt)
|
|
data = converter.read(source_path)
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to read {source}: {e}")
|
|
sys.exit(1)
|
|
|
|
generator = TypeScriptGenerator(interface_name=interface_name)
|
|
ts_output = generator.generate(data)
|
|
|
|
if output_path:
|
|
try:
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(ts_output)
|
|
formatter.print_success(f"Generated TypeScript interfaces: {output_path}")
|
|
except Exception as e:
|
|
formatter.print_error(f"Failed to write {output_path}: {e}")
|
|
sys.exit(1)
|
|
else:
|
|
click.echo(ts_output)
|
|
|
|
|
|
@main.command("interactive")
|
|
@click.pass_context
|
|
def interactive(ctx: click.Context) -> None:
|
|
"""Enter interactive mode for guided conversion."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
|
|
formatter.print_title("Config Converter - Interactive Mode")
|
|
|
|
supported_formats = BaseConverter.get_supported_formats()
|
|
formatter.print_info(f"Supported formats: {', '.join(supported_formats)}")
|
|
|
|
source = click.prompt("Source file path", type=click.Path(exists=True))
|
|
source_format = click.prompt("Source format (or press Enter for auto-detect)", type=str, default="", show_default=False)
|
|
|
|
if not source_format:
|
|
source_format = BaseConverter.guess_format(source)
|
|
if source_format is None:
|
|
formatter.print_error("Could not detect format. Please specify manually.")
|
|
return
|
|
|
|
target_format = click.prompt("Target format", type=click.Choice(supported_formats))
|
|
|
|
output_path = click.prompt("Output file path (or press Enter for stdout)", type=str, default="", show_default=False)
|
|
|
|
ctx.invoke(
|
|
convert,
|
|
source=source,
|
|
from_format=source_format,
|
|
to_format=target_format,
|
|
output_path=output_path if output_path else None,
|
|
overwrite=True,
|
|
)
|
|
|
|
|
|
@main.command("formats")
|
|
@click.pass_context
|
|
def list_formats(ctx: click.Context) -> None:
|
|
"""List all supported formats."""
|
|
formatter: OutputFormatter = ctx.obj["formatter"]
|
|
supported = BaseConverter.get_supported_formats()
|
|
formatter.print_info("Supported formats:")
|
|
for fmt in supported:
|
|
formatter.print_info(f" - {fmt}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|