From bfcf8bcbba6e15f71395887a89881dc64821bc32 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Thu, 20 Aug 2026 20:11:45 -0600 Subject: [PATCH] Let python-dotenv be the only reader of .env The file was parsed twice with different rules: by systemd via EnvironmentFile and by python-dotenv via load_dotenv. They disagree about quoting, inline comments, and values containing # or spaces. The live host logged systemd rejecting a line the bot read fine, on every start. The failure mode this sets up is nasty: a token containing a # would be truncated by one parser and not the other, and the result looks like a bad credential rather than a parsing problem. Drop EnvironmentFile from the unit and leave python-dotenv in charge. Anchor the path to the project root instead of searching upward from the working directory, so the same file is used whether the bot is started by systemd, from a shell anywhere, or by the test runner. Document the syntax rules in .env.example, including that trailing comments after a value are not stripped. Closes #19 --- .env.example | 6 ++++++ config.py | 15 ++++++++++++--- deploy/setup.sh | 5 ++++- tests/test_config.py | 27 +++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f2b6e09..19b534e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +# Syntax: KEY=value, one per line. Values are taken literally to end of line, +# so do NOT add trailing comments after a value. Quote anything containing +# spaces. Lines starting with # are ignored. +# +# This file is read only by the bot (python-dotenv); systemd does not parse it. + # ── Discord ──────────────────────────────────────────────────────────── # Required. Bot token from https://discord.com/developers/applications DISCORD_TOKEN= diff --git a/config.py b/config.py index 746b78f..5891527 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,16 @@ from dotenv import load_dotenv -load_dotenv() +# python-dotenv is the single owner of .env — the systemd unit deliberately does +# not set EnvironmentFile, because the two parsers disagree about quoting and +# inline comments, and a value they disagree on fails silently and looks like a +# bad credential. +# +# The path is anchored to the project root rather than left to search upward +# from the working directory, so the same file is picked up whether the bot is +# started by systemd, from a shell anywhere, or by the test runner. +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +load_dotenv(os.path.join(PROJECT_ROOT, ".env")) # ── Bot ─────────────────────────────────────────────────────────────── DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") @@ -70,9 +79,9 @@ def runtime_versions() -> dict: deployed without git, or without FFmpeg on PATH, should still start and say so plainly. """ - root = os.path.dirname(os.path.abspath(__file__)) return { - "commit": _first_line(["git", "rev-parse", "--short", "HEAD"], cwd=root) or "unknown", + "commit": _first_line( + ["git", "rev-parse", "--short", "HEAD"], cwd=PROJECT_ROOT) or "unknown", "yt_dlp": _first_line([sys.executable, "-m", "yt_dlp", "--version"]) or "unknown", "ffmpeg": _ffmpeg_version(), "python": platform.python_version(), diff --git a/deploy/setup.sh b/deploy/setup.sh index 05d2a8d..081513b 100644 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -75,7 +75,10 @@ Wants=network-online.target Type=simple User=$APP_USER WorkingDirectory=$APP_DIR -EnvironmentFile=$APP_DIR/.env +# No EnvironmentFile on purpose. The bot reads .env with python-dotenv, and +# having systemd parse the same file too means two parsers with different rules +# for quoting and inline comments — they disagree silently, and the result looks +# like a bad credential rather than a parsing problem. # Keep caches inside the (writable) app dir since HOME is read-only below. Environment=XDG_CACHE_HOME=$APP_DIR/.cache Environment=DENO_DIR=$APP_DIR/.cache/deno diff --git a/tests/test_config.py b/tests/test_config.py index 41f53e1..376c315 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -135,3 +135,30 @@ def test_log_runtime_leaks_no_secrets(caplog, monkeypatch): with caplog.at_level("INFO", logger="loopify"): config.log_runtime() assert "super-secret-token-value" not in caplog.text + + +# -- .env ownership ---------------------------------------------------- + +def test_dotenv_is_anchored_to_the_project_root(): + """ + Searching upward from the working directory would pick up a different .env + depending on where the bot was started from. + """ + import os + assert os.path.isfile(os.path.join(config.PROJECT_ROOT, "config.py")) + + +def test_the_systemd_unit_does_not_also_parse_env(): + """ + Two parsers over one file disagree on quoting and fail silently. Only + python-dotenv reads .env; the unit must not declare EnvironmentFile. + """ + import os + setup = os.path.join(config.PROJECT_ROOT, "deploy", "setup.sh") + with open(setup, encoding="utf-8") as handle: + body = handle.read() + active = [ + line for line in body.splitlines() + if "EnvironmentFile" in line and not line.lstrip().startswith("#") + ] + assert active == []