krz/daily-poem

Poetry emailed, at your will.

clone: git clone https://gitbay.org/krz/daily-poem.git

756d010b0945ac8cd7b4559ac39d9d4d9c7e9395

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T02:18:37Z

Fix the crash, make it importable, and check it

main.py has not been touched since the 2023 initial commit and could never have
run: line 29 set recipient_emails, line 32 read recipient_email. NameError before
a single message was sent. ruff's F821 catches it in a second, which is the
argument for the workflow this adds.

It was also entirely top-level, so importing the module fetched a poem over the
network and tried to send mail. The work is now in fetch_poem, build_message and
main, behind a __main__ guard — python main.py behaves exactly as before, and
importing it does nothing.

That makes it testable: two tests cover the message headers and the PoetryDB
response shape, with the request stubbed. json was imported and never used.

The sender and recipient are still empty strings to fill in; that is
configuration, not a bug.
 .github/workflows/test.yml | 38 ++++++++++++++++++++++
 .gitignore                 |  5 +++
 main.py                    | 80 +++++++++++++++++++++++++---------------------
 requirements-dev.in        |  2 ++
 requirements-dev.txt       | 22 +++++++++++++
 tests/test_main.py         | 47 +++++++++++++++++++++++++++
 6 files changed, 158 insertions(+), 36 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..ddd1cfc
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,38 @@
+name: Test
+
+# main.py had never run: it referenced recipient_email where the variable was
+# recipient_emails, so it raised NameError before sending anything. Nothing
+# would have caught that — ruff's F821 does, in a second.
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+
+      - uses: actions/setup-python@v7
+        with:
+          python-version: '3.11'
+
+      - name: Install
+        run: pip install -r requirements-dev.txt
+
+      - uses: astral-sh/ruff-action@v3.2.2
+        with:
+          version: "0.16.4"
+
+      - name: Lint
+        run: ruff check --no-fix
+
+      - name: Format
+        run: ruff format --check
+
+      - name: Tests
+        run: python -m pytest -q
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..95bb7a1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.venv/
diff --git a/main.py b/main.py
index bf3d530..9cd027f 100644
--- a/main.py
+++ b/main.py
@@ -1,39 +1,47 @@
-# Import Python packages
-from email.mime.text import MIMEText
+# Fetch a random poem from PoetryDB and mail it.
 import smtplib
-import json
+from email.mime.text import MIMEText
+
 import requests
 
-# Send API request for a random poem
-json_data = requests.get('https://poetrydb.org/random').json()
-
-# Extract the poem details from the JSON response
-title = json_data[0]['title']
-author = json_data[0]['author']
-line_count = json_data[0]['linecount']
-lines = ''
-for line in json_data[0]['lines']:
-  lines = lines + line + "\n"
-
-# A test print() statement to ensure the request and parsing processed the data 
-# correctly
-# print(title, "\n", author, "\n\n", lines)
-
-msg_body = title + "\n" + author + "\n\n" + lines
-
-# Create plaintext message container
-msg = MIMEText(msg_body)
-
-# Prepare the metadata of the message
-sender_email = ''
-recipient_emails = ''
-msg['Subject'] = 'Your Daily Poem (' + line_count + ' lines)'
-msg['From'] = sender_email
-msg['To'] = recipient_email
-
-# Send the message via our own SMTP server, but don't include the
-# envelope header.
-smtp_server = 'localhost'
-s = smtplib.SMTP(smtp_server)
-s.sendmail(sender_email, [recipient_emails], msg.as_string())
-s.quit()
+POETRY_URL = "https://poetrydb.org/random"
+SMTP_SERVER = "localhost"
+
+# Fill these in before running.
+SENDER_EMAIL = ""
+RECIPIENT_EMAILS = ""
+
+
+def fetch_poem(url=POETRY_URL):
+    """Return (title, author, line_count, body) for a random poem."""
+    json_data = requests.get(url, timeout=30).json()
+    poem = json_data[0]
+    lines = ""
+    for line in poem["lines"]:
+        lines = lines + line + "\n"
+    return poem["title"], poem["author"], poem["linecount"], lines
+
+
+def build_message(title, author, line_count, lines, sender, recipient):
+    """Return a plaintext MIMEText message for one poem."""
+    msg = MIMEText(title + "\n" + author + "\n\n" + lines)
+    msg["Subject"] = "Your Daily Poem (" + line_count + " lines)"
+    msg["From"] = sender
+    msg["To"] = recipient
+    return msg
+
+
+def main():
+    title, author, line_count, lines = fetch_poem()
+    msg = build_message(
+        title, author, line_count, lines, SENDER_EMAIL, RECIPIENT_EMAILS
+    )
+
+    # Send via the local SMTP server, without the envelope header.
+    s = smtplib.SMTP(SMTP_SERVER)
+    s.sendmail(SENDER_EMAIL, [RECIPIENT_EMAILS], msg.as_string())
+    s.quit()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/requirements-dev.in b/requirements-dev.in
new file mode 100644
index 0000000..2001f3e
--- /dev/null
+++ b/requirements-dev.in
@@ -0,0 +1,2 @@
+requests
+pytest
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..41f5576
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,22 @@
+# This file was autogenerated by uv via the following command:
+#    uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11
+certifi==2026.7.22
+    # via requests
+charset-normalizer==3.5.1
+    # via requests
+idna==3.19
+    # via requests
+iniconfig==2.3.0
+    # via pytest
+packaging==26.3
+    # via pytest
+pluggy==1.6.0
+    # via pytest
+pygments==2.21.0
+    # via pytest
+pytest==9.1.1
+    # via -r requirements-dev.in
+requests==2.34.2
+    # via -r requirements-dev.in
+urllib3==2.7.0
+    # via requests
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..cea5ba9
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,47 @@
+import unittest
+from unittest.mock import patch
+
+from main import build_message, fetch_poem
+
+
+class FakeResponse:
+    def __init__(self, payload):
+        self._payload = payload
+
+    def json(self):
+        return self._payload
+
+
+class TestBuildMessage(unittest.TestCase):
+    def test_headers_and_body(self):
+        msg = build_message("A Title", "An Author", "4", "one\ntwo\n", "from@x", "to@y")
+
+        self.assertEqual(msg["Subject"], "Your Daily Poem (4 lines)")
+        self.assertEqual(msg["From"], "from@x")
+        self.assertEqual(msg["To"], "to@y")
+        self.assertIn("A Title", msg.get_payload())
+        self.assertIn("An Author", msg.get_payload())
+        self.assertIn("one\ntwo", msg.get_payload())
+
+
+class TestFetchPoem(unittest.TestCase):
+    """PoetryDB is stubbed: the test covers the shape this code expects from it,
+    not the service being up."""
+
+    PAYLOAD = [
+        {
+            "title": "Sonnet",
+            "author": "Anon",
+            "linecount": "2",
+            "lines": ["first line", "second line"],
+        }
+    ]
+
+    def test_fields_are_extracted_and_lines_joined(self):
+        with patch("main.requests.get", return_value=FakeResponse(self.PAYLOAD)):
+            title, author, line_count, lines = fetch_poem()
+
+        self.assertEqual(title, "Sonnet")
+        self.assertEqual(author, "Anon")
+        self.assertEqual(line_count, "2")
+        self.assertEqual(lines, "first line\nsecond line\n")