diff --git a/CLAUDE.md b/CLAUDE.md index 6823678a..ba7e5b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,9 +68,11 @@ 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`. Both accept one or more positional `PATH` arguments (files, directories, or `-` for stdin) and an optional `-o`/`--output` flag. Additional option flags map directly to the option dataclass fields above. ``` +hcl2tojson file.tf # single file to stdout +hcl2tojson a.tf b.tf -o out/ # multiple files to output dir hcl2tojson --json-indent 2 --with-meta file.tf jsontohcl2 --indent 4 --no-align file.json ``` diff --git a/cli/hcl_to_json.py b/cli/hcl_to_json.py index dde8cfa0..bf9680ce 100644 --- a/cli/hcl_to_json.py +++ b/cli/hcl_to_json.py @@ -1,4 +1,5 @@ """``hcl2tojson`` CLI entry point — convert HCL2 files to JSON.""" + import argparse import json import os @@ -11,6 +12,7 @@ HCL_SKIPPABLE, _convert_single_file, _convert_directory, + _convert_multiple_files, _convert_stdin, ) @@ -35,12 +37,14 @@ def main(): ) parser.add_argument( "PATH", - help='The file or directory to convert (use "-" for stdin)', + nargs="+", + help='One or more files or directories to convert (use "-" for 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("--version", action="version", version=__version__) @@ -118,21 +122,40 @@ def main(): def convert(in_file, out_file): _hcl_to_json(in_file, out_file, options, json_indent=json_indent) - 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 - ) - 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", - ) + paths = args.PATH + output = args.output + + 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) + elif os.path.isdir(path): + _convert_directory( + path, + output, + convert, + args.skip, + HCL_SKIPPABLE, + in_extensions={".tf", ".hcl"}, + out_extension=".json", + ) + else: + raise RuntimeError(f"Invalid Path: {path}") else: - raise RuntimeError(f"Invalid Path: {args.PATH}") + for p in paths: + if not os.path.isfile(p): + raise RuntimeError(f"Invalid file: {p}") + if output is None: + for p in paths: + _convert_single_file(p, None, convert, args.skip, HCL_SKIPPABLE) + else: + _convert_multiple_files( + paths, + output, + convert, + args.skip, + HCL_SKIPPABLE, + out_extension=".json", + ) diff --git a/cli/helpers.py b/cli/helpers.py index b7d48376..7bc7d16f 100644 --- a/cli/helpers.py +++ b/cli/helpers.py @@ -1,8 +1,9 @@ """Shared file-conversion helpers for the HCL2 CLI commands.""" + 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 @@ -50,7 +51,7 @@ def _convert_directory( out_extension: str, ) -> 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) @@ -91,6 +92,23 @@ def _convert_directory( raise +def _convert_multiple_files( + in_paths: List[str], + out_path: str, + convert_fn: Callable[[IO, IO], None], + skip: bool, + skippable: Tuple[Type[BaseException], ...], + out_extension: str, +) -> 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) + + def _convert_stdin(convert_fn: Callable[[IO, IO], None]) -> None: convert_fn(sys.stdin, sys.stdout) sys.stdout.write("\n") diff --git a/cli/json_to_hcl.py b/cli/json_to_hcl.py index 826b7796..98d0e836 100644 --- a/cli/json_to_hcl.py +++ b/cli/json_to_hcl.py @@ -1,4 +1,5 @@ """``jsontohcl2`` CLI entry point — convert JSON files to HCL2.""" + import argparse import json import os @@ -12,6 +13,7 @@ JSON_SKIPPABLE, _convert_single_file, _convert_directory, + _convert_multiple_files, _convert_stdin, ) @@ -36,12 +38,14 @@ def main(): ) parser.add_argument( "PATH", - help='The file or directory to convert (use "-" for stdin)', + nargs="+", + help='One or more files or directories to convert (use "-" for 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("--version", action="version", version=__version__) @@ -116,21 +120,40 @@ def main(): 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 - ) - elif os.path.isdir(args.PATH): - _convert_directory( - args.PATH, - args.OUT_PATH, - convert, - args.skip, - JSON_SKIPPABLE, - in_extensions={".json"}, - out_extension=".tf", - ) + paths = args.PATH + output = args.output + + 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) + elif os.path.isdir(path): + _convert_directory( + path, + output, + convert, + args.skip, + JSON_SKIPPABLE, + in_extensions={".json"}, + out_extension=".tf", + ) + else: + raise RuntimeError(f"Invalid Path: {path}") else: - raise RuntimeError(f"Invalid Path: {args.PATH}") + for p in paths: + if not os.path.isfile(p): + raise RuntimeError(f"Invalid file: {p}") + if output is None: + for p in paths: + _convert_single_file(p, None, convert, args.skip, JSON_SKIPPABLE) + else: + _convert_multiple_files( + paths, + output, + convert, + args.skip, + JSON_SKIPPABLE, + out_extension=".tf", + ) diff --git a/docs/usage.md b/docs/usage.md index f6a5f6d6..7fae851c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -215,19 +215,23 @@ text = hcl2.reconstruct(tree) ### hcl2tojson -Convert HCL2 files to JSON. +Convert HCL2 files to JSON. Accepts one or more files, a directory, or stdin. ```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/ # convert a directory +hcl2tojson a.tf b.tf # multiple files to stdout +hcl2tojson a.tf b.tf -o output/ # multiple files to output directory +hcl2tojson *.tf -o output/ # shell glob expansion +cat main.tf | hcl2tojson - # read from stdin ``` **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) | | `--with-meta` | Add `__start_line__` / `__end_line__` metadata | @@ -238,23 +242,28 @@ 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 | | `--version` | Show version and exit | ### jsontohcl2 -Convert JSON files to HCL2. +Convert JSON files to HCL2. Accepts one or more files, a directory, or stdin. ```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/ # convert a directory +jsontohcl2 a.json b.json # multiple files to stdout +jsontohcl2 a.json b.json -o terraform/ # multiple files to output directory +jsontohcl2 *.json -o terraform/ # shell glob expansion +cat output.json | jsontohcl2 - # read from stdin ``` **Flags:** | Flag | Description | |---|---| +| `-o`, `--output` | Output path (file for single input, directory for multiple) | | `-s` | Skip un-parsable files | | `--indent N` | Indentation width (default: 2) | | `--colon-separator` | Use `:` instead of `=` 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..edd045a1 100644 --- a/test/unit/cli/test_hcl_to_json.py +++ b/test/unit/cli/test_hcl_to_json.py @@ -43,7 +43,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 +99,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,12 +184,12 @@ 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_requires_output(self): with tempfile.TemporaryDirectory() as tmpdir: in_dir = os.path.join(tmpdir, "input") os.mkdir(in_dir) @@ -199,6 +199,45 @@ def test_directory_requires_out_path(self): with self.assertRaises(RuntimeError): main() + 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_raises(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(RuntimeError): + main() + def test_invalid_path_raises_error(self): with patch("sys.argv", ["hcl2tojson", "/nonexistent/path/foo.tf"]): with self.assertRaises(RuntimeError): @@ -212,7 +251,7 @@ 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. @@ -224,7 +263,7 @@ def test_raise_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", in_path, out_path]): + with patch("sys.argv", ["hcl2tojson", in_path, "-o", out_path]): with self.assertRaises(Exception): main() @@ -320,7 +359,7 @@ 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"))) @@ -333,6 +372,6 @@ 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 patch("sys.argv", ["hcl2tojson", in_dir, "-o", out_dir]): with self.assertRaises(Exception): main() diff --git a/test/unit/cli/test_helpers.py b/test/unit/cli/test_helpers.py index ee07ac96..8d71c60e 100644 --- a/test/unit/cli/test_helpers.py +++ b/test/unit/cli/test_helpers.py @@ -5,7 +5,12 @@ from unittest import TestCase from unittest.mock import patch -from cli.helpers import _convert_single_file, _convert_directory, _convert_stdin +from cli.helpers import ( + _convert_single_file, + _convert_directory, + _convert_multiple_files, + _convert_stdin, +) def _write_file(path, content): @@ -172,6 +177,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() diff --git a/test/unit/cli/test_json_to_hcl.py b/test/unit/cli/test_json_to_hcl.py index d7ec678b..56dcae55 100644 --- a/test/unit/cli/test_json_to_hcl.py +++ b/test/unit/cli/test_json_to_hcl.py @@ -47,7 +47,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 +74,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,7 +133,7 @@ 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"))) @@ -143,6 +143,36 @@ def test_invalid_path_raises_error(self): with self.assertRaises(RuntimeError): main() + 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): def _run_json_to_hcl(self, json_dict, extra_flags=None):