diff --git a/config_convert/cli.py b/config_convert/cli.py new file mode 100644 index 0000000..a8cf734 --- /dev/null +++ b/config_convert/cli.py @@ -0,0 +1,328 @@ +"""Main CLI application for ConfigConvert.""" + +import sys +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table +from rich.syntax import Syntax + +from config_convert import __version__ +from config_convert.converters import ( + ConverterFactory, + JSONConverter, + YAMLConverter, + TOMLConverter, + ENVConverter, +) +from config_convert.validators import validate +from config_convert.utils import flatten_dict, unflatten_dict, generate_schema + +app = typer.Typer( + name="config-convert", + help="A CLI tool for bidirectional conversion between JSON, YAML, TOML, and ENV config formats.", + add_completion=True, +) +console = Console() + + +def get_format_from_path(file_path: str) -> str: + """Detect format from file extension.""" + ext = Path(file_path).suffix.lower() + format_map = { + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".env": "env", + } + if ext not in format_map: + raise ValueError(f"Unsupported file extension: {ext}") + return format_map[ext] + + +@app.callback() +def callback( + version: bool = typer.Option(False, "--version", "-V", help="Show version and exit"), +): + """ConfigConvert CLI - Convert between config formats.""" + if version: + console.print(f"ConfigConvert CLI v{__version__}") + raise typer.Exit(0) + + +def echo_success(message: str): + """Print success message.""" + console.print(f"[green]✓[/] {message}") + + +def echo_error(message: str): + """Print error message.""" + console.print(f"[red]✗[/] {message}") + + +@app.command("convert") +def convert( + input_file: Optional[str] = typer.Argument(None, help="Input file (auto-detect format from extension)"), + from_format: Optional[str] = typer.Option(None, "--from", "-f", help="Input format (overrides auto-detect)"), + to_format: str = typer.Option(..., "--to", "-t", help="Output format (json, yaml, toml, env)"), + output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Output file (stdout if not specified)"), + indent: Optional[int] = typer.Option(None, "--indent", "-i", help="Indentation spaces (default: 2)"), + stdin: bool = typer.Option(False, "--stdin", "-s", help="Read from stdin"), +): + """Convert between config formats.""" + if stdin and input_file: + echo_error("Cannot use both --stdin and input file") + raise typer.Exit(2) + + if not stdin and not input_file: + echo_error("Please specify input file or use --stdin") + raise typer.Exit(2) + + try: + if stdin: + data_str = sys.stdin.read() + fmt = from_format + if fmt is None: + echo_error("Please specify --from when using --stdin") + raise typer.Exit(2) + else: + fmt = from_format or get_format_from_path(input_file) + data_str = Path(input_file).read_text(encoding="utf-8") + + converter = ConverterFactory.get(fmt) + + try: + data = converter.loads(data_str) + except Exception as e: + echo_error(f"Failed to parse {fmt}: {e}") + raise typer.Exit(4) + + output_converter = ConverterFactory.get(to_format) + + if indent is None: + indent = 2 + + output_str = output_converter.dumps(data, indent=indent) + + if output_file: + Path(output_file).write_text(output_str, encoding="utf-8") + echo_success(f"Converted {fmt} → {to_format}: {output_file}") + else: + console.print(output_str) + + except ValueError as e: + echo_error(str(e)) + raise typer.Exit(2) + except FileNotFoundError: + echo_error(f"File not found: {input_file}") + raise typer.Exit(3) + except Exception as e: + echo_error(f"Conversion failed: {e}") + raise typer.Exit(5) + + +@app.command("validate") +def validate_cmd( + input_file: Optional[str] = typer.Argument(None, help="Input file to validate"), + fmt: Optional[str] = typer.Option(None, "--format", "-F", help="Format (auto-detected from extension)"), + stdin: bool = typer.Option(False, "--stdin", "-s", help="Read from stdin"), +): + """Validate syntax of a config file.""" + if stdin and input_file: + echo_error("Cannot use both --stdin and input file") + raise typer.Exit(2) + + try: + if stdin: + data_str = sys.stdin.read() + if fmt is None: + echo_error("Please specify --format when using --stdin") + raise typer.Exit(2) + input_format = fmt + elif input_file: + input_format = fmt or get_format_from_path(input_file) + data_str = Path(input_file).read_text(encoding="utf-8") + else: + echo_error("Please specify input file or use --stdin") + raise typer.Exit(2) + + is_valid, error = validate(data_str, input_format) + + if is_valid: + echo_success(f"Valid {input_format} syntax") + else: + echo_error(f"Invalid {input_format} syntax: {error}") + raise typer.Exit(4) + + except FileNotFoundError: + echo_error(f"File not found: {input_file}") + raise typer.Exit(3) + + +@app.command("flatten") +def flatten_cmd( + input_file: Optional[str] = typer.Argument(None, help="Input JSON/YAML/TOML file"), + format: str = typer.Option("env", "--format", "-f", help="Output format (env, json, yaml, toml)"), + output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Output file"), + stdin: bool = typer.Option(False, "--stdin", "-s", help="Read from stdin"), +): + """Flatten nested config to dot/bracket notation.""" + if stdin and input_file: + echo_error("Cannot use both --stdin and input file") + raise typer.Exit(2) + + try: + if stdin: + data_str = sys.stdin.read() + fmt = "json" + elif input_file: + fmt = get_format_from_path(input_file) + data_str = Path(input_file).read_text(encoding="utf-8") + else: + echo_error("Please specify input file or use --stdin") + raise typer.Exit(2) + + converter = ConverterFactory.get(fmt) + data = converter.loads(data_str) + + flat_data = flatten_dict(data) + output_converter = ConverterFactory.get(format) + output_str = output_converter.dumps(flat_data) + + if output_file: + Path(output_file).write_text(output_str, encoding="utf-8") + echo_success(f"Flattened to {format}: {output_file}") + else: + console.print(output_str) + + except FileNotFoundError: + echo_error(f"File not found: {input_file}") + raise typer.Exit(3) + except Exception as e: + echo_error(f"Flatten failed: {e}") + raise typer.Exit(5) + + +@app.command("unflatten") +def unflatten_cmd( + input_file: Optional[str] = typer.Argument(None, help="Input flattened file"), + format: str = typer.Option("json", "--format", "-f", help="Output format"), + output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Output file"), + stdin: bool = typer.Option(False, "--stdin", "-s", help="Read from stdin"), +): + """Unflatten dot/bracket notation to nested config.""" + if stdin and input_file: + echo_error("Cannot use both --stdin and input file") + raise typer.Exit(2) + + try: + if stdin: + data_str = sys.stdin.read() + fmt = "env" + elif input_file: + fmt = get_format_from_path(input_file) + data_str = Path(input_file).read_text(encoding="utf-8") + else: + echo_error("Please specify input file or use --stdin") + raise typer.Exit(2) + + converter = ConverterFactory.get(fmt) + flat_data = converter.loads(data_str) + + data = unflatten_dict(flat_data) + output_converter = ConverterFactory.get(format) + output_str = output_converter.dumps(data) + + if output_file: + Path(output_file).write_text(output_str, encoding="utf-8") + echo_success(f"Unflattened to {format}: {output_file}") + else: + console.print(output_str) + + except FileNotFoundError: + echo_error(f"File not found: {input_file}") + raise typer.Exit(3) + except Exception as e: + echo_error(f"Unflatten failed: {e}") + raise typer.Exit(5) + + +@app.command("schema") +def schema_cmd( + input_file: Optional[str] = typer.Argument(None, help="Input JSON/YAML/TOML file"), + output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Output file"), + stdin: bool = typer.Option(False, "--stdin", "-s", help="Read from stdin"), +): + """Generate JSON Schema from config.""" + if stdin and input_file: + echo_error("Cannot use both --stdin and input file") + raise typer.Exit(2) + + try: + if stdin: + data_str = sys.stdin.read() + fmt = "json" + elif input_file: + fmt = get_format_from_path(input_file) + data_str = Path(input_file).read_text(encoding="utf-8") + else: + echo_error("Please specify input file or use --stdin") + raise typer.Exit(2) + + converter = ConverterFactory.get(fmt) + data = converter.loads(data_str) + + schema = generate_schema(data) + import json + output_str = json.dumps(schema, indent=2) + + if output_file: + Path(output_file).write_text(output_str, encoding="utf-8") + echo_success(f"Schema generated: {output_file}") + else: + console.print(output_str) + + except FileNotFoundError: + echo_error(f"File not found: {input_file}") + raise typer.Exit(3) + except Exception as e: + echo_error(f"Schema generation failed: {e}") + raise typer.Exit(5) + + +@app.command("formats") +def formats_cmd(): + """List supported formats.""" + table = Table(title="Supported Formats") + table.add_column("Format", style="cyan") + table.add_column("Extensions", style="green") + table.add_column("Read", style="magenta") + table.add_column("Write", style="magenta") + + formats = [ + ("JSON", ".json", "✓", "✓"), + ("YAML", ".yaml, .yml", "✓", "✓"), + ("TOML", ".toml", "✓", "✓"), + ("ENV", ".env", "✓", "✓"), + ] + + for fmt in formats: + table.add_row(*fmt) + + console.print(table) + + +def main(): + """Main entry point.""" + ConverterFactory.register("json", JSONConverter()) + ConverterFactory.register("yaml", YAMLConverter()) + ConverterFactory.register("toml", TOMLConverter()) + ConverterFactory.register("env", ENVConverter()) + app() + + +if __name__ == "__main__": + main()