Skip to content

Commit 22fe960

Browse files
authored
Fix dotenv parser truncating unquoted values containing '#' (#4842)
* Fix dotenv parser truncating unquoted values containing '#' * fix empty values swallowing inline comments and quoted values absoprtion bug * bare keys with comments dropped bug fixed * bare key alternative fix
1 parent 6cae778 commit 22fe960

2 files changed

Lines changed: 94 additions & 8 deletions

File tree

src/huggingface_hub/utils/_dotenv.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,26 @@ def load_dotenv(dotenv_str: str, environ: dict[str, str] | None = None) -> dict[
2929
^\s*
3030
(?:export[^\S\n]+)? # optional export
3131
([A-Za-z_][A-Za-z0-9_]*) # key
32-
[^\S\n]*(=)?[^\S\n]*
33-
( # value group
32+
(?:
33+
[^\S\n]*
34+
(=) # equal sign
3435
(?:
35-
'(?:\\'|[^'])*' # single-quoted value
36-
| \"(?:\\\"|[^\"])*\" # double-quoted value
37-
| [^#\n\r]+? # unquoted value
36+
[^\S\n]*
37+
( # quoted value
38+
'(?:\\'|[^'])*' # single-quoted
39+
| \"(?:\\\"|[^\"])*\" # double-quoted
40+
)
41+
[^\S\n]*(?:\#[^\n\r]*)? # inline comment (needs no preceding whitespace after a quote)
42+
|
43+
(?:[^\S\n]+(?!\#))? # whitespace after '=' also separates an inline comment
44+
( # unquoted value (may contain '#')
45+
[^\n\r]*?
46+
)
47+
(?:[^\S\n]+\#[^\n\r]*)? # inline comment (must be preceded by whitespace)
3848
)
39-
)?
40-
[^\S\n]*(?:\#.*)?$ # optional inline comment
49+
|
50+
[^\S\n]*(?:\#[^\n\r]*)? # bare key (no '='), with an optional inline comment
51+
)$
4152
""",
4253
re.VERBOSE,
4354
)
@@ -52,7 +63,7 @@ def load_dotenv(dotenv_str: str, environ: dict[str, str] | None = None) -> dict[
5263
key = match.group(1)
5364
val = None
5465
if match.group(2): # if there is '='
55-
raw_val = match.group(3) or ""
66+
raw_val = match.group(3) or match.group(4) or ""
5667
val = raw_val.strip()
5768
# Remove surrounding quotes if quoted
5869
if val.startswith('"') and val.endswith('"'):

tests/test_utils_dotenv.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,78 @@ def test_single_quoted_values_are_literal():
107107
"ESCAPED_QUOTE": r"a\"b",
108108
}
109109
assert load_dotenv(r'DQ="line1\nline2"') == {"DQ": "line1\nline2"}
110+
111+
112+
def test_hash_in_unquoted_value_is_kept():
113+
# A "#" only starts an inline comment when preceded by whitespace. A "#" that is part of an
114+
# unquoted value (e.g. in a password, token or URL fragment) must be preserved, not truncated.
115+
data = """
116+
PASSWORD=p@ss#word
117+
TOKEN=abc#123
118+
URL=http://example.com/x#frag
119+
LEADING=#notacomment
120+
COMMENTED=value # actual comment
121+
"""
122+
assert load_dotenv(data) == {
123+
"PASSWORD": "p@ss#word",
124+
"TOKEN": "abc#123",
125+
"URL": "http://example.com/x#frag",
126+
"LEADING": "#notacomment",
127+
"COMMENTED": "value",
128+
}
129+
130+
131+
def test_empty_value_with_inline_comment():
132+
# Whitespace right after "=" also separates an inline comment: the value is empty, not the
133+
# comment text. Otherwise a comment would leak into env vars/secrets (e.g. `hf jobs --env-file`).
134+
data = """
135+
EMPTY= # comment
136+
EMPTY_MULTI_SPACE= # comment
137+
EMPTY_NO_COMMENT=
138+
LEADING=#notacomment
139+
"""
140+
assert load_dotenv(data) == {
141+
"EMPTY": "",
142+
"EMPTY_MULTI_SPACE": "",
143+
"EMPTY_NO_COMMENT": "",
144+
"LEADING": "#notacomment",
145+
}
146+
147+
148+
def test_comment_attached_to_closing_quote():
149+
# After a closing quote, a "#" starts a comment even without preceding whitespace.
150+
data = """
151+
DQ="value"# comment
152+
SQ='value'#comment
153+
SPACED="value" # comment
154+
HASH_INSIDE="a#b"
155+
"""
156+
assert load_dotenv(data) == {
157+
"DQ": "value",
158+
"SQ": "value",
159+
"SPACED": "value",
160+
"HASH_INSIDE": "a#b",
161+
}
162+
163+
164+
def test_bare_key_with_inline_comment():
165+
# A bare key (no "=") is resolved from the environment. A trailing comment must not prevent the
166+
# line from matching, otherwise the key is silently dropped by `--env-file` / `--secrets-file`.
167+
data = """
168+
BARE # comment
169+
BARE_NO_SPACE#comment
170+
BARE_PLAIN
171+
"""
172+
environ = {"BARE": "1", "BARE_NO_SPACE": "2", "BARE_PLAIN": "3"}
173+
assert load_dotenv(data, environ=environ) == {"BARE": "1", "BARE_NO_SPACE": "2", "BARE_PLAIN": "3"}
174+
175+
176+
def test_invalid_line_does_not_import_from_environ():
177+
# A key followed by arbitrary text is not a valid line: it must be ignored rather than treated
178+
# as a bare key, which would pull the host value in and clobber an explicit assignment above.
179+
data = """
180+
SECRET=explicit_value
181+
SECRET is documented above
182+
OTHER not an assignment
183+
"""
184+
assert load_dotenv(data, environ={"SECRET": "HOST_ENV", "OTHER": "HOST_ENV"}) == {"SECRET": "explicit_value"}

0 commit comments

Comments
 (0)