Files
config-converter-cli/configconverter/cli.py

396 lines
9.9 KiB
Python

"""CLI interface for config-converter-cli."""
import sys
from typing import List, Optional
import click
from rich.console import Console
from configconverter.converters import Converter
from configconverter.exceptions import (
ConfigConverterError,
ParseError,
QueryError,
ValidationError,
)
from configconverter.formatters import Formatter
from configconverter.query import QueryEngine
from configconverter.validators import Validator
console = Console()
converter = Converter()
formatter = Formatter()
query_engine = QueryEngine()
validator = Validator()
@click.group()
@click.version_option(version="1.0.0")
def main():
"""A CLI tool for converting and validating configuration files."""
pass
@main.command("convert")
@click.argument(
"input_file",
type=click.File("r"),
required=False,
)
@click.option(
"--from",
"-f",
"from_format",
type=click.Choice(["json", "yaml", "toml"]),
help="Input format (auto-detected if not specified)",
)
@click.option(
"--to",
"-t",
"to_format",
type=click.Choice(["json", "yaml", "toml"]),
required=True,
help="Output format",
)
@click.option(
"--indent",
"-i",
type=click.Choice(["2", "4", "8"]),
default="2",
help="Indentation level for output",
)
@click.option(
"--output",
"-o",
type=click.File("w"),
help="Output file (stdout if not specified)",
)
@click.option(
"--validate/--no-validate",
"-V",
default=True,
help="Validate input before conversion",
)
def convert(
input_file,
from_format: Optional[str],
to_format: str,
indent: str,
output,
validate: bool,
):
"""Convert a configuration file from one format to another.
INPUT_FILE can be a file path or '-' for stdin.
Examples:
convert config.json --to yaml -o config.yaml
convert config.yaml --from yaml --to toml
cat config.json | convert - --to yaml
"""
indent_val = int(indent)
try:
if input_file is None or input_file.name == "-":
content = click.get_text_stream("stdin").read()
else:
content = input_file.read()
if not content.strip():
if output:
output.write("")
return
if validate:
is_valid, error = validator.validate(content, from_format)
if not is_valid:
console.print(f"[bold red]Validation Error:[/bold red] {error.message}")
if error.line_number:
console.print(f"Line {error.line_number}")
sys.exit(1)
result = converter.convert(content, from_format or "auto", to_format)
if output:
output.write(result)
else:
console.print(result)
except ConfigConverterError as e:
console.print(f"[bold red]Error:[/bold red] {e.message}")
sys.exit(1)
except Exception as e:
console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
sys.exit(1)
@main.command("validate")
@click.argument(
"input_file",
type=click.File("r"),
required=False,
)
@click.option(
"--format",
"-F",
"format_",
type=click.Choice(["json", "yaml", "toml"]),
help="Format hint (auto-detected if not specified)",
)
@click.option(
"--quiet",
"-q",
is_flag=True,
default=False,
help="Only return exit code, no output",
)
def validate(input_file, format_: Optional[str], quiet: bool):
"""Validate the syntax of a configuration file.
INPUT_FILE can be a file path or '-' for stdin.
Examples:
validate config.json
validate config.yaml --format yaml
cat config.json | validate -
"""
try:
if input_file is None or input_file.name == "-":
content = click.get_text_stream("stdin").read()
else:
content = input_file.read()
if not content.strip():
if not quiet:
console.print("[bold green]Valid (empty)[/bold green]")
return
is_valid, error = validator.validate(content, format_)
if is_valid:
if not quiet:
detected = converter.detect_format(content)
console.print(f"[bold green]Valid {detected.upper()}[/bold green]")
else:
if not quiet:
validator.print_error(error, content, input_file.name if input_file else None)
sys.exit(1)
except Exception as e:
if not quiet:
console.print(f"[bold red]Error:[/bold red] {e}")
sys.exit(1)
@main.command("query")
@click.argument("expression", type=str)
@click.argument(
"input_file",
type=click.File("r"),
required=False,
)
@click.option(
"--format",
"-F",
"format_",
type=click.Choice(["json", "yaml", "toml"]),
help="Format hint (auto-detected if not specified)",
)
@click.option(
"--output",
"-o",
type=click.Choice(["json", "text"]),
default="json",
help="Output format",
)
def query(expression: str, input_file, format_: Optional[str], output: str):
"""Query configuration data using JMESPath.
EXPRESSION is a JMESPath query expression.
INPUT_FILE can be a file path or '-' for stdin.
Examples:
query "server.host" config.json
query "services[*].name" config.yaml
cat config.json | query "database.settings.port" -
"""
import json
try:
if input_file is None or (hasattr(input_file, 'name') and input_file.name == "-"):
content = click.get_text_stream("stdin").read()
elif input_file is not None:
content = input_file.read()
else:
content = click.get_text_stream("stdin").read()
if not content.strip():
console.print("null")
return
result = query_engine.query(content, expression, format_)
if result is None:
if output == "json":
console.print("null")
else:
console.print("(no match)")
else:
if output == "json":
console.print(json.dumps(result, indent=2, ensure_ascii=False))
else:
console.print(str(result))
except QueryError as e:
console.print(f"[bold red]Query Error:[/bold red] {e.message}")
sys.exit(1)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
sys.exit(1)
@main.command("batch")
@click.argument(
"input_files",
type=click.File("r"),
nargs=-1,
required=True,
)
@click.option(
"--from",
"-f",
"from_format",
type=click.Choice(["json", "yaml", "toml"]),
help="Input format (auto-detected if not specified)",
)
@click.option(
"--to",
"-t",
"to_format",
type=click.Choice(["json", "yaml", "toml"]),
required=True,
help="Output format",
)
@click.option(
"--indent",
"-i",
type=click.Choice(["2", "4", "8"]),
default="2",
help="Indentation level for output",
)
@click.option(
"--output-dir",
"-o",
type=click.Path(exists=False, file_okay=False, dir_okay=True),
help="Output directory for converted files",
)
def batch(
input_files,
from_format: Optional[str],
to_format: str,
indent: str,
output_dir,
):
"""Convert multiple files in batch mode.
INPUT_FILES is one or more file paths.
Examples:
batch *.json --to yaml --output-dir converted/
batch config1.yaml config2.yaml --to toml
"""
from pathlib import Path
indent_val = int(indent)
files = list(input_files)
if not files:
console.print("[bold red]No files specified[/bold red]")
sys.exit(1)
output_path = Path(output_dir) if output_dir else None
if output_path:
output_path.mkdir(parents=True, exist_ok=True)
for input_file in files:
try:
content = input_file.read()
if not content.strip():
continue
result = converter.convert(content, from_format or "auto", to_format)
if output_dir:
output_file = output_path / (Path(input_file.name).stem + f".{to_format}")
with open(output_file, "w") as f:
f.write(result)
console.print(f"[cyan]{input_file.name}[/cyan] -> [green]{output_file.name}[/green]")
else:
console.print(f"[bold]=== {input_file.name} ===[/bold]")
console.print(result)
console.print()
except Exception as e:
console.print(f"[bold red]Error processing {input_file.name}:[/bold red] {e}")
@main.command("format")
@click.argument(
"input_file",
type=click.File("r"),
required=False,
)
@click.option(
"--indent",
"-i",
type=click.Choice(["2", "4", "8"]),
default="2",
help="Indentation level",
)
@click.option(
"--to",
"-t",
"to_format",
type=click.Choice(["json", "yaml", "toml"]),
help="Output format (same as input if not specified)",
)
@click.option(
"--compact",
"-c",
is_flag=True,
default=False,
help="Use compact output",
)
def format_cmd(input_file, indent: str, to_format: Optional[str], compact: bool):
"""Format/pretty-print a configuration file.
INPUT_FILE can be a file path or '-' for stdin.
Examples:
format config.json --indent 4
format config.yaml --to json --compact
cat config.json | format - --indent 2
"""
try:
if input_file is None or input_file.name == "-":
content = click.get_text_stream("stdin").read()
else:
content = input_file.read()
if not content.strip():
return
result = formatter.format(content, indent=int(indent), compact=compact, format=to_format)
console.print(result)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
sys.exit(1)
if __name__ == "__main__":
main()