Initial upload: ConfigConvert CLI with full test suite and CI/CD
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled

This commit is contained in:
2026-02-04 07:05:18 +00:00
parent f6167eaf74
commit 044cf48226

328
config_convert/cli.py Normal file
View File

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