fix(cli): read .env as utf-8-sig so a BOM doesn't drop the first key
PowerShell 5.1 Set-Content -Encoding UTF8 and Windows Notepad write a UTF-8 BOM. load_dotenv(encoding="utf-8") kept U+FEFF on the first key name, so the canonical name was absent from os.environ and Hermes looked unconfigured with no error. utf-8-sig strips the BOM and is a no-op for BOM-less UTF-8; latin-1 fallback unchanged.
This commit is contained in:
parent
566b5b16a9
commit
aa1fac980d
|
|
@ -341,7 +341,11 @@ def _sanitize_loaded_credentials() -> None:
|
|||
|
||||
def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
|
||||
try:
|
||||
load_dotenv(dotenv_path=path, override=override, encoding="utf-8")
|
||||
# utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1
|
||||
# Set-Content -Encoding UTF8 / Notepad) and is a no-op for BOM-less
|
||||
# UTF-8. Plain "utf-8" would keep U+FEFF on the first key name and
|
||||
# silently drop it from os.environ under its canonical name.
|
||||
load_dotenv(dotenv_path=path, override=override, encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
|
||||
# Strip non-ASCII characters from credential env vars that were just
|
||||
|
|
|
|||
|
|
@ -6,21 +6,92 @@ import sys
|
|||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
|
||||
def test_utf8_bom_does_not_mangle_first_key(tmp_path, monkeypatch):
|
||||
"""A leading UTF-8 BOM must not prefix the first key name in os.environ.
|
||||
|
||||
PowerShell 5.1 ``Set-Content -Encoding UTF8`` and Windows Notepad write
|
||||
a BOM (EF BB BF). With encoding=utf-8, python-dotenv keeps U+FEFF on the
|
||||
first key so the canonical name is absent and callers see "not configured".
|
||||
"""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
env_file = home / ".env"
|
||||
env_file.write_bytes(
|
||||
b"\xef\xbb\xbfFIRST_KEY=first-value\nSECOND_KEY=second-value\n"
|
||||
)
|
||||
|
||||
monkeypatch.delenv("FIRST_KEY", raising=False)
|
||||
monkeypatch.delenv("SECOND_KEY", raising=False)
|
||||
monkeypatch.delenv("\ufeffFIRST_KEY", raising=False)
|
||||
|
||||
loaded = load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert loaded == [env_file]
|
||||
assert os.getenv("FIRST_KEY") == "first-value"
|
||||
assert os.getenv("SECOND_KEY") == "second-value"
|
||||
assert os.environ.get("\ufeffFIRST_KEY") is None
|
||||
|
||||
|
||||
def test_bomless_utf8_env_still_loads(tmp_path, monkeypatch):
|
||||
"""BOM-less UTF-8 .env files must keep loading after utf-8-sig."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
env_file = home / ".env"
|
||||
env_file.write_text("OPENAI_API_KEY=sk-plain\nSECOND_KEY=ok\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("SECOND_KEY", raising=False)
|
||||
|
||||
loaded = load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert loaded == [env_file]
|
||||
assert os.getenv("OPENAI_API_KEY") == "sk-plain"
|
||||
assert os.getenv("SECOND_KEY") == "ok"
|
||||
|
||||
|
||||
def test_latin1_env_falls_back(tmp_path, monkeypatch):
|
||||
"""Invalid UTF-8 bytes must still load via the latin-1 fallback."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
env_file = home / ".env"
|
||||
# 0xE9 is "é" in latin-1 and not a valid UTF-8 lead sequence alone.
|
||||
env_file.write_bytes(b"LATIN1_VALUE=caf\xe9\n")
|
||||
|
||||
monkeypatch.delenv("LATIN1_VALUE", raising=False)
|
||||
|
||||
loaded = load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert loaded == [env_file]
|
||||
assert os.getenv("LATIN1_VALUE") == "café"
|
||||
|
||||
|
||||
def test_utf8_bom_preserves_first_api_key_name(tmp_path, monkeypatch):
|
||||
"""Real-world case: BOM + first line is a provider API key name."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
env_file = home / ".env"
|
||||
env_file.write_bytes(
|
||||
b"\xef\xbb\xbfANTHROPIC_API_KEY=sk-test-123\nSECOND_KEY=ok\n"
|
||||
)
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("SECOND_KEY", raising=False)
|
||||
monkeypatch.delenv("\ufeffANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
loaded = load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert loaded == [env_file]
|
||||
assert os.getenv("ANTHROPIC_API_KEY") == "sk-test-123"
|
||||
assert os.getenv("SECOND_KEY") == "ok"
|
||||
assert os.environ.get("\ufeffANTHROPIC_API_KEY") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UTF-16 / UTF-32 .env sanitizer coverage
|
||||
#
|
||||
# Scope note: intentionally NO UTF-8-BOM assertions here. UTF-8 BOM handling
|
||||
# for _load_dotenv_with_fallback is #65124's un-merged fix; a test here would
|
||||
# couple the PRs. This suite covers only the sanitizer rewrite path for
|
||||
# UTF-16/32 (and UTF-8 / cp1252 regression guards for that path).
|
||||
# UTF-8 BOM handling for _load_dotenv_with_fallback is covered above (#65124).
|
||||
# This section covers the sanitizer rewrite path for UTF-16/32 (and UTF-8 /
|
||||
# cp1252 regression guards for that path).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue