Skip to content

Commit 7969a6e

Browse files
author
Dylan Huang
committed
Refactor secret loading in CLI to use python-dotenv
- Replaced manual parsing of .env files with `dotenv_values()` for improved handling of comments, quotes, and multi-line values. - Updated `load_secrets_from_env_file` function to return a filtered dictionary of environment variables, enhancing code clarity and maintainability.
1 parent a2165fb commit 7969a6e

File tree

2 files changed

+231
-12
lines changed

2 files changed

+231
-12
lines changed

eval_protocol/cli_commands/secrets.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import sys
1010
from typing import Dict
1111

12+
from dotenv import dotenv_values
13+
1214
from eval_protocol.auth import get_fireworks_api_key
1315
from eval_protocol.platform_api import create_or_update_fireworks_secret, get_fireworks_secret
1416
from .utils import _ensure_account_id, _get_questionary_style
@@ -17,24 +19,24 @@
1719
def load_secrets_from_env_file(env_file_path: str) -> Dict[str, str]:
1820
"""
1921
Load secrets from a .env file that should be uploaded to Fireworks.
22+
23+
Uses python-dotenv's dotenv_values() for proper parsing of .env files,
24+
which correctly handles:
25+
- End-of-line comments (e.g., KEY=value # comment)
26+
- Quoted values (single and double quotes)
27+
- Escape sequences
28+
- Multi-line values
2029
"""
2130
if not os.path.exists(env_file_path):
2231
return {}
2332

24-
# Load the .env file into a temporary environment
25-
env_vars = {}
26-
with open(env_file_path, "r") as f:
27-
for line in f:
28-
line = line.strip()
29-
if line and not line.startswith("#") and "=" in line:
30-
key, value = line.split("=", 1)
31-
key = key.strip()
32-
value = value.strip().strip('"').strip("'") # Remove quotes
33-
env_vars[key] = value
34-
return env_vars
33+
# Use dotenv_values for proper .env parsing (handles comments, quotes, etc.)
34+
parsed = dotenv_values(env_file_path)
35+
# Filter out None values and convert to Dict[str, str]
36+
return {k: v for k, v in parsed.items() if v is not None}
3537

3638

37-
def mask_secret_value(value: str) -> str:
39+
def mask_secret_value(value: str | None) -> str:
3840
"""
3941
Return a masked representation of a secret showing only a small prefix/suffix.
4042
Example: fw_3Z*******Xgnk

tests/cli_commands/test_secrets.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Tests for eval_protocol.cli_commands.secrets module."""
2+
3+
import os
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from eval_protocol.cli_commands.secrets import load_secrets_from_env_file, mask_secret_value
9+
10+
11+
class TestLoadSecretsFromEnvFile:
12+
"""Tests for load_secrets_from_env_file function."""
13+
14+
def test_basic_key_value(self, tmp_path: Path):
15+
"""Test basic KEY=value parsing."""
16+
env_file = tmp_path / ".env"
17+
env_file.write_text("MY_KEY=myvalue123\n")
18+
19+
result = load_secrets_from_env_file(str(env_file))
20+
21+
assert result == {"MY_KEY": "myvalue123"}
22+
23+
def test_end_of_line_comment(self, tmp_path: Path):
24+
"""Test that end-of-line comments are properly stripped.
25+
26+
This was a bug where manual parsing included the comment in the value.
27+
"""
28+
env_file = tmp_path / ".env"
29+
env_file.write_text("MY_API_KEY=test_dummy_value_abc123 # this is a comment\n")
30+
31+
result = load_secrets_from_env_file(str(env_file))
32+
33+
assert result == {"MY_API_KEY": "test_dummy_value_abc123"}
34+
# Ensure the comment is NOT included
35+
assert "# this" not in result["MY_API_KEY"]
36+
assert "comment" not in result["MY_API_KEY"]
37+
38+
def test_end_of_line_comment_no_space(self, tmp_path: Path):
39+
"""Test end-of-line comment without space before #."""
40+
env_file = tmp_path / ".env"
41+
env_file.write_text("KEY=value#this is a comment\n")
42+
43+
result = load_secrets_from_env_file(str(env_file))
44+
45+
# python-dotenv treats # without space as part of value
46+
# unless the value is quoted. This is the expected behavior.
47+
assert result == {"KEY": "value#this is a comment"}
48+
49+
def test_quoted_value_with_hash(self, tmp_path: Path):
50+
"""Test that quoted values preserve # character."""
51+
env_file = tmp_path / ".env"
52+
env_file.write_text('KEY="value#with#hashes"\n')
53+
54+
result = load_secrets_from_env_file(str(env_file))
55+
56+
assert result == {"KEY": "value#with#hashes"}
57+
58+
def test_double_quoted_values(self, tmp_path: Path):
59+
"""Test that double-quoted values are properly unquoted."""
60+
env_file = tmp_path / ".env"
61+
env_file.write_text('MY_KEY="value with spaces"\n')
62+
63+
result = load_secrets_from_env_file(str(env_file))
64+
65+
assert result == {"MY_KEY": "value with spaces"}
66+
67+
def test_single_quoted_values(self, tmp_path: Path):
68+
"""Test that single-quoted values are properly unquoted."""
69+
env_file = tmp_path / ".env"
70+
env_file.write_text("MY_KEY='value with spaces'\n")
71+
72+
result = load_secrets_from_env_file(str(env_file))
73+
74+
assert result == {"MY_KEY": "value with spaces"}
75+
76+
def test_comment_lines_ignored(self, tmp_path: Path):
77+
"""Test that full-line comments are ignored."""
78+
env_file = tmp_path / ".env"
79+
env_file.write_text("# This is a comment\nMY_KEY=myvalue\n# Another comment\n")
80+
81+
result = load_secrets_from_env_file(str(env_file))
82+
83+
assert result == {"MY_KEY": "myvalue"}
84+
85+
def test_empty_lines_ignored(self, tmp_path: Path):
86+
"""Test that empty lines are ignored."""
87+
env_file = tmp_path / ".env"
88+
env_file.write_text("KEY1=value1\n\n\nKEY2=value2\n")
89+
90+
result = load_secrets_from_env_file(str(env_file))
91+
92+
assert result == {"KEY1": "value1", "KEY2": "value2"}
93+
94+
def test_multiple_keys(self, tmp_path: Path):
95+
"""Test parsing multiple key-value pairs."""
96+
env_file = tmp_path / ".env"
97+
env_file.write_text("KEY1=value1\nKEY2=value2\nKEY3=value3\n")
98+
99+
result = load_secrets_from_env_file(str(env_file))
100+
101+
assert result == {"KEY1": "value1", "KEY2": "value2", "KEY3": "value3"}
102+
103+
def test_file_not_found(self, tmp_path: Path):
104+
"""Test that non-existent file returns empty dict."""
105+
env_file = tmp_path / "nonexistent.env"
106+
107+
result = load_secrets_from_env_file(str(env_file))
108+
109+
assert result == {}
110+
111+
def test_empty_file(self, tmp_path: Path):
112+
"""Test that empty file returns empty dict."""
113+
env_file = tmp_path / ".env"
114+
env_file.write_text("")
115+
116+
result = load_secrets_from_env_file(str(env_file))
117+
118+
assert result == {}
119+
120+
def test_value_with_equals_sign(self, tmp_path: Path):
121+
"""Test that values containing = are properly parsed."""
122+
env_file = tmp_path / ".env"
123+
env_file.write_text("KEY=value=with=equals\n")
124+
125+
result = load_secrets_from_env_file(str(env_file))
126+
127+
assert result == {"KEY": "value=with=equals"}
128+
129+
def test_quoted_value_with_comment(self, tmp_path: Path):
130+
"""Test quoted value followed by a comment."""
131+
env_file = tmp_path / ".env"
132+
env_file.write_text('MY_KEY="myvalue123" # this is a comment\n')
133+
134+
result = load_secrets_from_env_file(str(env_file))
135+
136+
assert result == {"MY_KEY": "myvalue123"}
137+
138+
def test_complex_env_file(self, tmp_path: Path):
139+
"""Test a complex .env file with various formats."""
140+
env_content = """# Configuration file
141+
# Last updated: 2024-01-15
142+
143+
# API Keys
144+
SERVICE_A_KEY=dummy_key_aaa # Service A key
145+
SERVICE_B_KEY="dummy_key_bbb" # Service B key
146+
147+
# Database settings
148+
DB_HOST=localhost
149+
DB_PORT=5432
150+
DB_NAME='mydb'
151+
152+
# Feature flags
153+
ENABLE_FEATURE=true # enable new feature
154+
155+
# Empty values are skipped
156+
"""
157+
env_file = tmp_path / ".env"
158+
env_file.write_text(env_content)
159+
160+
result = load_secrets_from_env_file(str(env_file))
161+
162+
assert result["SERVICE_A_KEY"] == "dummy_key_aaa"
163+
assert result["SERVICE_B_KEY"] == "dummy_key_bbb"
164+
assert result["DB_HOST"] == "localhost"
165+
assert result["DB_PORT"] == "5432"
166+
assert result["DB_NAME"] == "mydb"
167+
assert result["ENABLE_FEATURE"] == "true"
168+
# Ensure no comments leaked into values
169+
assert "# Service" not in result.get("SERVICE_A_KEY", "")
170+
assert "# Service" not in result.get("SERVICE_B_KEY", "")
171+
172+
def test_export_prefix_handled(self, tmp_path: Path):
173+
"""Test that 'export' prefix is handled (if supported by dotenv)."""
174+
env_file = tmp_path / ".env"
175+
env_file.write_text("export MY_KEY=myvalue123\n")
176+
177+
result = load_secrets_from_env_file(str(env_file))
178+
179+
# python-dotenv handles 'export' prefix
180+
assert result == {"MY_KEY": "myvalue123"}
181+
182+
183+
class TestMaskSecretValue:
184+
"""Tests for mask_secret_value function."""
185+
186+
def test_normal_length_value(self):
187+
"""Test masking a normal length secret."""
188+
result = mask_secret_value("abcdefghijklmnopqrstu")
189+
assert result == "abcdef***rstu"
190+
assert len(result) < len("abcdefghijklmnopqrstu")
191+
192+
def test_short_value(self):
193+
"""Test masking a very short secret."""
194+
result = mask_secret_value("abc")
195+
assert result == "a***c"
196+
197+
def test_empty_value(self):
198+
"""Test masking an empty value."""
199+
result = mask_secret_value("")
200+
assert result == "<empty>"
201+
202+
def test_none_value(self):
203+
"""Test masking None (edge case)."""
204+
result = mask_secret_value(None)
205+
assert result == "<empty>"
206+
207+
def test_exact_boundary_length(self):
208+
"""Test masking value at exactly prefix+suffix length (10 chars)."""
209+
# prefix_len=6, suffix_len=4, so <= 10 chars uses short format
210+
result = mask_secret_value("1234567890")
211+
assert result == "1***0"
212+
213+
def test_just_over_boundary(self):
214+
"""Test masking value just over the boundary."""
215+
# 11 chars should use the full format
216+
result = mask_secret_value("12345678901")
217+
assert result == "123456***8901"

0 commit comments

Comments
 (0)