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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
15 changes: 12 additions & 3 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 4 additions & 1 deletion deploy/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading