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, product and amount
  • 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.Source:https://dev.to/procwire/automatically-email-a-weekly-report-from-a-csv-with-python-32m5

Related posts