Every Monday morning, someone opens a spreadsheet, copies a few numbers into an email and hits send. It takes about fifteen minutes.
It's also the kind of task that gets skipped in the busy weeks, which are exactly the weeks when the numbers matter most.
This guide builds a script that does it for you. It reads a CSV, compares this week with last week, and delivers a short report to you. It's free to run, and the email part is optional, because email is the fiddliest bit to set up. Discord and the GitHub run page work without any mail account.
What the report looks like
Here's the output for a small shop's sales data:
Weekly sales report
2026-09-28 to 2026-10-04
Revenue: $175.98 (+114.6% vs the week before)
Sales: 7 (average $25.14)
Best day: Tuesday 2026-09-29 ($90.00)
Top products
1. Desk lamp: $90.00
2. Pen set: $49.98
3. Blue notebook: $36.00
Notes
- 1 of 11 rows were skipped because the date or amount couldn't be read.
The last section matters most. A report that quietly includes broken data is worse than no report. This one says when it skipped rows, and when the newest row in the data looks old.
Is this worth automating?
A weekly report is a good candidate. It's repetitive, it only reads data, and a mistake is easy to spot and cheap to fix. If you want a checklist for judging other tasks, I wrote one in When Not to Automate: 6 Signs a Task Isn't Worth It.
You could also build this in a no-code tool like Zapier. A scheduled GitHub Actions workflow does the same job for free, and I compared the two approaches in Zapier vs GitHub Actions for small business workflows.
What you need
- A GitHub repository for the script (a new, empty one is fine)
- Your data as a CSV with three columns:
date,productandamount - Optional, for delivery: a Discord webhook, or the login details of an email account
The script uses only what Python already includes, so there's nothing to install.
Step 1: Get your data into a CSV
A CSV is a plain text file with one row per line and commas between the values. Here's the sample behind the report above:
date,product,amount
2026-09-23,Blue notebook,12.00
2026-09-24,Desk lamp,45.00
2026-09-26,Pen set,24.99
2026-09-28,Blue notebook,12.00
2026-09-29,Desk lamp,45.00
2026-09-29,Desk lamp,45.00
2026-09-30,Pen set,24.99
2026-10-01,Blue notebook,12.00
2026-10-02,Desk lamp,N/A
2026-10-03,Pen set,24.99
2026-10-04,Blue notebook,12.00
Each row is one sale. Dates are written as year-month-day, and amounts use a dot for decimals. Notice the N/A on 2 October. That's on purpose, to show what happens with a messy row.
There are two ways to get your own data to the script:
-
Keep the file in your repository as
sales.csv. It's the simplest option, but someone has to update it. - Read it from a Google Sheet. In Google Sheets, go to File, then Share, then Publish to web, choose the sheet and the CSV format, and copy the link. The script reads that link directly. Think of it as the simplest kind of data feed. If the idea of one program asking another for data is new, what an API is covers the basics.
One warning about publishing a sheet. Anyone who has the link can read the data. For a hobby project that may be fine. For anything sensitive, keep the file in a private repository instead. Treat the link like a password, and store it as a secret, never in a file.
Step 2: Add the script
Create a file called weekly_report.py:
import csv
import html
import io
import json
import os
import re
import smtplib
import ssl
import sys
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
CSV_URL = os.environ.get("CSV_URL", "")
CSV_PATH = os.environ.get("CSV_PATH") or "sales.csv"
CURRENCY = os.environ.get("CURRENCY") or "$"
TITLE = os.environ.get("REPORT_TITLE") or "Weekly sales report"
# The report covers the 7 days up to yesterday, compared with the 7 days before that
today = (datetime.fromisoformat(os.environ["REPORT_DATE"]) if os.environ.get("REPORT_DATE")
else datetime.now(timezone.utc)).date()
end = today - timedelta(days=1)
start = end - timedelta(days=6)
prev_start, prev_end = start - timedelta(days=7), start - timedelta(days=1)
# Plain numbers like 1299.50 or 1,299.50. Anything else (like 12,50) is skipped, not guessed.
AMOUNT = re.compile(r"^-?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$")
def read_rows():
if CSV_URL:
with urllib.request.urlopen(CSV_URL, timeout=30) as response:
text = response.read().decode("utf-8-sig")
else:
with open(CSV_PATH, encoding="utf-8-sig") as file:
text = file.read()
return list(csv.DictReader(io.StringIO(text)))
def parse(rows):
"""Turn rows into (date, product, amount). Rows we can't read are counted, not hidden."""
sales, skipped = [], 0
for row in rows:
try:
day = datetime.strptime(row["date"].strip(), "%Y-%m-%d").date()
amount = row["amount"].strip().lstrip("$€£").strip()
if not AMOUNT.match(amount):
raise ValueError(amount)
sales.append((day, row["product"].strip() or "Unknown", float(amount.replace(",", ""))))
except (KeyError, ValueError, AttributeError):
skipped += 1
return sales, skipped
def money(value):
return f"{CURRENCY}{value:,.2f}"
def build_report(sales, skipped, row_count):
this_week = [s for s in sales if start <= s[0] <= end]
last_week = [s for s in sales if prev_start <= s[0] <= prev_end]
total = sum(s[2] for s in this_week)
before = sum(s[2] for s in last_week)
if before:
change = f"{(total - before) / before * 100:+.1f}% vs the week before"
else:
change = "no sales the week before to compare with"
by_product, by_day = defaultdict(float), defaultdict(float)
for day, product, amount in this_week:
by_product[product] += amount
by_day[day] += amount
top = sorted(by_product.items(), key=lambda item: item[1], reverse=True)[:5]
notes = []
if skipped:
notes.append(f"{skipped} of {row_count} rows were skipped because the date or amount couldn't be read.")
newest = max(s[0] for s in sales)
if (end - newest).days > 2:
notes.append(f"The newest row is dated {newest}, so the data may be out of date.")
facts = [("Revenue", f"{money(total)} ({change})"),
("Sales", f"{len(this_week)}" + (f" (average {money(total / len(this_week))})" if this_week else ""))]
if by_day:
best = max(by_day, key=by_day.get)
facts.append(("Best day", f"{best:%A} {best} ({money(by_day[best])})"))
return {"period": f"{start} to {end}", "facts": facts, "top": top, "notes": notes}
def as_text(report):
lines = [TITLE, report["period"], ""]
lines += [f"{label}: {value}" for label, value in report["facts"]]
if report["top"]:
lines += ["", "Top products"]
lines += [f"{i}. {name}: {money(amount)}" for i, (name, amount) in enumerate(report["top"], 1)]
if report["notes"]:
lines += ["", "Notes"] + [f"- {note}" for note in report["notes"]]
return "\n".join(lines)
def as_html(report):
esc = html.escape
cell = "padding:6px 12px;border-bottom:1px solid #ddd;"
facts = "".join(f"<tr><td style='{cell}'><b>{esc(label)}</b></td><td style='{cell}'>{esc(value)}</td></tr>"
for label, value in report["facts"])
top = "".join(f"<tr><td style='{cell}'>{esc(name)}</td><td style='{cell}text-align:right'>{esc(money(amount))}</td></tr>"
for name, amount in report["top"])
notes = "".join(f"<li>{esc(note)}</li>" for note in report["notes"])
return (f"<html><body style='font-family:Arial,sans-serif;color:#222'>"
f"<h2>{esc(TITLE)}</h2><p>{esc(report['period'])}</p>"
f"<table style='border-collapse:collapse'>{facts}</table>"
+ (f"<h3>Top products</h3><table style='border-collapse:collapse'>{top}</table>" if top else "")
+ (f"<h3>Notes</h3><ul>{notes}</ul>" if notes else "")
+ "</body></html>")
def send_discord(text):
body = json.dumps({"content": text[:1900]}).encode()
request = urllib.request.Request(os.environ["DISCORD_WEBHOOK"], data=body, headers={
"Content-Type": "application/json", "User-Agent": "weekly-report/1.0"})
urllib.request.urlopen(request, timeout=15).close()
def send_email(subject, text, html_body):
host, port = os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT") or "587")
user, password = os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"]
message = EmailMessage()
message["Subject"] = subject
message["From"] = os.environ.get("MAIL_FROM") or user
message["To"] = os.environ["MAIL_TO"]
message.set_content(text) # plain version, for email apps that don't show HTML
message.add_alternative(html_body, subtype="html")
context = ssl.create_default_context()
if port == 465:
server = smtplib.SMTP_SSL(host, port, context=context, timeout=30)
else:
server = smtplib.SMTP(host, port, timeout=30)
server.starttls(context=context)
with server:
server.login(user, password)
server.send_message(message)
rows = read_rows()
sales, skipped = parse(rows)
if not sales:
sys.exit("No readable rows. Check that the file has the columns: date, product, amount")
report = build_report(sales, skipped, len(rows))
text, page = as_text(report), as_html(report)
with open("report.txt", "w") as file:
file.write(text)
with open("report.html", "w") as file:
file.write(page)
print(text)
if os.environ.get("GITHUB_STEP_SUMMARY"): # shows the report on the run page
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as file:
fence = "`" * 3
file.write(f"{fence}\n{text}\n{fence}\n")
errors = []
for name, enabled, send in [
("Discord", os.environ.get("DISCORD_WEBHOOK"), lambda: send_discord(text)),
("Email", os.environ.get("SMTP_HOST"), lambda: send_email(f"{TITLE}: {report['period']}", text, page)),
]:
if enabled:
try:
send()
except Exception as error:
errors.append(f"{name}: {error}")
if errors:
print("\nCould not send:", *errors, sep="\n- ")
sys.exit(1)
It's a long script, but it has only four jobs:
- Read and clean the data. It reads the CSV, understands the dates and amounts, and counts any rows it can't read instead of hiding them.
- Compare the weeks. The report covers the seven days up to yesterday, and compares them with the seven days before. Run it on a Monday and you get the previous Monday to Sunday.
- Add notes. It warns if rows were skipped, or if the newest row is more than two days older than the end of the report.
-
Deliver it. It always writes
report.txtandreport.html, shows the report on the GitHub run page, and sends it to Discord or email if you've set those up.
A few choices are worth knowing about:
-
It skips numbers it can't be sure about. A value like
1,299.50is read as one thousand two hundred and ninety-nine and a half. A value like12,50, written with a decimal comma, could be twelve and a half or twelve hundred and fifty, so it's skipped and counted. Guessing wrong would quietly corrupt your total. - It fails when there's nothing to read. If the column names are wrong or the file is empty, it stops with a clear message instead of sending a report full of zeros.
- One delivery failing doesn't block the other. If the email fails but Discord works, you still get the report, and the run is marked as failed so you hear about it.
-
Names are escaped in the HTML. A product called
Tom & Jerry mugor something with angle brackets can't break the email.
Step 3: Add the workflow
Create .github/workflows/weekly-report.yml:
name: Weekly report
on:
schedule:
- cron: '17 7 * * 1' # Mondays at 07:17 UTC
workflow_dispatch: # adds a "Run workflow" button for testing
permissions:
contents: read
jobs:
report:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4 # use the latest major version
- name: Build and send the report
env:
CSV_URL: ${{ secrets.CSV_URL }} # leave the secret out to read sales.csv from the repo
REPORT_TITLE: Weekly sales report
CURRENCY: "$"
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
MAIL_TO: ${{ secrets.MAIL_TO }}
run: python3 weekly_report.py
- name: Keep a copy of the report
if: always()
uses: actions/upload-artifact@v4 # use the latest major version
with:
name: weekly-report
path: report.html
if-no-files-found: ignore
You don't have to set every secret. Any you leave out arrive as empty values, and the script treats that as "not set up". With no secrets at all, it reads sales.csv from your repository and shows the report on the run page.
A few notes:
-
The schedule. The
1at the end means Monday. The odd start time avoids the top of the hour, when GitHub is busiest and scheduled runs are more likely to be delayed. -
The saved copy. The last step keeps
report.htmlas a download on the run page, so you can open the formatted version without email. - The cost. One short run a week uses about 4 or 5 minutes a month.
Step 4: Choose how it reaches you
On the GitHub run page. Nothing to set up. Open the Actions tab, pick the latest run, and the report is at the top. It's a good way to check that everything works before adding anything else.
In Discord. Create a webhook (a private address that lets a script post into a channel) and save it as the DISCORD_WEBHOOK secret. If the idea is new, here's what a webhook is. Discord is the quickest way to get the report somewhere you'll actually see it.
By email. This one needs more setup. Add these as repository secrets:
-
SMTP_HOST: your email provider's outgoing mail server address -
SMTP_PORT: usually587, or465if your provider uses that instead -
SMTP_USERandSMTP_PASSWORD: the login for the sending account -
MAIL_TO: who receives it. Use commas for several people -
MAIL_FROM: optional. It defaults to the login address
Port 465 uses a connection that's encrypted from the start, and 587 upgrades to an encrypted connection after saying hello. The script handles both.
Two practical tips:
- Don't use your normal password. Many providers, Gmail among them, require an app password or a dedicated sending credential when a script logs in. Check your provider's documentation.
- Send the first one to yourself. New automated emails can land in spam. Check there, and mark it as not spam if needed.
Treat these like any other secret: store them in GitHub Secrets, never in a file.
Step 5: Test it
On your own computer, put the sample CSV in a file called sales.csv next to the script. Then run:
REPORT_DATE=2026-10-05 python3 weekly_report.py
REPORT_DATE pretends today is that date, so you get the same numbers as the example. Leave it out in real use, and the script uses today's date.
You should see the report printed in your terminal, and two new files, report.txt and report.html. Open the HTML file in a browser to see what the email will look like.
Then break it on purpose. Try these:
-
Rename a column in the CSV, for example
amounttoprice. The script should stop with "No readable rows. Check that the file has the columns: date, product, amount". -
Add a bad row, like a date written as
05/10/2026. The notes should say one more row was skipped. -
Set
REPORT_DATEto a date a few weeks later. The notes should warn that the newest row is old.
Finally, push everything to GitHub and use the "Run workflow" button on the Actions tab.
Making the report your own
The script is built around a sales example, but the pattern fits many reports.
-
Different columns. Change the names inside
parse(). For example, you could usecustomerinstead ofproduct. -
Different numbers. Add lines to
build_report(). A count of new customers or a total of refunds fits the same shape. -
Different period. Change
startandprev_startat the top for a monthly report. -
A different channel. To post to Slack or Telegram, replace
send_discord()with a function for that service.
If your data has refunds as negative amounts, they're included in the totals. If your CSV has one row per order and several items per order, remember that "Sales" counts rows, not orders.
What can go wrong
The published sheet link stops working. If someone unpublishes the sheet or changes the sharing settings, the script fails with an error. That's a good outcome, because you find out.
The columns get renamed. The script stops with a clear message. This is the most common cause of silent breakage in spreadsheet-based reports, and it's why the script fails instead of guessing.
The data is out of date. A feed that stopped updating would produce a report full of zeros. The note about the newest row catches this.
The email lands in spam, or the password expires. Check the spam folder first. If the run fails with a login error, create a new app password and update the secret.
The schedule stops. In a public repository, GitHub switches off scheduled workflows after 60 days without repository activity. If Monday comes and no report arrives, check the Actions tab. A heartbeat check can catch this automatically, and I cover that idea in how to monitor your automations when something breaks.
Time zones. The script works with dates only. If your sales cross midnight in a different time zone from the one in your data, check how your tool writes dates.
Quick answers
Can it read Excel files?
Not directly. Save or export the sheet as a CSV first, or publish a Google Sheet as CSV.
Can it send to several people?
Yes. Put the addresses in MAIL_TO, separated by commas.
Do I need a Google account?
No. A CSV file in your repository works fine. Google Sheets is just a convenient way to keep the data up to date.
Is the data safe?
Your CSV file and credentials stay in your repository's secrets. The one risk is the Google Sheets link, since anyone with it can read the sheet. Use a private repository if the data is sensitive.
Does it cost anything?
No. A weekly run is tiny on a private repository and free on a public one. Your email provider's own limits apply.
The short version
A weekly report is a small task that's easy to skip and annoying to redo by hand. A script that builds it, flags problems in the data, and delivers it somewhere you'll see it takes that job off your list.
Start with the GitHub run page, then add Discord, and add email last if you still want it.
You can find more practical automation guides on Procwire.
Top comments (0)