329 lines
10 KiB
Python
329 lines
10 KiB
Python
"""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()
|