Files
photoanalyzer/tests/unit/test_env_file.py
domverse c2f9c993f2 chore: run the app from a venv and read configuration from a dotenv
Running the application depended on work_item/scripts/python, which is the
work-item helper's launcher: it probes for conda with `command -v`, cannot see a
lazily-defined shell function, and falls back to a bare system interpreter with
none of the dependencies installed. The application now installs into an ordinary
virtualenv instead.

That exposed two packaging faults. Pillow, numpy, and scipy were declared
test-only although the application imports them at runtime — thumbnails decode
through Pillow and the perceptual hash is a DCT over decoded pixels — and an
editable install failed outright because setuptools could not choose between
photo_pipeline, migrations, and work_item. Both are fixed here, and the OpenAI
client moves to a `vision` extra so a review-only install does not pull an API
client it never calls.

Config.from_env now reads `.env`, or the file named by PHOTO_PIPELINE_ENV_FILE,
before it looks at the environment. The file is parsed, never executed: KEY=value
lines, comments, optional quotes, no interpolation and no export. Anything already
exported wins, so the file is standing configuration and the shell stays the
override for one run. The archived CLI's names are mapped as aliases
(LLM_API_KEY/GEMINI_API_KEY, LLM_BASE_URL, LIBRARY), so an existing
photo_analyzer.env configures the application unchanged.

.env, *.env, .venv/, and *.egg-info/ are ignored: the real configuration file
holds a live provider key and must never be committed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH4M1PHPwJsM9btcg8hzCs
2026-08-17 23:43:38 +02:00

70 lines
2.4 KiB
Python

"""Configuration from a dotenv file, including the archived CLI's variable names.
An operator who already has a ``photo_analyzer.env`` should not have to rewrite it
to run the application it was replaced by. The file is standing configuration; the
shell is what you meant this time, so the shell always wins.
"""
from __future__ import annotations
import os
from photo_pipeline.config import Config, load_env_file, parse_env_file
SAMPLE = """
# The archived CLI's shape, comments and all.
LLM_API_KEY=not-real
LLM_BASE_URL="https://example.invalid/v1beta/openai/"
LLM_MODEL='gemini-2.5-flash'
LIBRARY=/tmp/pictures
MAX_WORKERS=4
# commented=ignored
malformed line without an equals sign
"""
def test_the_file_is_parsed_and_never_executed():
values = parse_env_file(SAMPLE)
assert values["LLM_BASE_URL"] == "https://example.invalid/v1beta/openai/" # quotes stripped
assert values["LLM_MODEL"] == "gemini-2.5-flash"
assert values["MAX_WORKERS"] == "4"
assert "commented" not in values and "malformed line without an equals sign" not in values
def test_the_archived_cli_names_still_configure_the_application():
values = parse_env_file(SAMPLE)
assert values["OPENAI_API_KEY"] == "not-real"
assert values["OPENAI_BASE_URL"] == "https://example.invalid/v1beta/openai/"
assert values["PHOTO_PIPELINE_LIBRARY_ROOTS"] == "/tmp/pictures"
def test_an_explicit_shell_variable_beats_the_file(tmp_path, monkeypatch):
path = tmp_path / "photo_analyzer.env"
path.write_text(SAMPLE)
monkeypatch.setenv("OPENAI_API_KEY", "from-the-shell")
monkeypatch.delenv("PHOTO_PIPELINE_LIBRARY_ROOTS", raising=False)
applied = load_env_file(path)
assert "OPENAI_API_KEY" not in applied, "the file overrode an exported value"
assert os.environ["OPENAI_API_KEY"] == "from-the-shell"
assert os.environ["PHOTO_PIPELINE_LIBRARY_ROOTS"] == "/tmp/pictures"
assert Config.from_env().library_roots[0].name == "pictures"
def test_the_file_is_found_through_its_variable(tmp_path, monkeypatch):
path = tmp_path / "custom.env"
path.write_text("PHOTO_PIPELINE_PORT=9123\n")
monkeypatch.delenv("PHOTO_PIPELINE_PORT", raising=False)
monkeypatch.setenv("PHOTO_PIPELINE_ENV_FILE", str(path))
monkeypatch.chdir(tmp_path) # no ./.env here, so only the variable can find it
assert Config.from_env().port == 9123
def test_a_missing_file_is_not_an_error(tmp_path):
assert load_env_file(tmp_path / "nothing-here.env") == {}