Python Automation #5: Reporting Results — Email, Slack, and Discord Notifications
If you open a terminal every morning and dig through logs just to check whether a script ran fine, that check is itself one more manual chore. We built Excel reports in #2 and scraped data in #3 and #4, but if the results never reach a human, the automation is only half done. In this post we make the script report its own results — through Slack and Discord webhooks, and through email with an Excel file attached.
- #1 First steps with scripts
- #2 Excel automation
- #3 Web scraping: static pages
- #4 Web scraping: dynamic pages
- #5 Reporting results: email, Slack, and Discord notifications ← this post
- #6 Scheduling
- #7 Packaging as a CLI tool
The easiest path: webhooks #
Of all the ways to send a notification, the easiest is a webhook. Slack and Discord can issue you a unique URL; POST some JSON to it and the content shows up as a message in the channel you chose. No bot account, no OAuth, no SDK. In Slack, enable Incoming Webhooks in your app settings and pick a channel to get a URL of the form https://hooks.slack.com/services/.... In Discord, create a webhook from the Integrations menu in the channel settings. Once you have the URL, this is all the code there is.
import httpx
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/T000/B000/XXXX"
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1234/abcd"
def notify_slack(text: str) -> None:
httpx.post(SLACK_WEBHOOK_URL, json={"text": text}).raise_for_status()
def notify_discord(text: str) -> None:
httpx.post(DISCORD_WEBHOOK_URL, json={"content": text}).raise_for_status()Now a single line like notify_slack("Price scrape finished: 32 new rows") puts a message in the channel. The two services differ by exactly one JSON key: Slack uses text, Discord uses content. Keeping raise_for_status() on the call means that if the webhook URL has expired or is wrong, the script fails loudly instead of moving on in silence.
Making the message useful #
A good notification is one you can read without opening anything else — better than the single word “done.” On success, send the key numbers; on failure, send the error itself.
import traceback
try:
rows = collect_prices() # the collection function from part 3
notify_slack(f"[OK] Price scrape finished: {len(rows)} rows")
except Exception:
notify_slack(f"[FAIL] Price scrape failed\n{traceback.format_exc()[-1500:]}")
raiseThe heart of the failure message is traceback.format_exc(). It returns the full stack trace as a string, so you ship it inside the notification and can tell from the alert alone which line blew up and why. We slice off the last 1,500 characters because both Slack and Discord cap message length, and a traceback keeps its root cause at the end. The trailing raise matters too. If you swallow the exception just because a notification went out, the exit code becomes 0 and the scheduler we set up in the next post will mistake the run for a success.
Email: smtplib and an app password #
Webhooks are convenient, but sending files through them is clumsy. When the job is delivering the Excel report from #2 to a person, email is still the most reliable channel — and the standard library’s smtplib and email modules are all you need. There is one prerequisite: Gmail won’t accept your regular password for SMTP logins. Turn on 2-Step Verification in your Google account, then generate a 16-character password from the “App passwords” menu in your account settings. That 16-character string is the password your code will use.
import smtplib
import ssl
from email.message import EmailMessage
from pathlib import Path
def send_report(subject: str, body: str, attachment: Path) -> None:
msg = EmailMessage()
msg["From"] = "me@gmail.com"
msg["To"] = "boss@example.com"
msg["Subject"] = subject
msg.set_content(body)
msg.add_attachment(
attachment.read_bytes(),
maintype="application",
subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=attachment.name,
)
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as server:
server.login("me@gmail.com", "your 16-character app password")
server.send_message(msg)Now you call it like send_report("Weekly price report", "Collected results attached.", Path("price_report.xlsx")). A few points worth noting: the SMTP_SSL + port 465 combination uses an encrypted connection from the very first byte, and ssl.create_default_context() builds sane security defaults including certificate verification. Attaching a file is a single add_attachment() call, and that long string in subtype is the MIME type for xlsx files — swap it for pdf or csv if that’s what you’re sending.
Secrets: keep tokens out of the code #
The code above has one problem. The webhook URL and the app password are written right into it. A webhook URL is a password: anyone who knows it can post to that channel, and the moment it lands on GitHub, it’s leaked. Move the secrets into a .env file and install a loader with uv add python-dotenv.
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/XXXX
GMAIL_APP_PASSWORD=abcdabcdabcdabcdimport os
from dotenv import load_dotenv
load_dotenv() # load the .env file into environment variables
SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"]
GMAIL_APP_PASSWORD = os.environ["GMAIL_APP_PASSWORD"]os.environ["..."] raises a KeyError immediately when the value is missing. For an automation script, blowing up at startup on a missing setting is safer than quietly receiving None from os.environ.get(). And .env must go into .gitignore — if that file ends up in the repository, the whole separation was pointless.
Notify on success too, or only on failure? #
Technically you can send both. But a “[OK] done” message that arrives every day stops being read within a week. Once you go numb to them, the failure alert that actually matters gets buried in the noise. A common operational compromise looks like this:
- Failures are reported immediately. That’s the entire reason notifications exist.
- Successes are reported only when the numbers look wrong. If a job that normally collects 300 rows returns 5, it finished without an error but it effectively failed.
- A periodic summary once a week is plenty. A single line like “7 runs this week, 7 succeeded” is enough proof of life.
Putting it together: notifications on the scraper #
Attach everything above to the collection script from parts 3 and 4, and it looks like this.
import sys
import traceback
from notify import notify_slack
from scraper import collect_prices # the collection function from part 3
try:
rows = collect_prices()
if len(rows) < 100: # baseline for the usual volume
notify_slack(f"[WARN] Fewer rows than usual: {len(rows)}")
except Exception:
notify_slack(f"[FAIL] Price scrape failed\n{traceback.format_exc()[-1500:]}")
sys.exit(1)Silent on success, a warning when the count looks off, and an immediate alert with the traceback when an exception hits. sys.exit(1) also records the failure in the exit code — and that exit code is exactly what next post’s scheduler will read.
Wrap-up #
What this post built:
- Slack and Discord webhooks: one
httpx.postto send, withtraceback.format_exc()in failure messages so you can skip the log-digging step - Emailing Excel attachments with
smtplib+SMTP_SSL+ an app password - Webhook URLs and passwords moved into
.envand registered in.gitignore - An operating rule: failures immediately, successes only when the numbers look wrong
The script now produces results and reports them. One problem remains: a human still has to run it. In the next post (#6 Scheduling) we use cron and Task Scheduler to run scripts automatically at fixed times, and put the exit code we left behind here to work.