From 65f39dc2673b33b2b3add5a562488426c70bdd97 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 19 Aug 2025 12:47:12 +0100 Subject: [PATCH 1/3] drop rich print for json/yaml output --- src/envars/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/envars/cli.py b/src/envars/cli.py index 4460a1e..390688c 100644 --- a/src/envars/cli.py +++ b/src/envars/cli.py @@ -392,11 +392,11 @@ def output_command( resolved_vars = _get_resolved_variables(manager, loc, env, decrypt=True) if format == "dotenv": for k, v in resolved_vars.items(): - console.print(f"{k}={v}") + print(f"{k}={v}") elif format == "yaml": - console.print(yaml.dump({"envars": resolved_vars}, sort_keys=False)) + print(yaml.dump({"envars": resolved_vars}, sort_keys=False)) elif format == "json": - console.print(json.dumps({"envars": resolved_vars}, indent=2)) + print(json.dumps({"envars": resolved_vars}, indent=2)) else: error_console.print(f"[bold red]Error:[/] Invalid output format: {format}") raise typer.Exit(code=1) From 7cff2ef9a883d66925bdd740001b94c3ace756b0 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 19 Aug 2025 13:52:04 +0100 Subject: [PATCH 2/3] feat: Add test for multiline secret output Adds a test case to ensure that multiline secrets are correctly formatted when output in the dotenv format. This verifies that newline characters are properly escaped. --- src/envars/cli.py | 7 ++++++- tests/test_cli.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/envars/cli.py b/src/envars/cli.py index 390688c..c238a6f 100644 --- a/src/envars/cli.py +++ b/src/envars/cli.py @@ -392,7 +392,12 @@ def output_command( resolved_vars = _get_resolved_variables(manager, loc, env, decrypt=True) if format == "dotenv": for k, v in resolved_vars.items(): - print(f"{k}={v}") + if "\n" in v: + # Escape newlines and wrap in quotes for dotenv format + escaped_v = v.replace("\n", "\\n") + print(f'{k}="{escaped_v}"') + else: + print(f"{k}={v}") elif format == "yaml": print(yaml.dump({"envars": resolved_vars}, sort_keys=False)) elif format == "json": diff --git a/tests/test_cli.py b/tests/test_cli.py index 3c01acf..76d3c72 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1363,3 +1363,54 @@ def test_validate_command_with_invalid_value(self, tmp_path): result = runner.invoke(app, ["--file", file_path, "validate"]) assert result.exit_code == 1 assert "Value 'invalid_value' for variable 'MY_VAR' does not match validation regex" in result.stderr + + +def test_output_multiline_secret_dotenv(tmp_path): + encrypted_string = base64.b64encode(b"some_encrypted_bytes").decode("utf-8") + initial_content = f""" +configuration: + app: MyApp + kms_key: \"arn:aws:kms:us-east-1:123456789012:key/mrk-12345\" + environments: + - dev + locations: + - my_loc: \"loc123\" +environment_variables: + MY_MULTILINE_SECRET: + dev: + my_loc: !secret {encrypted_string} +""" + file_path = create_envars_file(tmp_path, initial_content) + + multiline_value = "line1\nline2\nline3" + + kms_client = boto3.client("kms", region_name="us-east-1") + with Stubber(kms_client) as stubber: + stubber.add_response( + "decrypt", + {"Plaintext": multiline_value.encode("utf-8")}, + { + "CiphertextBlob": b"some_encrypted_bytes", + "EncryptionContext": {"app": "MyApp", "env": "dev", "location": "my_loc"}, + }, + ) + with patch("boto3.client", return_value=kms_client): + result = runner.invoke( + app, + [ + "--file", + file_path, + "output", + "--format", + "dotenv", + "--env", + "dev", + "--loc", + "my_loc", + ], + ) + assert result.exit_code == 0, result.stderr + escaped_multiline_value = multiline_value.replace("\n", "\\n") + expected_output = f'MY_MULTILINE_SECRET="{escaped_multiline_value}"' + assert expected_output in result.stdout + stubber.assert_no_pending_responses() From 5f43a6ef3886a329d5f6477224113beed4fb7c3b Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 19 Aug 2025 15:04:20 +0100 Subject: [PATCH 3/3] fix: Correctly format multi-line decrypted secrets in YAML output --- src/envars/cli.py | 3 ++- src/envars/main.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/envars/cli.py b/src/envars/cli.py index c238a6f..f25e3ef 100644 --- a/src/envars/cli.py +++ b/src/envars/cli.py @@ -11,6 +11,7 @@ from .cloud_utils import get_default_location_name from .main import ( + PrettyDumper, Secret, _check_for_circular_dependencies, _get_decrypted_value, @@ -399,7 +400,7 @@ def output_command( else: print(f"{k}={v}") elif format == "yaml": - print(yaml.dump({"envars": resolved_vars}, sort_keys=False)) + print(yaml.dump({"envars": resolved_vars}, sort_keys=False, Dumper=PrettyDumper)) elif format == "json": print(json.dumps({"envars": resolved_vars}, indent=2)) else: diff --git a/src/envars/main.py b/src/envars/main.py index faa8ebc..a962b77 100644 --- a/src/envars/main.py +++ b/src/envars/main.py @@ -40,6 +40,21 @@ def secret_constructor(loader, node): return Secret(value) +def str_representer(dumper, data): + """Use the literal block style for multi-line strings.""" + if "\n" in data: + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +class PrettyDumper(yaml.Dumper): + pass + + +yaml.add_representer(str, str_representer, Dumper=PrettyDumper) +yaml.add_representer(Secret, secret_representer, Dumper=PrettyDumper) + + class SafeLoaderWithDuplicatesCheck(yaml.SafeLoader): def construct_mapping(self, node, deep=False): mapping = {}