Files
config-converter-cli/config_converter/cli.py
7000pctAUTO 3906e9f52c
Some checks failed
CI / test (push) Has been cancelled
Add source code files
2026-02-04 21:54:19 +00:00

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()