Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/envars/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from .cloud_utils import get_default_location_name
from .main import (
PrettyDumper,
Secret,
_check_for_circular_dependencies,
_get_decrypted_value,
Expand Down Expand Up @@ -392,11 +393,16 @@ 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}")
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":
console.print(yaml.dump({"envars": resolved_vars}, sort_keys=False))
print(yaml.dump({"envars": resolved_vars}, sort_keys=False, Dumper=PrettyDumper))
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)
Expand Down
15 changes: 15 additions & 0 deletions src/envars/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()