diff --git a/CLAUDE.md b/CLAUDE.md index 04a25cbd..2f813f46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,13 +79,49 @@ Follows the `json` module convention. All option parameters are keyword-only. ## CLI -Console scripts defined in `pyproject.toml`. Each uses argparse flags that map directly to the option dataclass fields above. +Console scripts defined in `pyproject.toml`. All three CLIs accept positional `PATH` arguments (files, directories, glob patterns, or `-` for stdin). When no `PATH` is given, stdin is read by default (like `jq`). +### Exit Codes + +All CLIs use structured error output (plain text to stderr) and distinct exit codes: + +| Code | `hcl2tojson` | `jsontohcl2` | `hq` | +|------|---|---|---| +| 0 | Success | Success | Success | +| 1 | Partial (some skipped) | JSON parse error | No results | +| 2 | All unparsable | Bad HCL structure | Parse error | +| 3 | — | — | Query error | +| 4 | I/O error | I/O error | I/O error | + +### `hcl2tojson` + +``` +hcl2tojson file.tf # single file to stdout +hcl2tojson dir/ # directory → NDJSON to stdout +hcl2tojson a.tf b.tf -o out/ # multiple files to output dir +hcl2tojson --ndjson 'modules/**/*.tf' # glob + NDJSON streaming +hcl2tojson --only resource,module file.tf # block type filtering +hcl2tojson --exclude variable file.tf # exclude block types +hcl2tojson --fields cpu,memory file.tf # field projection +hcl2tojson --compact file.tf # single-line JSON +hcl2tojson -q dir/ -o out/ # quiet (no stderr progress) +echo 'x = 1' | hcl2tojson # stdin (no args needed) ``` -hcl2tojson --json-indent 2 --with-meta file.tf + +Key flags: `--ndjson`, `--compact`, `--only`/`--exclude`, `--fields`, `-q`/`--quiet`, `--json-indent N`, `--with-meta`, `--with-comments`, `--strip-string-quotes` (breaks round-trip). + +### `jsontohcl2` + +``` +jsontohcl2 file.json # single file to stdout +jsontohcl2 --diff original.tf modified.json # preview changes +jsontohcl2 --dry-run file.json # convert without writing +jsontohcl2 --fragment - # attribute snippets from stdin jsontohcl2 --indent 4 --no-align file.json ``` +Key flags: `--diff ORIGINAL`, `--dry-run`, `--fragment`, `-q`/`--quiet`, `--indent N`, `--no-align`, `--colon-separator`. + Add new options as `parser.add_argument()` calls in the relevant entry point module. ## PostLexer (`postlexer.py`) diff --git a/cli/hcl_to_json.py b/cli/hcl_to_json.py index dde8cfa0..a952d164 100644 --- a/cli/hcl_to_json.py +++ b/cli/hcl_to_json.py @@ -1,46 +1,204 @@ """``hcl2tojson`` CLI entry point — convert HCL2 files to JSON.""" + import argparse import json import os -from typing import IO, Optional, TextIO +import sys +from typing import IO, List, Optional, TextIO from hcl2 import load from hcl2.utils import SerializationOptions from hcl2.version import __version__ from .helpers import ( + EXIT_IO_ERROR, + EXIT_PARSE_ERROR, + EXIT_PARTIAL, + EXIT_SUCCESS, HCL_SKIPPABLE, - _convert_single_file, + _collect_files, _convert_directory, + _convert_multiple_files, + _convert_single_file, _convert_stdin, + _error, + _expand_file_args, ) +_HCL_EXTENSIONS = {".tf", ".hcl"} + + +def _filter_data( + data: dict, + only: Optional[str] = None, + exclude: Optional[str] = None, + fields: Optional[str] = None, +) -> dict: + """Apply block-type filtering and field projection to parsed HCL data.""" + if only: + types = {t.strip() for t in only.split(",")} + data = {k: val for k, val in data.items() if k in types} + elif exclude: + types = {t.strip() for t in exclude.split(",")} + data = {k: val for k, val in data.items() if k not in types} + if fields: + field_set = {f.strip() for f in fields.split(",")} + data = _project_fields(data, field_set) + return data + + +def _project_fields(data, field_set): + """Keep only specified fields (plus metadata keys) in nested dicts. -def _hcl_to_json( + Structural keys (whose values are dicts or lists) are always preserved + so the block hierarchy stays intact. Only leaf attribute keys are + filtered. + """ + if isinstance(data, dict): + result = {} + for key, val in data.items(): + if key in field_set or key.startswith("__"): + result[key] = val + elif isinstance(val, (dict, list)): + projected = _project_fields(val, field_set) + if projected: + result[key] = projected + # else: leaf value not in field_set — drop it + return result + if isinstance(data, list): + out = [_project_fields(item, field_set) for item in data] + return [item for item in out if item] + return data + + +def _hcl_to_json( # pylint: disable=too-many-arguments,too-many-positional-arguments in_file: TextIO, out_file: IO, options: SerializationOptions, json_indent: Optional[int] = None, + only: Optional[str] = None, + exclude: Optional[str] = None, + fields: Optional[str] = None, ) -> None: data = load(in_file, serialization_options=options) + data = _filter_data(data, only, exclude, fields) json.dump(data, out_file, indent=json_indent) -def main(): +def _load_to_dict( + in_file: TextIO, + options: SerializationOptions, + only: Optional[str] = None, + exclude: Optional[str] = None, + fields: Optional[str] = None, +) -> dict: + """Load HCL2 and return the parsed dict (no JSON serialization).""" + data = load(in_file, serialization_options=options) + return _filter_data(data, only, exclude, fields) + + +def _stream_ndjson( # pylint: disable=too-many-arguments,too-many-positional-arguments + file_paths: List[str], + options: SerializationOptions, + json_indent: Optional[int], + skip: bool, + quiet: bool, + add_provenance: bool, + only: Optional[str] = None, + exclude: Optional[str] = None, + fields: Optional[str] = None, +) -> int: + """Stream one JSON object per file to stdout (NDJSON). + + Returns the worst exit code encountered. + """ + worst_exit = EXIT_SUCCESS + any_success = False + for file_path in file_paths: + if not quiet: + print(file_path, file=sys.stderr, flush=True) + try: + with open(file_path, "r", encoding="utf-8") as f: + data = _load_to_dict( + f, options, only=only, exclude=exclude, fields=fields + ) + except HCL_SKIPPABLE as exc: + if skip: + worst_exit = max(worst_exit, EXIT_PARTIAL) + continue + print( + _error(str(exc), error_type="parse_error", file=file_path), + file=sys.stderr, + ) + return EXIT_PARSE_ERROR + except (OSError, IOError) as exc: + if skip: + worst_exit = max(worst_exit, EXIT_PARTIAL) + continue + print( + _error(str(exc), error_type="io_error", file=file_path), + file=sys.stderr, + ) + return EXIT_IO_ERROR + + if add_provenance: + data = {"__file__": file_path, **data} + print(json.dumps(data, indent=json_indent), flush=True) + any_success = True + + if not any_success and worst_exit > EXIT_SUCCESS: + return EXIT_PARSE_ERROR + return worst_exit + + +_EXAMPLES = """\ +examples: + hcl2tojson file.tf # single file to stdout + hcl2tojson dir/ # directory to stdout (NDJSON) + hcl2tojson a.tf b.tf -o out/ # multiple files to output dir + hcl2tojson --ndjson 'modules/**/*.tf' # glob + NDJSON streaming + hcl2tojson --only resource,module file.tf # block type filtering + hcl2tojson --compact file.tf # single-line JSON + echo 'x = 1' | hcl2tojson # stdin (no args needed) + +exit codes: + 0 Success + 1 Partial success (some files skipped via -s) + 2 Parse error (all input unparsable) + 4 I/O error (file not found) +""" + + +def main(): # pylint: disable=too-many-branches,too-many-statements,too-many-locals """The ``hcl2tojson`` console_scripts entry point.""" parser = argparse.ArgumentParser( description="Convert HCL2 files to JSON", + epilog=_EXAMPLES, + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "-s", dest="skip", action="store_true", help="Skip un-parsable files" ) parser.add_argument( "PATH", - help='The file or directory to convert (use "-" for stdin)', + nargs="*", + help="Files, directories, or glob patterns to convert (default: stdin)", ) parser.add_argument( - "OUT_PATH", - nargs="?", - help="The path to write output to. Optional for single file (defaults to stdout)", + "-o", + "--output", + dest="output", + help="Output path (file for single input, directory for multiple inputs)", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Suppress progress output on stderr (errors still shown)", + ) + parser.add_argument( + "--ndjson", + action="store_true", + help="Output one JSON object per line (newline-delimited JSON)", ) parser.add_argument("--version", action="version", version=__version__) @@ -68,7 +226,8 @@ def main(): parser.add_argument( "--no-explicit-blocks", action="store_true", - help="Disable explicit block markers. Note: round-trip through json_to_hcl is NOT supported with this option.", + help="Disable explicit block markers. Note: round-trip through json_to_hcl " + "is NOT supported with this option.", ) parser.add_argument( "--no-preserve-heredocs", @@ -88,16 +247,40 @@ def main(): parser.add_argument( "--strip-string-quotes", action="store_true", - help="Strip surrounding double-quotes from serialized string values. Note: round-trip through json_to_hcl is NOT supported with this option.", + help="Strip surrounding double-quotes from serialized string values. " + "Note: round-trip through json_to_hcl is NOT supported with this option.", ) # JSON output formatting parser.add_argument( "--json-indent", type=int, - default=2, + default=None, metavar="N", - help="JSON indentation width (default: 2)", + help="JSON indentation width (default: 2 for TTY, compact otherwise)", + ) + parser.add_argument( + "--compact", + action="store_true", + help="Compact JSON output (alias for --json-indent 0)", + ) + + # Filtering + filter_group = parser.add_mutually_exclusive_group() + filter_group.add_argument( + "--only", + metavar="TYPES", + help="Comma-separated block types to include (e.g. resource,module)", + ) + filter_group.add_argument( + "--exclude", + metavar="TYPES", + help="Comma-separated block types to exclude (e.g. variable,output)", + ) + parser.add_argument( + "--fields", + metavar="FIELDS", + help="Comma-separated field names to keep in output", ) args = parser.parse_args() @@ -113,26 +296,151 @@ def main(): preserve_scientific_notation=not args.no_preserve_scientific, strip_string_quotes=args.strip_string_quotes, ) - json_indent = args.json_indent + + # Resolve JSON indent: --compact > explicit --json-indent > TTY default (2) > compact + if args.compact: + json_indent: Optional[int] = None + elif args.json_indent is not None: + json_indent = args.json_indent + elif sys.stdout.isatty(): + json_indent = 2 + else: + json_indent = None + + quiet = args.quiet + ndjson = args.ndjson + only = args.only + exclude = args.exclude + fields = args.fields def convert(in_file, out_file): - _hcl_to_json(in_file, out_file, options, json_indent=json_indent) + _hcl_to_json( + in_file, + out_file, + options, + json_indent=json_indent, + only=only, + exclude=exclude, + fields=fields, + ) + + # Default to stdin when no paths given + paths = args.PATH if args.PATH else ["-"] + paths = _expand_file_args(paths) + output = args.output + + try: + # NDJSON or multi-file streaming mode + if ndjson or _needs_ndjson_streaming(paths, output): + file_paths = _resolve_file_paths(paths, parser) + # NDJSON always uses compact output (one object per line) + ndjson_indent = None + exit_code = _stream_ndjson( + file_paths, + options, + ndjson_indent, + args.skip, + quiet, + add_provenance=len(file_paths) > 1, + only=only, + exclude=exclude, + fields=fields, + ) + if exit_code != EXIT_SUCCESS: + sys.exit(exit_code) + return - if args.PATH == "-": - _convert_stdin(convert) - elif os.path.isfile(args.PATH): - _convert_single_file( - args.PATH, args.OUT_PATH, convert, args.skip, HCL_SKIPPABLE + if len(paths) == 1: + path = paths[0] + if path == "-": + _convert_stdin(convert) + elif os.path.isfile(path): + _convert_single_file( + path, output, convert, args.skip, HCL_SKIPPABLE, quiet=quiet + ) + elif os.path.isdir(path): + _convert_directory( + path, + output, + convert, + args.skip, + HCL_SKIPPABLE, + in_extensions=_HCL_EXTENSIONS, + out_extension=".json", + quiet=quiet, + ) + else: + print( + _error( + f"File not found: {path}", + error_type="io_error", + file=path, + ), + file=sys.stderr, + ) + sys.exit(EXIT_IO_ERROR) + else: + # Validate all paths are files + for file_path in paths: + if not os.path.isfile(file_path): + print( + _error( + f"Invalid file: {file_path}", + error_type="io_error", + file=file_path, + ), + file=sys.stderr, + ) + sys.exit(EXIT_IO_ERROR) + if output is None: + for file_path in paths: + _convert_single_file( + file_path, + None, + convert, + args.skip, + HCL_SKIPPABLE, + quiet=quiet, + ) + else: + _convert_multiple_files( + paths, + output, + convert, + args.skip, + HCL_SKIPPABLE, + out_extension=".json", + quiet=quiet, + ) + except HCL_SKIPPABLE as exc: + print( + _error(str(exc), error_type="parse_error"), + file=sys.stderr, ) - elif os.path.isdir(args.PATH): - _convert_directory( - args.PATH, - args.OUT_PATH, - convert, - args.skip, - HCL_SKIPPABLE, - in_extensions={".tf", ".hcl"}, - out_extension=".json", + sys.exit(EXIT_PARSE_ERROR) + except (OSError, IOError) as exc: + print( + _error(str(exc), error_type="io_error"), + file=sys.stderr, ) - else: - raise RuntimeError(f"Invalid Path: {args.PATH}") + sys.exit(EXIT_IO_ERROR) + + +def _needs_ndjson_streaming(paths: List[str], output: Optional[str]) -> bool: + """Return True when a directory is given without an output path. + + In this case we stream NDJSON to stdout instead of requiring ``-o``. + """ + if output is not None: + return False + return len(paths) == 1 and os.path.isdir(paths[0]) + + +def _resolve_file_paths(paths: List[str], parser) -> List[str]: + """Expand directories into individual file paths for NDJSON streaming.""" + file_paths: List[str] = [] + for path in paths: + file_paths.extend(_collect_files(path, _HCL_EXTENSIONS)) + if not file_paths: + parser.error("no HCL files found in the given paths") + return file_paths diff --git a/cli/helpers.py b/cli/helpers.py index b7d48376..ef6062dc 100644 --- a/cli/helpers.py +++ b/cli/helpers.py @@ -1,25 +1,95 @@ """Shared file-conversion helpers for the HCL2 CLI commands.""" + +import glob as glob_mod import json import os import sys -from typing import Callable, IO, Set, Tuple, Type +from typing import Callable, IO, List, Optional, Set, Tuple, Type from lark import UnexpectedCharacters, UnexpectedToken +# Exit codes shared across CLIs +EXIT_SUCCESS = 0 +EXIT_PARTIAL = 1 # hcl2tojson: some files skipped; jsontohcl2: JSON parse error +EXIT_PARSE_ERROR = 2 # hcl2tojson: all unparsable; jsontohcl2: bad HCL structure +EXIT_IO_ERROR = 4 + # Exceptions that can be skipped when -s is passed HCL_SKIPPABLE = (UnexpectedToken, UnexpectedCharacters, UnicodeDecodeError) JSON_SKIPPABLE = (json.JSONDecodeError, UnicodeDecodeError) -def _convert_single_file( +def _error(msg: str, use_json: bool = False, **extra) -> str: + """Format an error message for stderr. + + When *use_json* is true the result is a single-line JSON object with + ``error`` and ``message`` keys (plus any *extra* fields). Otherwise + a plain ``Error: …`` string is returned. + """ + if use_json: + data: dict = {"error": extra.pop("error_type", "error"), "message": msg} + data.update(extra) + return json.dumps(data) + return f"Error: {msg}" + + +def _expand_file_args(file_args: List[str]) -> List[str]: + """Expand glob patterns in file arguments. + + For each arg containing glob metacharacters (``*``, ``?``, ``[``), + expand via :func:`glob.glob` with ``recursive=True``. Literal paths + and ``-`` (stdin) pass through unchanged. If a glob matches nothing, + the literal pattern is kept so the caller produces an IO error. + """ + expanded: List[str] = [] + for arg in file_args: + if arg == "-": + expanded.append(arg) + continue + if any(c in arg for c in "*?["): + matches = sorted(glob_mod.glob(arg, recursive=True)) + if matches: + expanded.extend(matches) + else: + expanded.append(arg) # keep literal — will produce IO error + else: + expanded.append(arg) + return expanded + + +def _collect_files(path: str, extensions: Set[str]) -> List[str]: + """Return a sorted list of files under *path* matching *extensions*. + + If *path* is ``-`` (stdin marker) or a plain file, it is returned as-is + in a single-element list. Directories are walked recursively. + """ + if path == "-": + return ["-"] + if os.path.isfile(path): + return [path] + if os.path.isdir(path): + files: List[str] = [] + for dirpath, _, filenames in os.walk(path): + for fname in sorted(filenames): + if os.path.splitext(fname)[1] in extensions: + files.append(os.path.join(dirpath, fname)) + files.sort() + return files + # Not a file or directory — return as-is so caller can report IO error + return [path] + + +def _convert_single_file( # pylint: disable=too-many-positional-arguments in_path: str, - out_path: str, + out_path: Optional[str], convert_fn: Callable[[IO, IO], None], skip: bool, skippable: Tuple[Type[BaseException], ...], + quiet: bool = False, ) -> None: with open(in_path, "r", encoding="utf-8") as in_file: - print(in_path, file=sys.stderr, flush=True) + if not quiet: + print(in_path, file=sys.stderr, flush=True) if out_path is not None: try: with open(out_path, "w", encoding="utf-8") as out_file: @@ -40,17 +110,18 @@ def _convert_single_file( raise -def _convert_directory( +def _convert_directory( # pylint: disable=too-many-positional-arguments,too-many-locals in_path: str, - out_path: str, + out_path: Optional[str], convert_fn: Callable[[IO, IO], None], skip: bool, skippable: Tuple[Type[BaseException], ...], in_extensions: Set[str], out_extension: str, + quiet: bool = False, ) -> None: if out_path is None: - raise RuntimeError("Positional OUT_PATH parameter shouldn't be empty") + raise RuntimeError("Output path is required for directory conversion (use -o)") if not os.path.exists(out_path): os.mkdir(out_path) @@ -79,7 +150,8 @@ def _convert_directory( processed_files.add(out_file_path) with open(in_file_path, "r", encoding="utf-8") as in_file: - print(in_file_path, file=sys.stderr, flush=True) + if not quiet: + print(in_file_path, file=sys.stderr, flush=True) try: with open(out_file_path, "w", encoding="utf-8") as out_file: convert_fn(in_file, out_file) @@ -91,6 +163,26 @@ def _convert_directory( raise +def _convert_multiple_files( # pylint: disable=too-many-positional-arguments + in_paths: List[str], + out_path: str, + convert_fn: Callable[[IO, IO], None], + skip: bool, + skippable: Tuple[Type[BaseException], ...], + out_extension: str, + quiet: bool = False, +) -> None: + """Convert multiple files into an output directory.""" + if not os.path.exists(out_path): + os.makedirs(out_path) + for in_path in in_paths: + base = os.path.splitext(os.path.basename(in_path))[0] + out_extension + file_out = os.path.join(out_path, base) + _convert_single_file( + in_path, file_out, convert_fn, skip, skippable, quiet=quiet + ) + + def _convert_stdin(convert_fn: Callable[[IO, IO], None]) -> None: convert_fn(sys.stdin, sys.stdout) sys.stdout.write("\n") diff --git a/cli/hq.py b/cli/hq.py index 71156abe..c67c4417 100755 --- a/cli/hq.py +++ b/cli/hq.py @@ -1,7 +1,6 @@ """``hq`` CLI entry point — query HCL2 files.""" import argparse -import glob as glob_mod import json import multiprocessing import os @@ -21,6 +20,7 @@ safe_eval, ) from hcl2.version import __version__ +from .helpers import _expand_file_args # noqa: F401 — re-exported for tests # Exit codes EXIT_SUCCESS = 0 @@ -385,28 +385,7 @@ def _collect_files(path: str) -> List[str]: return [path] -def _expand_file_args(file_args: List[str]) -> List[str]: - """Expand glob patterns in file arguments. - - For each arg containing glob metacharacters (``*``, ``?``, ``[``), - expand via :func:`glob.glob` with ``recursive=True``. Literal paths - and ``-`` (stdin) pass through unchanged. If a glob matches nothing, - the literal pattern is kept so the caller produces an IO error. - """ - expanded: List[str] = [] - for arg in file_args: - if arg == "-": - expanded.append(arg) - continue - if any(c in arg for c in "*?["): - matches = sorted(glob_mod.glob(arg, recursive=True)) - if matches: - expanded.extend(matches) - else: - expanded.append(arg) # keep literal — will produce IO error - else: - expanded.append(arg) - return expanded +# _expand_file_args is imported from .helpers and re-exported at module level. def _run_query_on_file( diff --git a/cli/json_to_hcl.py b/cli/json_to_hcl.py index 826b7796..cbe21840 100644 --- a/cli/json_to_hcl.py +++ b/cli/json_to_hcl.py @@ -1,7 +1,11 @@ """``jsontohcl2`` CLI entry point — convert JSON files to HCL2.""" + import argparse +import difflib import json import os +import sys +from io import StringIO from typing import TextIO from hcl2 import dump @@ -9,10 +13,16 @@ from hcl2.formatter import FormatterOptions from hcl2.version import __version__ from .helpers import ( + EXIT_IO_ERROR, + EXIT_PARSE_ERROR, + EXIT_PARTIAL, JSON_SKIPPABLE, - _convert_single_file, _convert_directory, + _convert_multiple_files, + _convert_single_file, _convert_stdin, + _error, + _expand_file_args, ) @@ -26,22 +36,88 @@ def _json_to_hcl( dump(data, out_file, deserializer_options=d_opts, formatter_options=f_opts) -def main(): +def _json_to_hcl_string( + in_file: TextIO, + d_opts: DeserializerOptions, + f_opts: FormatterOptions, +) -> str: + """Convert JSON input to an HCL string (for --diff / --dry-run).""" + buf = StringIO() + _json_to_hcl(in_file, buf, d_opts, f_opts) + return buf.getvalue() + + +def _json_to_hcl_fragment( + in_file: TextIO, + d_opts: DeserializerOptions, + f_opts: FormatterOptions, +) -> str: + """Convert a JSON fragment to HCL attribute assignments.""" + data = json.load(in_file) + # Wrap flat attributes in a top-level body, convert, return + buf = StringIO() + dump(data, buf, deserializer_options=d_opts, formatter_options=f_opts) + return buf.getvalue() + + +_EXAMPLES = """\ +examples: + jsontohcl2 file.json # single file to stdout + jsontohcl2 a.json b.json -o out/ # multiple files to output dir + jsontohcl2 --diff original.tf modified.json # preview changes + jsontohcl2 --dry-run file.json # convert without writing + jsontohcl2 --fragment - # attribute snippet from stdin + echo '{"x": 1}' | jsontohcl2 # stdin (no args needed) + +exit codes: + 0 Success + 1 JSON parse error + 2 Valid JSON but incompatible HCL structure + 4 I/O error (file not found) +""" + + +def main(): # pylint: disable=too-many-branches,too-many-statements,too-many-locals """The ``jsontohcl2`` console_scripts entry point.""" parser = argparse.ArgumentParser( description="Convert JSON files to HCL2", + epilog=_EXAMPLES, + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "-s", dest="skip", action="store_true", help="Skip un-parsable files" ) parser.add_argument( "PATH", - help='The file or directory to convert (use "-" for stdin)', + nargs="*", + help="Files, directories, or glob patterns to convert (default: stdin)", + ) + parser.add_argument( + "-o", + "--output", + dest="output", + help="Output path (file for single input, directory for multiple inputs)", ) parser.add_argument( - "OUT_PATH", - nargs="?", - help="The path to write output to. Optional for single file (defaults to stdout)", + "-q", + "--quiet", + action="store_true", + help="Suppress progress output on stderr (errors still shown)", + ) + parser.add_argument( + "--diff", + metavar="ORIGINAL", + help="Show unified diff against ORIGINAL file instead of writing output", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Convert and print to stdout without writing files", + ) + parser.add_argument( + "--fragment", + action="store_true", + help="Treat input as a JSON fragment (attribute dict, not full HCL document)", ) parser.add_argument("--version", action="version", version=__version__) @@ -112,25 +188,166 @@ def main(): vertically_align_attributes=not args.no_align, vertically_align_object_elements=not args.no_align, ) + quiet = args.quiet def convert(in_file, out_file): _json_to_hcl(in_file, out_file, d_opts, f_opts) - if args.PATH == "-": - _convert_stdin(convert) - elif os.path.isfile(args.PATH): - _convert_single_file( - args.PATH, args.OUT_PATH, convert, args.skip, JSON_SKIPPABLE + # Default to stdin when no paths given + paths = args.PATH if args.PATH else ["-"] + paths = _expand_file_args(paths) + output = args.output + + try: + # --diff mode: convert JSON, diff against original file + if args.diff: + if len(paths) != 1: + parser.error("--diff requires exactly one input file") + json_path = paths[0] + original_path = args.diff + + if not os.path.isfile(original_path): + print( + _error( + f"File not found: {original_path}", + error_type="io_error", + file=original_path, + ), + file=sys.stderr, + ) + sys.exit(EXIT_IO_ERROR) + + if json_path == "-": + hcl_output = _json_to_hcl_string(sys.stdin, d_opts, f_opts) + else: + with open(json_path, "r", encoding="utf-8") as f: + hcl_output = _json_to_hcl_string(f, d_opts, f_opts) + + with open(original_path, "r", encoding="utf-8") as f: + original_lines = f.readlines() + + converted_lines = hcl_output.splitlines(keepends=True) + diff_output = list( + difflib.unified_diff( + original_lines, + converted_lines, + fromfile=original_path, + tofile=f"(from {json_path})", + ) + ) + if diff_output: + sys.stdout.writelines(diff_output) + sys.exit(1) + return + + # --dry-run mode: convert to stdout without writing + if args.dry_run: + if len(paths) != 1: + parser.error("--dry-run requires exactly one input file") + json_path = paths[0] + if json_path == "-": + hcl_output = _json_to_hcl_string(sys.stdin, d_opts, f_opts) + else: + with open(json_path, "r", encoding="utf-8") as f: + hcl_output = _json_to_hcl_string(f, d_opts, f_opts) + sys.stdout.write(hcl_output) + return + + # --fragment mode: convert JSON fragment to HCL attributes + if args.fragment: + if len(paths) != 1: + parser.error("--fragment requires exactly one input file") + json_path = paths[0] + if json_path == "-": + hcl_output = _json_to_hcl_fragment(sys.stdin, d_opts, f_opts) + else: + with open(json_path, "r", encoding="utf-8") as f: + hcl_output = _json_to_hcl_fragment(f, d_opts, f_opts) + sys.stdout.write(hcl_output) + return + + if len(paths) == 1: + path = paths[0] + if path == "-": + _convert_stdin(convert) + elif os.path.isfile(path): + _convert_single_file( + path, output, convert, args.skip, JSON_SKIPPABLE, quiet=quiet + ) + elif os.path.isdir(path): + _convert_directory( + path, + output, + convert, + args.skip, + JSON_SKIPPABLE, + in_extensions={".json"}, + out_extension=".tf", + quiet=quiet, + ) + else: + print( + _error( + f"File not found: {path}", + error_type="io_error", + file=path, + ), + file=sys.stderr, + ) + sys.exit(EXIT_IO_ERROR) + else: + for file_path in paths: + if not os.path.isfile(file_path): + print( + _error( + f"Invalid file: {file_path}", + error_type="io_error", + file=file_path, + ), + file=sys.stderr, + ) + sys.exit(EXIT_IO_ERROR) + if output is None: + for file_path in paths: + _convert_single_file( + file_path, + None, + convert, + args.skip, + JSON_SKIPPABLE, + quiet=quiet, + ) + else: + _convert_multiple_files( + paths, + output, + convert, + args.skip, + JSON_SKIPPABLE, + out_extension=".tf", + quiet=quiet, + ) + except json.JSONDecodeError as exc: + print( + _error(str(exc), error_type="json_parse_error"), + file=sys.stderr, + ) + sys.exit(EXIT_PARTIAL) + except JSON_SKIPPABLE as exc: + print( + _error(str(exc), error_type="parse_error"), + file=sys.stderr, + ) + sys.exit(EXIT_PARTIAL) + except (KeyError, TypeError, ValueError) as exc: + print( + _error(str(exc), error_type="structure_error"), + file=sys.stderr, ) - elif os.path.isdir(args.PATH): - _convert_directory( - args.PATH, - args.OUT_PATH, - convert, - args.skip, - JSON_SKIPPABLE, - in_extensions={".json"}, - out_extension=".tf", + sys.exit(EXIT_PARSE_ERROR) + except (OSError, IOError) as exc: + print( + _error(str(exc), error_type="io_error"), + file=sys.stderr, ) - else: - raise RuntimeError(f"Invalid Path: {args.PATH}") + sys.exit(EXIT_IO_ERROR) diff --git a/docs/01_getting_started.md b/docs/01_getting_started.md index 5569322a..a421979e 100644 --- a/docs/01_getting_started.md +++ b/docs/01_getting_started.md @@ -153,21 +153,36 @@ python-hcl2 ships three console scripts: `hcl2tojson`, `jsontohcl2`, and [`hq`]( ### hcl2tojson -Convert HCL2 files to JSON. +Convert HCL2 files to JSON. Accepts files, directories, glob patterns, or stdin (default when no args given). ```sh -hcl2tojson main.tf # print JSON to stdout -hcl2tojson main.tf output.json # write to file -hcl2tojson terraform/ output/ # convert a directory -cat main.tf | hcl2tojson - # read from stdin +hcl2tojson main.tf # single file to stdout +hcl2tojson main.tf -o output.json # single file to output file +hcl2tojson terraform/ -o output/ # directory to output dir +hcl2tojson terraform/ # directory to stdout (NDJSON) +hcl2tojson --ndjson 'modules/**/*.tf' # glob + NDJSON streaming +hcl2tojson a.tf b.tf -o output/ # multiple files to output dir +hcl2tojson --only resource,module main.tf # block type filtering +hcl2tojson --fields cpu,memory main.tf # field projection +hcl2tojson --compact main.tf # single-line JSON +echo 'x = 1' | hcl2tojson # stdin (no args needed) ``` +**Exit codes:** 0 = success, 1 = partial (some skipped), 2 = all unparsable, 4 = I/O error. + **Flags:** | Flag | Description | |---|---| +| `-o`, `--output` | Output path (file for single input, directory for multiple) | | `-s` | Skip un-parsable files | -| `--json-indent N` | JSON indentation width (default: 2) | +| `-q`, `--quiet` | Suppress progress output on stderr | +| `--ndjson` | One JSON object per line (newline-delimited JSON) | +| `--compact` | Compact JSON output (no indentation) | +| `--json-indent N` | JSON indentation width (default: 2 for TTY, compact otherwise) | +| `--only TYPES` | Comma-separated block types to include | +| `--exclude TYPES` | Comma-separated block types to exclude | +| `--fields FIELDS` | Comma-separated field names to keep | | `--with-meta` | Add `__start_line__` / `__end_line__` metadata | | `--with-comments` | Include comments as `__comments__` / `__inline_comments__` object lists | | `--wrap-objects` | Wrap object values as inline HCL2 | @@ -176,24 +191,37 @@ cat main.tf | hcl2tojson - # read from stdin | `--no-preserve-heredocs` | Convert heredocs to plain strings | | `--force-parens` | Force parentheses around all operations | | `--no-preserve-scientific` | Convert scientific notation to standard floats | +| `--strip-string-quotes` | Strip surrounding double-quotes from string values (breaks round-trip) | | `--version` | Show version and exit | +> **Note on `--strip-string-quotes`:** This removes the surrounding `"..."` from serialized string values (e.g. `"\"my-bucket\""` becomes `"my-bucket"`). Useful for read-only workflows but round-trip through `jsontohcl2` is **not supported** with this option, as the parser cannot distinguish bare strings from expressions. + ### jsontohcl2 -Convert JSON files to HCL2. +Convert JSON files to HCL2. Accepts files, directories, glob patterns, or stdin (default when no args given). ```sh -jsontohcl2 output.json # print HCL2 to stdout -jsontohcl2 output.json main.tf # write to file -jsontohcl2 output/ terraform/ # convert a directory -cat output.json | jsontohcl2 - # read from stdin +jsontohcl2 output.json # single file to stdout +jsontohcl2 output.json -o main.tf # single file to output file +jsontohcl2 output/ -o terraform/ # directory conversion +jsontohcl2 --diff original.tf modified.json # preview changes as unified diff +jsontohcl2 --dry-run file.json # convert without writing +jsontohcl2 --fragment - # attribute snippets from stdin +echo '{"x": 1}' | jsontohcl2 # stdin (no args needed) ``` +**Exit codes:** 0 = success, 1 = JSON parse error, 2 = bad HCL structure, 4 = I/O error. + **Flags:** | Flag | Description | |---|---| +| `-o`, `--output` | Output path (file for single input, directory for multiple) | | `-s` | Skip un-parsable files | +| `-q`, `--quiet` | Suppress progress output on stderr | +| `--diff ORIGINAL` | Show unified diff against ORIGINAL file (exit 0 = identical, 1 = differs) | +| `--dry-run` | Convert and print to stdout without writing files | +| `--fragment` | Treat input as attribute dict, not full HCL document | | `--indent N` | Indentation width (default: 2) | | `--colon-separator` | Use `:` instead of `=` in object elements | | `--no-trailing-comma` | Omit trailing commas in object elements | diff --git a/test/unit/cli/test_hcl_to_json.py b/test/unit/cli/test_hcl_to_json.py index 4954d09c..19572555 100644 --- a/test/unit/cli/test_hcl_to_json.py +++ b/test/unit/cli/test_hcl_to_json.py @@ -6,6 +6,7 @@ from unittest import TestCase from unittest.mock import patch +from cli.helpers import EXIT_IO_ERROR, EXIT_PARSE_ERROR, EXIT_PARTIAL from cli.hcl_to_json import main @@ -43,7 +44,7 @@ def test_single_file_to_output(self): out_path = os.path.join(tmpdir, "test.json") _write_file(hcl_path, SIMPLE_HCL) - with patch("sys.argv", ["hcl2tojson", hcl_path, out_path]): + with patch("sys.argv", ["hcl2tojson", hcl_path, "-o", out_path]): main() result = json.loads(_read_file(out_path)) @@ -99,7 +100,7 @@ def test_directory_mode(self): _write_file(os.path.join(in_dir, "b.hcl"), SIMPLE_HCL) _write_file(os.path.join(in_dir, "readme.txt"), "not hcl") - with patch("sys.argv", ["hcl2tojson", in_dir, out_dir]): + with patch("sys.argv", ["hcl2tojson", in_dir, "-o", out_dir]): main() self.assertTrue(os.path.exists(os.path.join(out_dir, "a.json"))) @@ -184,25 +185,86 @@ def test_skip_flag(self): _write_file(os.path.join(in_dir, "good.tf"), SIMPLE_HCL) _write_file(os.path.join(in_dir, "bad.tf"), "this is {{{{ not valid hcl") - with patch("sys.argv", ["hcl2tojson", "-s", in_dir, out_dir]): + with patch("sys.argv", ["hcl2tojson", "-s", in_dir, "-o", out_dir]): main() self.assertTrue(os.path.exists(os.path.join(out_dir, "good.json"))) - def test_directory_requires_out_path(self): + def test_directory_to_stdout_ndjson(self): + """Directory without -o streams NDJSON to stdout.""" with tempfile.TemporaryDirectory() as tmpdir: in_dir = os.path.join(tmpdir, "input") os.mkdir(in_dir) - _write_file(os.path.join(in_dir, "a.tf"), SIMPLE_HCL) + _write_file(os.path.join(in_dir, "a.tf"), "a = 1\n") + _write_file(os.path.join(in_dir, "b.tf"), "b = 2\n") + stdout = StringIO() with patch("sys.argv", ["hcl2tojson", in_dir]): - with self.assertRaises(RuntimeError): + with patch("sys.stdout", stdout): main() - def test_invalid_path_raises_error(self): + lines = stdout.getvalue().strip().split("\n") + self.assertEqual(len(lines), 2) + for line in lines: + data = json.loads(line) + self.assertIn("__file__", data) + + def test_stdin_default_when_no_args(self): + """No PATH args reads from stdin (like jq).""" + stdout = StringIO() + stdin = StringIO(SIMPLE_HCL) + with patch("sys.argv", ["hcl2tojson"]): + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + main() + + result = json.loads(stdout.getvalue()) + self.assertEqual(result["x"], 1) + + def test_multiple_files_to_stdout(self): + with tempfile.TemporaryDirectory() as tmpdir: + path_a = os.path.join(tmpdir, "a.tf") + path_b = os.path.join(tmpdir, "b.tf") + _write_file(path_a, "a = 1\n") + _write_file(path_b, "b = 2\n") + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", path_a, path_b]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue() + self.assertIn('"a"', output) + self.assertIn('"b"', output) + + def test_multiple_files_to_output_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + path_a = os.path.join(tmpdir, "a.tf") + path_b = os.path.join(tmpdir, "b.tf") + out_dir = os.path.join(tmpdir, "out") + _write_file(path_a, "a = 1\n") + _write_file(path_b, "b = 2\n") + + with patch("sys.argv", ["hcl2tojson", path_a, path_b, "-o", out_dir]): + main() + + self.assertTrue(os.path.exists(os.path.join(out_dir, "a.json"))) + self.assertTrue(os.path.exists(os.path.join(out_dir, "b.json"))) + + def test_multiple_files_invalid_path_exits_4(self): + with tempfile.TemporaryDirectory() as tmpdir: + path_a = os.path.join(tmpdir, "a.tf") + _write_file(path_a, "a = 1\n") + + with patch("sys.argv", ["hcl2tojson", path_a, "/nonexistent.tf"]): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_IO_ERROR) + + def test_invalid_path_exits_4(self): with patch("sys.argv", ["hcl2tojson", "/nonexistent/path/foo.tf"]): - with self.assertRaises(RuntimeError): + with self.assertRaises(SystemExit) as cm: main() + self.assertEqual(cm.exception.code, EXIT_IO_ERROR) class TestSingleFileErrorHandling(TestCase): @@ -212,21 +274,22 @@ def test_skip_error_with_output_file(self): out_path = os.path.join(tmpdir, "out.json") _write_file(in_path, "this is {{{{ not valid hcl") - with patch("sys.argv", ["hcl2tojson", "-s", in_path, out_path]): + with patch("sys.argv", ["hcl2tojson", "-s", in_path, "-o", out_path]): main() # The partial output file is cleaned up on skipped errors. self.assertFalse(os.path.exists(out_path)) - def test_raise_error_with_output_file(self): + def test_parse_error_exits_2(self): with tempfile.TemporaryDirectory() as tmpdir: in_path = os.path.join(tmpdir, "test.tf") out_path = os.path.join(tmpdir, "out.json") _write_file(in_path, "this is {{{{ not valid hcl") - with patch("sys.argv", ["hcl2tojson", in_path, out_path]): - with self.assertRaises(Exception): + with patch("sys.argv", ["hcl2tojson", in_path, "-o", out_path]): + with self.assertRaises(SystemExit) as cm: main() + self.assertEqual(cm.exception.code, EXIT_PARSE_ERROR) def test_skip_error_to_stdout(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -240,7 +303,7 @@ def test_skip_error_to_stdout(self): self.assertEqual(stdout.getvalue(), "") - def test_raise_error_to_stdout(self): + def test_parse_error_to_stdout_exits_2(self): with tempfile.TemporaryDirectory() as tmpdir: in_path = os.path.join(tmpdir, "test.tf") _write_file(in_path, "this is {{{{ not valid hcl") @@ -248,8 +311,9 @@ def test_raise_error_to_stdout(self): stdout = StringIO() with patch("sys.argv", ["hcl2tojson", in_path]): with patch("sys.stdout", stdout): - with self.assertRaises(Exception): + with self.assertRaises(SystemExit) as cm: main() + self.assertEqual(cm.exception.code, EXIT_PARSE_ERROR) class TestHclToJsonFlags(TestCase): @@ -309,6 +373,54 @@ def test_json_indent_flag(self): # 4-space indent produces longer output than 2-space self.assertGreater(len(stdout_4.getvalue()), len(stdout_2.getvalue())) + def test_compact_flag(self): + with tempfile.TemporaryDirectory() as tmpdir: + hcl_path = os.path.join(tmpdir, "test.tf") + _write_file(hcl_path, "x = {\n a = 1\n}\n") + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--compact", hcl_path]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue().strip() + # Compact = single line (no newlines inside the JSON) + self.assertEqual(output.count("\n"), 0) + data = json.loads(output) + self.assertEqual(data["x"]["a"], 1) + + def test_tty_auto_indent(self): + """When stdout is a TTY and no --json-indent, default to indent=2.""" + with tempfile.TemporaryDirectory() as tmpdir: + hcl_path = os.path.join(tmpdir, "test.tf") + _write_file(hcl_path, "x = {\n a = 1\n}\n") + + stdout = StringIO() + stdout.isatty = lambda: True # type: ignore[assignment] + with patch("sys.argv", ["hcl2tojson", hcl_path]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue() + # Indented = multiple lines + self.assertGreater(output.count("\n"), 1) + + def test_non_tty_auto_compact(self): + """When stdout is not a TTY and no --json-indent, default to compact.""" + with tempfile.TemporaryDirectory() as tmpdir: + hcl_path = os.path.join(tmpdir, "test.tf") + _write_file(hcl_path, "x = {\n a = 1\n}\n") + + stdout = StringIO() + # StringIO.isatty() returns False by default + with patch("sys.argv", ["hcl2tojson", hcl_path]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue().strip() + # Compact = single line + self.assertEqual(output.count("\n"), 0) + class TestDirectoryEdgeCases(TestCase): def test_subdirectory_creation(self): @@ -320,12 +432,12 @@ def test_subdirectory_creation(self): _write_file(os.path.join(sub_dir, "nested.tf"), SIMPLE_HCL) - with patch("sys.argv", ["hcl2tojson", in_dir, out_dir]): + with patch("sys.argv", ["hcl2tojson", in_dir, "-o", out_dir]): main() self.assertTrue(os.path.exists(os.path.join(out_dir, "sub", "nested.json"))) - def test_directory_raise_error_without_skip(self): + def test_directory_parse_error_exits_2(self): with tempfile.TemporaryDirectory() as tmpdir: in_dir = os.path.join(tmpdir, "input") out_dir = os.path.join(tmpdir, "output") @@ -333,6 +445,239 @@ def test_directory_raise_error_without_skip(self): _write_file(os.path.join(in_dir, "bad.tf"), "this is {{{{ not valid hcl") - with patch("sys.argv", ["hcl2tojson", in_dir, out_dir]): - with self.assertRaises(Exception): + with patch("sys.argv", ["hcl2tojson", in_dir, "-o", out_dir]): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_PARSE_ERROR) + + +class TestStructuredErrors(TestCase): + def test_io_error_structured_stderr(self): + stderr = StringIO() + with patch("sys.argv", ["hcl2tojson", "/nonexistent.tf"]): + with patch("sys.stderr", stderr): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_IO_ERROR) + + output = stderr.getvalue() + self.assertIn("Error:", output) + + def test_parse_error_structured_stderr(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "bad.tf") + _write_file(path, "this is {{{{ not valid hcl") + + stderr = StringIO() + with patch("sys.argv", ["hcl2tojson", path]): + with patch("sys.stderr", stderr): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_PARSE_ERROR) + + output = stderr.getvalue() + self.assertIn("Error:", output) + + +class TestQuietFlag(TestCase): + def test_quiet_suppresses_progress(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, SIMPLE_HCL) + + stdout = StringIO() + stderr = StringIO() + with patch("sys.argv", ["hcl2tojson", "-q", path]): + with patch("sys.stdout", stdout), patch("sys.stderr", stderr): + main() + + # No progress output to stderr + self.assertEqual(stderr.getvalue(), "") + # But JSON still goes to stdout + result = json.loads(stdout.getvalue()) + self.assertEqual(result["x"], 1) + + def test_not_quiet_shows_progress(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, SIMPLE_HCL) + + stdout = StringIO() + stderr = StringIO() + with patch("sys.argv", ["hcl2tojson", path]): + with patch("sys.stdout", stdout), patch("sys.stderr", stderr): + main() + + self.assertIn("test.tf", stderr.getvalue()) + + +class TestGlobExpansion(TestCase): + def test_glob_pattern_expands(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.tf"), "a = 1\n") + _write_file(os.path.join(tmpdir, "b.tf"), "b = 2\n") + + stdout = StringIO() + pattern = os.path.join(tmpdir, "*.tf") + with patch("sys.argv", ["hcl2tojson", pattern]): + with patch("sys.stdout", stdout): main() + + output = stdout.getvalue() + self.assertIn('"a"', output) + self.assertIn('"b"', output) + + +class TestNdjson(TestCase): + def test_ndjson_flag_single_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, SIMPLE_HCL) + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--ndjson", path]): + with patch("sys.stdout", stdout): + main() + + lines = stdout.getvalue().strip().split("\n") + self.assertEqual(len(lines), 1) + data = json.loads(lines[0]) + self.assertEqual(data["x"], 1) + # Single file: no __file__ provenance + self.assertNotIn("__file__", data) + + def test_ndjson_flag_multiple_files(self): + with tempfile.TemporaryDirectory() as tmpdir: + path_a = os.path.join(tmpdir, "a.tf") + path_b = os.path.join(tmpdir, "b.tf") + _write_file(path_a, "a = 1\n") + _write_file(path_b, "b = 2\n") + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--ndjson", path_a, path_b]): + with patch("sys.stdout", stdout): + main() + + lines = stdout.getvalue().strip().split("\n") + self.assertEqual(len(lines), 2) + for line in lines: + data = json.loads(line) + self.assertIn("__file__", data) + + def test_ndjson_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + in_dir = os.path.join(tmpdir, "input") + os.mkdir(in_dir) + _write_file(os.path.join(in_dir, "a.tf"), "a = 1\n") + _write_file(os.path.join(in_dir, "b.tf"), "b = 2\n") + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--ndjson", in_dir]): + with patch("sys.stdout", stdout): + main() + + lines = stdout.getvalue().strip().split("\n") + self.assertEqual(len(lines), 2) + files = set() + for line in lines: + data = json.loads(line) + self.assertIn("__file__", data) + files.add(data["__file__"]) + self.assertEqual(len(files), 2) + + def test_ndjson_skip_bad_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + in_dir = os.path.join(tmpdir, "input") + os.mkdir(in_dir) + _write_file(os.path.join(in_dir, "good.tf"), SIMPLE_HCL) + _write_file(os.path.join(in_dir, "bad.tf"), "this is {{{{ not valid") + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--ndjson", "-s", in_dir]): + with patch("sys.stdout", stdout): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_PARTIAL) + + lines = [ln for ln in stdout.getvalue().strip().split("\n") if ln] + self.assertEqual(len(lines), 1) + + +HCL_WITH_BLOCKS = """\ +variable "name" { + default = "hello" +} + +resource "aws_instance" "main" { + ami = "abc-123" +} + +output "result" { + value = "world" +} +""" + + +class TestBlockFiltering(TestCase): + def test_only_single_type(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, HCL_WITH_BLOCKS) + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--only", "resource", path]): + with patch("sys.stdout", stdout): + main() + + data = json.loads(stdout.getvalue()) + self.assertIn("resource", data) + self.assertNotIn("variable", data) + self.assertNotIn("output", data) + + def test_only_multiple_types(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, HCL_WITH_BLOCKS) + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--only", "resource,variable", path]): + with patch("sys.stdout", stdout): + main() + + data = json.loads(stdout.getvalue()) + self.assertIn("resource", data) + self.assertIn("variable", data) + self.assertNotIn("output", data) + + def test_exclude_single_type(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, HCL_WITH_BLOCKS) + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--exclude", "variable", path]): + with patch("sys.stdout", stdout): + main() + + data = json.loads(stdout.getvalue()) + self.assertNotIn("variable", data) + self.assertIn("resource", data) + self.assertIn("output", data) + + +class TestFieldProjection(TestCase): + def test_fields_filter(self): + hcl = "x = 1\ny = 2\nz = 3\n" + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.tf") + _write_file(path, hcl) + + stdout = StringIO() + with patch("sys.argv", ["hcl2tojson", "--fields", "x,y", path]): + with patch("sys.stdout", stdout): + main() + + data = json.loads(stdout.getvalue()) + self.assertIn("x", data) + self.assertIn("y", data) + self.assertNotIn("z", data) diff --git a/test/unit/cli/test_helpers.py b/test/unit/cli/test_helpers.py index ee07ac96..ed9cafcb 100644 --- a/test/unit/cli/test_helpers.py +++ b/test/unit/cli/test_helpers.py @@ -1,11 +1,20 @@ # pylint: disable=C0103,C0114,C0115,C0116 +import json import os import tempfile from io import StringIO from unittest import TestCase from unittest.mock import patch -from cli.helpers import _convert_single_file, _convert_directory, _convert_stdin +from cli.helpers import ( + _collect_files, + _convert_single_file, + _convert_directory, + _convert_multiple_files, + _convert_stdin, + _error, + _expand_file_args, +) def _write_file(path, content): @@ -172,6 +181,99 @@ def convert(in_f, out_f): ) +class TestConvertMultipleFiles(TestCase): + def test_converts_all_files(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.tf"), "aaa") + _write_file(os.path.join(tmpdir, "b.tf"), "bbb") + + out_dir = os.path.join(tmpdir, "out") + converted = [] + + def convert(in_f, out_f): + converted.append(in_f.read()) + out_f.write("ok") + + _convert_multiple_files( + [os.path.join(tmpdir, "a.tf"), os.path.join(tmpdir, "b.tf")], + out_dir, + convert, + False, + (Exception,), + out_extension=".json", + ) + + self.assertEqual(len(converted), 2) + self.assertTrue(os.path.exists(os.path.join(out_dir, "a.json"))) + self.assertTrue(os.path.exists(os.path.join(out_dir, "b.json"))) + + def test_creates_output_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.tf"), "aaa") + + out_dir = os.path.join(tmpdir, "new_out") + + def convert(_in_f, out_f): + out_f.write("ok") + + _convert_multiple_files( + [os.path.join(tmpdir, "a.tf")], + out_dir, + convert, + False, + (Exception,), + out_extension=".json", + ) + + self.assertTrue(os.path.isdir(out_dir)) + + def test_skip_errors(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.tf"), "aaa") + _write_file(os.path.join(tmpdir, "b.tf"), "bbb") + + out_dir = os.path.join(tmpdir, "out") + converted = [] + + def convert(in_f, out_f): + data = in_f.read() + if "aaa" in data: + raise ValueError("boom") + converted.append(data) + out_f.write("ok") + + _convert_multiple_files( + [os.path.join(tmpdir, "a.tf"), os.path.join(tmpdir, "b.tf")], + out_dir, + convert, + True, + (ValueError,), + out_extension=".json", + ) + + self.assertEqual(len(converted), 1) + + def test_custom_out_extension(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.json"), "data") + + out_dir = os.path.join(tmpdir, "out") + + def convert(_in_f, out_f): + out_f.write("ok") + + _convert_multiple_files( + [os.path.join(tmpdir, "a.json")], + out_dir, + convert, + False, + (Exception,), + out_extension=".tf", + ) + + self.assertTrue(os.path.exists(os.path.join(out_dir, "a.tf"))) + + class TestConvertStdin(TestCase): def test_stdin_forward(self): stdout = StringIO() @@ -187,3 +289,113 @@ def convert(in_f, out_f): self.assertEqual(captured[0], "input") self.assertIn("output", stdout.getvalue()) + + +class TestError(TestCase): + def test_plain_text(self): + result = _error("something broke", use_json=False) + self.assertEqual(result, "Error: something broke") + + def test_json_format(self): + result = _error("parse failed", use_json=True, error_type="parse_error") + data = json.loads(result) + self.assertEqual(data["error"], "parse_error") + self.assertEqual(data["message"], "parse failed") + + def test_json_extra_fields(self): + result = _error("bad", use_json=True, error_type="io_error", file="x.tf") + data = json.loads(result) + self.assertEqual(data["file"], "x.tf") + + def test_json_default_error_type(self): + result = _error("oops", use_json=True) + data = json.loads(result) + self.assertEqual(data["error"], "error") + + +class TestExpandFileArgs(TestCase): + def test_literal_passthrough(self): + self.assertEqual(_expand_file_args(["a.tf", "b.tf"]), ["a.tf", "b.tf"]) + + def test_stdin_passthrough(self): + self.assertEqual(_expand_file_args(["-"]), ["-"]) + + def test_glob_expansion(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.tf"), "") + _write_file(os.path.join(tmpdir, "b.tf"), "") + _write_file(os.path.join(tmpdir, "c.json"), "") + + result = _expand_file_args([os.path.join(tmpdir, "*.tf")]) + self.assertEqual(len(result), 2) + self.assertTrue(all(r.endswith(".tf") for r in result)) + + def test_no_match_keeps_literal(self): + result = _expand_file_args(["/nonexistent/*.tf"]) + self.assertEqual(result, ["/nonexistent/*.tf"]) + + +class TestCollectFiles(TestCase): + def test_stdin(self): + self.assertEqual(_collect_files("-", {".tf"}), ["-"]) + + def test_single_file(self): + with tempfile.NamedTemporaryFile(suffix=".tf", delete=False) as f: + f.write(b"x = 1\n") + path = f.name + try: + self.assertEqual(_collect_files(path, {".tf"}), [path]) + finally: + os.unlink(path) + + def test_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + _write_file(os.path.join(tmpdir, "a.tf"), "") + _write_file(os.path.join(tmpdir, "b.hcl"), "") + _write_file(os.path.join(tmpdir, "c.txt"), "") + + result = _collect_files(tmpdir, {".tf", ".hcl"}) + basenames = [os.path.basename(f) for f in result] + self.assertEqual(sorted(basenames), ["a.tf", "b.hcl"]) + + def test_nonexistent_returns_literal(self): + self.assertEqual(_collect_files("/no/such/path", {".tf"}), ["/no/such/path"]) + + +class TestQuietMode(TestCase): + def test_quiet_suppresses_stderr(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.txt") + _write_file(path, "hello") + + stderr = StringIO() + stdout = StringIO() + + def convert(in_f, out_f): + out_f.write(in_f.read()) + + with patch("sys.stderr", stderr), patch("sys.stdout", stdout): + _convert_single_file( + path, None, convert, False, (Exception,), quiet=True + ) + + self.assertEqual(stderr.getvalue(), "") + self.assertIn("hello", stdout.getvalue()) + + def test_not_quiet_prints_to_stderr(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.txt") + _write_file(path, "hello") + + stderr = StringIO() + stdout = StringIO() + + def convert(in_f, out_f): + out_f.write(in_f.read()) + + with patch("sys.stderr", stderr), patch("sys.stdout", stdout): + _convert_single_file( + path, None, convert, False, (Exception,), quiet=False + ) + + self.assertIn("test.txt", stderr.getvalue()) diff --git a/test/unit/cli/test_json_to_hcl.py b/test/unit/cli/test_json_to_hcl.py index d7ec678b..60c14c3d 100644 --- a/test/unit/cli/test_json_to_hcl.py +++ b/test/unit/cli/test_json_to_hcl.py @@ -6,6 +6,7 @@ from unittest import TestCase from unittest.mock import patch +from cli.helpers import EXIT_IO_ERROR, EXIT_PARTIAL from cli.json_to_hcl import main @@ -47,7 +48,7 @@ def test_single_file_to_output(self): out_path = os.path.join(tmpdir, "test.tf") _write_file(json_path, SIMPLE_JSON) - with patch("sys.argv", ["jsontohcl2", json_path, out_path]): + with patch("sys.argv", ["jsontohcl2", json_path, "-o", out_path]): main() output = _read_file(out_path) @@ -74,7 +75,7 @@ def test_directory_mode(self): _write_file(os.path.join(in_dir, "a.json"), SIMPLE_JSON) _write_file(os.path.join(in_dir, "readme.txt"), "not json") - with patch("sys.argv", ["jsontohcl2", in_dir, out_dir]): + with patch("sys.argv", ["jsontohcl2", in_dir, "-o", out_dir]): main() self.assertTrue(os.path.exists(os.path.join(out_dir, "a.tf"))) @@ -133,15 +134,58 @@ def test_skip_flag_on_invalid_json(self): _write_file(os.path.join(in_dir, "good.json"), SIMPLE_JSON) _write_file(os.path.join(in_dir, "bad.json"), "{not valid json") - with patch("sys.argv", ["jsontohcl2", "-s", in_dir, out_dir]): + with patch("sys.argv", ["jsontohcl2", "-s", in_dir, "-o", out_dir]): main() self.assertTrue(os.path.exists(os.path.join(out_dir, "good.tf"))) - def test_invalid_path_raises_error(self): + def test_invalid_path_exits_4(self): with patch("sys.argv", ["jsontohcl2", "/nonexistent/path/foo.json"]): - with self.assertRaises(RuntimeError): + with self.assertRaises(SystemExit) as cm: main() + self.assertEqual(cm.exception.code, EXIT_IO_ERROR) + + def test_stdin_default_when_no_args(self): + """No PATH args reads from stdin.""" + stdout = StringIO() + stdin = StringIO(SIMPLE_JSON) + with patch("sys.argv", ["jsontohcl2"]): + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + main() + + output = stdout.getvalue().strip() + self.assertIn("x", output) + self.assertIn("1", output) + + def test_multiple_files_to_stdout(self): + with tempfile.TemporaryDirectory() as tmpdir: + path_a = os.path.join(tmpdir, "a.json") + path_b = os.path.join(tmpdir, "b.json") + _write_file(path_a, json.dumps({"a": 1})) + _write_file(path_b, json.dumps({"b": 2})) + + stdout = StringIO() + with patch("sys.argv", ["jsontohcl2", path_a, path_b]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue() + self.assertIn("a", output) + self.assertIn("b", output) + + def test_multiple_files_to_output_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + path_a = os.path.join(tmpdir, "a.json") + path_b = os.path.join(tmpdir, "b.json") + out_dir = os.path.join(tmpdir, "out") + _write_file(path_a, json.dumps({"a": 1})) + _write_file(path_b, json.dumps({"b": 2})) + + with patch("sys.argv", ["jsontohcl2", path_a, path_b, "-o", out_dir]): + main() + + self.assertTrue(os.path.exists(os.path.join(out_dir, "a.tf"))) + self.assertTrue(os.path.exists(os.path.join(out_dir, "b.tf"))) class TestJsonToHclFlags(TestCase): @@ -203,3 +247,153 @@ def test_open_empty_tuples_flag(self): default = self._run_json_to_hcl(data) expanded = self._run_json_to_hcl(data, ["--open-empty-tuples"]) self.assertNotEqual(default, expanded) + + +class TestStructuredErrors(TestCase): + def test_io_error_structured_stderr(self): + stderr = StringIO() + with patch("sys.argv", ["jsontohcl2", "/nonexistent.json"]): + with patch("sys.stderr", stderr): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_IO_ERROR) + + output = stderr.getvalue() + self.assertIn("Error:", output) + + def test_invalid_json_exits_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "bad.json") + _write_file(path, "{not valid json") + + stderr = StringIO() + with patch("sys.argv", ["jsontohcl2", path]): + with patch("sys.stderr", stderr): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, EXIT_PARTIAL) + + +class TestQuietFlag(TestCase): + def test_quiet_suppresses_progress(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.json") + _write_file(path, SIMPLE_JSON) + + stdout = StringIO() + stderr = StringIO() + with patch("sys.argv", ["jsontohcl2", "-q", path]): + with patch("sys.stdout", stdout), patch("sys.stderr", stderr): + main() + + self.assertEqual(stderr.getvalue(), "") + self.assertIn("x", stdout.getvalue()) + + def test_not_quiet_shows_progress(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.json") + _write_file(path, SIMPLE_JSON) + + stdout = StringIO() + stderr = StringIO() + with patch("sys.argv", ["jsontohcl2", path]): + with patch("sys.stdout", stdout), patch("sys.stderr", stderr): + main() + + self.assertIn("test.json", stderr.getvalue()) + + +class TestDiffMode(TestCase): + def test_diff_shows_changes(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Write original HCL + original_path = os.path.join(tmpdir, "original.tf") + _write_file(original_path, "x = 1\n") + + # Write modified JSON (x = 2) + json_path = os.path.join(tmpdir, "modified.json") + _write_file(json_path, json.dumps({"x": 2})) + + stdout = StringIO() + with patch( + "sys.argv", + ["jsontohcl2", "--diff", original_path, json_path], + ): + with patch("sys.stdout", stdout): + with self.assertRaises(SystemExit) as cm: + main() + # Exit code 1 = there are differences + self.assertEqual(cm.exception.code, 1) + + diff_output = stdout.getvalue() + self.assertIn("---", diff_output) + self.assertIn("+++", diff_output) + + def test_diff_no_changes(self): + with tempfile.TemporaryDirectory() as tmpdir: + json_path = os.path.join(tmpdir, "same.json") + _write_file(json_path, json.dumps({"x": 1})) + + # Use --dry-run to get the exact HCL output + stdout = StringIO() + with patch("sys.argv", ["jsontohcl2", "--dry-run", json_path]): + with patch("sys.stdout", stdout): + main() + + original_path = os.path.join(tmpdir, "original.tf") + _write_file(original_path, stdout.getvalue()) + + # Now diff — should be identical (exit 0) + stdout2 = StringIO() + with patch( + "sys.argv", + ["jsontohcl2", "--diff", original_path, json_path], + ): + with patch("sys.stdout", stdout2): + main() + + self.assertEqual(stdout2.getvalue(), "") + + +class TestDryRun(TestCase): + def test_dry_run_prints_to_stdout(self): + with tempfile.TemporaryDirectory() as tmpdir: + json_path = os.path.join(tmpdir, "test.json") + _write_file(json_path, SIMPLE_JSON) + + stdout = StringIO() + with patch("sys.argv", ["jsontohcl2", "--dry-run", json_path]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue() + self.assertIn("x", output) + self.assertIn("1", output) + + +class TestFragment(TestCase): + def test_fragment_from_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + json_path = os.path.join(tmpdir, "frag.json") + _write_file(json_path, json.dumps({"bucket": "my-data", "acl": "private"})) + + stdout = StringIO() + with patch("sys.argv", ["jsontohcl2", "--fragment", json_path]): + with patch("sys.stdout", stdout): + main() + + output = stdout.getvalue() + self.assertIn("bucket", output) + self.assertIn("acl", output) + self.assertIn("my-data", output) + + def test_fragment_from_stdin(self): + stdin = StringIO(json.dumps({"cpu": 512})) + stdout = StringIO() + with patch("sys.argv", ["jsontohcl2", "--fragment", "-"]): + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + main() + + output = stdout.getvalue() + self.assertIn("cpu", output) + self.assertIn("512", output)