"""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 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: assert input_file is not None 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()