<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Daily Python Projects]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!gfXP!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e0e0918-4155-47ff-ba5d-c061609cbc83_1000x1000.png</url><title>Daily Python Projects</title><link>https://dailypythonprojects.substack.com</link></image><generator>Substack</generator><lastBuildDate>Thu, 02 Jul 2026 15:26:49 GMT</lastBuildDate><atom:link href="https://dailypythonprojects.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Ardit Sulce]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[dailypythonprojects@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[dailypythonprojects@substack.com]]></itunes:email><itunes:name><![CDATA[Ardit Sulce]]></itunes:name></itunes:owner><itunes:author><![CDATA[Ardit Sulce]]></itunes:author><googleplay:owner><![CDATA[dailypythonprojects@substack.com]]></googleplay:owner><googleplay:email><![CDATA[dailypythonprojects@substack.com]]></googleplay:email><googleplay:author><![CDATA[Ardit Sulce]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[World Cup 2026 Tracker with Python: Day 3 - Visual Bracket Web App]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python-4f8</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python-4f8</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 02 Jul 2026 09:14:01 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c2958bae-a334-45e2-a9f6-d5275cc94751_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>World Cup 2026 Tracker</strong> &#8212; a three-day project that turns a free public football API into your own personal tournament dashboard.</p><ul><li><p><strong>Day 1:</strong> Fetch and Show the Knockout Phase</p></li><li><p><strong>Day 2:</strong> Bracket Printer (Terminal Art)</p></li><li><p><strong>Day 3:</strong> Visual Bracket Web App <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-24">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale &#8212; the tournament goes to the browser.</strong></p><p>The terminal versions from Days 1 and 2 were useful, but nobody can <em>share</em> a terminal screenshot. Today we make a real web app: a Flask backend that serves the live match data, and a single-page HTML front-end that renders the whole 32-team knockout bracket visually, with color-coded winners, live match highlighting, and auto-refresh every 60 seconds.</p><p>Open it in your browser. Send it to a friend. Deploy it. This is the portfolio piece.</p><h2>Project Task</h2><p>Build a two-piece web app:</p><p><strong>Flask backend (</strong><code>worldcup_web.py</code><strong>):</strong></p><ul><li><p>Serves an HTML page at <code>/</code></p></li><li><p>Exposes the live match data at <code>/api/matches</code> as JSON</p></li><li><p>Caches the upstream openfootball fetch (so multiple visitors don&#8217;t hammer their server)</p></li><li><p>Handles fetch failures gracefully</p></li></ul><p><strong>Single-page frontend (</strong><code>templates/index.html</code><strong>):</strong></p><ul><li><p>Fetches from <code>/api/matches</code></p></li><li><p>Renders the 32 knockout matches as a bracket-style grid using CSS Grid</p></li><li><p>Highlights winners in green</p></li><li><p>Highlights today&#8217;s matches with a bright yellow &#8220;TODAY&#8221; badge</p></li><li><p>Dims placeholder team names (like &#8220;Winner of #74&#8221;)</p></li><li><p>Shows scores including extra time and penalty shootouts</p></li><li><p>Auto-refreshes every 60 seconds</p></li></ul><p>This project gives you hands-on practice with Flask (routes, templates, JSON responses), CSS Grid, <code>fetch()</code> + <code>async/await</code> in JavaScript, and porting Python logic to JavaScript.</p><h2>Expected Output</h2><p><strong>Running the server:</strong></p><pre><code><code>python worldcup_web.py
</code></code></pre><pre><code><code>&#127942; World Cup Bracket running at http://127.0.0.1:5000
 * Serving Flask app 'worldcup_web'
 * Running on http://127.0.0.1:5000
</code></code></pre><p><strong>Open http://127.0.0.1:5000 in your browser:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!mjpC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!mjpC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 424w, https://substackcdn.com/image/fetch/$s_!mjpC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 848w, https://substackcdn.com/image/fetch/$s_!mjpC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 1272w, https://substackcdn.com/image/fetch/$s_!mjpC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!mjpC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png" width="1456" height="867" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:867,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:365716,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/204424662?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!mjpC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 424w, https://substackcdn.com/image/fetch/$s_!mjpC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 848w, https://substackcdn.com/image/fetch/$s_!mjpC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 1272w, https://substackcdn.com/image/fetch/$s_!mjpC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c05c84e-8e3e-481b-8f9a-80d0a7468e82_2904x1730.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The user can see up-to-date matches and their results. The match cards are clickable. Clicking them will show more information about the match in a modal:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4ZO6!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4ZO6!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 424w, https://substackcdn.com/image/fetch/$s_!4ZO6!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 848w, https://substackcdn.com/image/fetch/$s_!4ZO6!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 1272w, https://substackcdn.com/image/fetch/$s_!4ZO6!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4ZO6!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png" width="1456" height="1393" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1393,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:222155,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/204424662?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4ZO6!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 424w, https://substackcdn.com/image/fetch/$s_!4ZO6!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 848w, https://substackcdn.com/image/fetch/$s_!4ZO6!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 1272w, https://substackcdn.com/image/fetch/$s_!4ZO6!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa09a9dcb-c093-4161-a8b3-160c0eaf79b7_1708x1634.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h2>Setup Instructions</h2><p><strong>Install one new dependency:</strong></p><pre><code><code>pip install flask
</code></code></pre><p></p><p><strong>Create the project structure:</strong></p><pre><code><code>your-project/
&#9500;&#9472;&#9472; worldcup_web.py          # the Flask server
&#9492;&#9472;&#9472; templates/
    &#9492;&#9472;&#9472; index.html           # the single-page frontend
</code></code></pre><p><code>Note we have an html file which is located inside a templates directory.</code></p><p><strong>Run:</strong></p><pre><code><code>python worldcup_web.py
</code></code></pre><p>Then open http://127.0.0.1:5000 in your browser.</p><h2>Understanding Two-Piece Web Apps</h2><p>Every modern web app splits into two pieces:</p><ol><li><p><strong>The backend</strong> &#8212; a server that returns data (usually as JSON)</p></li><li><p><strong>The frontend</strong> &#8212; HTML/CSS/JS that fetches the data and renders it</p></li></ol><p>The pieces talk to each other over HTTP. Our backend has two routes:</p><pre><code><code>@app.route("/")
def index():
    return render_template("index.html")

@app.route("/api/matches")
def api_matches():
    data = get_matches()
    return jsonify({"ok": True, "matches": data["matches"], ...})
</code></code></pre><p>The first serves the HTML page (the frontend). The second serves the data (JSON). The frontend loads first, then it fetches from the second route to fill itself in.</p><p>This split scales endlessly: a single backend can serve a website, a mobile app, a CLI, and third-party integrations. <strong>Today&#8217;s tiny two-route Flask app is the exact shape of billion-dollar SaaS backends.</strong></p><h2>Understanding Flask Basics</h2><p>Flask is Python&#8217;s most-loved web framework. The core is three concepts:</p><pre><code><code>from flask import Flask, render_template, jsonify

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/api/matches")
def api_matches():
    return jsonify({"ok": True, "matches": [...]})

if __name__ == "__main__":
    app.run(debug=True, port=5000)
</code></code></pre><ul><li><p><code>Flask(__name__)</code> &#8212; creates the app. The <code>__name__</code> tells Flask where your project&#8217;s root is (for finding the <code>templates/</code> folder etc).</p></li><li><p><code>@app.route("/path")</code> &#8212; decorator that registers the function below as the handler for that URL.</p></li><li><p><code>render_template("index.html")</code> &#8212; Flask looks in <code>templates/</code> and returns the file&#8217;s contents. (It can also do template variables, but we don&#8217;t need that today.)</p></li><li><p><code>jsonify({...})</code> &#8212; serializes the dict to JSON and sets the <code>Content-Type</code> header correctly. Never do <code>return json.dumps(...)</code> &#8212; always use <code>jsonify</code>.</p></li></ul><p>Six lines is a real, working web server.</p><h2>Understanding Server-Side Caching</h2><p>Our backend hits openfootball&#8217;s server every time someone loads the page. If 100 people had the page open, and it polls every 60 seconds, that&#8217;s 100 requests per minute to a <em>static JSON file</em>. Rude.</p><p>The fix: <strong>cache the upstream response for 60 seconds</strong> in the server&#8217;s memory. Regardless of how many browsers hit us, we hit openfootball at most once a minute:</p><pre><code><code>CACHE_SECONDS = 60
_cache = {"data": None, "fetched_at": 0}

def get_matches():
    now = time.time()
    if _cache["data"] is None or now - _cache["fetched_at"] &gt; CACHE_SECONDS:
        response = requests.get(DATA_URL, timeout=10)
        response.raise_for_status()
        _cache["data"] = response.json()
        _cache["fetched_at"] = now
    return _cache["data"]
</code></code></pre><p>Two-key dict, one check, one write. That&#8217;s the whole cache.</p><blockquote><p><strong>The layered cache picture:</strong> each browser polls our Flask backend every 60 seconds. Our backend polls openfootball at most once per 60 seconds. Result: openfootball sees one request per minute, regardless of how many users we have. This is the <em>exact</em> shape of every real API service &#8212; layered caching all the way up.</p></blockquote><h2>Understanding CSS Grid for the Bracket</h2><p>The bracket needs six columns of matches side by side. CSS Grid is <em>born</em> for this:</p><pre><code><code>.bracket {
  display: grid;
  grid-template-columns: repeat(6, minmax(220px, 1fr));
  gap: 24px;
  overflow-x: auto;
}
</code></code></pre><p>Three properties:</p><ul><li><p><code>display: grid</code> &#8212; this container is a grid.</p></li><li><p><code>grid-template-columns: repeat(6, minmax(220px, 1fr))</code> &#8212; six columns, each at least 220px wide but growing to share available space equally.</p></li><li><p><code>overflow-x: auto</code> &#8212; if the viewport is too narrow, allow horizontal scroll instead of squishing.</p></li></ul><p>Then each column is a flexbox stacking match cards vertically:</p><pre><code><code>.round-matches {
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  gap: 10px;
}
</code></code></pre><p><code>justify-content: space-around</code> gives each match card equal breathing room &#8212; which visually approximates how bracket lines would space matches to feed into the next round. No SVG lines needed.</p><h2>Understanding Fetch and Async/Await</h2><p>To get data from our own backend, the frontend uses JavaScript&#8217;s <code>fetch()</code>:</p><pre><code><code>async function fetchAndRender() {
  try {
    const response = await fetch("/api/matches");
    const data = await response.json();

    if (!data.ok) {
      showError(data.error);
      return;
    }

    renderBracket(data.matches);
  } catch (err) {
    showError(err.message);
  }
}
</code></code></pre><p><code>fetch</code> returns a Promise. <code>await</code> waits for it to resolve. <code>.json()</code> also returns a Promise (parsing the body is async), so we <code>await</code> that too.</p><p>Three modern-JS habits worth internalizing:</p><ul><li><p><code>async</code><strong> on the function</strong> enables <code>await</code> inside it.</p></li><li><p><code>await</code><strong> in front of every Promise-returning call</strong> &#8212; the code reads sequentially even though it&#8217;s asynchronous.</p></li><li><p><code>try/catch</code> &#8212; network errors are exceptions in async code, catch them the same way as sync errors.</p></li></ul><p>This is the pattern for <em>every</em> frontend API call. Once you have it, you can talk to any HTTP API from a browser.</p><h2>Understanding Auto-Refresh with setInterval</h2><p>The page should update itself when new match results come in. <code>setInterval</code> schedules a repeating function call:</p><pre><code><code>const REFRESH_MS = 60 * 1000;   // 60 seconds

fetchAndRender();                // initial render
setInterval(fetchAndRender, REFRESH_MS);
</code></code></pre><p>That&#8217;s the whole auto-refresh mechanism. One line does what a whole &#8220;reload the page&#8221; workflow would.</p><p>Because our backend caches upstream fetches for 60 seconds and the frontend polls every 60 seconds, the <em>maximum</em> freshness delay is 120 seconds. That&#8217;s plenty for a tournament where matches take 90 minutes.</p><h2>Understanding Porting Python to JavaScript</h2><p>Days 1 and 2 built <code>is_placeholder</code>, <code>pretty_team</code>, <code>winner_of</code>, and score-formatting functions in Python. The frontend needs them all &#8212; because it&#8217;s the one drawing the cards.</p><p>Rewriting them in JavaScript is a great exercise in seeing how the languages relate:</p><pre><code><code>// Python:
//   if name.startswith("W") and name[1:].isdigit():
//       return "Winner of #" + name[1:]

// JavaScript:
if (/^W\d+$/.test(name)) return "Winner of #" + name.slice(1);
</code></code></pre><p><code>re.match</code> in Python, <code>RegExp.test</code> in JavaScript. <code>name[1:]</code> in Python, <code>name.slice(1)</code> in JavaScript. Same <em>ideas</em>, different syntax &#8212; like learning to write left-handed after a career of writing with your right.</p><p>Same for <code>winner_of</code>:</p><pre><code><code>function winnerOf(match) {
  if (!match.score) return 0;
  const s = match.score;
  if (s.p) return s.p[0] &gt; s.p[1] ? 1 : (s.p[1] &gt; s.p[0] ? 2 : 0);
  if (s.et &amp;&amp; s.et[0] !== s.et[1]) return s.et[0] &gt; s.et[1] ? 1 : 2;
  if (s.ft[0] !== s.ft[1]) return s.ft[0] &gt; s.ft[1] ? 1 : 2;
  return 0;
}
</code></code></pre><p>Same logic, JavaScript syntax. Every language has some form of <code>if</code>, <code>array indexing</code>, <code>comparison</code>. Once you know one language deeply, the second one is 90% translation.</p><h2>Understanding Rendering a Match Card</h2><p>The heart of the frontend is <code>renderMatch(match, todayStr)</code> &#8212; takes a match dict, returns a chunk of HTML. Built with a <strong>template literal</strong> (multi-line string with <code>${expression}</code> interpolation):</p><pre><code><code>function renderMatch(match, todayStr) {
  const played = !!match.score;
  const isToday = match.date === todayStr;
  const w = winnerOf(match);

  // ...build class names, score values, etc.

  return `
    &lt;div class="${cardClasses.join(' ')}"&gt;
      &lt;div class="match-meta"&gt;
        &lt;span class="match-num"&gt;${num}&lt;/span&gt;
        &lt;span&gt;${dateHtml}&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class="team"&gt;
        &lt;span class="${t1Cls.join(' ')}"&gt;${prettyTeam(match.team1)}&lt;/span&gt;
        &lt;span class="${s1Cls}"&gt;${s1}&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class="team"&gt;
        &lt;span class="${t2Cls.join(' ')}"&gt;${prettyTeam(match.team2)}&lt;/span&gt;
        &lt;span class="${s2Cls}"&gt;${s2}&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  `;
}
</code></code></pre><p>The <code>${...}</code> slots inject dynamic values. The resulting HTML string gets stitched together with <code>matches.map(renderMatch).join('')</code> and injected into the DOM with <code>element.innerHTML = ...</code>.</p><p>This is &#8220;template literal&#8221; rendering &#8212; the simplest form of client-side templating. React, Vue, Svelte add sophistication (reactive updates, components, virtual DOM) but at the core they&#8217;re doing the same thing: turn state into HTML.</p><h2>Understanding the Full Data Flow</h2><p>Let&#8217;s trace one match result appearing on your screen, from data source to green pixels:</p><ol><li><p>A referee blows the final whistle in Boston. Paraguay beats Germany 4-3 on penalties.</p></li><li><p>A few hours later, someone updates the openfootball JSON on GitHub.</p></li><li><p>Within 60 seconds, our Flask cache times out; on the next visitor request it re-fetches the JSON.</p></li><li><p>Within another 60 seconds, every open browser tab polls <code>/api/matches</code> and receives the fresh data.</p></li><li><p><code>renderBracket()</code> runs; <code>winnerOf()</code> returns <code>2</code> for Paraguay; <code>renderMatch()</code> builds a card with <code>class="team-name winner"</code> on Paraguay&#8217;s name.</p></li><li><p><code>element.innerHTML = ...</code> swaps in the new HTML; CSS applies <code>--win</code> (green) to Paraguay&#8217;s name.</p></li></ol><p><strong>Nowhere in that pipeline is anyone manually editing anything.</strong> From &#8220;match ends&#8221; to &#8220;your bracket updates&#8221; is fully automatic. That&#8217;s the shape of every modern data-driven web app.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete, deployable <strong>World Cup Bracket Tracker</strong>:</p><ul><li><p><strong>Day 1:</strong> Fetch from a live public API, parse JSON, print the knockout stage</p></li><li><p><strong>Day 2:</strong> Style the terminal output with <code>rich</code> &#8212; panels, colors, winner highlighting</p></li><li><p><strong>Day 3:</strong> Wrap it in a Flask web app with a visual bracket UI and auto-refresh</p></li></ul><p></p><p><strong>Next steps:</strong></p><ul><li><p>Deploy it: Render or Railway hosts Flask apps for free</p></li><li><p>Add a matchup detail modal: click a card, see scorers</p></li><li><p>Add team logos: openfootball has a companion repo with country flags</p></li><li><p>Add group standings: reuse Day 1 logic from the older version</p></li><li><p>Build a mobile-first view: the CSS Grid becomes a vertical stack on small screens</p></li><li><p>Swap Flask for FastAPI to try async</p></li></ul><p>You&#8217;ve built the foundation for a <strong>real full-stack data web app</strong>. &#128640;</p><h2>View Code Evolution</h2><p>Get the code of this project down below and compare today&#8217;s web app with Day 1&#8217;s plain-text output and Day 2&#8217;s colored terminal panels:</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python-4f8">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[World Cup 2026 Tracker with Python: Day 2 - Bracket Printer (Terminal Art)]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python-c8c</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python-c8c</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 01 Jul 2026 11:27:21 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/e4fce589-6f20-417f-93ea-d87537551b83_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>World Cup 2026 Tracker</strong> &#8212; a three-day project that turns a free public football API into your own personal tournament dashboard.</p><ul><li><p><strong>Day 1:</strong> Fetch and Show the Knockout Phase</p></li><li><p><strong>Day 2:</strong> Bracket Printer (Terminal Art) <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Visual Bracket Web App</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-24">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>This World Cup has been quite unexpected so far. Just yesterday, two strong teams were eliminated. We continue our world cup Python project and today we make it <em>look</em> like a tournament.</p><p>We will use the Python <a href="https://rich.readthedocs.io/en/latest/introduction.html">rich</a> library which turns any Python script&#8217;s terminal output into something that could pass for a designed UI. We&#8217;ll wrap each round in a colored panel, highlight winners in green, dim upcoming placeholder matches, mark today&#8217;s matches in yellow, and add a running &#8220;played / total&#8221; counter for each round.</p><p>Same data, dramatically better presentation. The only new dependency is <code>rich</code>.</p><h2>Project Task</h2><p>Extend yesterday&#8217;s script so it:</p><ul><li><p>Wraps each knockout round in a colored <code>rich</code> Panel</p></li><li><p>Uses a different color for each round (blue &#8594; cyan &#8594; magenta &#8594; yellow &#8594; gold)</p></li><li><p>Highlights the winning team&#8217;s name in <strong>bold green</strong></p></li><li><p>Dims placeholder team names (like &#8220;Winner of #74&#8221;) in italic gray</p></li><li><p>Marks today&#8217;s matches with a bright yellow &#8220;TODAY&#8221; tag instead of &#8220;vs&#8221;</p></li><li><p>Shows a &#8220;played / total&#8221; counter as each panel&#8217;s subtitle</p></li><li><p>Includes a header banner and a legend</p></li></ul><p>This project gives you hands-on practice with the <code>rich</code> library &#8212; Panels, Tables, Text styling &#8212; plus a small helper for detecting winners from the score object.</p><h2>Expected Output</h2><p><strong>Running the script:</strong></p><pre><code><code>python worldcup_bracket.py
</code></code></pre><p><strong>Output (rendered in your terminal with actual colors):</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qXm1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qXm1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 424w, https://substackcdn.com/image/fetch/$s_!qXm1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 848w, https://substackcdn.com/image/fetch/$s_!qXm1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 1272w, https://substackcdn.com/image/fetch/$s_!qXm1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qXm1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:815964,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/204423145?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!qXm1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 424w, https://substackcdn.com/image/fetch/$s_!qXm1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 848w, https://substackcdn.com/image/fetch/$s_!qXm1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 1272w, https://substackcdn.com/image/fetch/$s_!qXm1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6609ea0-da23-4745-8167-cc8bbdf1fb97_1668x1112.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>Same data as Day 1. Vastly better presentation.</p><h2>Setup Instructions</h2><p><strong>Install the new dependency:</strong></p><pre><code><code>pip install rich
</code></code></pre><p><code>rich</code> is the only new package. It works on Windows, Mac, and Linux terminals with no configuration.</p><p><strong>Run it:</strong></p><pre><code><code>python worldcup_bracket.py
</code></code></pre><h2>Understanding rich</h2><p><code>rich</code> is a Python library for making terminal output beautiful. It handles:</p><ul><li><p>Colors and text styles</p></li><li><p>Panels (bordered boxes)</p></li><li><p>Tables (aligned columns)</p></li><li><p>Progress bars, spinners, live updates</p></li><li><p>Markdown, syntax-highlighted code, tracebacks</p></li></ul><p>We&#8217;re using a small subset today: <code>Console</code> (for printing), <code>Panel</code> (the bordered boxes), <code>Table.grid</code> (for aligned columns), and <code>Text</code> (styled strings). The whole library is <em>thousands</em> of features deep; you can go as far as you want with it.</p><p>The pattern is:</p><pre><code><code>from rich.console import Console
from rich.panel import Panel
from rich.text import Text

console = Console()
console.print(Panel(Text("Hello!", style="bold green"), title="Greeting"))
</code></code></pre><p>Every <code>console.print()</code> renders to the terminal with full styling. Regular <code>print()</code> still works alongside it.</p><h2>Understanding rich.Text vs f-strings</h2><p>Yesterday we used f-strings for alignment. <code>rich</code> prefers you to build <code>Text</code> objects instead &#8212; they carry style information separately from the raw text:</p><pre><code><code>from rich.text import Text

t = Text("Argentina")
t.stylize("bold bright_green")     # winner: bright green
</code></code></pre><p>The style syntax is one or more space-separated tokens:</p><ul><li><p><strong>Colors:</strong> <code>red</code>, <code>green</code>, <code>blue</code>, <code>cyan</code>, <code>magenta</code>, <code>yellow</code>, <code>white</code>, <code>bright_black</code>, <code>gold1</code>, and dozens more</p></li><li><p><strong>Attributes:</strong> <code>bold</code>, <code>italic</code>, <code>dim</code>, <code>underline</code>, <code>strike</code>, <code>reverse</code></p></li><li><p><strong>Background:</strong> <code>on black</code>, <code>on grey11</code></p></li><li><p><strong>Combined:</strong> <code>"bold bright_green"</code> = bold + green; <code>"dim italic"</code> = dim + italic</p></li></ul><p>Style is separate from content &#8212; much cleaner than mixing ANSI escape codes into your strings.</p><h2>Understanding Panels</h2><p>A <code>Panel</code> is a bordered box with optional title and subtitle:</p><pre><code><code>from rich.panel import Panel

console.print(Panel(
    "Contents go here",
    title="[bold blue]Round of 32[/]",
    subtitle="[blue]7 / 16 played[/]",
    border_style="blue",
    padding=(1, 2),
))
</code></code></pre><p>Two things to notice:</p><ul><li><p><strong>The title/subtitle use inline BBCode-style markup</strong> &#8212; <code>[bold blue]</code> opens the style, <code>[/]</code> closes it. Concise for one-off text bits.</p></li><li><p><code>border_style</code> sets the color of the actual border characters.</p></li><li><p><code>padding=(vertical, horizontal)</code> controls internal whitespace: <code>(1, 2)</code> means one blank line top/bottom and two spaces left/right.</p></li></ul><p>Panels are our workhorse today &#8212; one per round, each in a different color.</p><h2>Understanding Table.grid for Alignment</h2><p>Regular <code>rich.Table</code> renders with visible borders between rows and columns. <code>Table.grid</code> gives you the same alignment behavior with <em>no</em> borders &#8212; perfect for laying out rows inside a Panel:</p><pre><code><code>from rich.table import Table

table = Table.grid(padding=(0, 1))
table.add_column(justify="left")     # match number
table.add_column(justify="left")     # date
table.add_column(justify="right")    # team1
table.add_column(justify="center")   # score / vs
table.add_column(justify="left")     # team2

table.add_row("#73", "2026-06-28", "South Africa", "0&#8211;1", "Canada")
table.add_row("#74", "2026-06-29", "Germany", "1&#8211;1 (3-4 pens)", "Paraguay")
</code></code></pre><p>The five columns line up automatically no matter what row content you add. Right-align for team1 + center for the score + left-align for team2 gives you that classic &#8220;team score team&#8221; look:</p><pre><code><code>     South Africa  0&#8211;1     Canada
          Germany  1&#8211;1 (3-4 pens)  Paraguay
</code></code></pre><p>This is <em>dramatically</em> easier than trying to do it with f-string width specifiers, especially once rich styling gets mixed in.</p><h2>Understanding Detecting the Winner</h2><p>To highlight a winner, we need to know <em>who</em> won. Football has three ways a knockout match can end, and each hides the winner in a different score field:</p><pre><code><code>def winner_of(match):
    """Return 1 if team1 won, 2 if team2 won, or 0 for a draw / unplayed."""
    if "score" not in match:
        return 0
    score = match["score"]

    # Penalty shootout: the penalty score decides it.
    if "p" in score:
        p1, p2 = score["p"]
        return 1 if p1 &gt; p2 else 2 if p2 &gt; p1 else 0

    # Extra time: if there's an et score AND it's not tied, that decides it.
    if "et" in score:
        g1, g2 = score["et"]
        if g1 != g2:
            return 1 if g1 &gt; g2 else 2

    # Regulation.
    g1, g2 = score["ft"]
    if g1 != g2:
        return 1 if g1 &gt; g2 else 2

    return 0
</code></code></pre><p>The order matters again: <strong>check </strong><code>p</code><strong> first</strong>, then <code>et</code>, then <code>ft</code>. A match with a penalty shootout has <em>all three</em> fields; if we checked <code>ft</code> first we&#8217;d get &#8220;tied&#8221; and never look at the shootout.</p><p>This function is small but foundational &#8212; Day 3&#8217;s web app uses the exact same logic to pick which team&#8217;s name to bold in the browser.</p><h2>Understanding Detecting Placeholders</h2><p>To style unresolved team names differently (dim italic), we need a way to tell &#8220;Canada&#8221; apart from &#8220;Winner of #74&#8221;. The check is the mirror image of yesterday&#8217;s <code>pretty_team()</code>:</p><pre><code><code>def is_placeholder(name):
    if name.startswith("W") and name[1:].isdigit():
        return True
    if name.startswith("L") and name[1:].isdigit():
        return True
    if len(name) == 2 and name[0].isdigit() and name[1].isalpha():
        return True
    if "/" in name:
        return True
    return False
</code></code></pre><p>Same patterns as yesterday, but instead of <em>transforming</em> the string, we&#8217;re <em>classifying</em> it. Same regex-free approach &#8212; just startswith, isdigit, and a couple of shape checks.</p><h2>Understanding Styling One Row</h2><p>The core styling function ties it all together &#8212; for each match, pick the right style for the team names, the score, and the date:</p><pre><code><code>def build_match_row(match, today_str):
    num = f"#{match.get('num', '?')}"
    date_str = match.get("date", "")
    played = "score" in match
    is_today = date_str == today_str
    w = winner_of(match)

    # Match number: always bold
    num_text = Text(num, style="bold")

    # Date: yellow if today AND unplayed, plain otherwise
    date_text = Text(date_str)
    if is_today and not played:
        date_text.stylize("bold yellow")

    # Teams: green if winner, dim italic if placeholder, plain white otherwise
    t1_text = styled_team(match["team1"], is_winner=(w == 1),
                          is_placeholder_name=is_placeholder(match["team1"]))
    t2_text = styled_team(match["team2"], is_winner=(w == 2),
                          is_placeholder_name=is_placeholder(match["team2"]))

    # Score column: real result, "TODAY", or "vs"
    if played:
        score_text = Text(format_score(match["score"]), style="bold white")
    elif is_today:
        score_text = Text("TODAY", style="bold yellow")
    else:
        score_text = Text("vs", style="dim")

    return num_text, date_text, t1_text, score_text, t2_text
</code></code></pre><p>The function returns a <strong>tuple of Text objects</strong> &#8212; one per column. <code>Table.grid.add_row(*result)</code> unpacks the tuple into the table&#8217;s columns. Small, composable, easy to test.</p><h2>Understanding Color-Coding by Round</h2><p>Each round gets its own color, and we keep them in a list of <code>(name, color)</code> tuples so the display order and color mapping stay in one place:</p><pre><code><code>ROUNDS = [
    ("Round of 32",            "bright_blue"),
    ("Round of 16",            "cyan"),
    ("Quarter-final",          "magenta"),
    ("Semi-final",             "yellow"),
    ("Match for third place",  "bright_black"),
    ("Final",                  "gold1"),
]

for round_name, color in ROUNDS:
    round_matches = [m for m in matches if m["round"] == round_name]
    ...
    console.print(build_round_panel(round_name, round_matches, color, today_str))
</code></code></pre><p>The palette goes cool-to-warm, culminating in gold for the Final. It&#8217;s a visual metaphor &#8212; the tournament heats up as it narrows. Small aesthetic choice, big effect.</p><h2>Understanding Adding a Legend</h2><p>At the bottom, a two-row <code>Table.grid</code> explains what the styles mean:</p><pre><code><code>legend = Table.grid(padding=(0, 2))
legend.add_column()
legend.add_column()
legend.add_column()

legend.add_row(
    Text("Legend:", style="bold"),
    Text("winner", style="bold bright_green"),
    Text("upcoming placeholder", style="dim italic"),
)
legend.add_row(
    Text(""),
    Text("today's match", style="bold yellow"),
    Text("score / result", style="bold white"),
)
console.print(legend)
</code></code></pre><p>Nothing dramatic &#8212; just a small ceremonial ending that helps users decode the colors. Every good chart has a legend; every good terminal UI can too.</p><h2>Understanding Everything Together</h2><p>Look at what the whole script does:</p><ol><li><p>Fetch the JSON (same as Day 1)</p></li><li><p>Loop through rounds in the desired order</p></li><li><p>For each round: filter matches, sort by match number, build a <code>Panel</code> containing a <code>Table.grid</code></p></li><li><p>For each match in the round: pick styles based on who won, whether it&#8217;s today, whether teams are placeholders</p></li><li><p>Print each panel</p></li><li><p>Print a legend</p></li></ol><p>That&#8217;s the entire day. <code>rich</code> handles all the styling and alignment work &#8212; we just describe what we want. <strong>The data logic barely changed from Day 1.</strong> All the new complexity is presentation. That&#8217;s the point: the same fetch-and-parse pipeline can power a plain print, a terminal UI, or (as we&#8217;ll see tomorrow) a web app.</p><h2>Practical Use Cases</h2><p><strong>1. Terminal dashboards:</strong></p><pre><code><code>Server monitoring, deployment logs, CI/CD summaries &#8212; rich turns any script into a designed UI.
</code></code></pre><p><strong>2. Interactive CLIs:</strong></p><pre><code><code>Combine rich with prompt_toolkit for full-featured terminal apps.
</code></code></pre><p><strong>3. Progress and status:</strong></p><pre><code><code>rich.progress and rich.live are worth exploring &#8212; real-time-updating panels are magical.
</code></code></pre><p><strong>4. Foundation for Day 3:</strong></p><pre><code><code>The same match-styling logic (winner detection, placeholder detection) drives the web app tomorrow.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we ship it. The <strong>Visual Bracket Web App</strong> wraps this whole thing in a Flask server that serves an HTML page &#8212; with a real, actual visual bracket diagram in HTML/CSS. Auto-refreshing every minute. Deployable. Shareable. This is the finale.</p><h2>View Code Evolution</h2><p>Compare today&#8217;s colorful bracket with yesterday&#8217;s plain-text version &#8212; and see how a few <code>rich</code> components (Panel, Table.grid, Text) turn a functional script into something that looks <em>designed</em>.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python-c8c">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[World Cup 2026 Tracker with Python: Day 1 - Fetch and Show the Knockout Phase]]></title><description><![CDATA[Today we start building a World Cup 2026 Tracker &#8212; a three-day project that turns a free public football API into your own personal tournament dashboard.]]></description><link>https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/world-cup-2026-tracker-with-python</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 30 Jun 2026 13:29:01 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/d17d483b-3c6a-419f-a5e0-153451fa4964_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>World Cup 2026 Tracker</strong> &#8212; a three-day project that turns a free public football API into your own personal tournament dashboard. By Friday, you&#8217;ll have a live web app showing the entire knockout bracket, updating itself as the matches play out.</p><p><strong>Why build this?</strong> Because we&#8217;re <em>in</em> the World Cup right now. The Round of 32 just kicked off, and through July 19, the bracket fills in match by match. This is the perfect moment to build something that pulls live tournament data and turns it into something useful. The skills &#8212; consuming an API, parsing JSON, building UIs around external data &#8212; apply to every kind of real-world data work, from finance to weather to fitness apps.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p><strong>What you&#8217;ll learn:</strong> This series teaches you HTTP API consumption with <code>requests</code>, JSON parsing, terminal art with <code>rich</code>, Flask web servers, and bridging Python data to HTML/CSS/JS frontends.</p><p><strong>Why this matters:</strong> By Day 3, you&#8217;ll have a deployable web app showing a live, visual knockout bracket &#8212; a portfolio piece you can share with anyone who likes football.</p><ul><li><p><strong>Day 1:</strong> Fetch and Show the Knockout Phase <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Bracket Printer (Terminal Art)</p></li><li><p><strong>Day 3:</strong> Visual Bracket Web App</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-24">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We start small but real: fetch live World Cup 2026 data from a free public JSON API, parse it, and print every knockout-stage match in the terminal. By the end of today you&#8217;ll be making a real HTTP request, working with real-world JSON, and seeing real match results from a tournament happening <em>right now</em>.</p><p>The display is intentionally simple &#8212; we&#8217;ll polish it on Day 2 with terminal art, and Day 3 turns it into a proper visual web app. Today is about the <em>data</em>. Once you can fetch and parse it, the rest of the week is presentation.</p><h2>The Data Source</h2><p>We&#8217;re using the wonderful <strong><a href="https://github.com/openfootball/worldcup.json">openfootball/worldcup.json</a></strong> project &#8212; a free, public-domain, no-API-key-required JSON dataset of every World Cup match. It&#8217;s maintained by hand by an Austrian developer named Gerald Bauer, updated daily, and used by dozens of real projects.</p><p>The endpoint is a single URL that returns all 104 matches:</p><pre><code><code>https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json
</code></code></pre><p>That&#8217;s it. No authentication, no signup, no rate limits to worry about. Hit the URL, get JSON. Perfect for learning.</p><blockquote><p><strong>A note on &#8220;live&#8221;:</strong> the data is updated by hand (usually daily), not in real time. For our purposes &#8212; building a tracker, learning APIs &#8212; that&#8217;s fine. Day 3 will auto-refresh every few minutes. For real-time scores you&#8217;d need a paid API; that&#8217;s a different project.</p></blockquote><h2>Project Task</h2><p>Build a Python script that:</p><ul><li><p>Fetches the openfootball JSON endpoint via HTTP</p></li><li><p>Parses the response as JSON</p></li><li><p>Filters the match list to just the knockout-stage matches</p></li><li><p>Sorts each round in bracket order (by match number)</p></li><li><p>Prints them grouped by round: Round of 32 &#8594; Round of 16 &#8594; QF &#8594; SF &#8594; 3rd place &#8594; Final</p></li><li><p>Shows the score correctly whether the match was decided in regulation, extra time, or penalties</p></li><li><p>Renders placeholder team codes (<code>W73</code>, <code>2A</code>) as readable text (<code>Winner of match 73</code>, <code>Group A runner-up</code>)</p></li></ul><p>This project gives you hands-on practice with <code>requests</code>, JSON parsing, list comprehensions, sort keys, dict access patterns, and the small details that make the difference between &#8220;the data is there&#8221; and &#8220;the output is readable.&#8221;</p><h2>Expected Output</h2><p><strong>Running the script:</strong></p><pre><code><code>python worldcup.py
</code></code></pre><p><strong>Output:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!SpvB!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!SpvB!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 424w, https://substackcdn.com/image/fetch/$s_!SpvB!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 848w, https://substackcdn.com/image/fetch/$s_!SpvB!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 1272w, https://substackcdn.com/image/fetch/$s_!SpvB!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!SpvB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png" width="1456" height="1189" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1189,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1049128,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/204272640?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!SpvB!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 424w, https://substackcdn.com/image/fetch/$s_!SpvB!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 848w, https://substackcdn.com/image/fetch/$s_!SpvB!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 1272w, https://substackcdn.com/image/fetch/$s_!SpvB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0b0c234-3f0b-49e9-8cdb-e11d6ed129c8_1846x1508.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Played matches show their full score <em>including</em> penalty shootouts. Future matches show placeholders that automatically resolve into real team names as the bracket fills in.</p><h2>Setup Instructions</h2><p><strong>Install one dependency:</strong></p><pre><code><code>pip install requests</code></code></pre><p>That&#8217;s it. Everything else is standard library.</p><p><strong>Run it:</strong></p><pre><code><code>python worldcup.py</code></code></pre><p></p><h2>Understanding the Data Source</h2><p>Before writing any code, <em>look at the data</em>. Open the URL in a browser and scroll through it. The structure is:</p><pre><code><code>{
  "name": "World Cup 2026",
  "matches": [
    {
      "round": "Matchday 1",
      "date": "2026-06-11",
      "time": "13:00 UTC-6",
      "team1": "Mexico",
      "team2": "South Africa",
      "score": { "ft": [2, 0], "ht": [1, 0] },
      "goals1": [...],
      "ground": "Mexico City"
    },
    {
      "round": "Round of 32",
      "num": 73,
      "date": "2026-06-28",
      "team1": "2A",
      "team2": "2B",
      "ground": "Los Angeles (Inglewood)"
    },
    ...
  ]
}
</code></code></pre><p>Three things to notice for today:</p><ul><li><p><strong>Every match has </strong><code>round</code><strong>, </strong><code>team1</code><strong>, </strong><code>team2</code><strong>.</strong> The knockout matches also have <code>num</code> &#8212; their match number (73-104).</p></li><li><p><code>score</code><strong> only appears after the match is played.</strong> Future matches have no <code>score</code> key at all.</p></li><li><p><strong>Knockout matches have </strong><em><strong>placeholder</strong></em><strong> teams</strong> until the dependency matches resolve. <code>"2A"</code> means &#8220;Group A runner-up.&#8221; <code>"W73"</code> means &#8220;Winner of match 73.&#8221; As real matches finish, openfootball replaces those placeholders with actual team names &#8212; automatically, for free, on our side.</p></li></ul><h2>Understanding requests for an API</h2><p><code>requests</code> is Python&#8217;s go-to library for HTTP. Hitting our endpoint is two lines:</p><pre><code><code>import requests

response = requests.get(DATA_URL, timeout=10)
response.raise_for_status()
data = response.json()
</code></code></pre><p>Three habits worth forming on day one of API work:</p><ul><li><p><strong>Always set a timeout.</strong> Without one, a slow server can hang your script forever. 10 seconds is sensible.</p></li><li><p><strong>Always call </strong><code>raise_for_status()</code><strong>.</strong> It throws on 4xx/5xx responses, so you catch network failures <em>immediately</em> instead of crashing while parsing an HTML error page as JSON.</p></li><li><p><code>response.json()</code><strong> is the magic call.</strong> It does <code>json.loads(response.text)</code> for you, returning a Python dict (or list).</p></li></ul><p>That&#8217;s the entire API interaction. The hard part of API work isn&#8217;t the HTTP call &#8212; it&#8217;s everything you do with the data afterward.</p><h2>Understanding Filtering by Round</h2><p>The JSON has all 104 matches &#8212; group stage plus knockouts. We want just the knockout ones. A list comprehension and a fixed list of round names handles it:</p><pre><code><code>KNOCKOUT_ROUNDS = [
    "Round of 32",
    "Round of 16",
    "Quarter-final",
    "Semi-final",
    "Match for third place",
    "Final",
]

for round_name in KNOCKOUT_ROUNDS:
    round_matches = [m for m in matches if m["round"] == round_name]
    # ... print them
</code></code></pre><p>The order of <code>KNOCKOUT_ROUNDS</code> defines the <em>display order</em> &#8212; Round of 32 first, Final last. Just by looping that list in order, we get the bracket in the right sequence. No sorting algorithm needed for the rounds themselves.</p><h2>Understanding Match Numbers and Sort Order</h2><p>Within a round, matches have a <code>num</code> field &#8212; 73, 74, 75, etc. The order of matches in the JSON isn&#8217;t guaranteed, so we sort each round&#8217;s matches by <code>num</code>:</p><pre><code><code>round_matches.sort(key=lambda m: m.get("num", 0))
</code></code></pre><p>A <code>lambda</code> is just a small inline function &#8212; <code>lambda m: m.get("num", 0)</code> takes a match <code>m</code> and returns its <code>num</code> field (or <code>0</code> if missing).</p><p>Why <code>m.get("num", 0)</code> and not <code>m["num"]</code>? Because group-stage matches don&#8217;t have a <code>num</code> field, and using <code>m["num"]</code> would crash if any non-knockout match slipped through. <code>dict.get(key, default)</code><strong> is the safe accessor.</strong> Use it whenever a key might not exist &#8212; it returns the default value instead of raising <code>KeyError</code>.</p><h2>Understanding the Score Structure</h2><p>This is the most interesting JSON detail of the day. Football matches can end in three ways, and openfootball encodes each one slightly differently:</p><pre><code><code># 1. Regulation: just full-time
"score": {"ft": [2, 0], "ht": [1, 0]}

# 2. Extra time: full-time was a draw, extra time decided it
"score": {"ft": [1, 1], "et": [2, 1], "ht": [0, 1]}

# 3. Penalty shootout: full-time was a draw, extra time too, decided on pens
"score": {"ft": [1, 1], "et": [1, 1], "p": [4, 3], "ht": [0, 1]}
</code></code></pre><p>So three keys to know:</p><ul><li><p><code>ft</code> &#8212; full-time score, always present</p></li><li><p><code>et</code> &#8212; extra-time aggregate (only if the match went to extra time)</p></li><li><p><code>p</code> &#8212; penalty shootout result (only if there was a shootout)</p></li></ul><p>For the knockout stage, all three matter. A 1-1 draw in regulation that ended 4-3 on penalties looks very different on the bracket than just &#8220;1-1.&#8221; So our score formatter handles all three:</p><pre><code><code>def format_score(score):
    g1, g2 = score["ft"]

    if "p" in score:                       # penalty shootout
        p1, p2 = score["p"]
        if "et" in score:
            g1, g2 = score["et"]           # show the ET tie, not the FT one
        return f"{g1} &#8212; {g2} ({p1}-{p2} pens)"

    if "et" in score:                       # decided in extra time
        g1, g2 = score["et"]
        return f"{g1} &#8212; {g2} (a.e.t.)"

    return f"{g1} &#8212; {g2}"                   # regulation
</code></code></pre><p>The order matters: <strong>check </strong><code>p</code><strong> first</strong>, then <code>et</code>, then default to regulation. A penalty shootout always <em>also</em> has <code>et</code> &#8212; if we checked <code>et</code> first, we&#8217;d report shootouts as &#8220;extra time&#8221; results and miss the decisive penalty score.</p><h2></h2><h2>Practical Use Cases</h2><p><strong>1. The API consumption pattern:</strong></p><pre><code><code>fetch &#8594; parse &#8594; filter &#8594; display works for weather, crypto, stocks, GitHub, anything.
</code></code></pre><p><strong>2. The placeholder-resolution pattern:</strong></p><pre><code><code>APIs often return raw codes that need translation. Build a small helper, keep callers clean.
</code></code></pre><p><strong>3. Tournament data in general:</strong></p><pre><code><code>The same JSON format covers Premier League, La Liga, Bundesliga, every past World Cup.
</code></code></pre><p><strong>4. Foundation for the rest of the week:</strong></p><pre><code><code>Day 2 makes the display beautiful. Day 3 puts it in the browser. Same fetch + parse pipeline.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we make this <em>look like a bracket</em>. We&#8217;ll use the <code>rich</code> library to draw the actual knockout bracket diagram in your terminal &#8212; with colors, lines connecting matches across rounds, winners highlighted, and the whole thing as one cohesive visual. Same data source, way more wow.</p><h2>Skeleton and Solution</h2><p>Below you will find both a downloadable skeleton.py file to help you code the project with comment guides and the downloadable solution.py file containing the correct solution.</p><p>Get the code skeleton here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/view/j1DPnQ_7oWaCFvtAH0xucw&quot;,&quot;text&quot;:&quot;View Code Skeleton&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/view/j1DPnQ_7oWaCFvtAH0xucw"><span>View Code Skeleton</span></a></p><p></p><p>Get the code solution here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/Gfud81KXxI_oPtq-jIU_KQ&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/Gfud81KXxI_oPtq-jIU_KQ"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Email Productivity Suite: Day 3 - AI Email Assistant (Gemini) ]]></title><description><![CDATA[Yesterday we sent one email. Today we send a hundred &#8212; each personalized to its recipient.]]></description><link>https://dailypythonprojects.substack.com/p/email-productivity-suite-day-3-ai</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/email-productivity-suite-day-3-ai</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Fri, 26 Jun 2026 17:55:26 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/83bdfaa3-6401-4d81-a01b-1c983863cdd0_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>Email Productivity Suite</strong> &#8212; a complete set of tools for one of the most universal time-sinks in modern life: writing and sending email.</p><ul><li><p><strong>Day 1:</strong> Email Sender Desktop App</p></li><li><p><strong>Day 2:</strong> Mail Merge from CSV</p></li><li><p><strong>Day 3:</strong> AI Email Assistant (Gemini) <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-23">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale &#8212; and the most fun one.</strong></p><p>You have an email to write. Maybe to your professor about a missed deadline. Maybe to a client whose invoice you need to chase. Maybe to your landlord about a leaky tap. You know what you <em>want</em> to say in two seconds. Writing it politely &#8212; picking the right tone, the right opening, the right level of firmness &#8212; takes ten minutes you don&#8217;t have.</p><p>Today we will have AI write our emails in the GUI. You just type a <strong>rough note</strong> in plain language:</p><blockquote><p>&#8220;tell my professor i can&#8217;t make tomorrow&#8217;s office hours, doctor&#8217;s appointment, ask if we can meet thursday&#8221;</p></blockquote><p>Click the tone &#8212; <strong>polite, firm, concise, apologetic</strong> &#8212; and Gemini rewrites it as a polished email. One more click and it sends through the same Gmail engine you built on Day 1.</p><p>This is the kind of AI tool you&#8217;ll <em>actually use</em> &#8212; not a demo, a real piece of your workflow.</p><h2>Project Task</h2><p>Build an AI Email Assistant with Tkinter and Gemini that:</p><ul><li><p>Takes a rough draft as input &#8212; any tone, any messiness</p></li><li><p>Lets the user pick a target tone: Polite / Firm / Concise / Apologetic / Friendly</p></li><li><p>Sends the rough draft to Gemini with a tone-specific instruction</p></li><li><p>Displays the polished version in a separate editable text box</p></li><li><p>Lets the user tweak the AI output before sending</p></li><li><p>Suggests a subject line automatically based on the content</p></li><li><p>Sends the finished email through the same <code>send_email()</code> function from Day 1</p></li><li><p>Handles missing API keys, network errors, and Gmail errors gracefully</p></li></ul><p>This project gives you hands-on practice with the <code>langchain-google-genai</code> library, prompt engineering for tone control, integrating LLM output into a real workflow, two-pane editing UIs, and capping off a multi-day project series cleanly.</p><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>python ai_email_assistant.py</code></code></pre><p><strong>That will display the app. In the app, you can write the instructions to the AI about the email and pick a tone:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4JBN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4JBN!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 424w, https://substackcdn.com/image/fetch/$s_!4JBN!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 848w, https://substackcdn.com/image/fetch/$s_!4JBN!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 1272w, https://substackcdn.com/image/fetch/$s_!4JBN!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4JBN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/adcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:665059,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203733136?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4JBN!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 424w, https://substackcdn.com/image/fetch/$s_!4JBN!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 848w, https://substackcdn.com/image/fetch/$s_!4JBN!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 1272w, https://substackcdn.com/image/fetch/$s_!4JBN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fadcd454b-66b9-4dac-b5de-ee2df61f434f_1812x1208.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Then, further down an email with subject will be generated:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!0oY2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!0oY2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 424w, https://substackcdn.com/image/fetch/$s_!0oY2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 848w, https://substackcdn.com/image/fetch/$s_!0oY2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 1272w, https://substackcdn.com/image/fetch/$s_!0oY2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!0oY2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:643660,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203733136?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!0oY2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 424w, https://substackcdn.com/image/fetch/$s_!0oY2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 848w, https://substackcdn.com/image/fetch/$s_!0oY2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 1272w, https://substackcdn.com/image/fetch/$s_!0oY2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff461b88d-352c-4660-9cc0-03dc02b59e67_1908x1272.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Pressing &#8220;SEND EMAIL&#8221; will send out the email to the sender&#8217;s email address. Here is what I received to my the sender&#8217;s address:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Xdmd!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Xdmd!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 424w, https://substackcdn.com/image/fetch/$s_!Xdmd!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 848w, https://substackcdn.com/image/fetch/$s_!Xdmd!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 1272w, https://substackcdn.com/image/fetch/$s_!Xdmd!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Xdmd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:450500,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203733136?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Xdmd!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 424w, https://substackcdn.com/image/fetch/$s_!Xdmd!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 848w, https://substackcdn.com/image/fetch/$s_!Xdmd!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 1272w, https://substackcdn.com/image/fetch/$s_!Xdmd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa85cc3d4-d409-414b-8245-cc94965aec71_1746x1164.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Cool, isn&#8217;t it?</p><p></p><h2>Setup Instructions</h2><p><strong>Step 1 &#8212; Install the AI dependency:</strong></p><pre><code><code>pip install langchain-google-genai
</code></code></pre><p>That&#8217;s the only new thing this week.</p><p><strong>Step 2 &#8212; Get a Gemini API key (free, 1 minute):</strong></p><ol><li><p>Go to <a href="https://aistudio.google.com/apikey">aistudio.google.com/apikey</a>.</p></li><li><p>Sign in with any Google account.</p></li><li><p>Click <strong>Create API key</strong>.</p></li><li><p>Copy the key it shows you.</p></li></ol><p>The free tier gives you generous daily quota &#8212; plenty for personal email rewriting.</p><p><strong>Step 3 &#8212; Paste your API key into the script:</strong></p><p>Open <code>ai_email_assistant.py</code> and find this line near the top:</p><pre><code><code>GOOGLE_API_KEY = "PASTE_YOUR_KEY_HERE"
</code></code></pre><p>Replace <code>PASTE_YOUR_KEY_HERE</code> with your actual key. Save.</p><blockquote><p><strong>Security note:</strong> keeping the key directly in the code is the simplest approach for learning. For real apps, store it in an environment variable or a <code>.env</code> file, then read it via <code>os.environ["GOOGLE_API_KEY"]</code>. We use the inline approach so the project runs without extra setup.</p></blockquote><p><strong>Step 4 &#8212; Use the same Gmail App Password from Day 1.</strong></p><p>If you already set up your App Password earlier this week, you&#8217;re done. Otherwise see Day 1 &#8212; 5-minute setup.</p><p><strong>Run it:</strong></p><pre><code><code>python ai_email_assistant.py
</code></code></pre><h2>Understanding LangChain + Gemini</h2><p><code>langchain-google-genai</code> is the glue between Python and Google&#8217;s Gemini models. The basic shape is three steps:</p><pre><code><code>from langchain_google_genai import ChatGoogleGenerativeAI

llm = ChatGoogleGenerativeAI(
    model="gemini-2.5-flash",
    google_api_key=GOOGLE_API_KEY,
    temperature=0.4,        # how creative; lower = more focused
)

response = llm.invoke("Rewrite this email politely: tell my prof I'll be late")
print(response.content)
</code></code></pre><p>Three things to know:</p><ul><li><p><code>gemini-2.5-flash</code> &#8212; Google&#8217;s fast, cheap model. Perfect for short text rewriting. The smarter <code>gemini-3.5-flash</code> exists too; you can swap it in any time, you just pay a small latency cost.</p></li><li><p><code>temperature=0.4</code> &#8212; moderate creativity. 0 is fully deterministic (same input &#8594; same output every time); 1 is wildly creative. For tone rewriting, 0.3&#8211;0.5 hits the right spot: consistent voice with a bit of natural variation.</p></li><li><p><code>response.content</code> &#8212; the actual text. The full object has tokens, metadata, etc.; for our use we just want the string.</p></li></ul><h2>Understanding Prompt Engineering for Tone</h2><p>The whole AI part of the app boils down to <em>one well-crafted prompt template</em>. We need Gemini to:</p><ol><li><p>Read the user&#8217;s rough note.</p></li><li><p>Rewrite it as a professional email.</p></li><li><p>Apply the chosen tone.</p></li><li><p>Return <em>only</em> the email &#8212; no preamble, no explanation.</p></li></ol><p>Here&#8217;s a template that does exactly that:</p><pre><code><code>PROMPT_TEMPLATE = """You are an expert email writer. The user has a rough \
draft of an email they want to send. Rewrite it as a polished, well-structured \
email in a {tone} tone.

Rules:
- Output ONLY the email body. No preamble, no commentary, no markdown.
- Do not include a subject line - the user handles that separately.
- Keep it appropriately brief; do not pad with filler.
- Preserve every concrete detail from the rough draft.
- Sign off appropriately for the tone.

Tone: {tone}

Rough draft:
{rough_draft}

Polished email:"""
</code></code></pre><p>Three prompt-writing habits in this one example:</p><ul><li><p><strong>Set the role</strong> &#8212; &#8220;You are an expert email writer&#8221; primes the model for the task.</p></li><li><p><strong>List explicit rules</strong> &#8212; bullet rules are dramatically more effective than vague prose at controlling output. &#8220;Output ONLY the email body&#8221; prevents the model&#8217;s tendency to add &#8220;Sure! Here&#8217;s your email:&#8221; preambles.</p></li><li><p><strong>Show the structure</strong> &#8212; ending with &#8220;Polished email:&#8221; tells the model what comes next is the answer. Sounds silly, works brilliantly.</p></li></ul><p>The <code>{tone}</code> and <code>{rough_draft}</code> are placeholders we&#8217;ll fill at runtime &#8212; same <code>str.format()</code> trick from Day 2:</p><pre><code><code>prompt = PROMPT_TEMPLATE.format(tone="polite", rough_draft=user_text)
</code></code></pre><h2>Understanding Tone-Specific Variations</h2><p>&#8220;Polite&#8221; and &#8220;Firm&#8221; and &#8220;Concise&#8221; each have different sweet spots. We can make the rewrite <em>much</em> better by giving each tone a bit of guidance:</p><pre><code><code>TONES = {
    "Polite": "warm, respectful, hedged where appropriate",
    "Firm": "direct and assertive, but not aggressive; clear next steps",
    "Concise": "as short as possible while staying complete; no filler words",
    "Apologetic": "genuinely apologetic, takes ownership, offers to make it right",
    "Friendly": "warm and conversational; appropriate for someone you know well",
}
</code></code></pre><p>In the prompt:</p><pre><code><code>prompt = PROMPT_TEMPLATE.format(
    tone=f"{tone_name} ({TONES[tone_name]})",
    rough_draft=user_text,
)
</code></code></pre><p>So the model sees <code>Tone: Polite (warm, respectful, hedged where appropriate)</code>. The parenthesized hint is the real instruction &#8212; the label is just for the user. This is <em>prompt scaffolding</em>: giving the model enough context to do the job well, without making the user write a paragraph every time.</p><h2>Understanding Subject Line Suggestions</h2><p>Most rough drafts don&#8217;t include a subject &#8212; but the user needs one. We can have Gemini suggest one in the <em>same call</em> by asking it for a tiny JSON-shaped response:</p><pre><code><code>SUBJECT_PROMPT = """Suggest a concise email subject line for this email body. \
Reply with ONLY the subject line - no quotes, no preamble.

Email body:
{body}

Subject line:"""

def suggest_subject(llm, body):
    response = llm.invoke(SUBJECT_PROMPT.format(body=body))
    return response.content.strip()
</code></code></pre><p>A separate small call keeps things simple &#8212; one call per output. Both are short (a few hundred tokens) so latency adds up to maybe two seconds total. Much cleaner than trying to coerce structured output from the model.</p><blockquote><p><strong>Aside:</strong> for production, you&#8217;d probably want <code>with_structured_output</code> (like we did in Week 19&#8217;s travel planner) and get both subject and body in one structured call. For a personal-use tool, two simple <code>invoke</code> calls are easier to reason about.</p></blockquote><h2>Understanding the Two-Pane Layout</h2><p>The whole app is a top-down flow: rough on top, polished on bottom. Tkinter&#8217;s vertical stacking is perfect:</p><pre><code><code># Top pane: rough draft
tk.Label(root, text="YOUR ROUGH DRAFT").pack(anchor="w")
self.rough_text = tk.Text(root, height=6, wrap="word")
self.rough_text.pack(fill="x", padx=10)

# Tone buttons
tone_bar = tk.Frame(root)
tone_bar.pack(pady=8)
for tone in TONES:
    tk.Button(tone_bar, text=tone,
              command=lambda t=tone: self.rewrite(t)).pack(side="left", padx=4)

# Big "Rewrite" button
tk.Button(root, text="&#10024; REWRITE WITH AI", ...).pack(pady=4)

# Bottom pane: polished output
tk.Label(root, text="POLISHED EMAIL (edit before sending)").pack(anchor="w")
self.subject_entry = tk.Entry(root, textvariable=self.subject_var)
self.subject_entry.pack(fill="x", padx=10)
self.polished_text = tk.Text(root, height=10, wrap="word")
self.polished_text.pack(fill="both", expand=True, padx=10)
</code></code></pre><p>The polished pane is a <strong>regular </strong><code>Text</code><strong> widget</strong> &#8212; the user can edit it. That&#8217;s the key UX choice: AI suggests, human approves. The output isn&#8217;t locked; you&#8217;re free to fix any phrase before sending.</p><h2>Understanding the Threading Question</h2><p>LLM calls take 1&#8211;3 seconds. If we call <code>llm.invoke()</code> directly from the button handler, Tkinter freezes for that whole time &#8212; the window won&#8217;t repaint, won&#8217;t respond to clicks, looks crashed.</p><p>For a personal-use tool with quick calls, the simplest fix is <code>root.update_idletasks()</code> after a &#8220;Thinking...&#8221; status update, then let the call complete. The window freezes briefly but the user knows why:</p><pre><code><code>self.set_status("&#10024; Thinking...")
self.root.update_idletasks()
result = llm.invoke(prompt)
</code></code></pre><p>For longer calls or a heavier app, you&#8217;d use <code>threading.Thread</code> and <code>root.after()</code> to keep the GUI responsive. For our case &#8212; a few seconds, a clear status message &#8212; the simple approach is the right tradeoff. Less code to teach, less code to break.</p><h2>Understanding Reusing send_email Again</h2><p>The whole point of the architecture lesson this week:</p><pre><code><code># Top of ai_email_assistant.py
from email_sender import send_email
</code></code></pre><p>Day 1&#8217;s <code>send_email()</code> ships emails from Day 1&#8217;s app, from Day 2&#8217;s mail-merge loop, and from Day 3&#8217;s AI assistant. <strong>Three completely different products, one engine.</strong> That&#8217;s what separating pure functions from UI gets you.</p><p>When the user clicks Send, we feed Gemini&#8217;s polished output into the same function:</p><pre><code><code>def on_send(self):
    sender = self.from_var.get().strip()
    password = self.password_var.get()
    recipient = self.to_var.get().strip()
    subject = self.subject_var.get().strip()
    body = self.polished_text.get("1.0", "end-1c").strip()

    try:
        send_email(sender, password, recipient, subject, body)
        self.set_status(f"&#10003; Email sent to {recipient}")
    except smtplib.SMTPAuthenticationError:
        self.set_status("&#10007; Gmail authentication failed. Check your App Password.")
    except Exception as e:
        self.set_status(f"&#10007; Send failed: {e}")
</code></code></pre><p>Same shape as Day 1. Same SMTP errors. Same status updates.</p><h2></h2><h2>Understanding the Bigger Picture</h2><p>Three days of email tools. One pure send function. One templating helper. One AI rewriter.</p><ul><li><p><strong>Day 1</strong> built the engine.</p></li><li><p><strong>Day 2</strong> wrapped it in a loop.</p></li><li><p><strong>Day 3</strong> put intelligence in front of it.</p></li></ul><p>Each layer adds value; none of them rewrite the layer below. That&#8217;s not a Python pattern &#8212; that&#8217;s the discipline behind every well-built software product. Today you wrote one piece of an &#8220;email-productivity-AI-assistant&#8221;; the same shape works for any AI-powered desktop app you&#8217;ll build from here.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>Email Productivity Suite</strong>:</p><ul><li><p><strong>Day 1:</strong> Send a single email through Gmail SMTP via a desktop app</p></li><li><p><strong>Day 2:</strong> Send personalized emails to a CSV of recipients</p></li><li><p><strong>Day 3:</strong> AI-rewrite rough drafts in any tone before sending</p></li></ul><p><strong>You now have:</strong></p><p>&#9989; <strong>Python email skills</strong> &#8212; <code>smtplib</code>, <code>EmailMessage</code>, Gmail SMTP, App Passwords &#9989; <strong>CSV-driven personalization</strong> &#8212; <code>DictReader</code>, <code>str.format()</code>, batch processing &#9989; <strong>LangChain + Gemini integration</strong> &#8212; building a real LLM-powered tool &#9989; <strong>Prompt engineering</strong> &#8212; tone control, output constraints, structured prompts &#9989; <strong>A reusable architecture</strong> &#8212; pure functions composed across three tools</p><p><strong>Real-world applications:</strong></p><ul><li><p>&#128232; <strong>Personal email speed</strong> &#8212; quick drafts become professional emails in seconds</p></li><li><p>&#127891; <strong>Student communications</strong> &#8212; emailing professors, advisors, departments</p></li><li><p>&#128188; <strong>Freelancer workflows</strong> &#8212; invoicing, follow-ups, client check-ins</p></li><li><p>&#128736;&#65039; <strong>Automation triggers</strong> &#8212; long script finishes &#8594; script emails you the result</p></li><li><p>&#129302; <strong>Custom AI tools</strong> &#8212; you now know how to wrap any LLM into a desktop app</p></li></ul><p><strong>Next steps:</strong></p><ul><li><p>Add an inbox reader (IMAP) &#8212; let Gemini summarize and respond to incoming emails</p></li><li><p>Save templates &#8212; keep your favorite rewritten emails for one-click reuse</p></li><li><p>Multi-recipient AI batch &#8212; combine Day 2 mail merge with Day 3 AI rewriting</p></li><li><p>Voice input &#8212; speak your rough draft, transcribe with Whisper, rewrite with Gemini</p></li><li><p>Use <code>keyring</code> or <code>python-dotenv</code> to manage your API key and Gmail credentials safely</p></li></ul><p>You&#8217;ve built a <strong>real, daily-use AI tool</strong> in three days. &#128640;</p><h2>View Code Evolution</h2><p>Compare today&#8217;s AI assistant with Day 1&#8217;s single sender and Day 2&#8217;s mail merge &#8212; and see how the same pure <code>send_email()</code> function composes with templating and an LLM to produce three very different end-user tools.</p><p></p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/email-productivity-suite-day-3-ai">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Email Productivity Suite: Day 2 - Mail Merge from CSV]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/email-productivity-suite-day-2-mail</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/email-productivity-suite-day-2-mail</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 25 Jun 2026 14:11:49 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/70098b86-ded7-4701-a788-c502511dcf25_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>Email Productivity Suite</strong> &#8212; a complete set of tools for one of the most universal time-sinks in modern life: writing and sending email.</p><ul><li><p><strong>Day 1:</strong> Email Sender Desktop App</p></li><li><p><strong>Day 2:</strong> Mail Merge from CSV <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> AI Email Assistant (Gemini)</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-23">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we sent one email. Today we send <strong>a hundred</strong> &#8212; each personalized to its recipient.</p><p>Mail merge is the technique behind every personalized email you&#8217;ve ever received: the company has a list of names and a template (&#8221;Hi {first_name}, your order #{order_id} has shipped&#8221;), and a script fills the blanks for each row and sends. Today you build that script. Load a CSV of recipients, write your email <em>once</em> with <code>{placeholder}</code> variables, hit Send All, and watch personalized emails go out one by one.</p><p>Same <code>send_email()</code> function from Day 1 &#8212; wrapped in a loop with smarter UI around it.</p><h2>Project Task</h2><p>Build a CSV-driven mail merge tool with Tkinter that:</p><ul><li><p>Loads a CSV of recipients with at minimum an <code>email</code> column</p></li><li><p>Auto-detects the other columns as available template variables</p></li><li><p>Lets you write subject and body with <code>{column_name}</code> placeholders</p></li><li><p>Shows a <strong>live preview</strong> of how the email will look for the first recipient</p></li><li><p>Sends one personalized email per row when Send All is clicked</p></li><li><p>Adds a polite delay between sends to avoid Gmail rate-limiting</p></li><li><p>Reports progress per recipient (sent / failed / row count)</p></li><li><p>Continues to the next row if one fails &#8212; never crashes mid-batch</p></li><li><p>Logs every send with status to a scrollable log box</p></li></ul><p>This project gives you hands-on practice with <code>csv.DictReader</code>, Python&#8217;s <code>str.format()</code> for templating, batch processing with error recovery, real-time progress reporting in Tkinter, and turning a single-use function into a reusable engine.</p><h2>Expected Output</h2><p><strong>Running the tool:</strong></p><pre><code><code>python mail_merge.py
</code></code></pre><p><strong>Here is what you will see when you run the program. </strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!OtCT!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!OtCT!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 424w, https://substackcdn.com/image/fetch/$s_!OtCT!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 848w, https://substackcdn.com/image/fetch/$s_!OtCT!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 1272w, https://substackcdn.com/image/fetch/$s_!OtCT!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!OtCT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1131828,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203557314?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!OtCT!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 424w, https://substackcdn.com/image/fetch/$s_!OtCT!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 848w, https://substackcdn.com/image/fetch/$s_!OtCT!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 1272w, https://substackcdn.com/image/fetch/$s_!OtCT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F19987d23-8528-4533-9ae6-072d2848bf66_2736x1824.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>You can write an email in the GUI and use variables such as {first_name} and {course} in the email:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!NhDp!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!NhDp!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 424w, https://substackcdn.com/image/fetch/$s_!NhDp!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 848w, https://substackcdn.com/image/fetch/$s_!NhDp!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 1272w, https://substackcdn.com/image/fetch/$s_!NhDp!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!NhDp!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png" width="994" height="806" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:806,&quot;width&quot;:994,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:346482,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203557314?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!NhDp!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 424w, https://substackcdn.com/image/fetch/$s_!NhDp!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 848w, https://substackcdn.com/image/fetch/$s_!NhDp!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 1272w, https://substackcdn.com/image/fetch/$s_!NhDp!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff830db64-fa69-4537-9e86-3f159f1a9b8f_994x806.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>When you press SEND ALL, the app will pull the actual value from the respective column (i.e., <strong>first_name</strong> and <strong>course</strong>) in the CSV file for each row:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-356!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-356!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 424w, https://substackcdn.com/image/fetch/$s_!-356!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 848w, https://substackcdn.com/image/fetch/$s_!-356!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 1272w, https://substackcdn.com/image/fetch/$s_!-356!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-356!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png" width="1038" height="692" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:692,&quot;width&quot;:1038,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:257672,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203557314?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-356!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 424w, https://substackcdn.com/image/fetch/$s_!-356!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 848w, https://substackcdn.com/image/fetch/$s_!-356!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 1272w, https://substackcdn.com/image/fetch/$s_!-356!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63ff2d5c-7abf-4701-925a-75eb13b51b66_1038x692.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Each person in the CSV will get a personalized email. Here is the email Carol received:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4nxC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4nxC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 424w, https://substackcdn.com/image/fetch/$s_!4nxC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 848w, https://substackcdn.com/image/fetch/$s_!4nxC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 1272w, https://substackcdn.com/image/fetch/$s_!4nxC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4nxC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png" width="1456" height="491" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:491,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:85350,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203557314?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4nxC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 424w, https://substackcdn.com/image/fetch/$s_!4nxC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 848w, https://substackcdn.com/image/fetch/$s_!4nxC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 1272w, https://substackcdn.com/image/fetch/$s_!4nxC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4cc16214-eb32-4f6c-84cb-b443b634871e_1620x546.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Each member receives an email with their own data fetched from the CSV.</p><h2>Setup Instructions</h2><p><strong>Install (nothing new):</strong></p><p>Same as Day 1 &#8212; pure standard library (<code>smtplib</code>, <code>csv</code>, <code>tkinter</code>). No <code>pip install</code>.</p><p><strong>Prepare a recipients CSV:</strong></p><p>A sample <code>students.csv</code> is provided with this project. The format is simple:</p><pre><code><code>email,first_name,course,amount
alice@example.com,Alice,Python Bootcamp,250
bob@example.com,Bob,Data Science 101,180
carol@example.com,Carol,Web Dev Foundations,200
david@example.com,David,Python Bootcamp,250
</code></code></pre><p><strong>You can copy the above, paste it in an empty text file and save as students.csv.</strong></p><p><strong>Run the program:</strong></p><pre><code><code>python mail_merge.py
</code></code></pre><h2>Understanding csv.DictReader</h2><p>The CSV-handling workhorse for today is <code>csv.DictReader</code>. It reads the header row, then yields each subsequent row as a dictionary keyed by column name:</p><pre><code><code>import csv

with open("students.csv", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    fieldnames = reader.fieldnames           # ['email', 'first_name', ...]
    recipients = list(reader)                # [{'email': 'alice@...', ...}, ...]
</code></code></pre><p>Two things to know:</p><ul><li><p><code>reader.fieldnames</code> &#8212; the column headers. We&#8217;ll use these to tell the user which <code>{variables}</code> they can put in their template.</p></li><li><p><code>list(reader)</code> &#8212; materializes all rows into memory. Fine for hundreds of rows; for hundreds of thousands you&#8217;d iterate the reader directly.</p></li></ul><p><code>DictReader</code> gives us a clean shape: a list of dicts, each one perfectly ready to feed into <code>.format(**row)</code>.</p><h2>Understanding Python&#8217;s str.format with **kwargs</h2><p>Templating in Python is built into the language &#8212; no Jinja, no Mustache, just <code>str.format()</code>:</p><pre><code><code>template = "Hi {first_name}, your {course} starts soon."
row = {"first_name": "Alice", "course": "Python Bootcamp"}

personalized = template.format(**row)
# "Hi Alice, your Python Bootcamp starts soon."
</code></code></pre><p>The <code>**row</code> syntax unpacks the dict into keyword arguments. So <code>template.format(**row)</code> is equivalent to <code>template.format(first_name="Alice", course="Python Bootcamp", ...)</code>.</p><p>It works for <em>any</em> number of variables. The template grabs whichever placeholders it needs and ignores the rest. The dict can have 10 columns &#8212; your template might only use 2 of them; that&#8217;s fine.</p><h2>Understanding Safe Personalization</h2><p>What happens if your template has <code>{first_name}</code> but a CSV row is missing that column? <code>format()</code> raises <code>KeyError</code>. That&#8217;s exactly what you want for a typo &#8212; but you want it to fail <em>cleanly</em> for the user, not crash the script:</p><pre><code><code>def personalize(template, row):
    """Fill {placeholders} in template with values from row dict."""
    try:
        return template.format(**row)
    except KeyError as e:
        raise ValueError(f"Template references {e} but the CSV has no such column.")
</code></code></pre><p>Wrapping in a function with a clearer error means the GUI can show &#8220;Template references &#8216;first_name&#8217; but the CSV has no such column&#8221; instead of a stack trace. Errors as <em>messages</em>, not crashes.</p><h2>Understanding the Live Preview</h2><p>The live preview shows how the first recipient&#8217;s email will look &#8212; it runs the same <code>personalize()</code> function the batch will use:</p><pre><code><code>def refresh_preview(self):
    if not self.recipients:
        self.preview_text.delete("1.0", "end")
        return

    first = self.recipients[0]
    try:
        subject = personalize(self.subject_var.get(), first)
        body = personalize(self.body_text.get("1.0", "end-1c"), first)
    except ValueError as e:
        self.preview_text.delete("1.0", "end")
        self.preview_text.insert("1.0", f"&#9888;&#65039; {e}")
        return

    preview = f"To: {first['email']}\nSubject: {subject}\n\n{body}"
    self.preview_text.delete("1.0", "end")
    self.preview_text.insert("1.0", preview)
</code></code></pre><p>Bind it to keystrokes on the subject and body widgets:</p><pre><code><code>subject_entry.bind("&lt;KeyRelease&gt;", lambda _e: self.refresh_preview())
self.body_text.bind("&lt;KeyRelease&gt;", lambda _e: self.refresh_preview())
</code></code></pre><p>The user types <code>{first_name}</code> &#8212; the preview instantly shows &#8220;Alice.&#8221; Type <code>{first_nam}</code> (typo) &#8212; the preview shows a clear warning before they hit Send. This is the small UX detail that separates <em>I built a script</em> from <em>I built a tool</em>.</p><h2>Understanding the Send-All Loop</h2><p>The batch loop wraps yesterday&#8217;s <code>send_email()</code> in a <code>for</code> loop with three additions: a delay between sends, per-row error handling, and progress logging:</p><pre><code><code>import time

def send_all(self):
    sender = self.from_var.get().strip()
    password = self.password_var.get()
    subject_template = self.subject_var.get()
    body_template = self.body_text.get("1.0", "end-1c")

    successes = 0
    failures = 0
    total = len(self.recipients)

    self.log(f"Sending to {total} recipients...")

    for i, row in enumerate(self.recipients, 1):
        recipient = row.get("email", "").strip()

        try:
            subject = personalize(subject_template, row)
            body = personalize(body_template, row)
            send_email(sender, password, recipient, subject, body)
            self.log(f"  [{i}/{total}]  {recipient:&lt;30}  &#8594;  &#10003; sent")
            successes += 1
        except Exception as e:
            self.log(f"  [{i}/{total}]  {recipient:&lt;30}  &#8594;  &#10007; {e}")
            failures += 1

        time.sleep(1)   # be polite to Gmail

    self.log(f"\nDone. {successes} sent, {failures} failed.")
</code></code></pre><p>Three patterns to internalize:</p><ul><li><p><code>time.sleep(1)</code> &#8212; one second between sends. Gmail&#8217;s free tier rate-limits at around 100/day; even at the limit, one-second pacing keeps you well under any per-minute throttle. Tomorrow&#8217;s AI version can use 2-3 seconds for extra safety.</p></li><li><p><code>try/except</code><strong> per row</strong> &#8212; one bad recipient doesn&#8217;t stop the batch. The failure is logged; the next row goes out.</p></li><li><p><code>successes</code><strong> and </strong><code>failures</code><strong> counters</strong> &#8212; the user sees a useful summary at the end, not a wall of green checkmarks.</p></li></ul><h2>Understanding Reusing send_email from Day 1</h2><p>We don&#8217;t rewrite <code>send_email</code>. We <em>import</em> it:</p><pre><code><code># At the top of mail_merge.py
from email_sender import send_email
</code></code></pre><p>This is the architecture lesson of the week. Day 1&#8217;s <code>send_email()</code> was a pure function &#8212; no Tkinter, no I/O outside of the SMTP call &#8212; <em>exactly so</em> this would work. Day 3 will import it again.</p><p>If you wrote <code>send_email()</code> as a method on the Day 1 <code>EmailSenderApp</code> class, today&#8217;s loop would be much harder. <strong>Pure functions compose; methods on classes don&#8217;t.</strong></p><h2>Understanding the Architecture</h2><p>The whole app is built on three layers:</p><ol><li><p><code>send_email()</code> &#8212; the SMTP primitive (imported from Day 1)</p></li><li><p><code>personalize()</code> &#8212; template + dict &#8594; final string</p></li><li><p><code>send_all()</code> &#8212; the loop that ties them together</p></li></ol><p>Each layer does one thing. Each layer is reusable. <strong>Day 3&#8217;s AI assistant will reuse the same three</strong> &#8212; Gemini produces the body, <code>personalize()</code> fills in any placeholders, <code>send_email()</code> sends it. That&#8217;s the discipline that makes week-by-week projects compose.</p><h2>Practical Use Cases</h2><p><strong>1. Notify a class / team / customer list:</strong></p><pre><code><code>Drop the names into a CSV, write your email once, hit Send All.
</code></code></pre><p><strong>2. Send personalized invoices, certificates, receipts:</strong></p><pre><code><code>{first_name}, {amount}, {invoice_id} - one email per row.
</code></code></pre><p><strong>3. Course communications:</strong></p><pre><code><code>Reminders, deadline alerts, assignment feedback &#8212; anywhere you'd otherwise BCC.
</code></code></pre><p><strong>4. Event RSVPs and follow-ups:</strong></p><pre><code><code>"Hi {name}, thanks for attending {event}! Your certificate is at {link}."
</code></code></pre><p><strong>5. Foundation for Day 3:</strong></p><pre><code><code>Tomorrow's AI assistant uses the same send_email() function and the same personalize() trick.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we add <strong>AI</strong>. The <strong>AI Email Assistant</strong> takes a quick rough draft &#8212; &#8220;tell my boss I&#8217;ll be out tomorrow morning, doctor&#8217;s appointment&#8221; &#8212; and Gemini rewrites it as a polished email in your chosen tone (polite / firm / concise / apologetic). One click and it&#8217;s ready to send via the same <code>send_email()</code> you built on Day 1.</p><h2>View Code Evolution</h2><p>Compare today&#8217;s mail merge tool with Day 1&#8217;s single sender &#8212; and see how a small <code>for</code> loop, a templating helper, and per-row error handling turn one email into a personalized hundred.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/email-productivity-suite-day-2-mail">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Email Productivity Suite: Day 1 - Email Sender (Tkinter)]]></title><description><![CDATA[Create a GUI app with Python to send out emails.]]></description><link>https://dailypythonprojects.substack.com/p/email-productivity-suite-day-1-email</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/email-productivity-suite-day-1-email</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 24 Jun 2026 15:17:39 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c4c0d4ec-29c7-4dbe-b0d7-1fe139b95b17_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>Email Productivity Suite</strong> &#8212; a complete set of tools for one of the most universal time-sinks in modern life: writing and sending email. By Friday, you&#8217;ll have a desktop app that sends single emails, mail-merges to a CSV list, and uses AI to rewrite rough drafts in any tone you want.</p><p><strong>Why build this?</strong> Because email isn&#8217;t going away &#8212; AI made it <em>easier to write but more of it gets sent</em>. Python automation around email is one of those rare skills that pays off in every job, every side project, every freelance gig.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p><strong>What you&#8217;ll learn:</strong> This series teaches you Python&#8217;s <code>smtplib</code> and <code>email</code> modules, Gmail authentication via app passwords, sending plain and HTML email, attaching files, CSV-driven personalization, and integrating LLMs into a real desktop workflow.</p><p><strong>Why this matters:</strong> By Day 3, you&#8217;ll paste a rough message &#8212; &#8220;tell my professor I cant make tomorrow&#8217;s office hours&#8221; &#8212; and the AI rewrites it as a polite, professional email, ready to send. That&#8217;s the kind of tool you&#8217;ll <em>actually use</em>.</p><ul><li><p><strong>Day 1:</strong> Email Sender Desktop App <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Mail Merge from CSV</p></li><li><p><strong>Day 3:</strong> AI Email Assistant (Gemini)</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-23">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We start with the foundation of everything else this week: a desktop app that sends one email. Type the recipient, subject, and body, click Send, and your email goes out via Gmail. By the end you&#8217;ll understand how Python actually talks to a mail server &#8212; the same code powers everything from cron-job notifications to 100-recipient mail merges.</p><p>This is the <em>engine</em>. Days 2 and 3 wrap new features around it.</p><h2>Project Task</h2><p>Build a Tkinter desktop email sender that:</p><ul><li><p>Provides input fields for: From (your Gmail), App Password, To, Subject, Body</p></li><li><p>Sends the email through Gmail&#8217;s SMTP server using <code>smtplib</code></p></li><li><p>Shows clear status: connecting, authenticating, sending, success/failure</p></li><li><p>Hides the password field with asterisks</p></li><li><p>Validates input (no empty recipient, no missing password) before sending</p></li><li><p>Reports detailed error messages when authentication or sending fails</p></li><li><p>Has a &#8220;Clear&#8221; button to reset the form</p></li><li><p>Doesn&#8217;t crash on network errors &#8212; failures are caught and reported</p></li></ul><p>This project gives you hands-on practice with <code>smtplib</code>, the <code>email.message</code> module, Gmail&#8217;s STARTTLS authentication, Tkinter form layout, password fields, multi-line text widgets, and the cleanest pattern for separating GUI from logic in a desktop app.</p><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>python email_sender.py
</code></code></pre><p>That will open the following app in your computer. Below, I already filled in the sender email address and its app password (see further below on how to get an app password for your Gmail account). I also wrote an email.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!paG_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!paG_!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 424w, https://substackcdn.com/image/fetch/$s_!paG_!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 848w, https://substackcdn.com/image/fetch/$s_!paG_!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 1272w, https://substackcdn.com/image/fetch/$s_!paG_!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!paG_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:740446,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203415739?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!paG_!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 424w, https://substackcdn.com/image/fetch/$s_!paG_!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 848w, https://substackcdn.com/image/fetch/$s_!paG_!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 1272w, https://substackcdn.com/image/fetch/$s_!paG_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe9a98e32-5fb7-4e86-9454-6efdc2751217_2046x1364.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>Pressing the SEND EMAIL button will send out the email from the sender&#8217;s address to the receiver&#8217;s address. Here is what I received in my gmail account:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!lcp-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lcp-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 424w, https://substackcdn.com/image/fetch/$s_!lcp-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 848w, https://substackcdn.com/image/fetch/$s_!lcp-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 1272w, https://substackcdn.com/image/fetch/$s_!lcp-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!lcp-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png" width="590" height="453" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:453,&quot;width&quot;:590,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:49627,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/203415739?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!lcp-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 424w, https://substackcdn.com/image/fetch/$s_!lcp-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 848w, https://substackcdn.com/image/fetch/$s_!lcp-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 1272w, https://substackcdn.com/image/fetch/$s_!lcp-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc174e98-8de5-4eb3-bea5-a76eed5742f4_590x453.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h2>Setup Instructions</h2><p><strong>Step 1 &#8212; Install (no dependencies):</strong></p><p>This entire project uses Python&#8217;s <em>standard library</em> &#8212; <code>smtplib</code>, <code>email</code>, <code>tkinter</code>. Nothing to install.</p><pre><code><code>python email_sender.py
</code></code></pre><p><strong>Step 2 &#8212; Create a Gmail App Password (5 minutes):</strong></p><p>You can&#8217;t use your regular Gmail password anymore &#8212; Google requires an <strong>App Password</strong> for Python scripts since May 2025.</p><ol><li><p>Go to <a href="https://myaccount.google.com/">myaccount.google.com</a> and sign in.</p></li><li><p>Click <strong>Security</strong> in the left sidebar.</p></li><li><p>Under &#8220;How you sign in to Google,&#8221; enable <strong>2-Step Verification</strong> if you haven&#8217;t already. <em>(App passwords require it.)</em></p></li><li><p>Once 2FA is on, go directly to <a href="https://myaccount.google.com/apppasswords">myaccount.google.com/apppasswords</a>.</p></li><li><p>Type any name (e.g., &#8220;Python Email&#8221;) and click <strong>Create</strong>.</p></li><li><p>Google shows a <strong>16-character password</strong> in a yellow box. Copy it.</p></li><li><p>Paste it into the App Password field in the app &#8212; <em>no spaces, just the 16 characters</em>.</p></li></ol><blockquote><p><strong>Security note:</strong> for simplicity, this app keeps the password in the form text field, in memory only. It&#8217;s never saved to disk. A more secure setup uses <code>keyring</code> or environment variables &#8212; we&#8217;ll mention that briefly on Day 3.</p></blockquote><h2>Understanding SMTP and Gmail</h2><p>SMTP &#8212; <strong>Simple Mail Transfer Protocol</strong> &#8212; is the protocol that&#8217;s moved email between servers since the 1980s. It&#8217;s still the protocol. Your script connects to Gmail&#8217;s SMTP server, authenticates, and hands over a message; Gmail does the rest.</p><p>Python&#8217;s <code>smtplib</code> handles the protocol:</p><pre><code><code>import smtplib

with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
    smtp.starttls()                          # upgrade to encrypted connection
    smtp.login("you@gmail.com", "your_app_password")
    smtp.send_message(msg)
</code></code></pre><p>Three terms worth knowing:</p><ul><li><p><code>smtp.gmail.com</code> &#8212; Gmail&#8217;s SMTP server address.</p></li><li><p><strong>Port 587</strong> &#8212; the standard &#8220;submission&#8221; port for outgoing mail.</p></li><li><p><strong>STARTTLS</strong> &#8212; starts the connection in plain text, then upgrades to TLS encryption. Required by Gmail.</p></li></ul><p>There&#8217;s also a <em>port 465</em> variant using <code>SMTP_SSL</code> (encrypted from the start). Both work for Gmail; we use 587/STARTTLS because it&#8217;s the most common pattern in production code.</p><h2>Understanding the email.message Module</h2><p><code>smtplib</code> sends bytes. To build a proper email &#8212; with a Subject, From, To, and a body &#8212; we use <code>email.message.EmailMessage</code>:</p><pre><code><code>from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "you@gmail.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "Hello"
msg.set_content("This is the email body.")
</code></code></pre><p><code>EmailMessage</code> is the modern (Python 3.6+) way to construct emails. It handles headers, encoding, MIME types, and attachments cleanly. The older <code>MIMEText</code> / <code>MIMEMultipart</code> classes still work but feel clunky compared to this one.</p><p>Setting headers is dictionary-style (<code>msg["Subject"] = ...</code>); setting the body is method-style (<code>msg.set_content(...)</code>). That&#8217;s just a quirk of the API &#8212; once you know it, the rest is straightforward.</p><h2>Understanding the Pure Send Function</h2><p>We keep the email-sending logic in a <strong>pure function</strong> &#8212; no Tkinter, no GUI. It takes the fields, sends the email, and either returns or raises an exception. This makes it reusable for Days 2 and 3 and easy to test in isolation:</p><pre><code><code>def send_email(sender, password, recipient, subject, body):
    msg = EmailMessage()
    msg["From"] = sender
    msg["To"] = recipient
    msg["Subject"] = subject
    msg.set_content(body)

    with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
        smtp.starttls()
        smtp.login(sender, password)
        smtp.send_message(msg)
</code></code></pre><p>A simple, four-step function. The Tkinter app just calls it. Tomorrow&#8217;s mail-merge tool calls it in a loop. Day 3&#8217;s AI assistant calls it after the AI is done. <strong>One sending function, many surfaces around it.</strong></p><h2>Understanding Tkinter Form Layout</h2><p>For a form-style UI, <code>grid()</code> is much cleaner than <code>pack()</code>. You think in rows and columns; labels go in column 0, inputs in column 1:</p><pre><code><code>frame = tk.Frame(root)
frame.pack(padx=10, pady=10, fill=tk.BOTH, expand=True)

tk.Label(frame, text="From:").grid(row=0, column=0, sticky="w")
tk.Entry(frame, textvariable=self.from_var, width=50).grid(row=0, column=1, sticky="ew")

tk.Label(frame, text="To:").grid(row=1, column=0, sticky="w")
tk.Entry(frame, textvariable=self.to_var, width=50).grid(row=1, column=1, sticky="ew")

frame.columnconfigure(1, weight=1)   # input column stretches with the window
</code></code></pre><p>Three small details that earn their place:</p><ul><li><p><code>sticky="w"</code> &#8212; labels left-align (&#8221;west&#8221;) instead of centering on their row.</p></li><li><p><code>sticky="ew"</code> &#8212; inputs stretch horizontally (&#8221;east-west&#8221;) to fill the column.</p></li><li><p><code>columnconfigure(1, weight=1)</code> &#8212; declares &#8220;column 1 gets the extra space when the window resizes.&#8221; Without this, the input column never grows.</p></li></ul><h2>Understanding Password Fields</h2><p>Tkinter has a one-line way to hide a password as it&#8217;s typed &#8212; <code>show="&#9679;"</code> on an <code>Entry</code>:</p><pre><code><code>tk.Entry(frame, textvariable=self.password_var, width=50, show="\u2022").grid(...)
</code></code></pre><p><code>\u2022</code> is the bullet character. (<code>"*"</code> works too; bullets just look cleaner.) The variable still holds the real password &#8212; <code>show</code> only affects what the user <em>sees</em>, not what&#8217;s stored.</p><h2>Understanding the Body Text Widget</h2><p>A single-line <code>Entry</code> won&#8217;t work for a multi-line email body &#8212; we need a <code>Text</code> widget:</p><pre><code><code>self.body_text = tk.Text(frame, width=50, height=10, wrap="word")
self.body_text.grid(row=4, column=1, sticky="nsew")

# Reading the body later:
body = self.body_text.get("1.0", "end-1c")  # from line 1 char 0 to end, minus the trailing newline
</code></code></pre><p>Two <code>Text</code>-widget quirks you&#8217;ll see a lot:</p><ul><li><p><code>get("1.0", "end-1c")</code> &#8212; Tkinter <code>Text</code> indexes use <code>"line.column"</code> format. <code>"1.0"</code> is &#8220;line 1, column 0&#8221; (the start). <code>"end-1c"</code> is &#8220;end minus 1 character&#8221; &#8212; that strips the trailing newline that <code>Text</code> always appends.</p></li><li><p><code>wrap="word"</code> &#8212; wraps lines at word boundaries instead of mid-word.</p></li></ul><h2>Understanding Catching SMTP Errors</h2><p>The send function can fail in many ways: wrong password, bad recipient, network down, Gmail rate-limiting. <code>smtplib</code> raises distinct exceptions for each &#8212; we catch them specifically so we can give the user a useful message:</p><pre><code><code>try:
    send_email(sender, password, to_addr, subject, body)
    self.set_status(f"\u2713 Email sent to {to_addr}")

except smtplib.SMTPAuthenticationError:
    self.set_status("\u2717 Authentication failed: check your "
                    "Gmail address and App Password.")

except smtplib.SMTPRecipientsRefused as e:
    self.set_status(f"\u2717 Recipient refused: {e}")

except smtplib.SMTPException as e:
    self.set_status(f"\u2717 Send failed: {e}")

except Exception as e:
    self.set_status(f"\u2717 Error: {e}")
</code></code></pre><p>Specific exceptions on top, generic exceptions on the bottom &#8212; the classic exception-handling pattern. <code>SMTPAuthenticationError</code> in particular is the one your users will hit most often, almost always because they pasted the App Password with spaces in it. A clear message saves them a lot of confusion.</p><h2>Understanding Validation Before Send</h2><p>The send button should refuse to fire when the form is incomplete. A small validation function returns the first error or <code>None</code>:</p><pre><code><code>def validate(self):
    if not self.from_var.get().strip():    return "Please enter your Gmail address."
    if not self.password_var.get():        return "Please enter your App Password."
    if not self.to_var.get().strip():      return "Please enter a recipient."
    if not self.subject_var.get().strip(): return "Please enter a subject."
    body = self.body_text.get("1.0", "end-1c").strip()
    if not body:                           return "Please enter a message body."
    return None
</code></code></pre><p>Then in the send handler:</p><pre><code><code>error = self.validate()
if error:
    self.set_status(f"\u2717 {error}")
    return
</code></code></pre><p>This is <em>much</em> better than letting the SMTP call fail with a cryptic error. The user sees the actual problem in plain English, before the script even tries to connect.</p><h2>Understanding the Status Bar</h2><p>A status bar at the bottom of the window gives the user feedback as the script runs. Three lines do it:</p><pre><code><code>self.status_var = tk.StringVar(value="Ready. Fill in the form and click Send.")
tk.Label(self.root, textvariable=self.status_var, anchor="w",
         bd=1, relief="sunken", padx=8, pady=4).pack(fill="x", side="bottom")
</code></code></pre><p>And inside the send method, we update it at each step:</p><pre><code><code>self.set_status("Connecting to smtp.gmail.com...")
self.root.update_idletasks()   # force the GUI to repaint NOW

self.set_status("Authenticating...")
self.root.update_idletasks()

# ... send ...

self.set_status(f"\u2713 Email sent to {to_addr}")
</code></code></pre><p><code>self.root.update_idletasks()</code> is the magic ingredient. Without it, Tkinter batches GUI updates and the user sees nothing happen until the whole <code>send_email</code> call finishes. Calling <code>update_idletasks()</code> forces an immediate repaint &#8212; so the user sees &#8220;Connecting...&#8221; then &#8220;Authenticating...&#8221; then &#8220;Sending...&#8221; in real time, instead of a frozen window followed by &#8220;Done.&#8221;</p><h2>Coming Tomorrow</h2><p>Tomorrow we go from one email to many. The <strong>Mail Merge</strong> tool loads a CSV of recipients, lets you write the email <em>once</em> with placeholder variables like <code>{first_name}</code> and <code>{course}</code>, and personalizes one email per row &#8212; sending each with a polite delay between them. Same <code>send_email()</code> function, just a loop around it.</p><h2>Skeleton and Solution</h2><p>Below you will find both a downloadable skeleton.py file to help you code the project with comment guides and the downloadable solution.py file containing the correct solution.</p><p>Get the code skeleton here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/view/dfSx1jL-ixHePFOPY2fUWA&quot;,&quot;text&quot;:&quot;View Code Skeleton&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/view/dfSx1jL-ixHePFOPY2fUWA"><span>View Code Skeleton</span></a></p><p></p><p>Get the code solution here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/SDMt8jBUXJ03sqNXSoe-0A&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/SDMt8jBUXJ03sqNXSoe-0A"><span>View Code Solution</span></a></p><p></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Image Toolkit App with Pillow: Day 3 - Meme Generator & Collage Maker]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day-f5f</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day-f5f</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Fri, 19 Jun 2026 14:01:06 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c55cf824-066b-422b-a3e2-a6535a364b2c_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>Image Toolkit</strong> with Pillow that lets you edit photos in a desktop app, watermark whole folders of images at once, and turn photos into shareable memes and collages.</p><ul><li><p><strong>Day 1:</strong> Photo Filters Studio (Tkinter)</p></li><li><p><strong>Day 2:</strong> Batch Watermarker</p></li><li><p><strong>Day 3:</strong> Meme Generator &amp; Collage Maker <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-22">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale &#8212; and the fun one.</strong> Today we build two image tools in a single app: a <strong>meme generator</strong> with the classic top+bottom Impact-font caption (white text, thick black outline) and a <strong>collage maker</strong> that arranges several photos into a single grid image.</p><p>Both are real, shareable image tools &#8212; output you&#8217;d actually post somewhere. And they reuse every Pillow skill from Days 1 and 2: image opening, text drawing, RGBA, positioning, batch handling. The lesson lands: the same fundamentals power <em>every</em> image tool you&#8217;ll ever build.</p><h2>Project Task</h2><p>Build a two-mode image studio with Tkinter and Pillow that:</p><p><strong>Meme Mode:</strong></p><ul><li><p>Loads an image</p></li><li><p>Lets the user type top and bottom caption text</p></li><li><p>Draws captions in the classic meme style (uppercase, white with black outline)</p></li><li><p>Auto-fits the text to the image width (no clipping)</p></li><li><p>Live preview as text changes</p></li><li><p>Saves the finished meme</p></li></ul><p><strong>Collage Mode:</strong></p><ul><li><p>Loads multiple images</p></li><li><p>Lays them out in a configurable grid (2&#215;2, 3&#215;2, 3&#215;3)</p></li><li><p>Resizes each image consistently</p></li><li><p>Adds a configurable colored border between images</p></li><li><p>Saves the finished collage</p></li></ul><p>This project gives you hands-on practice with text styling and stroke outlines, dynamic font sizing, grid layout math, the <code>Image.paste</code> compositing pattern, mode tabs in Tkinter, and reusing one image pipeline across two visual products.</p><h2>Expected Output</h2><p><strong>Running the studio:</strong></p><pre><code><code>python meme_collage.py
</code></code></pre><p><strong>Meme Mode:</strong></p><p>The user can write two pieces of text in the GUI on the right side and the image will be updated instantly:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qETF!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qETF!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!qETF!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!qETF!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!qETF!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qETF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png" width="1456" height="1033" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1033,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2540764,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202721939?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!qETF!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!qETF!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!qETF!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!qETF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68afdf76-97bc-412f-bbda-fd79e4db9376_2424x1720.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><br></p><p><strong>Collage Mode:</strong></p><p>In collage mode, the user can select multiple images in their computer and then user the configuration settings on the right to create a collage view of those images:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ikR_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ikR_!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!ikR_!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!ikR_!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!ikR_!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ikR_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png" width="1456" height="1033" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/dfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1033,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2048881,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202721939?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ikR_!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!ikR_!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!ikR_!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!ikR_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdfba2485-0880-43b3-89a9-edb7a84b4c5d_2424x1720.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Each mode is its own clean tool, sharing a preview area and <code>Save</code> workflow.</p><h2>Setup Instructions</h2><p><strong>Install Pillow:</strong></p><pre><code><code>pip install Pillow
</code></code></pre><p>(One dependency, same as Days 1 and 2.)</p><p><strong>Run it:</strong></p><pre><code><code>python meme_collage.py
</code></code></pre><p>For meme mode, use any image (the <code>sample.jpg</code> from Day 1 works great). For collage mode, you&#8217;ll want 2&#8211;9 images &#8212; drop a few photos into a folder and load them.</p><h2>Understanding the Two-Mode Architecture</h2><p>The app is structured around <strong>tabs</strong>: meme mode and collage mode each get their own controls and their own logic, but share the same preview area, save workflow, and font-loading helper.</p><pre><code><code>class ImageStudio:
    def __init__(self, root):
        # shared state lives on self
        self.preview_image = None
        self.tk_image = None
        # ...

        self.notebook = ttk.Notebook(root)
        self.meme_tab = MemeTab(self.notebook, self)
        self.collage_tab = CollageTab(self.notebook, self)
        self.notebook.add(self.meme_tab.frame, text="Meme")
        self.notebook.add(self.collage_tab.frame, text="Collage")
</code></code></pre><p>Two small structural choices that matter:</p><ul><li><p><strong>Each tab gets its own class.</strong> Meme and collage are different enough that mixing their state on one giant class gets ugly. Splitting them keeps each tool readable.</p></li><li><p><strong>The shared parent (</strong><code>self</code><strong>) holds the preview machinery.</strong> Both tabs call <code>parent.set_preview(image)</code> to update the central preview &#8212; that way the GUI bit lives in one place.</p></li></ul><p><code>ttk.Notebook</code> is the standard Tkinter tabbed-pane widget. Switch between Meme and Collage with a click; each tab carries its own state without stomping on the other.</p><h2>Understanding the Meme Style</h2><p>The classic meme look has three rules:</p><ol><li><p><strong>Bold sans-serif font</strong> (Impact traditionally; we use a system bold).</p></li><li><p><strong>Uppercase text.</strong></p></li><li><p><strong>White fill, thick black outline</strong> &#8212; readable against any background.</p></li></ol><p>Pillow&#8217;s <code>draw.text(...)</code> supports outlines directly via two parameters: <code>stroke_width</code> and <code>stroke_fill</code>. The whole meme caption is one call:</p><pre><code><code>def draw_meme_text(draw, text, position, font, fill="white", outline="black", stroke=4):
    draw.text(
        position,
        text.upper(),
        font=font,
        fill=fill,
        stroke_width=stroke,
        stroke_fill=outline,
        anchor="mm",   # center the text at the anchor point
    )
</code></code></pre><p>Two parameters earn the magic:</p><ul><li><p><code>stroke_width=4</code> &#8212; pixels of outline thickness. 3 is too thin; 6 is too cartoony. Around 4 hits the sweet spot for most image sizes.</p></li><li><p><code>anchor="mm"</code> &#8212; the text is <strong>m</strong>iddle-horizontal, <strong>m</strong>iddle-vertical-anchored at the position you give it. This makes centering trivial: pass the center point of where the text should be, not its top-left corner.</p></li></ul><h2>Understanding Auto-Fitting Text Width</h2><p>A meme caption that overruns the image looks broken. We auto-shrink the font until the rendered text fits the image width:</p><pre><code><code>def fit_font_to_width(text, max_width, target_size):
    """Find the largest font size &lt;= target that fits in max_width."""
    size = target_size
    while size &gt; 10:
        font = load_meme_font(size)
        bbox = ImageDraw.Draw(Image.new("RGB", (10, 10))).textbbox(
            (0, 0), text.upper(), font=font, stroke_width=4
        )
        text_width = bbox[2] - bbox[0]
        if text_width &lt;= max_width:
            return font
        size -= 4
    return load_meme_font(10)
</code></code></pre><p>The pattern: start at the user&#8217;s requested size and shrink in steps until the text fits. The loop only kicks in for long captions &#8212; short text gets full size, no measurement overhead.</p><blockquote><p><strong>Note we include </strong><code>stroke_width=4</code><strong> in the measurement.</strong> The outline adds to the text&#8217;s actual width, so we have to account for it &#8212; or the loop returns a size that fits the <em>unstroked</em> text but overflows once outlined.</p></blockquote><h2>Understanding the Meme Builder</h2><p>The full meme function ties everything together. Top text goes near the top, bottom text near the bottom, both center-aligned:</p><pre><code><code>def make_meme(image, top_text, bottom_text, target_size=60):
    img = image.convert("RGB").copy()
    draw = ImageDraw.Draw(img)

    # Margin from each edge
    margin = max(20, img.height // 20)

    if top_text:
        font = fit_font_to_width(top_text, img.width - 2 * margin, target_size)
        bbox = draw.textbbox((0, 0), top_text.upper(), font=font, stroke_width=4)
        text_h = bbox[3] - bbox[1]
        center = (img.width // 2, margin + text_h // 2)
        draw_meme_text(draw, top_text, center, font)

    if bottom_text:
        font = fit_font_to_width(bottom_text, img.width - 2 * margin, target_size)
        bbox = draw.textbbox((0, 0), bottom_text.upper(), font=font, stroke_width=4)
        text_h = bbox[3] - bbox[1]
        center = (img.width // 2, img.height - margin - text_h // 2)
        draw_meme_text(draw, bottom_text, center, font)

    return img
</code></code></pre><p>The pattern repeats for top and bottom &#8212; fit the font, measure the rendered height, compute the center point, draw. Pure function, no side effects: hand it an image and some text, get back the meme. Same as Day 2&#8217;s <code>apply_watermark</code> &#8212; and that consistency is the point.</p><h2>Understanding Grid Math for Collages</h2><p>The collage problem boils down to: given N images and a grid (R rows &#215; C columns), how big should each cell be, and where does each image go?</p><p>The cleanest approach is to <strong>fix a target cell size and compute the canvas from there</strong>, rather than the other way around:</p><pre><code><code>def make_collage(images, rows, cols, border, border_color, cell_size=400):
    cell_w = cell_h = cell_size
    canvas_w = cols * cell_w + (cols + 1) * border
    canvas_h = rows * cell_h + (rows + 1) * border

    canvas = Image.new("RGB", (canvas_w, canvas_h), border_color)

    for index, img in enumerate(images[:rows * cols]):
        row = index // cols
        col = index % cols
        x = border + col * (cell_w + border)
        y = border + row * (cell_h + border)
        canvas.paste(fit_into_cell(img, cell_w, cell_h), (x, y))

    return canvas
</code></code></pre><p>A few moves worth understanding:</p><ul><li><p><code>(cols + 1) * border</code> &#8212; there&#8217;s a border on each side <em>and</em> between every column. For 3 columns, that&#8217;s 4 border-widths total. Same logic vertically.</p></li><li><p><code>row = index // cols</code><strong>, </strong><code>col = index % cols</code> &#8212; integer division and modulo turn a flat list index into a 2D grid position. Image 0 &#8594; (0, 0); image 1 &#8594; (0, 1); image 3 in a 3-col grid &#8594; (1, 0). Classic pattern.</p></li><li><p><code>canvas.paste(img, (x, y))</code> &#8212; Pillow&#8217;s basic compositing: draw <code>img</code> onto <code>canvas</code> at the given top-left corner.</p></li></ul><p><code>Image.new("RGB", (w, h), color)</code> creates the canvas filled with the border color, so empty space <em>between</em> the pasted images is automatically the right color. We never have to &#8220;draw the borders&#8221; &#8212; they&#8217;re just the parts of the canvas that don&#8217;t get pasted over.</p><h2>Understanding the fit_into_cell Helper</h2><p>Real photos come in different aspect ratios &#8212; landscape, portrait, square. Just resizing them all to the same dimensions distorts them. The right move is <strong>fit and center</strong>: shrink each photo to fit inside the cell preserving its aspect ratio, then center it on a square cell-sized canvas:</p><pre><code><code>def fit_into_cell(img, cell_w, cell_h):
    """Resize `img` to fit in a cell_w x cell_h cell, centered, no distortion."""
    img = img.copy()
    img.thumbnail((cell_w, cell_h), Image.Resampling.LANCZOS)

    # Center it on a cell-sized canvas
    cell = Image.new("RGB", (cell_w, cell_h), (240, 240, 240))
    offset = ((cell_w - img.width) // 2, (cell_h - img.height) // 2)
    cell.paste(img, offset)
    return cell
</code></code></pre><p><code>thumbnail</code> preserves aspect ratio; the centering math handles whatever&#8217;s left over with a light gray pad. The result: every cell is exactly <code>cell_w &#215; cell_h</code>, no distortion, every photo placed sensibly. The collage layout math from above doesn&#8217;t need to care about individual photo sizes.</p><h2></h2><h2>Understanding Saving Both Modes</h2><p>Each mode runs through a Save button that triggers a save dialog and writes the <em>current preview-equivalent image</em> (full resolution, not the screen-shrunk version):</p><pre><code><code>def save_meme(self):
    if not self.image:
        return
    final = make_meme(self.image, self.top_var.get(), self.bottom_var.get(),
                      self.size_var.get())
    path = filedialog.asksaveasfilename(defaultextension=".jpg", ...)
    if path:
        final.save(path, quality=92)
</code></code></pre><p>Note we <strong>rebuild the meme at full resolution before saving</strong> &#8212; the preview was a shrunken version. The user saw the layout; the save call produces the high-quality version of <em>exactly that layout</em>. Preview is a window onto truth, not its own truth.</p><h2>Understanding the Full Picture</h2><p>Three days of image work, one shape:</p><ul><li><p><strong>Day 1</strong> took <em>one image</em> and applied filters interactively.</p></li><li><p><strong>Day 2</strong> took <em>one folder</em> and looped over it.</p></li><li><p><strong>Day 3</strong> takes <em>one image</em> (meme) or <em>many images</em> (collage) and produces a single new image.</p></li></ul><p>The same Pillow primitives &#8212; <code>Image.open</code>, <code>copy</code>, <code>convert</code>, <code>paste</code>, <code>ImageDraw.Draw</code>, <code>ImageFont</code>, <code>ImageEnhance</code> &#8212; assembled in different shapes. There&#8217;s no special trick for &#8220;memes&#8221; or &#8220;collages&#8221; &#8212; only for combining the primitives sensibly. That&#8217;s the actual lesson of the week.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>Pillow image toolkit</strong>:</p><ul><li><p><strong>Day 1:</strong> Live photo filters and adjustments in a Tkinter studio</p></li><li><p><strong>Day 2:</strong> Batch-watermark whole folders with live preview</p></li><li><p><strong>Day 3:</strong> Meme generator and collage maker in a tabbed app</p></li></ul><p><strong>You now have:</strong></p><p>&#9989; <strong>Pillow fundamentals</strong> &#8212; <code>Image</code>, <code>ImageDraw</code>, <code>ImageFont</code>, <code>ImageOps</code>, <code>ImageEnhance</code>, <code>ImageFilter</code> &#9989; <strong>The PIL &#8596; Tkinter bridge</strong> &#8212; <code>ImageTk.PhotoImage</code>, with the GC reference rule baked in &#9989; <strong>Real-world habits</strong> &#8212; EXIF orientation, RGBA compositing, JPEG quality &#9989; <strong>Live-preview architecture</strong> &#8212; same function powers preview and save, so what users see is what they get &#9989; <strong>Multiple working tools</strong> &#8212; filters, watermarker, meme generator, collage maker</p><p><strong>Real-world applications:</strong></p><ul><li><p>&#127912; <strong>Social media content</strong> &#8212; branded posts, memes, collages, watermarked photos</p></li><li><p>&#128247; <strong>Photography workflow</strong> &#8212; batch process exports, add copyright, generate previews</p></li><li><p>&#128722; <strong>E-commerce</strong> &#8212; watermark product photos at scale</p></li><li><p>&#128218; <strong>Documentation and tutorials</strong> &#8212; annotate screenshots, generate diagrams</p></li><li><p>&#129302; <strong>Automation</strong> &#8212; feed any of these into a script that runs on every uploaded image</p></li></ul><p><strong>Next steps:</strong></p><ul><li><p>Add a real font picker (browse local fonts)</p></li><li><p>Support emoji and unicode characters (Pillow can do it with a unicode font)</p></li><li><p>Add an undo stack (keep a list of past <code>edited</code> images)</p></li><li><p>Build a CLI version so it&#8217;s scriptable</p></li><li><p>Try Pillow&#8217;s <code>ImageDraw.rounded_rectangle</code> for fancier collage borders</p></li></ul><p>You&#8217;ve built the foundation for a <strong>real image-processing pipeline</strong>. &#128640;</p><h2>View Code Evolution</h2><p>Compare today&#8217;s two-mode studio with Day 1&#8217;s filters and Day 2&#8217;s batch watermarker &#8212; and see how the same Pillow primitives assemble into completely different tools when you change the shape of the loop around them.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day-f5f">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Image Toolkit App with Pillow: Day 2 - Batch Watermarker]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day-2f8</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day-2f8</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 18 Jun 2026 15:13:44 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7c98806e-2f23-4755-a49a-c4b1a8ab0112_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>Image Toolkit</strong> with Pillow that lets you edit photos in a desktop app, watermark whole folders of images at once, and turn photos into shareable memes and collages.</p><ul><li><p><strong>Day 1:</strong> Photo Filters Studio (Tkinter)</p></li><li><p><strong>Day 2:</strong> Batch Watermarker <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Meme Generator &amp; Collage Maker</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-22">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we edited one photo at a time. Today we <strong>process hundreds at once.</strong></p><p>The Batch Watermarker takes a folder of images, applies a text watermark with the position, opacity, and color you pick, and writes the watermarked copies to an output folder. Live preview while you tune the settings, then one click processes the whole batch &#8212; leaving every original untouched.</p><p>This is where image processing gets <em>useful</em>. Photographers protecting their work, content creators branding their screenshots, ecommerce sellers labeling product photos &#8212; the workflow is the same, and you can build it in 200 lines of Python.</p><h2>Project Task</h2><p>Build a batch text watermarker with Tkinter and Pillow that:</p><ul><li><p>Lets the user pick an input folder of images and an output folder</p></li><li><p>Lets the user type the watermark text</p></li><li><p>Lets the user choose the position: one of 5 anchor points (corners + center)</p></li><li><p>Lets the user adjust opacity (0&#8211;100%) and font size with sliders</p></li><li><p>Shows a live preview of the watermark on the first image in the folder</p></li><li><p>Processes the whole folder when &#8220;Apply to All&#8221; is clicked</p></li><li><p>Preserves EXIF orientation and original quality</p></li><li><p>Reports progress per file and continues on errors</p></li><li><p>Never overwrites originals &#8212; writes to a separate output folder</p></li></ul><p>This project gives you hands-on practice with Pillow&#8217;s <code>ImageDraw</code> and <code>ImageFont</code>, RGBA compositing for proper transparency, working with image folders, anchor-based positioning, and turning a single-image operation into a batch pipeline.</p><h2>Expected Output</h2><p><strong>Running the watermarker:</strong></p><pre><code><code>python batch_watermarker.py
</code></code></pre><p><strong>Application Window:</strong></p><p>The user can pick an input folder where the images are and an output folder to save the watermarked images.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4k9Y!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4k9Y!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!4k9Y!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!4k9Y!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!4k9Y!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4k9Y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png" width="1456" height="1033" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1033,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:942700,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202592775?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4k9Y!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!4k9Y!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!4k9Y!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!4k9Y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb5d761e2-165b-402d-a928-5fd3e3a52c89_2424x1720.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>Once the user has selected the folder the program already applies watermarks and displays the first image in the GUI. </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!TdWH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!TdWH!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!TdWH!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!TdWH!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!TdWH!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!TdWH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png" width="1456" height="1033" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/dbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1033,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2192704,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202592775?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!TdWH!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 424w, https://substackcdn.com/image/fetch/$s_!TdWH!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 848w, https://substackcdn.com/image/fetch/$s_!TdWH!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!TdWH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdbc125ff-89d9-4cf3-b2ba-fc61c2347927_2424x1720.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The user can click the APPLY TO ALL button to process all images and a new output folder with the generated images will be generated. Here is a snapshot of that folder:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Nx99!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Nx99!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 424w, https://substackcdn.com/image/fetch/$s_!Nx99!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 848w, https://substackcdn.com/image/fetch/$s_!Nx99!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 1272w, https://substackcdn.com/image/fetch/$s_!Nx99!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Nx99!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png" width="1456" height="901" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:901,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:677295,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202592775?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Nx99!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 424w, https://substackcdn.com/image/fetch/$s_!Nx99!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 848w, https://substackcdn.com/image/fetch/$s_!Nx99!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 1272w, https://substackcdn.com/image/fetch/$s_!Nx99!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d347460-e7f8-4f20-9778-69fc8c84a755_1496x926.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h2>Setup Instructions</h2><p><strong>Install Pillow:</strong></p><pre><code><code>pip install Pillow
</code></code></pre><p>(Same single dependency as Day 1.)</p><p><strong>Run it:</strong></p><pre><code><code>python batch_watermarker.py
</code></code></pre><p>You&#8217;ll need a folder of images to point at. To try it quickly, copy <code>sample.jpg</code> from Day 1 into a folder a few times &#8212; even one image works.</p><h2>Understanding the Two-Step Architecture</h2><p>The whole app is built around one simple split:</p><pre><code><code>apply_watermark(image, settings)  &#8594;  the pure image operation
process_folder(input, output, settings)  &#8594;  the batch loop
</code></code></pre><p>The first function takes a single PIL image plus a dict of settings and returns a watermarked copy. The second function walks the input folder, calls the first function for each image, and saves the result.</p><p>This split is the whole point of today&#8217;s lesson. <strong>Once you have a clean single-image function, batching it is just a </strong><code>for</code><strong> loop with error handling around it.</strong> Days 1, 2, and 3 of this week all reuse the same shape: do one thing well, then loop.</p><h2>Understanding RGBA Compositing</h2><p>A watermark isn&#8217;t just text drawn on top of an image &#8212; it&#8217;s text with <em>transparency</em>. If you set opacity to 60%, that means &#8220;60% white text, 40% whatever pixel is underneath.&#8221; That&#8217;s RGBA compositing, and it&#8217;s the foundation of every transparency effect in Pillow.</p><p>The pattern:</p><ol><li><p>Convert the photo to RGBA (so it can mix with transparent things).</p></li><li><p>Create a <em>separate transparent overlay</em> the same size as the photo.</p></li><li><p>Draw the text onto the overlay with an alpha channel set by opacity.</p></li><li><p>Composite the overlay onto the photo with <code>Image.alpha_composite</code>.</p></li></ol><pre><code><code>def apply_watermark(image, text, position, font_size, opacity, color):
    # 1. Force RGBA so compositing works
    base = image.convert("RGBA")

    # 2. Transparent overlay
    overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay)

    # 3. Compute the alpha (0-255) from opacity (0.0-1.0)
    fill_rgb = (255, 255, 255) if color == "white" else (0, 0, 0)
    fill_rgba = (*fill_rgb, int(opacity * 255))

    # 4. Position the text and draw it
    font = ImageFont.truetype(...)  # see below
    x, y = compute_position(text, font, base.size, position)
    draw.text((x, y), text, font=font, fill=fill_rgba)

    # 5. Composite and return
    return Image.alpha_composite(base, overlay)
</code></code></pre><p>The critical move is <strong>drawing the text onto a separate transparent overlay</strong>, not directly onto the photo. Drawing directly works for fully opaque text, but the moment you want partial transparency, the alpha values in <code>fill</code> get ignored unless the <em>canvas</em> itself supports alpha. The overlay does.</p><h2>Understanding ImageDraw and ImageFont</h2><p><code>ImageDraw</code> is Pillow&#8217;s drawing API. You wrap a canvas image and call drawing methods on it:</p><pre><code><code>from PIL import ImageDraw, ImageFont

draw = ImageDraw.Draw(overlay)
draw.text((50, 50), "Hello", font=font, fill=(255, 255, 255, 200))
</code></code></pre><p>For text, you need a font &#8212; and this is where things get a little fiddly across platforms. The cleanest approach: <strong>try a likely system font, fall back to Pillow&#8217;s built-in if it isn&#8217;t found</strong>:</p><pre><code><code>def load_font(size):
    candidates = [
        "DejaVuSans-Bold.ttf",                       # Linux
        "/Library/Fonts/Arial Bold.ttf",             # macOS
        "C:/Windows/Fonts/arialbd.ttf",              # Windows
    ]
    for path in candidates:
        try:
            return ImageFont.truetype(path, size)
        except (OSError, IOError):
            continue
    # Last resort: a tiny built-in font (size is not adjustable on this one)
    return ImageFont.load_default()
</code></code></pre><p>The <code>truetype</code> paths are platform-specific, so we try a few. <code>load_default()</code> always works but the size is fixed &#8212; only acceptable as a fallback.</p><h2>Understanding Text Measurement and Positioning</h2><p>To anchor a watermark to a corner, you need to know <strong>how big the text actually is</strong> in pixels. The modern way in Pillow is <code>textbbox</code>:</p><pre><code><code># Measure the rendered size of the text
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
</code></code></pre><p><code>textbbox((x, y), text)</code> returns <code>(left, top, right, bottom)</code> &#8212; the bounding box of where the text <em>would</em> be drawn if you called <code>draw.text((x, y), ...)</code>. Subtract to get width and height.</p><p>With that, anchor positioning is just math:</p><pre><code><code>def compute_position(text_w, text_h, img_w, img_h, position, pad=20):
    if position == "top-left":     return (pad, pad)
    if position == "top-right":    return (img_w - text_w - pad, pad)
    if position == "bottom-left":  return (pad, img_h - text_h - pad)
    if position == "bottom-right": return (img_w - text_w - pad, img_h - text_h - pad)
    if position == "center":       return ((img_w - text_w) // 2, (img_h - text_h) // 2)
    return (pad, pad)
</code></code></pre><p>The <code>pad</code> of 20 px keeps the text off the very edge &#8212; a small touch that makes a huge visual difference. Without padding, watermarks look glued to the corner; with padding, they look intentionally placed.</p><h2></h2><h2>Understanding Folder Iteration</h2><p>Listing images in a folder needs care. Folders contain hidden files, non-image files, subfolders. <code>pathlib</code> makes it tidy:</p><pre><code><code>from pathlib import Path

IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"}

def list_images(folder):
    return sorted(
        p for p in Path(folder).iterdir()
        if p.is_file() and p.suffix.lower() in IMAGE_EXTS
    )
</code></code></pre><p>Three small choices that matter:</p><ul><li><p><strong>Set lookup for extensions</strong> &#8212; <code>O(1)</code> membership and easy to extend.</p></li><li><p><code>p.suffix.lower()</code> &#8212; match <code>.JPG</code> as well as <code>.jpg</code>.</p></li><li><p><code>sorted(...)</code> &#8212; predictable order in the progress log, instead of whatever the filesystem coughs up.</p></li></ul><h2>Understanding Saving and Format</h2><p>The watermarked image is in RGBA mode. That&#8217;s fine for PNG, but JPEG can&#8217;t store transparency. So before saving JPEG, we flatten the alpha:</p><pre><code><code>def save_image(img, path):
    if path.suffix.lower() in {".jpg", ".jpeg"}:
        # Flatten to RGB so JPEG can save it
        img = img.convert("RGB")
        img.save(path, quality=92)
    else:
        img.save(path)
</code></code></pre><p><code>convert("RGB")</code> drops the alpha channel by blending against black &#8212; fine for our case because the <em>photo</em> underneath has no transparent areas. (If you needed to preserve transparency with JPEG-style compression, you&#8217;d save WebP or PNG instead.)</p><p>The <code>quality=92</code> is genuinely worth setting &#8212; Pillow&#8217;s JPEG default is 75, which is enough for thumbnails but visibly lossy on photos. 92 keeps the output looking essentially identical to the input.</p><h2></h2><h2>Coming Tomorrow</h2><p>Tomorrow we make things <em>fun</em>. The <strong>Meme Generator &amp; Collage Maker</strong> uses today&#8217;s text drawing skills for meme captions (the classic top + bottom text in white-with-black-outline) and adds a collage builder that arranges multiple photos into a single grid image. Two image tools, one app, ready to share.</p><h2>View Code Evolution</h2><p>Compare today&#8217;s batch watermarker with yesterday&#8217;s filters studio and see how the single-image transformation pattern scales to whole folders with one loop and a little error handling.</p><p></p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day-2f8">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Image Toolkit App with Pillow: Day 1 - Photo Filters Studio (Tkinter) ]]></title><description><![CDATA[Build a GUI app that loads a photo and has buttons and sliders to transform the image in real time, and save the result]]></description><link>https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/image-toolkit-app-with-pillow-day</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 16 Jun 2026 17:30:13 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/dbb86657-2209-4de4-941b-75b0e0f79984_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>Image Toolkit</strong> with Pillow that lets you edit photos in a desktop app, watermark whole folders of images at once, and turn photos into shareable memes and collages.</p><p><strong>Why build this?</strong> Because image manipulation is one of the most <em>fun</em> and visible domains in Python. Click a button, see your photo transform &#8212; instant feedback you can&#8217;t get with text-based tools. Plus the skills transfer everywhere: any time you need to resize, watermark, filter, or generate images, this is the foundation.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p><strong>What you&#8217;ll learn:</strong> This series teaches you Pillow (the modern PIL), Tkinter GUI design with image previews, color and pixel manipulation, image filters and convolutions, batch processing, EXIF orientation handling, and the classic bridges between PIL and Tkinter.</p><p><strong>Why this matters:</strong> By Day 3, you&#8217;ll have built a meme generator and collage maker that produces images you&#8217;d actually post. That&#8217;s portfolio-level fun.</p><ul><li><p><strong>Day 1:</strong> Photo Filters Studio (Tkinter) <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Batch Watermarker</p></li><li><p><strong>Day 3:</strong> Meme Generator &amp; Collage Maker</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-22">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We start with the <strong>visual heart</strong> of any image toolkit: a filter studio. Load a photo, see it on screen, click filter buttons or drag sliders, watch the image transform in real time, and save the result. By the end you&#8217;ll have a polished little Photoshop-lite &#8212; and the Pillow fundamentals to do <em>anything</em> image-related in Python.</p><p>The classic six one-click filters (Grayscale, Sepia, Blur, Sharpen, Edge Detect, Invert) plus three live sliders (Brightness, Contrast, Saturation). Reset returns to the original. Save writes a new file &#8212; your original is never touched.</p><h2>Project Task</h2><p>Build a photo filter desktop app with Tkinter and Pillow that:</p><ul><li><p>Loads an image via a file dialog (JPG, PNG, etc.)</p></li><li><p>Displays the image in a preview area, resized to fit</p></li><li><p>Correctly handles phone-photo rotation (EXIF orientation)</p></li><li><p>Applies one-click filters: Grayscale, Sepia, Blur, Sharpen, Edge Detect, Invert</p></li><li><p>Provides live sliders for Brightness, Contrast, and Saturation</p></li><li><p>Re-renders the preview every time the user changes something</p></li><li><p>Has a Reset button to return to the original</p></li><li><p>Saves the edited image as a new file (Save As&#8230;)</p></li><li><p>Never modifies the original &#8212; every edit happens on a copy</p></li></ul><p>This project gives you hands-on practice with Pillow&#8217;s <code>Image</code>, <code>ImageFilter</code>, <code>ImageEnhance</code>, and <code>ImageOps</code> modules, Tkinter file dialogs, scales/sliders, the <code>ImageTk</code> bridge for previewing PIL images in a GUI, and the small architecture choices that keep an image editor sane.</p><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>python photo_filters.py
</code></code></pre><p><strong>Application Window:</strong></p><p>Here is a snapshot of how the finished app looks like:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!J0ta!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!J0ta!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 424w, https://substackcdn.com/image/fetch/$s_!J0ta!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 848w, https://substackcdn.com/image/fetch/$s_!J0ta!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 1272w, https://substackcdn.com/image/fetch/$s_!J0ta!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!J0ta!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif" width="356" height="260" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:260,&quot;width&quot;:356,&quot;resizeWidth&quot;:356,&quot;bytes&quot;:406760,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/gif&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202315690?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!J0ta!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 424w, https://substackcdn.com/image/fetch/$s_!J0ta!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 848w, https://substackcdn.com/image/fetch/$s_!J0ta!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 1272w, https://substackcdn.com/image/fetch/$s_!J0ta!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F70f5bd13-9fd4-484c-8435-8ac073df489f_356x260.gif 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5qBi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5qBi!,w_424,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 424w, https://substackcdn.com/image/fetch/$s_!5qBi!,w_848,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 848w, https://substackcdn.com/image/fetch/$s_!5qBi!,w_1272,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 1272w, https://substackcdn.com/image/fetch/$s_!5qBi!,w_1456,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5qBi!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:null,&quot;width&quot;:null,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:5635111,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/gif&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202315690?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!5qBi!,w_424,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 424w, https://substackcdn.com/image/fetch/$s_!5qBi!,w_848,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 848w, https://substackcdn.com/image/fetch/$s_!5qBi!,w_1272,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 1272w, https://substackcdn.com/image/fetch/$s_!5qBi!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a614b9d-0291-42c6-9416-04fb0836fdfd_2000x1456.gif 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><h2>Setup Instructions</h2><p><strong>Install Pillow:</strong></p><pre><code><code>pip install Pillow
</code></code></pre><p>That&#8217;s it &#8212; Tkinter ships with Python. (On Linux: <code>sudo apt-get install python3-tk</code> if it isn&#8217;t already there.)</p><p><strong>Get a test image:</strong></p><p>A <code>sample.jpg</code> is provided below &#8212; a colorful generated scene that shows off every filter beautifully. You can also use any photo from your computer.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Rw7F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Rw7F!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Rw7F!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Rw7F!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Rw7F!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Rw7F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg" width="800" height="600" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:600,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:31247,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/202315690?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Rw7F!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 424w, https://substackcdn.com/image/fetch/$s_!Rw7F!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 848w, https://substackcdn.com/image/fetch/$s_!Rw7F!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!Rw7F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7c2cbdc-1d95-4522-bc13-d8997c0282cb_800x600.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p><strong>Run the app:</strong></p><pre><code><code>python photo_filters.py
</code></code></pre><h2>Understanding the Image-Editing Architecture</h2><p>Before any code, the architecture matters. There are three images alive at all times:</p><pre><code><code>self.original   &#8594;  The image as loaded from disk. NEVER touched again.
self.edited     &#8594;  The current edited version. This is what gets saved.
self.preview    &#8594;  A small, screen-sized copy of `edited` for display only.
</code></code></pre><p>Two rules fall out of this:</p><ol><li><p><strong>Filters and slider changes modify </strong><code>edited</code> &#8212; they never touch <code>original</code>.</p></li><li><p><strong>Every change is followed by a </strong><code>refresh_preview()</code> &#8212; which creates a fresh <code>preview</code> from <code>edited</code> and updates the GUI.</p></li></ol><p><code>Reset</code> is then trivial: <code>self.edited = self.original.copy()</code> &#8212; and refresh. This separation prevents the most common bugs in image editors (irreversible damage, slow re-renders, getting &#8220;stuck&#8221; on a filter).</p><h2>Understanding Pillow Basics</h2><p>Pillow&#8217;s central object is <code>Image</code>. Opening, copying, and saving look like this:</p><pre><code><code>from PIL import Image

img = Image.open("sample.jpg")   # lazy load &#8212; actual pixels read on demand
edited = img.copy()              # always work on a copy
edited.save("output.jpg")        # save anywhere with any supported extension
</code></code></pre><p>Three habits worth forming on day one:</p><ul><li><p><strong>Always </strong><code>.copy()</code><strong> before editing.</strong> Pillow&#8217;s filters often return new images, but it&#8217;s safer to think of the original as immutable.</p></li><li><p><strong>Don&#8217;t worry about format on open.</strong> Pillow detects JPG/PNG/BMP/GIF/WebP/HEIC automatically.</p></li><li><p><strong>The format on save is taken from the extension</strong> &#8212; <code>output.png</code> writes PNG, <code>output.jpg</code> writes JPEG.</p></li></ul><h2>Understanding EXIF Orientation</h2><p>Here&#8217;s a real-world trap: photos taken on a phone often look correct in Photos but appear <em>rotated 90&#176;</em> when opened with Pillow. That&#8217;s because the camera saves the image <em>in its native sensor orientation</em> and stores the intended rotation as <strong>EXIF metadata</strong> &#8212; and Pillow doesn&#8217;t apply it automatically.</p><p>The fix is one line:</p><pre><code><code>from PIL import ImageOps

img = Image.open("photo.jpg")
img = ImageOps.exif_transpose(img)   # apply the EXIF rotation
</code></code></pre><p><code>exif_transpose</code> reads the EXIF orientation tag and physically rotates/flips the image to match what the user expects. Without it, your &#8220;perfectly working&#8221; app will mysteriously fail on the first phone selfie someone tries. Call it once, right after opening &#8212; done.</p><h2>Understanding ImageTk: PIL &#8594; Tkinter Bridge</h2><p>Tkinter&#8217;s widgets can&#8217;t display a PIL <code>Image</code> directly. The bridge is <code>ImageTk.PhotoImage</code>, which wraps a PIL image into a Tkinter-compatible image object:</p><pre><code><code>from PIL import ImageTk

self.tk_image = ImageTk.PhotoImage(self.preview)
self.preview_label.config(image=self.tk_image)
</code></code></pre><p><strong>The classic gotcha:</strong> if you don&#8217;t keep a reference to <code>self.tk_image</code>, Python garbage-collects it and the image vanishes from the screen with no error. Always store it on <code>self.</code> &#8212; never as a local variable inside a method.</p><p>This is the #1 most common &#8220;my image won&#8217;t show&#8221; bug in Pillow+Tkinter code. Now you&#8217;ll never write it.</p><h2>Understanding Resizing for Preview</h2><p>A 4000&#215;3000 photo can&#8217;t be shown at full size &#8212; and resizing on every slider tick would be sluggish. The pattern: resize once, when the image changes, to a screen-friendly version.</p><p>Pillow&#8217;s <code>thumbnail()</code> method resizes <em>in place</em> and preserves aspect ratio:</p><pre><code><code>preview = self.edited.copy()
preview.thumbnail((600, 600), Image.Resampling.LANCZOS)
self.preview = preview
</code></code></pre><p>Two details that matter:</p><ul><li><p><code>thumbnail</code><strong> is in-place.</strong> It doesn&#8217;t return a new image &#8212; it modifies the one you call it on. So we <code>copy()</code> first.</p></li><li><p><code>Image.Resampling.LANCZOS</code> is the modern constant for the high-quality downsampling filter. Older code uses <code>Image.LANCZOS</code> (deprecated) or <code>Image.ANTIALIAS</code> (removed). LANCZOS is the right choice for shrinking photos.</p></li></ul><h2>Understanding ImageFilter: One-Click Filters</h2><p>For Blur, Sharpen, and Edge Detect, Pillow ships built-in convolution kernels in <code>ImageFilter</code>:</p><pre><code><code>from PIL import ImageFilter

self.edited = self.edited.filter(ImageFilter.BLUR)
self.edited = self.edited.filter(ImageFilter.SHARPEN)
self.edited = self.edited.filter(ImageFilter.FIND_EDGES)
</code></code></pre><p>A &#8220;convolution&#8221; is just: for each pixel, look at its neighbors, compute a weighted average. Blur averages neighbors. Sharpen subtracts a blurred version from the original. Edge detect amplifies differences between neighbors. You don&#8217;t need to write the math &#8212; <code>ImageFilter</code> has clean, named presets.</p><h2>Understanding ImageOps: Grayscale and Invert</h2><p>For pure color transformations, <code>ImageOps</code> is the right toolbox:</p><pre><code><code>from PIL import ImageOps

self.edited = ImageOps.grayscale(self.edited).convert("RGB")
self.edited = ImageOps.invert(self.edited)
</code></code></pre><p>A subtlety with grayscale: <code>ImageOps.grayscale</code> returns a single-channel (&#8221;L&#8221; mode) image. Converting back to &#8220;RGB&#8221; keeps the rest of the pipeline happy &#8212; later filters and the <code>ImageTk</code> display expect 3-channel images.</p><p><code>invert</code> simply flips every pixel value (255 &#8722; v), giving you that classic film-negative look.</p><h2>Understanding Sepia: Custom Per-Channel Math</h2><p>Sepia isn&#8217;t built in &#8212; and that&#8217;s a <em>good thing</em>, because it&#8217;s the perfect excuse to teach per-pixel/per-channel manipulation. The standard sepia formula transforms each pixel&#8217;s RGB values:</p><pre><code><code>new_R = 0.393&#183;R + 0.769&#183;G + 0.189&#183;B
new_G = 0.349&#183;R + 0.686&#183;G + 0.168&#183;B
new_B = 0.272&#183;R + 0.534&#183;G + 0.131&#183;B
</code></code></pre><p>Implemented in Pillow:</p><pre><code><code>def apply_sepia(img):
    img = img.convert("RGB")
    pixels = img.load()         # an indexable pixel grid

    for y in range(img.height):
        for x in range(img.width):
            r, g, b = pixels[x, y]
            tr = int(0.393 * r + 0.769 * g + 0.189 * b)
            tg = int(0.349 * r + 0.686 * g + 0.168 * b)
            tb = int(0.272 * r + 0.534 * g + 0.131 * b)
            pixels[x, y] = (min(tr, 255), min(tg, 255), min(tb, 255))

    return img
</code></code></pre><p><code>img.load()</code> returns a pixel-access object you can read and assign with <code>pixels[x, y]</code>. The <code>min(..., 255)</code> clamps values that overflow the 0&#8211;255 range. Slow for huge images but perfectly fine for the screen-sized previews we&#8217;re editing.</p><blockquote><p><strong>Aside:</strong> for large images and production code, you&#8217;d use numpy or <code>Image.point()</code> for vectorized math. But the pixel loop is <em>the</em> canonical way to teach what every image filter is doing under the hood, so we use it here. Worth the few seconds.</p></blockquote><h2>Understanding ImageEnhance: Adjustable Effects</h2><p>Brightness, Contrast, and Saturation aren&#8217;t on/off filters &#8212; the user dials them. That&#8217;s what <code>ImageEnhance</code> is for:</p><pre><code><code>from PIL import ImageEnhance

enhancer = ImageEnhance.Brightness(self.edited)
self.edited = enhancer.enhance(1.4)   # 1.0 = unchanged; &gt;1 brighter, &lt;1 darker
</code></code></pre><p>The number is a <em>factor</em>, not an amount:</p><ul><li><p><code>1.0</code> &#8212; leave as is</p></li><li><p><code>1.5</code> &#8212; 50% brighter / more contrast / more saturated</p></li><li><p><code>0.5</code> &#8212; 50% darker / flatter / desaturated</p></li><li><p><code>0.0</code> &#8212; completely black / no contrast / fully grayscale</p></li></ul><p>This factor pattern is uniform across <code>Brightness</code>, <code>Contrast</code>, <code>Color</code> (saturation), and <code>Sharpness</code>. Once you know one, you know all four.</p><h2>Understanding the Slider Refresh Pattern</h2><p>Sliders fire continuously as the user drags. To keep the UI snappy, each slider re-applies <em>all three enhancements</em> from the original, every time:</p><pre><code><code>def update_adjustments(self, _event=None):
    img = self.original_for_adjust.copy()

    img = ImageEnhance.Brightness(img).enhance(self.brightness_var.get())
    img = ImageEnhance.Contrast(img).enhance(self.contrast_var.get())
    img = ImageEnhance.Color(img).enhance(self.saturation_var.get())

    self.edited = img
    self.refresh_preview()
</code></code></pre><p>The key choice: we start each update from <code>self.original_for_adjust</code>, a <em>snapshot taken when the user last clicked a filter</em>. That way the sliders work on top of &#8220;grayscale&#8221;, &#8220;sepia&#8221;, etc., but moving a slider doesn&#8217;t accumulate enhancements every frame (which would amplify them out of control).</p><p>This snapshot-based pattern is the small architectural trick that makes the studio feel like a real editor instead of a glitchy demo.</p><h2>Understanding Saving</h2><p>Pillow infers the output format from the file extension:</p><pre><code><code>def save_as(self):
    path = filedialog.asksaveasfilename(
        defaultextension=".jpg",
        filetypes=[("JPEG", "*.jpg *.jpeg"),
                   ("PNG",  "*.png"),
                   ("All",  "*.*")],
    )
    if not path:
        return
    self.edited.save(path)
</code></code></pre><p>Two important details:</p><ul><li><p><strong>JPEG can&#8217;t store transparency.</strong> If the user has applied filters that introduce an alpha channel (rare here, but possible), save to PNG. Otherwise force RGB before saving JPEG: <code>self.edited.convert("RGB").save(path)</code>.</p></li><li><p><strong>JPEG quality defaults to 75.</strong> For photos worth sharing, add <code>quality=92</code> to the save call.</p></li></ul><p>Our app saves whatever PIL deems best for the extension, which works for the 95% case.</p><h2>Coming Tomorrow</h2><p>Tomorrow we go from one photo to a whole folder. The <strong>Batch Watermarker</strong> adds a text or logo watermark to every image in a folder, with position controls, opacity, and a live preview &#8212; turning today&#8217;s manual workflow into a one-click batch operation.</p><h2>Skeleton and Solution</h2><p>Below you will find both a downloadable skeleton.py file to help you code the project with comment guides and the downloadable solution.py file containing the correct solution.</p><p>Get the code skeleton here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/view/kVbf2AscvYdAzspL2Cre6Q&quot;,&quot;text&quot;:&quot;View Code Skeleton&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/view/kVbf2AscvYdAzspL2Cre6Q"><span>View Code Skeleton</span></a></p><p></p><p>Get the code solution here:</p><p></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/3PPtVOnXpmHqOwj5H043Iw&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/3PPtVOnXpmHqOwj5H043Iw"><span>View Code Solution</span></a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Web Scraping with BeautifulSoup: Day 3 - Search & Analyze the Scraped Data]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day-b09</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day-b09</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Fri, 12 Jun 2026 10:41:28 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7028c97c-fc13-48de-ac46-49c3b0324e67_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>Web Scraping with BeautifulSoup</strong> suite that extracts a complete product catalog from a real website, scales up to thousands of items across many pages, and turns the scraped data into a searchable mini-database.</p><ul><li><p><strong>Day 1:</strong> Scrape a Single Page</p></li><li><p><strong>Day 2:</strong> Multi-Page Scraping with Pagination</p></li><li><p><strong>Day 3:</strong> Search &amp; Analyze the Scraped Data <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-21">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale.</strong> You have a dataset of 1,000 scraped books. Now what? <em>Now you query it.</em> Today we turn the CSV into an interactive command-line search engine &#8212; keyword search, filters by category, price, and rating, sorting, and on-demand statistics. The scraping pipeline becomes a real tool you can use.</p><p>This is what makes scraping worth doing. Data sitting in a CSV is just rows. The same data behind a query interface becomes a mini-database &#8212; <em>yours</em> to ask questions of.</p><h2>Project Task</h2><p>Build an interactive book search and analysis tool that:</p><ul><li><p>Loads <code>all_books.csv</code> from Day 2 into a pandas DataFrame</p></li><li><p>Offers a friendly REPL &#8212; type a command, see results</p></li><li><p>Supports keyword search across titles and descriptions</p></li><li><p>Filters by category, price range, rating, and stock status</p></li><li><p>Combines filters (<code>category=Fiction min_price=20 rating&gt;=4</code>)</p></li><li><p>Sorts results by any field (<code>sort=price desc</code>)</p></li><li><p>Shows summary statistics on the full catalog or any filter</p></li><li><p>Exports the current view to a CSV</p></li><li><p>Has a clear <code>help</code> command and gracefully handles bad input</p></li></ul><p>This project gives you hands-on practice with pandas filtering, boolean masks, string matching, building a small CLI loop, parsing command arguments, and turning a dataset into a tool people can actually use.</p><h2>Expected Output</h2><p><strong>Running the tool:</strong></p><pre><code><code>python search_books.py</code></code></pre><p><strong>Interactive session:</strong></p><p>The user can interact with the program in the terminal. All the text in red color are commands the user has submitted:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!B-Ao!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!B-Ao!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 424w, https://substackcdn.com/image/fetch/$s_!B-Ao!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 848w, https://substackcdn.com/image/fetch/$s_!B-Ao!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 1272w, https://substackcdn.com/image/fetch/$s_!B-Ao!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!B-Ao!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png" width="1372" height="3798" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:3798,&quot;width&quot;:1372,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:611827,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/201726257?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!B-Ao!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 424w, https://substackcdn.com/image/fetch/$s_!B-Ao!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 848w, https://substackcdn.com/image/fetch/$s_!B-Ao!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 1272w, https://substackcdn.com/image/fetch/$s_!B-Ao!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0d8aba4a-53cd-445e-b19c-0235d1646207_1372x3798.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>You drop a <code>search</code> command and your dataset answers. Combine filters and it slices in real time. That&#8217;s a tool.</p><h2>Setup Instructions</h2><p><strong>Install pandas:</strong></p><pre><code><code>pip install pandas</code></code></pre><p>(You also need <code>all_books.csv</code> &#8212; the output of Day 2. If you haven&#8217;t run that yet, run it first, or use the sample file provided below)</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://drive.google.com/file/d/1seMU8EnNK4sTkqLJ9mg_Yi-I4hr82K-N/view?usp=sharing&quot;,&quot;text&quot;:&quot;Download all_books.csv&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://drive.google.com/file/d/1seMU8EnNK4sTkqLJ9mg_Yi-I4hr82K-N/view?usp=sharing"><span>Download all_books.csv</span></a></p><p><strong>Run the program:</strong></p><pre><code><code>python search_books.py
</code></code></pre><p>The tool drops you into an interactive prompt. Type <code>help</code> to see the commands; type <code>quit</code> to exit.</p><h2>Understanding the REPL Loop</h2><p>A REPL &#8212; Read, Evaluate, Print, Loop &#8212; is just a <code>while</code> loop around <code>input()</code> and a dispatch on what the user typed. It&#8217;s the simplest way to build an interactive tool, and it suits this kind of dataset exploration perfectly:</p><pre><code><code>def run_repl(df):
    current = df  # the current "view" - starts as the whole dataset

    while True:
        line = input("&gt; ").strip()
        if not line:
            continue
        if line == "quit":
            break

        # parse the command and dispatch
        current = handle_command(line, df, current)
</code></code></pre><p>Three habits worth noticing:</p><ul><li><p><strong>An empty line just continues</strong> &#8212; common enough to handle explicitly.</p></li><li><p><strong>The user typed string is </strong><code>.strip()</code><strong>ped</strong> &#8212; trailing whitespace from a paste shouldn&#8217;t break commands.</p></li><li><p><code>current</code><strong> holds the filtered view</strong>, separate from <code>df</code> (the full catalog) &#8212; so <code>reset</code> always has the full data to return to.</p></li></ul><h2>Understanding Command Parsing</h2><p>Each command starts with a word (<code>search</code>, <code>filter</code>, <code>sort</code>, <code>stats</code>...) followed by arguments. The simplest parse: split on whitespace, take the first token as the command name:</p><pre><code><code>def handle_command(line, df, current):
    parts = line.split()
    cmd = parts[0].lower()
    args = parts[1:]

    if cmd == "search":
        return command_search(current, " ".join(args))
    if cmd == "filter":
        return command_filter(df, args)
    if cmd == "stats":
        command_stats(current)
        return current
    # ...
</code></code></pre><p>Each command gets its own function. <code>command_search</code> and <code>command_filter</code> <em>return</em> the new view (so the REPL can update <code>current</code>); <code>command_stats</code> just prints and returns the view unchanged. This keeps the dispatch readable &#8212; each command does one thing in one place.</p><h2>Understanding pandas String Search</h2><p>The <code>search</code> command should match across titles <em>and</em> descriptions, case-insensitively. pandas&#8217; <code>.str.contains()</code> does the heavy lifting:</p><pre><code><code>def command_search(df, query):
    if not query:
        print("  Usage: search &lt;keyword&gt;")
        return df

    title_match = df["title"].str.contains(query, case=False, na=False)
    desc_match = df["description"].str.contains(query, case=False, na=False)

    matches = df[title_match | desc_match]
    show_results(matches, f"Found {len(matches)} matches for '{query}'")
    return matches
</code></code></pre><p>A few important details:</p><ul><li><p><code>case=False</code> &#8212; case-insensitive matching. <code>"python"</code> finds <code>"Python"</code>.</p></li><li><p><code>na=False</code> &#8212; treats missing values as &#8220;no match&#8221; instead of <code>NaN</code>. Without it, you get errors when the column has any blanks.</p></li><li><p><code>|</code> &#8212; boolean OR between the two masks. A book matches if <em>either</em> the title or the description contains the keyword.</p></li></ul><p>The result is a new DataFrame &#8212; a filtered view of the catalog &#8212; that becomes the new <code>current</code>. The next command operates on those matches.</p><h2>Understanding Boolean Masks</h2><p>Filtering in pandas is built on <strong>boolean masks</strong>: a column of <code>True</code>/<code>False</code> the same length as the DataFrame, used to keep only the <code>True</code> rows.</p><pre><code><code># Books over &#163;30
mask = df["price"] &gt;= 30
expensive = df[mask]

# Books over &#163;30 AND rated 4+
mask = (df["price"] &gt;= 30) &amp; (df["rating"] &gt;= 4)
expensive_and_good = df[mask]
</code></code></pre><p>Two non-obvious rules every pandas user hits:</p><ul><li><p><strong>Use </strong><code>&amp;</code><strong> and </strong><code>|</code>, not <code>and</code> and <code>or</code>. The Python keywords don&#8217;t work on column-level booleans.</p></li><li><p><strong>Wrap each condition in parentheses</strong>. <code>&amp;</code> has higher precedence than <code>&gt;=</code>, so without parens the order is wrong and you get errors.</p></li></ul><p><code>(condition) &amp; (condition) &amp; (condition)</code> is how every multi-filter query in pandas looks. Get used to it.</p><h2>Understanding the Filter Command</h2><p>The <code>filter</code> command takes key-value pairs like <code>category=Fiction min_price=20 rating&gt;=4</code>. We parse each piece, build a list of conditions, then combine them:</p><pre><code><code>def command_filter(df, args):
    mask = pd.Series(True, index=df.index)   # start: keep everything
    applied = []

    for arg in args:
        if arg.startswith("category="):
            value = arg.split("=", 1)[1].lower()
            mask &amp;= df["category"].str.lower().str.contains(value, na=False)
            applied.append(f"category={value}")

        elif arg.startswith("min_price="):
            value = float(arg.split("=", 1)[1])
            mask &amp;= df["price"] &gt;= value
            applied.append(f"price&gt;={value}")

        elif arg.startswith("max_price="):
            value = float(arg.split("=", 1)[1])
            mask &amp;= df["price"] &lt;= value
            applied.append(f"price&lt;={value}")

        elif arg.startswith("rating&gt;="):
            value = int(arg.split("=", 1)[1])
            mask &amp;= df["rating"] &gt;= value
            applied.append(f"rating&gt;={value}")

    result = df[mask]
    print(f"  Filter applied: {', '.join(applied)}")
    show_results(result, f"{len(result)} books match.")
    return result
</code></code></pre><p>Two patterns worth taking away:</p><ul><li><p><strong>Build masks incrementally with </strong><code>&amp;=</code> &#8212; starting from <code>True</code> everywhere, each condition narrows the result. Whether the user passes one filter or four, the same loop handles it.</p></li><li><p><strong>Always re-filter from the full catalog</strong>, not the current view. That way <code>filter category=Fiction</code> <em>replaces</em> the previous filter rather than narrowing within it &#8212; usually what you actually want.</p></li></ul><h2>Understanding the Sort Command</h2><p><code>sort price desc</code> reorders the current view. The parse picks up the column and direction:</p><pre><code><code>def command_sort(df, args):
    if not args:
        print("  Usage: sort &lt;field&gt; [asc|desc]")
        return df

    field = args[0]
    ascending = not (len(args) &gt; 1 and args[1].lower() == "desc")

    if field not in df.columns:
        print(f"  Unknown field: {field}")
        return df

    sorted_df = df.sort_values(field, ascending=ascending).head(10)
    direction = "ascending" if ascending else "descending"
    print(f"  Sorted by {field} ({direction}). Showing top 10:")
    show_results(sorted_df, "")
    return df  # return original; don't permanently sort the view
</code></code></pre><p>A subtlety: <code>sort</code> should <em>display</em> the sorted top-10 but <strong>not change</strong> the filter state. So we show the sorted view, but return the unsorted current view. The user expects &#8220;show me the top by price&#8221; to be a <em>view</em>, not a state change &#8212; the next <code>filter</code> shouldn&#8217;t be operating on a sliced sorted list.</p><h2>Understanding Formatting Aligned Output</h2><p>A search result is just a DataFrame &#8212; but <code>print(df)</code> looks ugly. We format each row manually so titles align, prices line up by the decimal, and ratings use stars:</p><pre><code><code>def show_results(df, header_message):
    if header_message:
        print(f"\n  {header_message}")
    if df.empty:
        print("  (no results)")
        return

    print()
    print(f"    {'Title':&lt;40} {'Category':&lt;18} {'Price':&gt;8} {'Rating':&gt;10}")
    print("    " + "&#9472;" * 80)

    for _, row in df.head(20).iterrows():
        title = row["title"]
        if len(title) &gt; 40:
            title = title[:37] + "..."
        stars = "&#9733;" * int(row["rating"])
        print(f"    {title:&lt;40} {row['category']:&lt;18} "
              f"&#163;{row['price']:&gt;6.2f}   {stars:&gt;8}")
</code></code></pre><p><code>.head(20)</code> caps the output &#8212; nobody scrolls through 1,000 rows in a terminal. The column widths (<code>:&lt;40</code>, <code>:&lt;18</code>, <code>:&gt;8</code>) and right-aligned price with two decimals (<code>&gt;6.2f</code>) make the table read like a real product, not raw data.</p><h2>Understanding the Stats Command</h2><p><code>stats</code> runs on the <em>current</em> view, so the same command answers both &#8220;stats for the full catalog&#8221; and &#8220;stats for what I just filtered.&#8221; It&#8217;s all pandas aggregation:</p><pre><code><code>def command_stats(df):
    if df.empty:
        print("  No books in current view.")
        return

    print(f"\n  Books:            {len(df)}")
    print(f"  Categories:       {df['category'].nunique()}")
    print(f"  Average price:    &#163;{df['price'].mean():.2f}")
    print(f"  Price range:      &#163;{df['price'].min():.2f} &#8211; &#163;{df['price'].max():.2f}")
    print(f"  Avg rating:       {df['rating'].mean():.2f} / 5")
    in_stock = (df["stock_count"] &gt; 0).sum()
    print(f"  In stock:         {in_stock} ({in_stock / len(df) * 100:.0f}%)")

    print("\n  Rating breakdown:")
    for rating in range(1, 6):
        count = (df["rating"] == rating).sum()
        stars = "&#9733;" * rating
        print(f"    {stars:&lt;13} {count:&gt;3}")

    print("\n  Top 5 categories by book count:")
    top = df["category"].value_counts().head(5)
    for cat, count in top.items():
        print(f"    {cat:&lt;22} {count:&gt;3}")
</code></code></pre><p>The reusable trick: <code>df['category'].value_counts()</code> &#8212; counts how often each value appears, sorted descending, in one line. It&#8217;s the fastest way to build a &#8220;top categories&#8221; or &#8220;top X&#8221; list from any column.</p><h2>Understanding the Export Command</h2><p><code>save results.csv</code> writes the current view to a file. One line:</p><pre><code><code>def command_save(df, args):
    if not args:
        print("  Usage: save &lt;filename&gt;")
        return df
    path = args[0]
    df.to_csv(path, index=False)
    print(f"  &#10003; Exported {len(df)} books to {path}")
    return df
</code></code></pre><p>This is the closing of the loop: scrape &#8594; enrich &#8594; query &#8594; <em>export the answer</em>. The user can take their filtered subset into pandas, Excel, or another tool. The tool becomes a <em>gateway</em>, not just an endpoint.</p><h2>Understanding Graceful Error Handling</h2><p>Users mistype things. A REPL that crashes on a typo is unusable, so each command wraps its risky parts in <code>try/except</code>:</p><pre><code><code>try:
    value = float(arg.split("=", 1)[1])
except ValueError:
    print(f"  Could not parse: {arg}")
    continue
</code></code></pre><p>The unknown-command fallback is just as important &#8212; <code>help</code> is a single press away, and bad input is just a printed message:</p><pre><code><code>print(f"  Unknown command: {cmd}. Type 'help' for the list.")
</code></code></pre><p>Quiet, clear, forgiving. A tool you&#8217;d actually use.</p><h2>Understanding Why This Is the Right Finale</h2><p>Look at the three days together:</p><ul><li><p><strong>Day 1</strong> taught you to <em>extract from one page</em>.</p></li><li><p><strong>Day 2</strong> taught you to <em>scale</em> across many pages with pagination and detail-page enrichment.</p></li><li><p><strong>Day 3</strong> taught you to <em>use</em> the result &#8212; turning raw scraped data into answers.</p></li></ul><p>The arc matters: scraping by itself isn&#8217;t valuable, it&#8217;s <em>the dataset and what you do with it</em> that matters. Every real scraping project ends with a query interface, a dashboard, an analysis &#8212; something that turns rows into decisions. Today&#8217;s REPL is the smallest, simplest version of that, and it makes the whole week click into place.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>web scraping pipeline</strong>:</p><ul><li><p><strong>Day 1:</strong> Extract structured data from a single web page</p></li><li><p><strong>Day 2:</strong> Scale across pagination + detail pages, with polite delays</p></li><li><p><strong>Day 3:</strong> Turn the scraped catalog into an interactive search tool</p></li></ul><p><strong>You now have:</strong></p><p>&#9989; <strong>Scraping fundamentals</strong> &#8212; <code>requests</code>, BeautifulSoup, CSS class selection, attributes &#9989; <strong>Pagination and multi-level scraping</strong> &#8212; listing pages + detail pages, combined cleanly &#9989; <strong>Polite scraping habits</strong> &#8212; User-Agent, timeouts, delays, error handling &#9989; <strong>Querying skills with pandas</strong> &#8212; boolean masks, string search, sorting, aggregation &#9989; <strong>A real tool</strong> &#8212; your scraped data, queryable in real time</p><p></p><p><strong>Next steps:</strong></p><ul><li><p>Add a charts module: matplotlib distribution plots for price and rating</p></li><li><p>Persist the dataset to SQLite for fast queries on larger catalogs</p></li><li><p>Build a Streamlit version of the query interface</p></li><li><p>Add a watcher: nightly rescrape, diff against the previous CSV</p></li><li><p>Handle login-required sites with <code>requests.Session</code></p></li></ul><p>You&#8217;ve built the foundation for a <strong>real scraping-and-analysis pipeline</strong>. &#128640;</p><h2>View Code Evolution</h2><p>Compare today&#8217;s query tool with Day 1&#8217;s single-page scraper and Day 2&#8217;s full-catalog scraper &#8212; and see how a clean three-step pipeline (extract &#8594; scale &#8594; query) is the shape of every real scraping project.</p><p></p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day-b09">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Web Scraping with BeautifulSoup: Day 2 - Multi-Page Scraping with Pagination]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day-3e6</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day-3e6</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 11 Jun 2026 13:01:54 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c1c8b461-9457-41a2-a6f6-c98f5fac084a_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>Web Scraping with BeautifulSoup</strong> suite that extracts a complete product catalog from a real website, scales up to thousands of items across many pages, and turns the scraped data into a searchable mini-database.</p><ul><li><p><strong>Day 1:</strong> Scrape a Single Page</p></li><li><p><strong>Day 2:</strong> Multi-Page Scraping with Pagination <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Search &amp; Analyze the Scraped Data</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-21">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we scraped 20 books from one page. Today we scrape <strong>1,000 books from 50 pages</strong> &#8212; the full catalog of books.toscrape.com. Then we go deeper: for each book, we also visit its detail page to pull richer information you can&#8217;t get from the listing &#8212; the exact UPC, real stock count, full description, and category.</p><p>This is what a real scraper looks like: it follows links, scales across pages, and handles failures politely. By the end of today, you&#8217;ll have a single CSV holding every book on the site, ready for Day 3&#8217;s search tool.</p><h2>Project Task</h2><p>Build a multi-page book scraper that:</p><ul><li><p>Scrapes every book across all 50 catalog pages</p></li><li><p>Detects the &#8220;next page&#8221; link to follow pagination automatically</p></li><li><p>Visits each book&#8217;s detail page to pull richer fields (UPC, stock count, description, category)</p></li><li><p>Adds a polite delay between requests</p></li><li><p>Shows clear progress (page X of Y, book X of Y)</p></li><li><p>Continues running if one page or detail page fails</p></li><li><p>Saves everything to a single CSV</p></li><li><p>Reports a final summary with counts and totals</p></li></ul><p>This project gives you hands-on practice with pagination, two-level scraping (listing &#8594; detail page), polite rate limiting, robust error handling, progress reporting, and combining data from multiple HTTP requests &#8212; the techniques behind every production-grade scraper.</p><h2>Expected Output</h2><p><strong>Running the full-catalog scraper:</strong></p><pre><code><code>python scrape_all_books.py
</code></code></pre><p><strong>Console Output:</strong></p><p>The script will run and visit different pages (50 in total) of the books.toscrape.com website and print out info for each book in the terminal:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ayU9!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ayU9!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 424w, https://substackcdn.com/image/fetch/$s_!ayU9!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 848w, https://substackcdn.com/image/fetch/$s_!ayU9!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!ayU9!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ayU9!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1593361,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/201590935?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ayU9!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 424w, https://substackcdn.com/image/fetch/$s_!ayU9!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 848w, https://substackcdn.com/image/fetch/$s_!ayU9!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 1272w, https://substackcdn.com/image/fetch/$s_!ayU9!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf0fae04-3ce2-4052-89c0-5eb9bfcc9ae3_2580x1720.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p><strong>Generated </strong><code>all_books.csv</code><strong>:</strong></p><p><strong>In addition to being printed in the terminal, the data will also be saved in a CSV file. Here is a snapshot of the file:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!_FR2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!_FR2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 424w, https://substackcdn.com/image/fetch/$s_!_FR2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 848w, https://substackcdn.com/image/fetch/$s_!_FR2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 1272w, https://substackcdn.com/image/fetch/$s_!_FR2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!_FR2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png" width="1456" height="148" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/cb610141-7278-491a-89cc-6804278054d8_1926x196.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:148,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:108995,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/201590935?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!_FR2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 424w, https://substackcdn.com/image/fetch/$s_!_FR2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 848w, https://substackcdn.com/image/fetch/$s_!_FR2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 1272w, https://substackcdn.com/image/fetch/$s_!_FR2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb610141-7278-491a-89cc-6804278054d8_1926x196.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>We will have 1,000 rows, 9 columns, every book on the site. Ready for tomorrow&#8217;s search tool.</p><h2>Setup Instructions</h2><p><strong>Install Required Packages:</strong></p><pre><code><code>pip install requests beautifulsoup4
</code></code></pre><p>(Same as Day 1 &#8212; no new dependencies.)</p><p><strong>Run it:</strong></p><pre><code><code>python scrape_all_books.py</code></code></pre><p>The full run takes <strong>roughly 5-10 minutes</strong> depending on your internet &#8212; that&#8217;s 50 listing pages + 1,000 detail pages = <strong>1,050 requests</strong> with a 0.2 second delay between each. The script prints progress so you&#8217;ll know it&#8217;s working.</p><blockquote><p><strong>Tip:</strong> if you just want to see it work without waiting, set <code>MAX_PAGES = 3</code> near the top of the script for a quick three-page test.</p></blockquote><h2>Understanding Pagination</h2><p>The first scaling problem is <em>finding all the pages</em>. We could hardcode <code>page-1.html</code> through <code>page-50.html</code>, but that breaks the moment the site adds page 51. The robust way is to <strong>let the page tell us where to go next</strong>.</p><p>Look at the bottom of any catalog page and you&#8217;ll find:</p><pre><code><code>&lt;li class="next"&gt;
  &lt;a href="page-2.html"&gt;next&lt;/a&gt;
&lt;/li&gt;
</code></code></pre><p>So our strategy is: scrape the current page, look for <code>&lt;li class="next"&gt;</code>, follow it. When that link doesn&#8217;t exist, we&#8217;re done.</p><pre><code><code>def find_next_url(soup, current_url):
    """Return the absolute URL of the next page, or None if this is the last."""
    next_li = soup.find("li", class_="next")
    if next_li is None:
        return None  # we've reached the end of the catalog
    next_href = next_li.find("a")["href"]
    return urljoin(current_url, next_href)
</code></code></pre><p>That <code>urljoin(current_url, ...)</code> is doing real work: pages 2 through 50 use relative links like <code>page-3.html</code>, and <code>urljoin</code> resolves each one <em>against the current page&#8217;s URL</em>. The result is always a correct absolute URL.</p><h2>Understanding the Main Pagination Loop</h2><p>With <code>find_next_url</code> in hand, the main scrape becomes a small <code>while</code> loop:</p><pre><code><code>url = BASE_URL
page_number = 0
all_books = []

while url:
    page_number += 1
    print(f"Page {page_number}:  {url}")

    html = fetch_page(url)
    if html is None:
        break  # can't fetch this page, stop the run

    soup = BeautifulSoup(html, "html.parser")
    books = scrape_listing_page(soup)
    all_books.extend(books)

    url = find_next_url(soup, url)   # None when we've reached the end
    time.sleep(REQUEST_DELAY)
</code></code></pre><p>The whole loop reads in plain English: <em>fetch this page, extract its books, find the next URL, sleep, repeat</em>. When <code>find_next_url</code> returns <code>None</code>, the loop ends naturally. No page counting, no hardcoded limit, no fragility.</p><h2>Understanding Polite Scraping</h2><p>Hitting a server with 1,050 requests as fast as Python can send them is rude &#8212; and a great way to get rate-limited or banned. The fix is a small delay between requests:</p><pre><code><code>import time

REQUEST_DELAY = 0.2   # seconds between requests

# ...inside the loop, after each request:
time.sleep(REQUEST_DELAY)
</code></code></pre><p>0.2 seconds is invisible to you but a <em>huge</em> break for the server &#8212; five requests per second is gentle for any well-running site. The fact that books.toscrape.com explicitly invites scraping doesn&#8217;t change the principle: <strong>every scraper you write should pause between requests</strong>. The habit matters more than this particular site&#8217;s tolerance.</p><h2>Understanding Two-Level Scraping</h2><p>A listing page tells you a book exists. The <strong>detail page</strong> tells you everything <em>about</em> it &#8212; the UPC, the precise stock count, the full description, the category. To get those, we visit each book&#8217;s detail URL.</p><p>The pattern is two-level: scrape the listing, then for each book in that listing, fetch and scrape its detail page.</p><pre><code><code>def enrich_with_details(book):
    """Fetch the book's detail page and add the extra fields to its dict."""
    html = fetch_page(book["url"])
    if html is None:
        # detail fetch failed; keep the listing data, add empty extras
        book.update(stock_count=None, category=None, upc=None, description=None)
        return book

    soup = BeautifulSoup(html, "html.parser")
    book["upc"] = extract_upc(soup)
    book["stock_count"] = extract_stock_count(soup)
    book["category"] = extract_category(soup)
    book["description"] = extract_description(soup)
    return book
</code></code></pre><p>The function takes a book dict (from the listing scrape) and returns the <em>same dict</em> with the extra fields added. This composes cleanly &#8212; listing data and detail data end up in one place.</p><h2></h2><h2>Understanding find_next_sibling</h2><p><code>find_next_sibling("p")</code> is worth a closer look &#8212; it&#8217;s the move for &#8220;find me the <em>next paragraph after this element</em>.&#8221; That&#8217;s how the description is structured on the site: a header div followed by the description paragraph at the same level.</p><pre><code><code>header = soup.find("div", id="product_description")
description_p = header.find_next_sibling("p")
</code></code></pre><p>This pattern shows up constantly in scraping when content is logically <em>related</em> but lives in <em>separate tags</em>. Sibling navigation lets you say &#8220;the thing right after this one,&#8221; without having to know its position in the document. You&#8217;ll reach for it again and again.</p><h2></h2><h2>Understanding Saving the Combined Data</h2><p>The output schema is the union of listing data and detail data &#8212; nine columns total:</p><pre><code><code>FIELDNAMES = [
    "title", "price", "rating", "availability",
    "stock_count", "category", "upc", "description", "url",
]

with open("all_books.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
    writer.writeheader()
    writer.writerows(all_books)
</code></code></pre><p>Because every book is a dict with the same keys, <code>DictWriter</code> aligns the columns correctly even when a few rows have empty <code>description</code> fields. The result is a single, clean CSV &#8212; 1,001 lines including the header &#8212; that becomes the input for Day 3.</p><h2>Coming Tomorrow</h2><p>Tomorrow we put the dataset to work. The <strong>Search &amp; Analyze tool</strong> takes <code>all_books.csv</code> and turns it into a query interface: search by title or keyword, filter by category, price range, and rating, and generate summary statistics &#8212; average prices per category, distribution of ratings, top-N by any field. Your scraped data becomes a small but real, useful database.</p><h2>View Code Evolution</h2><p>Compare today&#8217;s full-catalog scraper with yesterday&#8217;s single-page version and see how a small pagination loop and a detail-page step scale 20 books into 1,000 &#8212; without rewriting the extraction logic.</p><p></p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day-3e6">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Web Scraping with BeautifulSoup: Day 1 - Scrape a Single Page]]></title><description><![CDATA[Learn how to fetch a web page, parse it with BeautifulSoup, and extract structured data from a list of repeated items.]]></description><link>https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/web-scraping-with-beautifulsoup-day</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 09 Jun 2026 15:48:37 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ef42ca5c-aacf-40fc-b5ee-659e8fa1729c_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>Web Scraping with BeautifulSoup</strong> suite that extracts a complete product catalog from a real website, scales up to thousands of items across many pages, and turns the scraped data into a searchable mini-database.</p><p><strong>Why build this?</strong> Because web scraping is one of the most genuinely useful Python skills. Data that&#8217;s locked inside a website becomes <em>yours</em> &#8212; to analyze, search, compare, or feed into other tools. Every analyst, data scientist, and automation engineer needs this skill.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p><strong>What you&#8217;ll learn:</strong> This series teaches you HTTP requests, HTML parsing with BeautifulSoup, CSS selectors, handling lists of repeated elements, pagination, polite scraping with delays, structured data extraction, and querying the result.</p><p><strong>Why this matters:</strong> By Day 3, you&#8217;ll have scraped a complete catalog of 1,000 books and built a tool to search and analyze them. That&#8217;s a real, portfolio-worthy project &#8212; and the techniques transfer directly to scraping any catalog-style site.</p><ul><li><p><strong>Day 1:</strong> Scrape a Single Page <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Multi-Page Scraping with Pagination</p></li><li><p><strong>Day 3:</strong> Search &amp; Analyze the Scraped Data</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-21">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Today you learn the <em>fundamentals</em>: fetching a web page, parsing it with BeautifulSoup, and extracting structured data from a list of repeated items.</p><p>The target is <strong>books.toscrape.com</strong> &#8212; a real, public site explicitly built for scraping practice. It&#8217;s the official sandbox of the Python scraping community. Today&#8217;s mission: scrape every book on the homepage (20 books), capture each one&#8217;s title, price, rating, and availability, and save the result as a clean CSV.</p><p>By the end, you&#8217;ll understand how every catalog-style scraper works &#8212; because the pattern is the same whether you&#8217;re scraping books, products, jobs, real estate, or any other list of repeated items.</p><h2>Project Task</h2><p>Build a single-page book scraper that:</p><ul><li><p>Fetches the books.toscrape.com homepage with <code>requests</code></p></li><li><p>Sets a polite User-Agent header to identify your script</p></li><li><p>Parses the HTML with BeautifulSoup</p></li><li><p>Finds every book card on the page</p></li><li><p>For each book, extracts:</p><ul><li><p>Full title (from the link&#8217;s <code>title</code> attribute, not the truncated text)</p></li><li><p>Price as a real number (no <code>&#163;</code> symbol)</p></li><li><p>Star rating as an integer (1&#8211;5)</p></li><li><p>Stock availability (in stock / out of stock)</p></li><li><p>Link to the book&#8217;s detail page</p></li></ul></li><li><p>Reports progress as it works</p></li><li><p>Saves all books to a CSV with proper column types</p></li></ul><p>This project gives you hands-on practice with <code>requests</code>, BeautifulSoup, CSS class selection, attribute access, text cleanup, and writing structured data to CSV &#8212; the core of every web scraper.</p><h2>Expected Output</h2><p><strong>Running the scraper:</strong></p><pre><code><code>python scrape_books.py</code></code></pre><p><strong>Console Output:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!4xGv!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4xGv!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 424w, https://substackcdn.com/image/fetch/$s_!4xGv!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 848w, https://substackcdn.com/image/fetch/$s_!4xGv!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 1272w, https://substackcdn.com/image/fetch/$s_!4xGv!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!4xGv!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png" width="1372" height="1862" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1862,&quot;width&quot;:1372,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:359183,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/201318939?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4xGv!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 424w, https://substackcdn.com/image/fetch/$s_!4xGv!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 848w, https://substackcdn.com/image/fetch/$s_!4xGv!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 1272w, https://substackcdn.com/image/fetch/$s_!4xGv!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee95baf1-44eb-463b-b64e-d46c90378172_1372x1862.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p><strong>Generated </strong><code>books.csv</code><strong>:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5Evl!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5Evl!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 424w, https://substackcdn.com/image/fetch/$s_!5Evl!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 848w, https://substackcdn.com/image/fetch/$s_!5Evl!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 1272w, https://substackcdn.com/image/fetch/$s_!5Evl!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5Evl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:683680,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/201318939?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!5Evl!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 424w, https://substackcdn.com/image/fetch/$s_!5Evl!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 848w, https://substackcdn.com/image/fetch/$s_!5Evl!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 1272w, https://substackcdn.com/image/fetch/$s_!5Evl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd0d564a-cd9a-4bb7-9b87-06b244c84dd1_1884x1256.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Real prices as numbers, ratings as integers, links absolute &#8212; ready for analysis without further cleanup.</p><h2>Setup Instructions</h2><p><strong>Install Required Packages:</strong></p><pre><code><code>pip install requests beautifulsoup4
</code></code></pre><p>That&#8217;s it &#8212; two libraries. <code>requests</code> fetches the page, <code>beautifulsoup4</code> parses it.</p><p><strong>Run it:</strong></p><pre><code><code>python scrape_books.py
</code></code></pre><p>The script reads from the live site and writes <code>books.csv</code> next to itself.</p><h2>Understanding the Target Site</h2><p>Before writing any code, <strong>always look at the page in your browser</strong>. Open https://books.toscrape.com/ and right-click any book &#8594; &#8220;Inspect Element.&#8221;</p><p>You&#8217;ll see that every book on the page is wrapped in an <code>&lt;article&gt;</code> tag with the class <code>product_pod</code>:</p><pre><code><code>&lt;article class="product_pod"&gt;
  &lt;div class="image_container"&gt;
    &lt;a href="catalogue/a-light-in-the-attic_1000/index.html"&gt;
      &lt;img src="..." alt="A Light in the Attic"&gt;
    &lt;/a&gt;
  &lt;/div&gt;
  &lt;p class="star-rating Three"&gt;&lt;/p&gt;
  &lt;h3&gt;
    &lt;a href="catalogue/a-light-in-the-attic_1000/index.html"
       title="A Light in the Attic"&gt;A Light in the ...&lt;/a&gt;
  &lt;/h3&gt;
  &lt;div class="product_price"&gt;
    &lt;p class="price_color"&gt;&#163;51.77&lt;/p&gt;
    &lt;p class="instock availability"&gt; In stock &lt;/p&gt;
  &lt;/div&gt;
&lt;/article&gt;
</code></code></pre><p>This structure is the <em>whole game</em>. Every scraper boils down to: find the repeated container, then pluck specific fields out of each one. The HTML inspector tells you the class names and tags to target &#8212; that&#8217;s how you know what to put in your code.</p><h2>Understanding requests and Headers</h2><p>Scraping starts with downloading the page. <code>requests.get()</code> does the heavy lifting:</p><pre><code><code>import requests

url = "https://books.toscrape.com/"
headers = {"User-Agent": "Mozilla/5.0 (Python Scraper)"}

response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()      # blow up clearly if the page didn't load

html = response.text
</code></code></pre><p>Three habits worth forming from day one:</p><ul><li><p><strong>Always set a User-Agent.</strong> Many sites block the default <code>python-requests/...</code> UA. A real-looking string makes your scraper a polite, well-behaved visitor.</p></li><li><p><strong>Always set a timeout.</strong> Without one, a slow or unresponsive server can hang your script forever. Ten seconds is sensible for most pages.</p></li><li><p><strong>Call </strong><code>raise_for_status()</code><strong>.</strong> It throws an exception on 4xx/5xx responses, so you catch bad pages <em>immediately</em> instead of trying to parse an error page.</p></li></ul><h2>Understanding BeautifulSoup</h2><p><code>BeautifulSoup</code> turns HTML text into a tree you can search. You hand it the raw HTML and a parser name:</p><pre><code><code>from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "html.parser")
</code></code></pre><p>The two most-used methods:</p><ul><li><p><code>soup.find(tag, class_="...")</code> &#8212; returns the <em>first</em> matching element (or <code>None</code>).</p></li><li><p><code>soup.find_all(tag, class_="...")</code> &#8212; returns a <em>list</em> of every matching element.</p></li></ul><p>Note the <code>class_</code> with the trailing underscore &#8212; <code>class</code> is a reserved word in Python, so BeautifulSoup uses <code>class_</code> for matching on CSS classes. It catches everyone the first time.</p><h2>Understanding find_all for Repeated Items</h2><p>The first real scraping move is grabbing every book card on the page. We saw each one is an <code>&lt;article class="product_pod"&gt;</code>, so:</p><pre><code><code>book_cards = soup.find_all("article", class_="product_pod")

print(f"Found {len(book_cards)} books")
# Found 20 books
</code></code></pre><p><code>find_all</code> returns a list &#8212; we now iterate through it and extract from each card individually. This is the <em>heart</em> of catalog scraping: find the repeating container with <code>find_all</code>, then operate on each one in a loop.</p><h2>Understanding Field Extraction</h2><p>Inside each <code>&lt;article&gt;</code>, we pull out the four fields. Each one is its own little trick:</p><p><strong>Title</strong> &#8212; from the <code>&lt;a&gt;</code> tag inside the <code>&lt;h3&gt;</code>. The visible link text is truncated (&#8221;A Light in the &#8230;&#8221;), but the full title is in the <code>title</code> attribute:</p><pre><code><code>title_link = card.find("h3").find("a")
title = title_link["title"]           # "A Light in the Attic"  (the full one)
</code></code></pre><p><strong>Price</strong> &#8212; inside <code>&lt;p class="price_color"&gt;</code>, including the <code>&#163;</code> symbol. We extract the text, strip the symbol, convert to a float:</p><pre><code><code>price_text = card.find("p", class_="price_color").get_text()
price = float(price_text.replace("&#163;", "").strip())
</code></code></pre><p><strong>Rating</strong> &#8212; in the <em>class name</em> of <code>&lt;p class="star-rating Three"&gt;</code>. The number is encoded as a word:</p><pre><code><code>rating_tag = card.find("p", class_="star-rating")
rating_word = rating_tag["class"][1]       # ["star-rating", "Three"] -&gt; "Three"
rating = RATING_MAP[rating_word]           # {"One":1, "Two":2, ...}
</code></code></pre><p><strong>Availability</strong> &#8212; inside <code>&lt;p class="instock availability"&gt;</code>. The text has leading/trailing whitespace:</p><pre><code><code>availability = card.find("p", class_="instock availability").get_text(strip=True)
# "In stock"
</code></code></pre><p>Each extraction is two or three lines, but together they capture everything we need. <strong>The pattern is always the same: locate, extract, clean.</strong></p><h2>Understanding Why title= Beats the Link Text</h2><p>You might be tempted to use the visible text &#8212; <code>title_link.get_text()</code> &#8212; for the title. Don&#8217;t. The visible text is <em>deliberately truncated</em>: &#8220;A Light in the &#8230;&#8221; instead of &#8220;A Light in the Attic.&#8221;</p><p>The full title lives in the <code>title</code> attribute of the same link. Why? Because that&#8217;s what websites use to show a tooltip when you hover. The HTML stores the real value there even when the visible text is shortened for layout.</p><p><strong>Lesson:</strong> when scraping, the visible text isn&#8217;t always the data you want. Inspect the HTML, find the cleanest source, and use that. Attribute values (<code>title</code>, <code>data-*</code>, <code>value</code>, <code>href</code>) often hold the real, untruncated information.</p><h2>Understanding Resolving Relative URLs</h2><p>The <code>href</code> on each book is <em>relative</em>: <code>catalogue/a-light-in-the-attic_1000/index.html</code>. To save a useful link, we need the <em>absolute</em> URL. <code>urllib.parse.urljoin</code> handles this correctly even when the page has a base path:</p><pre><code><code>from urllib.parse import urljoin

base_url = "https://books.toscrape.com/"
relative = card.find("h3").find("a")["href"]

absolute = urljoin(base_url, relative)
# 'https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html'
</code></code></pre><p>Manual string concatenation breaks on edge cases. <code>urljoin</code> always produces a correct URL &#8212; use it without thinking about it.</p><h2>Understanding Saving to CSV</h2><p>We could use pandas, but the standard library&#8217;s <code>csv</code> module is perfect for clean, structured data:</p><pre><code><code>import csv

with open("books.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["title", "price", "rating",
                                            "availability", "url"])
    writer.writeheader()
    writer.writerows(books)
</code></code></pre><p>Two pieces of CSV trivia worth knowing:</p><ul><li><p><code>newline=""</code> &#8212; without this, the <code>csv</code> module sometimes writes blank lines between rows on Windows. Always include it.</p></li><li><p><code>encoding="utf-8"</code> &#8212; book titles can include accented characters, em-dashes, smart quotes. UTF-8 handles them all; the default encoding doesn&#8217;t on every OS.</p></li></ul><p><code>DictWriter</code> is the natural match for our extracted books &#8212; each book is already a dict with the right keys.</p><h2>Understanding Polite Scraping</h2><p>books.toscrape.com explicitly invites scraping (&#8221;We love being scraped!&#8221;), so today&#8217;s single fetch is fine. But the habits start now: identify yourself in the User-Agent, set a timeout, and check <code>raise_for_status</code>. Tomorrow when we hit 50 pages in a row, we&#8217;ll add a delay between requests too &#8212; but that&#8217;s a Day 2 detail. Today: one polite request, gracefully handled.</p><h2>Practical Use Cases</h2><p><strong>1. Catalog extraction:</strong></p><pre><code><code>The same pattern (find_all the cards, extract per card) scrapes any e-commerce listing page.
</code></code></pre><p><strong>2. Job listings:</strong></p><pre><code><code>Sites like Indeed and Remote OK use repeated cards just like book listings.
</code></code></pre><p><strong>3. Real-estate aggregation:</strong></p><pre><code><code>Pull property cards from a listing page &#8212; same logic, different selectors.
</code></code></pre><p><strong>4. Research and data collection:</strong></p><pre><code><code>Academic papers, datasets, contest leaderboards &#8212; catalog patterns are everywhere.
</code></code></pre><p><strong>5. Foundation for Day 2:</strong></p><pre><code><code>Once you can scrape one page, scaling to many is just a loop with pagination.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we go from 20 books to <strong>1,000</strong>. You&#8217;ll follow the &#8220;next page&#8221; link, scrape all 50 pages of the catalog, and add detail-page scraping for richer data &#8212; UPC, exact stock count, full description, and category. We also add polite delays between requests because real scrapers don&#8217;t hammer servers.</p><h2>Skeleton and Solution</h2><p>Below you will find both a downloadable skeleton.py file to help you code the project with comment guides and the downloadable solution.py file containing the correct solution.</p><p>Get the code skeleton here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/view/W8-gfZyx_tECBctPu4ODfA&quot;,&quot;text&quot;:&quot;View Skeleton&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/view/W8-gfZyx_tECBctPu4ODfA"><span>View Skeleton</span></a></p><p></p><p>Get the code solution here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/LuKHP5dv5mE23mZJLIeYlw&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/LuKHP5dv5mE23mZJLIeYlw"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build a CSV Sales Toolkit : Day 3 - Batch CSV Processor]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-3-batch</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-3-batch</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Fri, 05 Jun 2026 14:37:37 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/65cdb943-46a1-4ad6-bdea-5bdcc29ae6ec_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>CSV Sales Toolkit</strong> that cleans messy data, generates insightful reports, and processes whole folders of files at once &#8212; the exact workflow of real data work.</p><ul><li><p><strong>Day 1:</strong> Visual CSV Cleaner</p></li><li><p><strong>Day 2:</strong> Sales Analyzer &amp; Report</p></li><li><p><strong>Day 3:</strong> Batch CSV Processor <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-20">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale.</strong> You have a cleaner and an analyzer. Today they grow up. Instead of working on one file at a time, today&#8217;s tool points at a <strong>folder of monthly sales CSVs</strong>, cleans every one, combines them all into a single dataset, and produces a <strong>consolidated multi-month report</strong> &#8212; with per-month comparisons, growth percentages, and a full-period summary.</p><p>This is the moment your script becomes a <em>tool</em> someone could automate. Drop a new month&#8217;s file into the folder, run the script, get the updated report. That&#8217;s how real data work scales.</p><h2>Project Task</h2><p>Build a batch CSV processor that:</p><ul><li><p>Discovers every CSV in a designated folder automatically</p></li><li><p>Cleans each file using the Day 1 cleaning pipeline</p></li><li><p>Reports the result of each file (rows, missing values fixed)</p></li><li><p>Continues to the next file if one fails, instead of crashing</p></li><li><p>Merges all cleaned data into a single combined DataFrame</p></li><li><p>Runs the Day 2 analysis on the combined data</p></li><li><p>Adds per-month aggregates (revenue per month, month-over-month change)</p></li><li><p>Identifies the best month, top product overall, and trends</p></li><li><p>Saves both the combined CSV and the consolidated report</p></li><li><p>Prints a progress summary as it works</p></li></ul><p>This project gives you hands-on practice with <code>pathlib</code> for folder scanning, batch file handling, robust error handling, <code>pd.concat</code> for combining DataFrames, time-grouped aggregations with <code>dt.to_period</code>, and turning your earlier scripts into reusable functions.</p><h2>Expected Output</h2><p><strong>Folder structure before running:</strong></p><pre><code><code>monthly_sales/
&#9500;&#9472;&#9472; sales_2026_01.csv     (30 orders, messy)
&#9500;&#9472;&#9472; sales_2026_02.csv     (25 orders, messy)
&#9500;&#9472;&#9472; sales_2026_03.csv     (27 orders, messy)
&#9492;&#9472;&#9472; sales_2026_04.csv     (28 orders, messy)
</code></code></pre><p><strong>Running the processor:</strong></p><pre><code><code>python batch_process.py
</code></code></pre><p><strong>Console Output:</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!T6a8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!T6a8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 424w, https://substackcdn.com/image/fetch/$s_!T6a8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 848w, https://substackcdn.com/image/fetch/$s_!T6a8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 1272w, https://substackcdn.com/image/fetch/$s_!T6a8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!T6a8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png" width="1372" height="3612" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:3612,&quot;width&quot;:1372,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:597803,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199481329?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!T6a8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 424w, https://substackcdn.com/image/fetch/$s_!T6a8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 848w, https://substackcdn.com/image/fetch/$s_!T6a8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 1272w, https://substackcdn.com/image/fetch/$s_!T6a8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb886998e-f648-4b46-9767-4656ea932ec7_1372x3612.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>You drop a fifth month into the folder next quarter &#8212; and rerunning produces an updated five-month report with no changes to the code. That&#8217;s the win.</p><h2>Setup Instructions</h2><p><strong>Install pandas:</strong></p><pre><code><code>pip install pandas
</code></code></pre><p><strong>Get the data files:</strong></p><p>Download the <code>monthly_sales/</code> folder:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://drive.google.com/file/d/1ZM1--zluyoqXJcqMVRFXn_Fzc7GI-Rfs/view?usp=sharing&quot;,&quot;text&quot;:&quot;Download&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://drive.google.com/file/d/1ZM1--zluyoqXJcqMVRFXn_Fzc7GI-Rfs/view?usp=sharing"><span>Download</span></a></p><p></p><p>It contains four monthly CSVs. Place the folder next to your script.</p><p><strong>Run it:</strong></p><pre><code><code>python batch_process.py
</code></code></pre><p>The script reads every CSV in <code>monthly_sales/</code> and writes two new files: <code>all_sales_clean.csv</code> (the merged clean data) and <code>consolidated_report.txt</code> (the report).</p><h2>Understanding Folder Scanning with pathlib</h2><p>The first batch trick is <strong>finding</strong> the files automatically. We don&#8217;t hardcode filenames &#8212; we ask the folder what&#8217;s in it. <code>pathlib</code> makes this elegant:</p><pre><code><code>from pathlib import Path

folder = Path("monthly_sales")
csv_files = sorted(folder.glob("*.csv"))

print(f"Found {len(csv_files)} CSV files")
for path in csv_files:
    print(f"  {path.name}")
</code></code></pre><p>Three things to know:</p><ul><li><p><code>Path("monthly_sales")</code> &#8212; pathlib&#8217;s modern, OS-aware way to point at a folder.</p></li><li><p><code>.glob("*.csv")</code> &#8212; returns every file matching the pattern.</p></li><li><p><code>sorted(...)</code> &#8212; files come back in filesystem order, which can vary. Sorting alphabetically gives you predictable chronological order when filenames include dates.</p></li></ul><p>Adding a new monthly file later is now a non-event. The script discovers it automatically.</p><h2>Understanding Reusable Cleaning Functions</h2><p>We need the Day 1 cleaning logic &#8212; but as a <em>function</em>, not a GUI button. So we lift it into a standalone helper:</p><pre><code><code>TEXT_COLUMNS = ["customer_name", "product", "category", "status"]

def clean_dataframe(df):
    """Apply every cleaning step from Day 1 and return the result."""
    for col in TEXT_COLUMNS:
        df[col] = df[col].astype(str).str.strip()

    df["category"] = df["category"].str.title()
    df["status"] = df["status"].str.lower()

    df["unit_price"] = (
        df["unit_price"].astype(str)
        .str.replace("$", "", regex=False)
        .str.replace(",", "", regex=False)
    )
    df["unit_price"] = pd.to_numeric(df["unit_price"], errors="coerce")

    df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
    df["quantity"] = df["quantity"].fillna(1).astype(int)

    category_avg = df.groupby("category")["unit_price"].transform("mean")
    df["unit_price"] = df["unit_price"].fillna(category_avg).round(2)

    df["total_price"] = (df["quantity"] * df["unit_price"]).round(2)

    return df
</code></code></pre><p>This is the <strong>same logic as Day 1</strong>, packaged so it can be called over and over. That&#8217;s the broader lesson: <strong>a function you wrote for one file works for a thousand, the moment you stop hardcoding the data and start passing it as an argument.</strong></p><h2>Understanding the Batch Loop</h2><p>The heart of batch processing is the loop. For each file: load, clean, collect. The key habit is <strong>defensive error handling</strong> &#8212; one bad file should not destroy the entire run:</p><pre><code><code>cleaned_dfs = []
failures = []

for i, path in enumerate(csv_files, 1):
    try:
        df = pd.read_csv(path)
        df = clean_dataframe(df)
        cleaned_dfs.append(df)
        print(f"  [{i}/{len(csv_files)}] {path.name}   &#10003;  {len(df)} rows cleaned")
    except Exception as e:
        failures.append((path.name, str(e)))
        print(f"  [{i}/{len(csv_files)}] {path.name}   &#10007;  {e}")
</code></code></pre><p>Two important moves:</p><ul><li><p><code>try/except</code><strong> around each file</strong> &#8212; if one CSV is corrupt or has unexpected columns, the loop logs the failure and continues. The other files still process.</p></li><li><p><code>failures</code><strong> list</strong> &#8212; collecting errors instead of letting them kill the script means you get a <em>complete</em> picture of what happened, even when some files broke.</p></li></ul><p>A batch tool that crashes on the third file out of fifty is barely better than running them manually. Robustness is the feature.</p><h2>Understanding pd.concat for Merging</h2><p>After cleaning, we combine the per-file DataFrames into one. <code>pd.concat</code> is the tool:</p><pre><code><code>combined = pd.concat(cleaned_dfs, ignore_index=True)
combined.to_csv("all_sales_clean.csv", index=False)
</code></code></pre><p><code>pd.concat</code> stacks DataFrames vertically &#8212; row after row &#8212; as long as they share columns. <code>ignore_index=True</code> is important: without it, you keep each file&#8217;s original row indexes (0, 1, 2... then 0, 1, 2... again). With it, you get a single fresh sequence &#8212; much easier to work with.</p><p>The merged file is <em>also</em> a deliverable. Now you have a single clean CSV holding every month&#8217;s data, ready for any future analysis.</p><h2>Understanding Period Grouping</h2><p>The headline new capability today is <strong>monthly comparisons</strong>. With the merged data, we can group by month using pandas&#8217; time-period feature:</p><pre><code><code>combined["order_date"] = pd.to_datetime(combined["order_date"])

monthly = (
    completed.assign(month=completed["order_date"].dt.to_period("M"))
    .groupby("month")
    .agg(
        orders=("order_id", "count"),
        revenue=("total_price", "sum"),
    )
)
</code></code></pre><p>Two new ideas here:</p><ul><li><p><code>.dt.to_period("M")</code> &#8212; converts a date like <code>2026-01-15</code> into the period <code>2026-01</code>. Every date in January becomes the same period, which is exactly what you need to group by month.</p></li><li><p><code>.agg(name=(column, function), ...)</code> &#8212; the <em>named-aggregation</em> form of <code>agg</code>. You get to <em>name</em> each output column and pick what it&#8217;s computed from, in one clean call. Much more readable than the older positional form.</p></li></ul><p>The result is a small DataFrame with one row per month, ready to compare.</p><h2>Understanding Month-over-Month Change</h2><p>A monthly total tells you what happened. A month-over-month <em>change</em> tells you the <em>direction</em>. Pandas&#8217; <code>pct_change()</code> makes this a one-liner:</p><pre><code><code>monthly["change"] = monthly["revenue"].pct_change() * 100
# Output:
# 2026-01     &#8212;      (first month, no prior to compare to)
# 2026-02   -2.7%
# 2026-03  +15.3%
# 2026-04   +5.9%
</code></code></pre><p><code>pct_change()</code> returns the percentage change between each row and the one before it. The first row gets <code>NaN</code> &#8212; there&#8217;s no earlier value to compare to &#8212; which we display as an em-dash.</p><p>This single column turns a list of monthly revenues into a <em>story</em>: &#8220;Dipped in Feb, surged 15% in March, and kept climbing.&#8221; That&#8217;s the kind of sentence reports are supposed to enable.</p><h2>Understanding Formatting the Monthly Table</h2><p>The monthly section needs careful column alignment to look like a real report:</p><pre><code><code>lines.append("  Month        Orders   Revenue       Change")
lines.append("  " + "-" * 48)

for month, row in monthly.iterrows():
    change = row["change"]
    if pd.isna(change):
        change_str = "    &#8212;"            # first month, nothing to compare to
    else:
        sign = "+" if change &gt;= 0 else ""
        change_str = f"{sign}{change:.1f}%"

    lines.append(
        f"  {str(month):&lt;10}"
        f"  {int(row['orders']):&gt;4}"
        f"     ${row['revenue']:&gt;8,.2f}"
        f"   {change_str:&gt;6}"
    )
</code></code></pre><p>A few details earn their place:</p><ul><li><p><code>pd.isna(change)</code> &#8212; the safe way to check for missing values. (<code>change == NaN</code> always returns False &#8212; never use <code>==</code> for NaN.)</p></li><li><p><code>"+" if change &gt;= 0 else ""</code> &#8212; manually prepending <code>+</code> makes positive numbers visually distinct from negative ones at a glance.</p></li><li><p><code>{...:&lt;10}</code><strong> / </strong><code>{...:&gt;4}</code><strong> / </strong><code>{...:&gt;8,.2f}</code> &#8212; left- and right-alignment with widths. The column headers and the data rows use the same widths, so everything lines up.</p></li></ul><p>These small touches turn raw numbers into a table that reads like something from a real product.</p><h2>Understanding Why This Architecture Works</h2><p>Look at the three days side by side:</p><ul><li><p><strong>Day 1</strong> built a <code>clean_dataframe</code> workflow inside a GUI.</p></li><li><p><strong>Day 2</strong> built <code>build_report</code> from clean data.</p></li><li><p><strong>Day 3</strong> wraps both in a loop over a folder.</p></li></ul><p>Each layer was rewritten exactly <em>once</em>: when its job changed. The cleaning logic didn&#8217;t change today &#8212; we just call it in a loop. The reporting structure didn&#8217;t change much either &#8212; we added monthly comparisons. <strong>Most of today&#8217;s code is glue: scanning a folder, iterating, concatenating, formatting.</strong> That&#8217;s a real pattern for building software: solve one case well, then loop the solution.</p><h2>Practical Use Cases</h2><p><strong>1. Monthly business reporting:</strong></p><pre><code><code>Drop each new month's export into the folder, rerun, get an updated report &#8212; forever.
</code></code></pre><p><strong>2. Year-end consolidation:</strong></p><pre><code><code>Process 12 monthly files into one yearly summary, with month-over-month growth.
</code></code></pre><p><strong>3. Multi-source data merging:</strong></p><pre><code><code>Combine exports from different stores, regions, or systems into one analysis.
</code></code></pre><p><strong>4. Building data pipelines:</strong></p><pre><code><code>The pattern (discover &#8594; clean &#8594; merge &#8594; analyze) is the skeleton of every ETL job.
</code></code></pre><p><strong>5. Automated jobs:</strong></p><pre><code><code>Schedule this script and it generates fresh reports nightly without supervision.
</code></code></pre><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>CSV Sales Toolkit</strong>.</p><ul><li><p><strong>Day 1:</strong> Visual CSV cleaner &#8212; fixes real-world data mess with a click</p></li><li><p><strong>Day 2:</strong> Sales analyzer &#8212; turns clean data into business insights</p></li><li><p><strong>Day 3:</strong> Batch processor &#8212; scales the whole pipeline to a folder of files</p></li></ul><p><strong>You now have:</strong></p><p>&#9989; <strong>Data cleaning skills</strong> &#8212; whitespace, casing, types, missing values, the real toolkit &#9989; <strong>Aggregation skills</strong> &#8212; <code>groupby</code>, <code>agg</code>, <code>pct_change</code>, period grouping &#9989; <strong>Batch processing skills</strong> &#8212; <code>pathlib</code>, looped error handling, <code>pd.concat</code> &#9989; <strong>Report formatting skills</strong> &#8212; alignment, currency, percent, multi-section output &#9989; <strong>A clean architecture</strong> &#8212; three layers, reused not rewritten</p><p><strong>Real-world applications:</strong></p><ul><li><p>&#128202; <strong>Sales and finance reporting</strong> &#8212; exactly the workflow this teaches</p></li><li><p>&#129534; <strong>Receipt or invoice consolidation</strong> &#8212; batch-clean and merge</p></li><li><p>&#127991;&#65039; <strong>Inventory analysis</strong> &#8212; same patterns apply to product/stock data</p></li><li><p>&#128200; <strong>Marketing reporting</strong> &#8212; clean campaign exports, aggregate, compare</p></li><li><p>&#129302; <strong>Personal automation</strong> &#8212; turn a 30-minute manual job into a 10-second script</p></li></ul><p><strong>Next steps:</strong></p><ul><li><p>Add charts: matplotlib bar/line plots embedded in a PDF report</p></li><li><p>Make it incremental: detect new files and only process those</p></li><li><p>Schedule it: run nightly with cron, send the report by email</p></li><li><p>Add forecasting: use the monthly trend to predict next month</p></li><li><p>Database storage: write the cleaned merged data into SQLite for queries</p></li></ul><p>You&#8217;ve built the foundation for a <strong>real data automation pipeline</strong>. </p><h2>View Code Evolution</h2><p>Compare today&#8217;s batch processor with the Day 1 cleaner and Day 2 analyzer &#8212; and see how a folder loop, robust error handling, and one good architecture turn a small script into a tool that scales.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-3-batch">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a CSV Sales Toolkit : Day 2 - Sales Analyzer & Report]]></title><description><![CDATA[Today we build a Python program that loads a messy CSV, see it in a table, click a button, and watch the data transform &#8212; bad values fixed, missing cells filled.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-2-sales</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-2-sales</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 03 Jun 2026 12:51:02 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/32a57b28-319d-4e32-a1f9-345c7d50661e_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>CSV Sales Toolkit</strong> that cleans messy data, generates insightful reports, and processes whole folders of files at once &#8212; the exact workflow of real data work.</p><ul><li><p><strong>Day 1:</strong> Visual CSV Cleaner</p></li><li><p><strong>Day 2:</strong> Sales Analyzer &amp; Report <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Batch CSV Processor</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-20">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we cleaned the data. Today we <strong>make it talk</strong>. Clean data is only useful if it tells you something &#8212; so today we build a Sales Analyzer that takes <code>orders_clean.csv</code> and computes the numbers a business actually cares about: total revenue, best-selling products, revenue by category, daily trends, and average order value.</p><p>The result is a formatted text report you can read at a glance or email to someone &#8212; turning a spreadsheet of rows into actual answers.</p><h2>Project Task</h2><p>Build a sales analyzer that:</p><ul><li><p>Loads the cleaned orders CSV from Day 1</p></li><li><p>Separates completed orders from cancelled ones</p></li><li><p>Calculates headline metrics: total revenue, order count, average order value</p></li><li><p>Finds the best-selling products (by quantity and by revenue)</p></li><li><p>Breaks revenue down by category</p></li><li><p>Computes a daily sales trend</p></li><li><p>Calculates the cancellation rate</p></li><li><p>Formats everything into a clean, readable report</p></li><li><p>Saves the report to a text file</p></li></ul><p>This project gives you hands-on practice with pandas aggregation, <code>groupby</code>, sorting, filtering, and turning raw numbers into a formatted business report &#8212; the analysis half of real data work.</p><h2>Expected Output</h2><p><strong>Running the analyzer:</strong></p><pre><code><code>python analyze_sales.py</code></code></pre><p><strong>Console Output (and saved report):</strong></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!B06E!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!B06E!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 424w, https://substackcdn.com/image/fetch/$s_!B06E!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 848w, https://substackcdn.com/image/fetch/$s_!B06E!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 1272w, https://substackcdn.com/image/fetch/$s_!B06E!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!B06E!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:550235,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199456161?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!B06E!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 424w, https://substackcdn.com/image/fetch/$s_!B06E!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 848w, https://substackcdn.com/image/fetch/$s_!B06E!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 1272w, https://substackcdn.com/image/fetch/$s_!B06E!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ed14b2c-f634-4cf9-9001-8e894affef18_1488x992.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>The same text is printed to the console and saved as <code>sales_report.txt</code> &#8212; readable, shareable, done.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qj7M!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qj7M!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 424w, https://substackcdn.com/image/fetch/$s_!qj7M!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 848w, https://substackcdn.com/image/fetch/$s_!qj7M!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 1272w, https://substackcdn.com/image/fetch/$s_!qj7M!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qj7M!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:949100,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199456161?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!qj7M!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 424w, https://substackcdn.com/image/fetch/$s_!qj7M!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 848w, https://substackcdn.com/image/fetch/$s_!qj7M!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 1272w, https://substackcdn.com/image/fetch/$s_!qj7M!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7a580d83-a383-443b-aff1-b43fd6e04f1f_2964x1976.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h2>Setup Instructions</h2><p><strong>Install pandas:</strong></p><pre><code><code>pip install pandas
</code></code></pre><p><strong>Get the data file:</strong></p><p>You need <code>orders_clean.csv</code> &#8212; the output of Day 1. Run the Day 1 cleaner first, or use the cleaned file provided below:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://drive.google.com/file/d/1uY6BirMazbQiQOw9HtC9qHwvJynC0G2H/view?usp=sharing&quot;,&quot;text&quot;:&quot;Download orders_clean.csv&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://drive.google.com/file/d/1uY6BirMazbQiQOw9HtC9qHwvJynC0G2H/view?usp=sharing"><span>Download orders_clean.csv</span></a></p><p></p><p>Place the <em>orders_clean.csv</em> file in the same folder as the script.</p><p><strong>Run the Python script:</strong></p><pre><code><code>python analyze_sales.py</code></code></pre><p>The script reads <code>orders_clean.csv</code> and writes <code>sales_report.txt</code>.</p><h2>Understanding Why Clean Data Matters Here</h2><p>Day 1 wasn&#8217;t busywork &#8212; today is where it pays off. Every calculation below depends on the cleaning:</p><ul><li><p><strong>Total revenue</strong> sums <code>total_price</code> &#8212; only possible because prices are real numbers, not <code>$12.99</code> text.</p></li><li><p><strong>Revenue by category</strong> groups by <code>category</code> &#8212; only gives 3 groups because we standardized the capitalization.</p></li><li><p><strong>Completed vs. cancelled</strong> filters on <code>status</code> &#8212; only works because we made the status values consistent.</p></li></ul><p>Analysis on dirty data produces <em>confidently wrong</em> answers. That&#8217;s worse than no answer. Clean first, analyze second &#8212; always.</p><h2>Understanding Loading and Filtering</h2><p>We start by loading the clean CSV and splitting it by status. Most metrics should count <em>completed</em> orders only &#8212; a cancelled order earned no revenue:</p><pre><code><code>import pandas as pd

df = pd.read_csv("orders_clean.csv")

# Split by status
completed = df[df["status"] == "completed"]
cancelled = df[df["status"] == "cancelled"]
</code></code></pre><p><code>df[df["status"] == "completed"]</code> is <strong>boolean filtering</strong> &#8212; the inner part builds a column of <code>True</code>/<code>False</code>, and the outer part keeps only the <code>True</code> rows. This is the single most common pandas operation, and it&#8217;s how we make sure cancelled orders don&#8217;t inflate revenue.</p><h2>Understanding Headline Metrics</h2><p>The top-level numbers are simple aggregations on the completed orders:</p><pre><code><code>total_revenue = completed["total_price"].sum()
order_count = len(completed)
items_sold = completed["quantity"].sum()

# Average order value &#8212; guard against dividing by zero
average_order = total_revenue / order_count if order_count else 0

# Cancellation rate as a percentage
cancellation_rate = len(cancelled) / len(df) * 100
</code></code></pre><p>Each metric is one line. <code>.sum()</code> adds a column, <code>len()</code> counts rows. The only subtlety is <strong>guarding the division</strong> &#8212; <code>if order_count else 0</code> prevents a crash if there are no completed orders. Defensive arithmetic like this is what separates a script that works on <em>your</em> data from one that works on <em>any</em> data.</p><h2>Understanding groupby for Category Revenue</h2><p>To break revenue down by category, we use <code>groupby</code> &#8212; pandas&#8217; tool for &#8220;split into groups, then calculate per group&#8221;:</p><pre><code><code>category_revenue = (
    completed.groupby("category")["total_price"]
    .sum()
    .sort_values(ascending=False)
)
</code></code></pre><p>Read it as a pipeline:</p><ol><li><p><code>groupby("category")</code> &#8212; split the rows into one group per category.</p></li><li><p><code>["total_price"]</code> &#8212; we only care about the price column.</p></li><li><p><code>.sum()</code> &#8212; total it within each group.</p></li><li><p><code>.sort_values(ascending=False)</code> &#8212; order the result, biggest first.</p></li></ol><p>The result is a <code>Series</code>: category names on the left, total revenue on the right. This four-step pattern &#8212; <em>group, select, aggregate, sort</em> &#8212; is the backbone of nearly every report you&#8217;ll ever write.</p><h2>Understanding Top Products</h2><p>&#8220;Best-selling&#8221; has two meanings, and a good report shows both. Some products sell in high <em>volume</em>; others bring in more <em>money</em>. Same <code>groupby</code> pattern, different column:</p><pre><code><code># By revenue &#8212; which products earn the most money
top_by_revenue = (
    completed.groupby("product")["total_price"]
    .sum()
    .sort_values(ascending=False)
    .head(5)
)

# By quantity &#8212; which products sell the most units
top_by_quantity = (
    completed.groupby("product")["quantity"]
    .sum()
    .sort_values(ascending=False)
    .head(5)
)
</code></code></pre><p><code>.head(5)</code> keeps only the top five after sorting. Showing both lists is genuinely useful: a cheap product can top the quantity list while barely registering on revenue &#8212; that contrast is an <em>insight</em>, not just a number.</p><h2>Understanding the Daily Trend</h2><p>To see sales over time, we group by date. The <code>order_date</code> column is text, so we convert it to real dates first &#8212; that makes it sort chronologically and behave correctly:</p><pre><code><code>completed = completed.copy()
completed["order_date"] = pd.to_datetime(completed["order_date"])

daily_sales = (
    completed.groupby("order_date")["total_price"]
    .sum()
    .sort_index()
)

# The single best day
best_day = daily_sales.idxmax()       # the date with the highest total
best_amount = daily_sales.max()        # that highest total
</code></code></pre><p>Two new tools here:</p><ul><li><p><code>pd.to_datetime</code> turns date <em>text</em> into real date objects, so <code>2026-01-09</code> sorts after <code>2026-01-08</code>, not alphabetically.</p></li><li><p><code>.idxmax()</code> returns the <em>index label</em> of the maximum value &#8212; here, the date of the best day &#8212; while <code>.max()</code> returns the value itself. The pair answers &#8220;what was the best day, and how good was it?&#8221;</p></li></ul><blockquote><p><strong>Note the </strong><code>.copy()</code> &#8212; when you take a filtered slice of a DataFrame and then modify it, pandas warns you. <code>.copy()</code> makes a clean, independent copy so the modification is unambiguous and warning-free.</p></blockquote><h2>Understanding Formatting the Report</h2><p>Raw numbers aren&#8217;t a report &#8212; <em>formatting</em> makes them readable. Python&#8217;s f-strings give you fine control over alignment and number display:</p><pre><code><code># Currency: 2 decimals, comma thousands separator
print(f"  Total revenue:  ${total_revenue:,.2f}")
# -&gt; "  Total revenue:  $986.40"

# Percentage: 1 decimal place
print(f"  Cancellation rate:  {cancellation_rate:.1f}%")
# -&gt; "  Cancellation rate:  20.0%"

# Aligned columns: pad the name to a fixed width
for product, revenue in top_by_revenue.items():
    print(f"  {product:&lt;20} ${revenue:&gt;9,.2f}")
</code></code></pre><p>The format codes earn their keep:</p><ul><li><p><code>:,.2f</code> &#8212; comma thousands separator, exactly 2 decimals (money).</p></li><li><p><code>:.1f</code> &#8212; 1 decimal place (percentages).</p></li><li><p><code>:&lt;20</code> &#8212; left-align in a 20-character space (product names line up).</p></li><li><p><code>:&gt;9</code> &#8212; right-align in a 9-character space (numbers line up by the decimal).</p></li></ul><p>Aligned columns are the difference between a report that looks professional and one that looks like a data dump.</p><h2>Understanding Building Report Sections</h2><p>Rather than <code>print</code> scattered everywhere, we build the report as a list of text lines, then join it once at the end. This way the <em>same text</em> goes to both the console and the file:</p><pre><code><code>def build_report(df):
    lines = []
    lines.append("=" * 80)
    lines.append("SALES ANALYSIS REPORT")
    lines.append("=" * 80)
    lines.append("")
    # ... append every section ...
    return "\n".join(lines)
</code></code></pre><p>Collecting lines in a list and <code>"\n".join()</code>-ing them at the end is cleaner than dozens of <code>print</code> calls. You build the report <em>once</em>, as a string &#8212; then you can print it, save it, or both, with no duplication.</p><h2>Understanding Saving the Report</h2><p>Writing the finished report to a text file is a few lines with a <code>with</code> block:</p><pre><code><code>report_text = build_report(df)

print(report_text)                       # show it on screen

with open("sales_report.txt", "w") as f: # and save it to a file
    f.write(report_text)
</code></code></pre><p>The <code>with open(...)</code> block handles the file safely &#8212; it&#8217;s automatically closed even if something goes wrong. Because <code>build_report</code> returned one string, sending it to <em>both</em> the screen and the file is trivial. A <code>.txt</code> report is something a non-technical person can open, read, and forward &#8212; which is often the whole point of doing the analysis.</p><h2>Practical Use Cases</h2><p><strong>1. Weekly or monthly sales summaries:</strong></p><pre><code><code>Run the analyzer on the latest data, get a report ready to send to your team.
</code></code></pre><p><strong>2. Spotting your best products:</strong></p><pre><code><code>The revenue vs. quantity lists reveal which products to promote or restock.
</code></code></pre><p><strong>3. Tracking cancellation problems:</strong></p><pre><code><code>A rising cancellation rate is an early warning worth watching.
</code></code></pre><p><strong>4. Finding peak sales days:</strong></p><pre><code><code>The daily trend shows when customers buy &#8212; useful for planning promotions.
</code></code></pre><p><strong>5. Foundation for Day 3:</strong></p><pre><code><code>Tomorrow we run this analysis across a whole folder of monthly files at once.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow is the finale: the <strong>Batch CSV Processor</strong>. Instead of one file, you&#8217;ll point the tool at a <em>folder</em> of monthly sales CSVs &#8212; it cleans every one, analyzes them all, merges the data, and produces a single consolidated multi-month report. Real batch processing.</p><h2>View Code Evolution</h2><p>Compare today&#8217;s analyzer with yesterday&#8217;s cleaner and see how clean, typed data turns directly into meaningful business metrics.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-2-sales">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a CSV Sales Toolkit : Day 1 - CSV Sales Cleaner]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-1-csv</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-csv-sales-toolkit-day-1-csv</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 02 Jun 2026 12:36:52 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/fe074992-e8fb-4549-9c59-1474564d4325_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>CSV Sales Toolkit</strong> that cleans messy data, generates insightful reports, and processes whole folders of files at once &#8212; the exact workflow of real data work.</p><p><strong>Why build this?</strong> Because real-world data is <em>always</em> messy. Inconsistent capitalization, stray whitespace, dollar signs in number columns, missing values &#8212; every dataset has them. The ability to reliably clean data is one of the most practical skills in all of Python.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p><strong>What you&#8217;ll learn:</strong> This series teaches you data cleaning, type conversion, missing-value handling, aggregation and reporting, and batch file processing &#8212; the core skills behind data analysis and automation.</p><p><strong>Why this matters:</strong> By Day 3, you&#8217;ll have a tool that points at a folder of monthly sales files, cleans every one, and produces a single consolidated business report. That&#8217;s a genuinely useful piece of software.</p><ul><li><p><strong>Day 1:</strong> Visual CSV Cleaner <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Sales Analyzer &amp; Report</p></li><li><p><strong>Day 3:</strong> Batch CSV Processor</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-20">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We start at the foundation of all data work: <strong>cleaning</strong>. But instead of a script that runs and prints text, we&#8217;re building a <strong>visual desktop app</strong>. You load a messy CSV, <em>see</em> it in a table, click a button, and watch the data transform &#8212; bad values fixed, missing cells filled, all in front of you.</p><p>The input file looks fine at a glance. But it has stray whitespace, three different spellings of &#8220;Electronics,&#8221; dollar signs stuck in the price column, and missing values. Today you build a tool that fixes all of it &#8212; and <em>shows</em> you the difference.</p><h2>Project Task</h2><p>Build a visual CSV cleaner with Tkinter that:</p><ul><li><p>Loads a raw orders CSV and displays it in a table (Treeview)</p></li><li><p>Shows a &#8220;before&#8221; view of the messy data</p></li><li><p>Cleans the data on a button click:</p><ul><li><p>Strips whitespace from text columns</p></li><li><p>Standardizes category names to Title Case</p></li><li><p>Standardizes order status to lowercase</p></li><li><p>Removes <code>$</code> signs and converts prices to numbers</p></li><li><p>Converts quantity to whole numbers</p></li><li><p>Fills missing values sensibly</p></li><li><p>Adds a calculated <code>total_price</code> column</p></li></ul></li><li><p>Displays the cleaned &#8220;after&#8221; data in the same table</p></li><li><p>Shows a status log of exactly what was cleaned</p></li><li><p>Saves the cleaned data to a new CSV via a Save button</p></li></ul><p>This project gives you hands-on practice with pandas data cleaning, Tkinter GUIs, the Treeview table widget, and connecting a data pipeline to a visual interface &#8212; a practical mix of data and app-building skills.</p><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>python csv_cleaner.py
</code></code></pre><p><strong>Application Window:</strong></p><p>Running the code will produce this desktop app. Here we have already loaded the orders_raw.csv file which you can <a href="https://drive.google.com/file/d/1REpTTz5lzXhXh5fW6vtT3yms3X9jBKRp/view?usp=sharing">download here</a>:</p><p></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!97BL!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!97BL!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 424w, https://substackcdn.com/image/fetch/$s_!97BL!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 848w, https://substackcdn.com/image/fetch/$s_!97BL!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 1272w, https://substackcdn.com/image/fetch/$s_!97BL!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!97BL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1317913,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199452299?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!97BL!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 424w, https://substackcdn.com/image/fetch/$s_!97BL!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 848w, https://substackcdn.com/image/fetch/$s_!97BL!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 1272w, https://substackcdn.com/image/fetch/$s_!97BL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa03040db-6b9a-4051-8526-b8ab2ca81135_2196x1464.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>After clicking &#8220;Clean Data&#8221; we see this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!lo66!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lo66!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 424w, https://substackcdn.com/image/fetch/$s_!lo66!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 848w, https://substackcdn.com/image/fetch/$s_!lo66!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 1272w, https://substackcdn.com/image/fetch/$s_!lo66!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!lo66!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1353607,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199452299?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!lo66!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 424w, https://substackcdn.com/image/fetch/$s_!lo66!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 848w, https://substackcdn.com/image/fetch/$s_!lo66!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 1272w, https://substackcdn.com/image/fetch/$s_!lo66!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43ecb685-d8d8-4be2-b5fc-d1f84b575bcb_2196x1464.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>Watch the table change in place: <code>electronics</code> becomes <code>Electronics</code>, <code>$12.99</code> becomes <code>12.99</code>, the empty quantity in row 1004 fills with <code>1</code>, and a new <code>total_price</code> column appears.</p><h2>Setup Instructions</h2><p><strong>Install pandas:</strong></p><pre><code><code>pip install pandas
</code></code></pre><p>Tkinter ships with Python &#8212; nothing else to install.</p><p><strong>Get the data file:</strong></p><p>Download <code>orders_raw.csv</code> (provided with this project) and keep it handy &#8212; you&#8217;ll load it from inside the app.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://drive.google.com/file/d/1REpTTz5lzXhXh5fW6vtT3yms3X9jBKRp/view?usp=sharing&quot;,&quot;text&quot;:&quot;Download CSV&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://drive.google.com/file/d/1REpTTz5lzXhXh5fW6vtT3yms3X9jBKRp/view?usp=sharing"><span>Download CSV</span></a></p><p><strong>Run the .py program:</strong></p><pre><code><code>python csv_cleaner.py
</code></code></pre><h2>Understanding the Messy Data</h2><p>Before building anything, look at <code>orders_raw.csv</code>. These are the six problems hiding in it:</p><ol><li><p><strong>Whitespace</strong> &#8212; <code>" Alice Johnson"</code> and <code>"Bob Smith "</code> have stray spaces.</p></li><li><p><strong>Inconsistent categories</strong> &#8212; <code>Electronics</code>, <code>electronics</code>, <code>ELECTRONICS</code> are the <em>same</em> category spelled three ways.</p></li><li><p><strong>Inconsistent status</strong> &#8212; <code>completed</code>, <code>Completed</code>, <code>COMPLETED</code> &#8212; same problem.</p></li><li><p><strong>Currency symbols</strong> &#8212; some prices are <code>$12.99</code> (text!) instead of <code>12.99</code> (a number).</p></li><li><p><strong>Missing quantities</strong> &#8212; a few <code>quantity</code> cells are empty.</p></li><li><p><strong>Missing prices</strong> &#8212; a few <code>unit_price</code> cells are empty.</p></li></ol><p>Each one breaks analysis later. Sum revenue and <code>$12.99</code> crashes the math. Group by category and you get six groups instead of three. Cleaning is what makes analysis <em>possible</em>.</p><h2>Understanding the App Structure</h2><p>We organize the whole app as a class. The class holds the DataFrame as <code>self.df</code>, so every method &#8212; load, clean, save &#8212; works on the same shared data:</p><pre><code><code>import tkinter as tk
from tkinter import ttk, filedialog
import pandas as pd

class CSVCleanerApp:
    def __init__(self, root):
        self.root = root
        self.df = None          # the DataFrame, shared across all methods
        self.build_ui()

    def build_ui(self):
        # buttons, table, status log go here
        ...
</code></code></pre><p><code>self.df</code> starts as <code>None</code> (nothing loaded yet). <code>load_csv()</code> fills it, <code>clean_data()</code> transforms it, <code>save_csv()</code> writes it out. Storing the data on <code>self</code> is what lets separate button clicks act on one shared dataset.</p><h2>Understanding the Treeview Table</h2><p>Tkinter&#8217;s <code>ttk.Treeview</code> is the widget for showing tabular data &#8212; rows and columns, like a spreadsheet:</p><pre><code><code># Create the table
self.tree = ttk.Treeview(self.root, show="headings")

# Define which columns exist
self.tree["columns"] = ("order_id", "customer_name", "product")

# Set the header text for each column
for col in self.tree["columns"]:
    self.tree.heading(col, text=col)
    self.tree.column(col, width=120)

# Add a row of data
self.tree.insert("", "end", values=(1001, "Alice Johnson", "Wireless Mouse"))
</code></code></pre><p>Three ideas:</p><ul><li><p><code>show="headings"</code> hides Treeview&#8217;s default empty first column &#8212; you almost always want this.</p></li><li><p><code>.heading()</code> sets the visible column title; <code>.column()</code> sets its width.</p></li><li><p><code>.insert("", "end", values=(...))</code> adds one row; the tuple of values fills the columns left to right.</p></li></ul><h2>Understanding Displaying a DataFrame</h2><p>The table widget doesn&#8217;t know about pandas &#8212; so we need a function that takes any DataFrame and renders it into the Treeview. The pattern is: clear the table, set up the columns, then add every row.</p><pre><code><code>def show_dataframe(self, df):
    # 1. Clear any existing rows
    self.tree.delete(*self.tree.get_children())

    # 2. Set the columns to match the DataFrame
    self.tree["columns"] = list(df.columns)
    for col in df.columns:
        self.tree.heading(col, text=col)
        self.tree.column(col, width=110)

    # 3. Add every row from the DataFrame
    for _, row in df.iterrows():
        self.tree.insert("", "end", values=list(row))
</code></code></pre><p>This one function is reused for <em>both</em> the before and after views. Load the messy data &#8212; call <code>show_dataframe</code>. Clean it &#8212; call <code>show_dataframe</code> again. The table just re-renders whatever DataFrame you hand it.</p><h2>Understanding Loading a File</h2><p>The &#8220;Load CSV&#8221; button opens a file picker with <code>filedialog</code>, then reads the chosen file:</p><pre><code><code>from tkinter import filedialog

def load_csv(self):
    path = filedialog.askopenfilename(
        title="Select a CSV file",
        filetypes=[("CSV files", "*.csv")]
    )
    if not path:          # user clicked Cancel
        return

    self.df = pd.read_csv(path)
    self.show_dataframe(self.df)
    self.log(f"Loaded {path} &#8212; {len(self.df)} rows, {len(self.df.columns)} columns")
</code></code></pre><p><code>filedialog.askopenfilename</code> returns the selected path as a string &#8212; or an empty string if the user cancels, which is why we check <code>if not path</code>. Once loaded, we show the data and log what happened.</p><h2>Understanding the Cleaning Pipeline</h2><p>The cleaning logic is pure pandas &#8212; the same regardless of GUI or CLI. We break it into one method per problem, all called by <code>clean_data()</code>:</p><pre><code><code>def clean_data(self):
    if self.df is None:
        self.log("Load a CSV first!")
        return

    df = self.df

    # --- Strip whitespace from text columns ---
    text_cols = ["customer_name", "product", "category", "status"]
    for col in text_cols:
        df[col] = df[col].astype(str).str.strip()

    # --- Standardize capitalization ---
    df["category"] = df["category"].str.title()   # electronics -&gt; Electronics
    df["status"] = df["status"].str.lower()        # COMPLETED -&gt; completed

    # --- Fix the price column ---
    df["unit_price"] = df["unit_price"].astype(str).str.replace("$", "", regex=False)
    df["unit_price"] = pd.to_numeric(df["unit_price"], errors="coerce")

    # ... (quantity, missing values, total_price) ...

    self.df = df
    self.show_dataframe(self.df)
</code></code></pre><p>The key tools:</p><ul><li><p><code>.str.strip()</code><strong> / </strong><code>.str.title()</code><strong> / </strong><code>.str.lower()</code> &#8212; string operations applied to a whole column via the <code>.str</code> accessor.</p></li><li><p><code>.str.replace("$", "", regex=False)</code> &#8212; strips the dollar sign so the text can become a number.</p></li><li><p><code>pd.to_numeric(..., errors="coerce")</code> &#8212; converts text to numbers; anything unconvertible becomes <code>NaN</code> (missing) instead of crashing.</p></li></ul><h2>Understanding Missing Value Handling</h2><p>After converting types, some cells are missing. For sales data we <em>fill</em> rather than drop &#8212; dropping rows loses real orders.</p><p>Missing <strong>quantity</strong> &#8594; fill with <code>1</code> (an order existed, so at least one item):</p><pre><code><code>df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
df["quantity"] = df["quantity"].fillna(1).astype(int)
</code></code></pre><p>Missing <strong>unit_price</strong> &#8594; fill with that <em>category&#8217;s average price</em> (a flat number would distort things &#8212; a missing webcam price shouldn&#8217;t become $5):</p><pre><code><code>category_avg = df.groupby("category")["unit_price"].transform("mean")
df["unit_price"] = df["unit_price"].fillna(category_avg).round(2)
</code></code></pre><p><code>groupby(...).transform("mean")</code> computes each category&#8217;s average and aligns it to every row, so each gap is filled with <em>its own category&#8217;s</em> average. &#8220;Handle missing values&#8221; always means <em>think about what the right value is.</em></p><h2>Understanding the Calculated Column</h2><p>Clean, properly-typed data lets you <em>derive</em> new information. Total price is just quantity &#215; unit price &#8212; one line, now that both columns are real numbers:</p><pre><code><code>df["total_price"] = (df["quantity"] * df["unit_price"]).round(2)
</code></code></pre><p>This is the payoff of cleaning. If <code>unit_price</code> were still text with dollar signs, this multiplication would fail outright.</p><h2>Understanding the Status Log</h2><p>A good tool <em>tells the user what it did</em>. We add a scrolling text box at the bottom and append a line for each action:</p><pre><code><code>from tkinter import scrolledtext

# In build_ui:
self.status_log = scrolledtext.ScrolledText(self.root, height=6)

def log(self, message):
    self.status_log.insert(tk.END, message + "\n")
    self.status_log.see(tk.END)   # auto-scroll to the newest line
</code></code></pre><p>Each cleaning step calls <code>self.log(...)</code>. The user gets a running history &#8212; &#8220;stripped whitespace,&#8221; &#8220;filled 3 missing quantities&#8221; &#8212; instead of changes happening invisibly.</p><h2>Understanding Saving the Result</h2><p>The &#8220;Save Clean CSV&#8221; button uses a <em>save</em> dialog and writes the DataFrame out:</p><pre><code><code>def save_csv(self):
    if self.df is None:
        self.log("Nothing to save &#8212; load and clean a file first.")
        return

    path = filedialog.asksaveasfilename(
        defaultextension=".csv",
        filetypes=[("CSV files", "*.csv")]
    )
    if not path:
        return

    self.df.to_csv(path, index=False)
    self.log(f"Saved clean data to {path}")
</code></code></pre><p><code>asksaveasfilename</code> lets the user choose where to save. <code>index=False</code> in <code>to_csv</code> is important &#8212; without it, pandas writes its internal row numbers as a junk extra column.</p><h2>Practical Use Cases</h2><p><strong>1. Cleaning exported reports:</strong></p><pre><code><code>E-commerce, accounting, and CRM exports are notoriously messy &#8212; clean them on arrival.
</code></code></pre><p><strong>2. A reusable cleaning tool:</strong></p><pre><code><code>Build it once, then clean any sales CSV by loading it into the app.
</code></code></pre><p><strong>3. Visual data inspection:</strong></p><pre><code><code>Seeing the table before and after catches problems a script would hide.
</code></code></pre><p><strong>4. Non-technical users:</strong></p><pre><code><code>A GUI means colleagues can clean data without touching code.
</code></code></pre><p><strong>5. Foundation for Day 2:</strong></p><pre><code><code>Tomorrow's analyzer needs clean, typed data &#8212; this app produces exactly that.</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we put the clean data to work. The <strong>Sales Analyzer</strong> takes <code>orders_clean.csv</code> and computes real business insights &#8212; revenue by category, best-selling products, daily trends, average order value &#8212; and generates a formatted summary report.</p><h2>Skeleton and Solution</h2><p>Below you will find both a downloadable skeleton.py file to help you code the project with comment guides and the downloadable solution.py file containing the correct solution.</p><p>Get the code skeleton here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/view/ngPdoU65tK_WmPku2uhgWQ&quot;,&quot;text&quot;:&quot;View Code Skeleton&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/view/ngPdoU65tK_WmPku2uhgWQ"><span>View Code Skeleton</span></a></p><p></p><p>Get the code solution here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/mvFr1rE85fcLVDOgupikfA&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/mvFr1rE85fcLVDOgupikfA"><span>View Code Solution</span></a></p><p></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Build an AI Travel Itinerary Planner: Day 3 - Travel Planner Web App]]></title><description><![CDATA[Today we wrap both in a real web app. No more editing variables in a script &#8212; users open a page, type a destination, pick the trip length, list their interests, and click a button.]]></description><link>https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner-4f4</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner-4f4</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Fri, 29 May 2026 11:29:57 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/e0c332cb-b70a-495a-b722-31098baa8894_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>AI Travel Itinerary Planner</strong> that turns a destination into a beautiful, interactive map you can explore and share &#8212; perfect for trip planning!</p><ul><li><p><strong>Day 1:</strong> Interactive Trip Map with Folium</p></li><li><p><strong>Day 2:</strong> AI Itinerary Generator with Gemini</p></li><li><p><strong>Day 3:</strong> Travel Planner Web App <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-19">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale!</strong> We have a map engine and an AI itinerary generator. Today we wrap both in a real <strong>web app</strong>. No more editing variables in a script &#8212; users open a page, type a destination, pick the trip length, list their interests, and click a button. The AI plans the trip and the interactive map renders right on the page, ready to download and share.</p><p>We&#8217;re using <strong>Streamlit</strong>, so the whole app is pure Python &#8212; no HTML, CSS, or JavaScript.</p><h2>Project Task</h2><p>Build a travel planner web app that:</p><ul><li><p>Provides a clean web interface (destination, days, interests inputs)</p></li><li><p>Generates the itinerary with Gemini when the user clicks a button</p></li><li><p>Renders the interactive Folium map directly in the page</p></li><li><p>Shows the itinerary as a readable day-by-day breakdown</p></li><li><p>Lets users download the map as a standalone HTML file</p></li><li><p>Uses session state so the map persists across interactions</p></li><li><p>Shows loading spinners and friendly error messages</p></li><li><p>Reuses the Day 2 AI engine and the Day 1 map engine unchanged</p></li></ul><p>This project gives you hands-on practice with Streamlit, web app layout, session state, embedding interactive components, file downloads, and turning a script into a shareable product &#8212; essential skills for building and publishing data apps.</p><h2>Expected Output</h2><p><strong>Running the web app:</strong></p><pre><code><code>streamlit run travel_app.py</code></code></pre><p>A browser opens automatically at http://localhost:8501 and the user can set the preferences for their trip:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ZjUa!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ZjUa!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 424w, https://substackcdn.com/image/fetch/$s_!ZjUa!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 848w, https://substackcdn.com/image/fetch/$s_!ZjUa!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 1272w, https://substackcdn.com/image/fetch/$s_!ZjUa!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ZjUa!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png" width="1029" height="686" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:686,&quot;width&quot;:1029,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:187398,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199448080?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ZjUa!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 424w, https://substackcdn.com/image/fetch/$s_!ZjUa!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 848w, https://substackcdn.com/image/fetch/$s_!ZjUa!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 1272w, https://substackcdn.com/image/fetch/$s_!ZjUa!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb325b6a5-9a08-4804-bb4e-34e5e7b9ddbd_1029x686.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>After they click &#8220;Generate Trip&#8221; they see their itinerary in an interactive map:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!WnA2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!WnA2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 424w, https://substackcdn.com/image/fetch/$s_!WnA2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 848w, https://substackcdn.com/image/fetch/$s_!WnA2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 1272w, https://substackcdn.com/image/fetch/$s_!WnA2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!WnA2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:844556,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199448080?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!WnA2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 424w, https://substackcdn.com/image/fetch/$s_!WnA2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 848w, https://substackcdn.com/image/fetch/$s_!WnA2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 1272w, https://substackcdn.com/image/fetch/$s_!WnA2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e8cdeb-d5e3-4d96-a19a-65fc8cc08cb8_1608x1072.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h2>Setup Instructions</h2><p><strong>Install Required Packages:</strong></p><pre><code><code>pip install streamlit streamlit-folium langchain langchain-google-genai folium
</code></code></pre><p><strong>Add Your Google API Key:</strong></p><p>Open <code>travel_app.py</code> and paste your key into the <code>GOOGLE_API_KEY</code> line near the top:</p><pre><code><code>GOOGLE_API_KEY = "your-key-here"
</code></code></pre><p>(Get a free key at <a href="https://aistudio.google.com/app/apikey">Google AI Studio</a>.)</p><p><strong>Run the app:</strong></p><pre><code><code>streamlit run travel_app.py
</code></code></pre><p>Your browser opens automatically. To stop the app, press <code>Ctrl+C</code> in the terminal.</p><blockquote><p><strong>Security note:</strong> For simplicity, this project keeps the API key as a plain string in the script. That&#8217;s fine for learning and personal use, but if you share your code or push it to GitHub, your key would be exposed. The safer approach is to store the key in a <code>.env</code> file and load it with the <code>python-dotenv</code> package, or &#8212; for a deployed Streamlit app &#8212; use Streamlit&#8217;s built-in <strong>secrets management</strong>. Both keep secrets out of your source code.</p></blockquote><h2>Understanding Streamlit</h2><p>Streamlit turns a Python script into a web app. There&#8217;s no routing, no HTML templates, no frontend code &#8212; you call functions, and Streamlit renders widgets.</p><pre><code><code>import streamlit as st

st.title("My App")                      # a heading
name = st.text_input("Your name")        # an input box
if st.button("Greet"):                   # a button
    st.write(f"Hello, {name}!")          # output text
</code></code></pre><p>The mental model that matters: <strong>Streamlit re-runs your entire script top to bottom every time the user interacts with anything.</strong> Click a button, move a slider, type in a box &#8212; the whole script runs again. That&#8217;s simple and predictable, but it has one big consequence we have to design around (see session state below).</p><h2>Understanding the App Layout</h2><p>We split the screen into a <strong>sidebar</strong> for inputs and a <strong>main area</strong> for the map. <code>st.sidebar</code> puts widgets on the left:</p><pre><code><code>import streamlit as st

st.set_page_config(page_title="AI Travel Planner", page_icon="&#9992;&#65039;", layout="wide")

st.title("&#9992;&#65039; AI Travel Itinerary Planner")
st.caption("Describe your trip and get an interactive map in seconds.")

# --- Sidebar inputs ---
with st.sidebar:
    st.header("Plan your trip")
    destination = st.text_input("Destination", value="Rome")
    days = st.slider("Trip length (days)", min_value=1, max_value=7, value=3)
    interests = st.text_area("Your interests", value="food, history, art")
    generate = st.button("Generate Trip", type="primary")
</code></code></pre><p><code>layout="wide"</code> gives the map room to breathe. <code>st.slider</code> is a natural fit for trip length &#8212; bounded, no invalid input possible. <code>type="primary"</code> makes the button stand out.</p><h2>Understanding the Download Button</h2><p>A shareable map should be downloadable. Streamlit&#8217;s <code>st.download_button</code> lets users save a file &#8212; and Folium can render a map to an HTML string in memory with <code>get_root().render()</code>:</p><pre><code><code>def map_to_html(trip_map):
    """Render the Folium map to a standalone HTML string."""
    return trip_map.get_root().render()

st.download_button(
    label="&#128229; Download Map as HTML",
    data=map_to_html(st.session_state.trip_map),
    file_name="my_trip.html",
    mime="text/html",
)
</code></code></pre><p>No temp files, no disk writes &#8212; the HTML is generated in memory and streamed straight to the user&#8217;s browser as a download. The file they get is the same standalone map from Days 1 and 2.</p><h2>Understanding the Itinerary Display</h2><p>Below the map, we show the trip as readable text. We group stops by day and use an emoji per category for a friendly touch:</p><pre><code><code>CATEGORY_EMOJI = {
    "food": "&#127860;",
    "sightseeing": "&#128247;",
    "hotel": "&#128719;&#65039;",
    "activity": "&#11088;",
    "transport": "&#9992;&#65039;",
}

def show_itinerary(itinerary):
    st.subheader("Your Itinerary")
    stops = itinerary_to_stops(itinerary)

    for day in get_days(stops):
        st.markdown(f"**Day {day}**")
        for stop in get_stops_for_day(stops, day):
            emoji = CATEGORY_EMOJI.get(stop["category"], "&#128205;")
            st.markdown(f"{emoji} **{stop['name']}** &#8212; {stop['description']}")
</code></code></pre><p><code>st.markdown</code> renders bold text and emoji cleanly. The same <code>get_days</code> and <code>get_stops_for_day</code> helpers from Day 1 organize the output &#8212; more reuse of code we already trust.</p><h2>Understanding the Full Picture</h2><p>Look at how the three days fit together:</p><ul><li><p><strong>Day 1</strong> built <code>build_trip_map()</code> &#8212; takes stop dicts, returns an interactive map.</p></li><li><p><strong>Day 2</strong> built <code>generate_itinerary()</code> and <code>itinerary_to_stops()</code> &#8212; turns plain English into stop dicts via Gemini.</p></li><li><p><strong>Day 3</strong> added <em>only</em> the web layer &#8212; Streamlit widgets, session state, <code>st_folium</code>, a download button.</p></li></ul><p>The map engine and the AI engine were never rewritten. Day 3&#8217;s job was purely to wrap them in an interface. That&#8217;s the mark of well-structured code: <strong>each layer has one job, and new layers build on top without disturbing what&#8217;s below.</strong></p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>AI-powered travel planning web app</strong>!</p><ul><li><p><strong>Day 1:</strong> Interactive map engine with Folium</p></li><li><p><strong>Day 2:</strong> AI itinerary generation with Gemini + LangChain v1</p></li><li><p><strong>Day 3:</strong> A real web app that ties it together</p></li></ul><p><strong>You now have:</strong></p><p>&#9989; <strong>Interactive maps</strong> &#8212; color-coded, route-drawn, popup-rich &#9989; <strong>AI itinerary generation</strong> &#8212; plain English in, structured trip out &#9989; <strong>A real web interface</strong> &#8212; no script editing required &#9989; <strong>Downloadable, shareable maps</strong> &#8212; standalone HTML &#9989; <strong>Clean architecture</strong> &#8212; layered engine, AI, and UI</p><p><strong>Real-world applications:</strong></p><ul><li><p><strong>Personal trip planning</strong> &#8212; your own travel tool</p></li><li><p><strong>Travel blogs</strong> &#8212; let readers generate and explore routes</p></li><li><p><strong>Travel agencies</strong> &#8212; rapid first-draft itineraries for clients</p></li><li><p><strong>Portfolio project</strong> &#8212; AI + maps + web app in one</p></li><li><p><strong>A product starting point</strong> &#8212; add accounts, saved trips, bookings</p></li></ul><p><strong>Next steps:</strong></p><ul><li><p>Deploy it free on Streamlit Community Cloud so anyone can use it</p></li><li><p>Let users edit or reorder stops before downloading</p></li><li><p>Add hotel and restaurant suggestions with booking links</p></li><li><p>Support multi-city trips</p></li><li><p>Add trip cost estimates</p></li></ul><p>You&#8217;ve built the foundation for a <strong>real travel-tech product</strong>! &#128640;</p><h2>View Code Evolution</h2><p>Compare today&#8217;s web app with the Day 2 generator and the Day 1 map engine &#8212; and see how a clean, layered design lets you add a whole new interface without touching the code beneath it.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner-4f4">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build an AI Travel Itinerary Planner: Day 2 - AI Itinerary Generator with Gemini ]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner-eb0</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner-eb0</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 28 May 2026 11:15:51 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ea6b78da-b193-4028-b70f-20387dbd8eb2_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>AI Travel Itinerary Planner</strong> that turns a destination into a beautiful, interactive map you can explore and share &#8212; perfect for trip planning!</p><ul><li><p><strong>Day 1:</strong> Interactive Trip Map with Folium</p></li><li><p><strong>Day 2:</strong> AI Itinerary Generator with Gemini <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Travel Planner Web App</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-19">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we built the map engine and hand-fed it a Lisbon itinerary. Today the <strong>AI writes the itinerary for us</strong>. You describe a trip in plain English &#8212; &#8220;4 days in Rome, I love food and ancient history&#8221; &#8212; and Gemini, through LangChain v1, returns a fully structured itinerary with coordinates baked in. That output drops straight into yesterday&#8217;s map code, unchanged.</p><p>This is the payoff of Day 1&#8217;s design: because we built a stable data shape first, the AI just has to fill it.</p><h2>Project Task</h2><p>Build an AI itinerary generator that:</p><ul><li><p>Takes a plain-English trip request (destination, days, interests)</p></li><li><p>Uses LangChain v1 + Gemini to generate a structured itinerary</p></li><li><p>Returns each stop with name, day, lat, lon, category, and description</p></li><li><p>Uses Pydantic models so the AI output is validated and type-safe</p></li><li><p>Uses <code>with_structured_output()</code> &#8212; no manual JSON parsing</p></li><li><p>Feeds the AI itinerary into the Day 1 Folium map engine</p></li><li><p>Handles API errors and missing keys gracefully</p></li><li><p>Saves the finished map as a shareable HTML file</p></li></ul><p>This project gives you hands-on practice with LangChain v1, the Gemini API, structured AI output, Pydantic schema validation, and connecting an AI layer to a visualization layer &#8212; essential skills for AI-powered applications.</p><h2>Expected Output</h2><p><strong>Running the AI generator:</strong></p><pre><code><code>python ai_trip_map.py
</code></code></pre><p><strong>Console Output:</strong><br>The user can define their trip preferences in the terminal and the program will print out an itinerary:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!aeBV!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!aeBV!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 424w, https://substackcdn.com/image/fetch/$s_!aeBV!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 848w, https://substackcdn.com/image/fetch/$s_!aeBV!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 1272w, https://substackcdn.com/image/fetch/$s_!aeBV!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!aeBV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1304776,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199445384?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!aeBV!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 424w, https://substackcdn.com/image/fetch/$s_!aeBV!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 848w, https://substackcdn.com/image/fetch/$s_!aeBV!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 1272w, https://substackcdn.com/image/fetch/$s_!aeBV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c96742e-bd0a-44c3-8662-d4d3568db71d_2484x1656.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>After that runs an HTML file will be created which can be opened in the browser. It will contain the itinerary with interactive map pins:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!XJiQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!XJiQ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 424w, https://substackcdn.com/image/fetch/$s_!XJiQ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 848w, https://substackcdn.com/image/fetch/$s_!XJiQ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 1272w, https://substackcdn.com/image/fetch/$s_!XJiQ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!XJiQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png" width="1413" height="942" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:942,&quot;width&quot;:1413,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1389962,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199445384?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!XJiQ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 424w, https://substackcdn.com/image/fetch/$s_!XJiQ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 848w, https://substackcdn.com/image/fetch/$s_!XJiQ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 1272w, https://substackcdn.com/image/fetch/$s_!XJiQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6f4c8e7b-7538-490b-ada1-1d0662010ad9_1413x942.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p></p><h2>Setup Instructions</h2><p><strong>Install Required Packages:</strong></p><pre><code><code>pip install langchain langchain-google-genai folium
</code></code></pre><p><strong>Get Your Google API Key:</strong></p><ol><li><p>Go to <a href="https://aistudio.google.com/app/apikey">Google AI Studio</a></p></li><li><p>Click &#8220;Create API Key&#8221; and copy it</p></li><li><p>Assign the key to the GOOGLE_API_KEY variable as a string in the code.</p></li></ol><p><strong>Run it:</strong></p><pre><code><code>python ai_trip_map.py</code></code></pre><h2>Understanding Structured Output</h2><p>The hardest part of using an LLM in real code is that it returns <em>text</em>. Free-form text is unreliable &#8212; you&#8217;d be writing fragile parsers and praying the model uses the right quote style.</p><p>LangChain&#8217;s <code>with_structured_output()</code> solves this. You define the shape you want as a Pydantic model, and the AI is <em>constrained</em> to return exactly that shape &#8212; already parsed into Python objects:</p><pre><code><code>from pydantic import BaseModel
from langchain_google_genai import ChatGoogleGenerativeAI

class Person(BaseModel):
    name: str
    age: int

llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
structured_llm = llm.with_structured_output(Person)

result = structured_llm.invoke("Tell me about a person named Alice, age 30")
# result is a Person object: Person(name='Alice', age=30)
</code></code></pre><p>No JSON parsing. No string cleanup. You get a typed object, and if the AI returns something malformed, Pydantic raises a clear error instead of silently corrupting your data.</p><h2>Understanding the Pydantic Schema</h2><p>We need the AI to return the <em>exact</em> data shape our Day 1 map expects. So we describe it as Pydantic models &#8212; one for a single stop, one for the whole itinerary:</p><pre><code><code>from pydantic import BaseModel, Field

class Stop(BaseModel):
    """A single stop on the trip."""
    name: str = Field(description="Name of the place")
    day: int = Field(description="Which day of the trip this stop is on")
    lat: float = Field(description="Latitude coordinate")
    lon: float = Field(description="Longitude coordinate")
    category: str = Field(description="One of: food, sightseeing, hotel, activity, transport")
    description: str = Field(description="One-sentence description of the stop")

class Itinerary(BaseModel):
    """A complete multi-day travel itinerary."""
    trip_title: str = Field(description="A short title for the trip")
    stops: list[Stop] = Field(description="All stops across all days, in visiting order")
</code></code></pre><p><strong>Why the </strong><code>Field(description=...)</code><strong> matters:</strong> those descriptions aren&#8217;t just documentation. LangChain sends them to the model as part of the schema. They&#8217;re how you tell the AI &#8220;category must be one of these five values&#8221; or &#8220;give me one sentence, not a paragraph.&#8221; Good field descriptions are effectively prompt engineering.</p><h2>Understanding the LangChain v1 + Gemini Setup</h2><p>LangChain v1 keeps the model setup clean. You create the chat model, then attach the schema:</p><pre><code><code>import os
from langchain_google_genai import ChatGoogleGenerativeAI

def build_ai():
    llm = ChatGoogleGenerativeAI(
        model="gemini-2.5-flash",
        google_api_key=os.getenv("GOOGLE_API_KEY"),
        temperature=0.7,  # a little creativity for varied suggestions
    )
    # Constrain every response to the Itinerary schema
    return llm.with_structured_output(Itinerary)
</code></code></pre><p><code>temperature=0.7</code> is a deliberate choice: trip planning benefits from variety, so we don&#8217;t want the near-deterministic <code>0</code> you&#8217;d use for data extraction. The structured-output constraint still guarantees the <em>shape</em> &#8212; temperature only affects the <em>content</em>.</p><h2>Understanding the Prompt</h2><p>Even with structured output, the prompt still matters &#8212; it&#8217;s where you give the AI its instructions and quality bar:</p><pre><code><code>def build_prompt(destination, days, interests):
    return f"""You are an expert travel planner.

Create a {days}-day travel itinerary for {destination}.
The traveler is interested in: {interests}.

Requirements:
- Plan exactly 3 stops per day.
- Spread stops geographically so each day is walkable where possible.
- Include a mix of categories, with at least one food stop per day.
- Use real, well-known places with accurate latitude and longitude.
- Keep each description to a single vivid sentence.
- Order stops within each day in a sensible visiting sequence.
"""
</code></code></pre><p>Notice we ask for <strong>accurate latitude and longitude</strong> directly. Gemini knows the coordinates of famous landmarks well, so for a travel planner this is reliable enough &#8212; and it keeps the project to a single API call with no separate geocoding service.</p><h2>Understanding the AI-to-Map Handoff</h2><p>Here&#8217;s where Day 1&#8217;s design pays off. The AI returns an <code>Itinerary</code> object full of <code>Stop</code> objects. Our map engine expects a list of plain dicts. One small conversion bridges them:</p><pre><code><code>def itinerary_to_stops(itinerary):
    """Convert the AI's Pydantic Itinerary into the dict list the map expects."""
    return [
        {
            "name": stop.name,
            "day": stop.day,
            "lat": stop.lat,
            "lon": stop.lon,
            "category": stop.category,
            "description": stop.description,
        }
        for stop in itinerary.stops
    ]
</code></code></pre><p>After this, <strong>every map function from Day 1 works untouched</strong> &#8212; <code>build_trip_map</code>, <code>add_day_to_map</code>, <code>fit_map_to_stops</code>, all of it. The AI is just a new data source plugged into a finished engine.</p><h2>Understanding Error Handling</h2><p>AI calls fail in ways normal code doesn&#8217;t &#8212; missing keys, rate limits, network drops. A travel app shouldn&#8217;t crash on any of them:</p><pre><code><code>def generate_itinerary(ai, destination, days, interests):
    if not os.getenv("GOOGLE_API_KEY"):
        print("&#10007; No GOOGLE_API_KEY found. Set it as an environment variable.")
        return None

    prompt = build_prompt(destination, days, interests)

    try:
        return ai.invoke(prompt)
    except Exception as e:
        print(f"&#10007; Itinerary generation failed: {e}")
        return None
</code></code></pre><p>The check for the missing key happens <em>before</em> the call &#8212; a fast, friendly failure. The <code>try/except</code> catches everything else and reports it instead of dumping a stack trace.</p><h2>Understanding Why Pydantic Validation Helps</h2>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner-eb0">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build an AI Travel Itinerary Planner: Day 1 - Interactive Trip Map with Folium]]></title><description><![CDATA[Build an interactive HTML map with color-coded day markers, drawn routes, detailed popups, and professional touches like a minimap and fullscreen.]]></description><link>https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-an-ai-travel-itinerary-planner</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 27 May 2026 10:42:27 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/66278b0f-7610-4f33-8ee4-00d21c9fcadd_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build an <strong>AI Travel Itinerary Planner</strong> that turns a destination into a beautiful, interactive map you can explore and share &#8212; perfect for trip planning!</p><p><strong>Why build this?</strong> Because planning a trip means juggling places, routes, and notes across a dozen browser tabs. You&#8217;ll build a tool that puts everything on one interactive map &#8212; and by Day 3, an AI generates the whole itinerary for you.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p><strong>What you&#8217;ll learn:</strong> Interactive map creation, geographic data visualization, AI-powered content generation, and building shareable web apps &#8212; essential skills for location-based applications.</p><p><strong>Why users love this:</strong> Type a city, get a gorgeous map with every stop pinned, routes drawn, and tips in every popup. It feels like a real travel product.</p><ul><li><p><strong>Day 1:</strong> Interactive Trip Map with Folium <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> AI Itinerary Generator with Gemini</p></li><li><p><strong>Day 3:</strong> Travel Planner Web App</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-19">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We&#8217;re starting with the <strong>map engine</strong> &#8212; the visual heart of the whole project. <strong>No AI yet.</strong> Today you master Folium: take a structured trip itinerary and turn it into a rich, interactive HTML map with color-coded day markers, drawn routes, detailed popups, and professional touches like a minimap and fullscreen mode.</p><p>By the end, you&#8217;ll have a real interactive map you can open in any browser and share. On Day 2, the AI will <em>generate</em> the data you&#8217;re hand-feeding it today.</p><h2>Project Task</h2><p>Build an interactive trip map generator that:</p><ul><li><p>Takes a structured multi-day itinerary (list of stops with name, day, coordinates, description, category)</p></li><li><p>Plots every stop as a marker on a Folium map</p></li><li><p>Color-codes markers by day (Day 1 = blue, Day 2 = green, etc.)</p></li><li><p>Uses category-based icons (food, sightseeing, hotel, activity)</p></li><li><p>Draws a route line connecting stops in order, per day</p></li><li><p>Rich HTML popups with stop name, description, and category</p></li><li><p>Auto-centers and auto-zooms to fit all stops</p></li><li><p>Adds a minimap, fullscreen button, and layer control</p></li><li><p>Saves the result as a standalone, shareable HTML file</p></li></ul><p>This project gives you hands-on practice with Folium, interactive maps, geographic visualization, HTML generation, and structured data handling &#8212; essential skills for location-based applications.</p><h2>Expected Output</h2><p><strong>Running the map generator:</strong></p><pre><code><code>python trip_map.py
</code></code></pre><p>Running the script will generate an HTML file which can be opened in the browser:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!UNSM!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!UNSM!,w_424,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 424w, https://substackcdn.com/image/fetch/$s_!UNSM!,w_848,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 848w, https://substackcdn.com/image/fetch/$s_!UNSM!,w_1272,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 1272w, https://substackcdn.com/image/fetch/$s_!UNSM!,w_1456,c_limit,f_webp,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!UNSM!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif" width="500" height="288" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:288,&quot;width&quot;:500,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:9772464,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/gif&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/199188254?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!UNSM!,w_424,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 424w, https://substackcdn.com/image/fetch/$s_!UNSM!,w_848,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 848w, https://substackcdn.com/image/fetch/$s_!UNSM!,w_1272,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 1272w, https://substackcdn.com/image/fetch/$s_!UNSM!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6d89f8ec-4fef-41d5-8e75-eb9447032bde_500x288.gif 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><br></p><h2>Setup Instructions</h2><p><strong>Install Required Package:</strong></p><pre><code><code>pip install folium</code></code></pre><p>That&#8217;s it &#8212; one dependency. Folium generates the map as HTML; no server needed.</p><p><strong>Run it:</strong></p><pre><code><code>python trip_map.py
</code></code></pre><p>Then open <code>trip_map.html</code> in any browser.</p><h2>Understanding the Itinerary Data Structure</h2><p>Before drawing anything, we need a clean data structure. Each stop is a dictionary, and the trip is a list of them:</p><pre><code><code>itinerary = [
    {
        "name": "Time Out Market",
        "day": 1,
        "lat": 38.7067,
        "lon": -9.1459,
        "category": "food",
        "description": "Historic food hall with dozens of local vendors."
    },
    {
        "name": "S&#227;o Jorge Castle",
        "day": 1,
        "lat": 38.7139,
        "lon": -9.1335,
        "category": "sightseeing",
        "description": "Moorish castle with panoramic views over the city."
    },
    # ... more stops
]
</code></code></pre><p><strong>Why this structure?</strong></p><p>It is flat, predictable, and easy to generate. On Day 2, Gemini will return <em>exactly this shape</em> as JSON &#8212; so the map code you write today works unchanged when the AI takes over. That is the whole point of starting with the map: you build a stable target, then plug the AI into it.</p><h2>Understanding Folium Basics</h2><p>Folium is a Python wrapper around Leaflet.js. You build a map in Python, and it writes the HTML/JavaScript for you.</p><pre><code><code>import folium

# Create a map centered on a location
m = folium.Map(location=[38.7223, -9.1393], zoom_start=13)

# Add a marker
folium.Marker(
    location=[38.7067, -9.1459],
    popup="Time Out Market",
    tooltip="Click for details"
).add_to(m)

# Save to HTML
m.save("map.html")
</code></code></pre><p>Three core ideas:</p><ul><li><p><strong>The map object</strong> (<code>folium.Map</code>) is the canvas &#8212; everything gets added to it.</p></li><li><p><strong>Elements</strong> (markers, lines, layers) are created, then attached with <code>.add_to(m)</code>.</p></li><li><p><code>.save()</code> writes a complete, standalone HTML file &#8212; no server, no dependencies for the viewer.</p></li></ul><h2>Understanding Color-Coded Day Markers</h2><p>To make a multi-day trip readable, each day gets its own color. Folium markers accept an <code>Icon</code> with a <code>color</code> and an <code>icon</code> glyph:</p><pre><code><code>DAY_COLORS = ["blue", "green", "purple", "orange", "red", "darkblue"]

def get_day_color(day_number):
    # Day 1 -&gt; index 0, wraps around for long trips
    return DAY_COLORS[(day_number - 1) % len(DAY_COLORS)]

folium.Marker(
    location=[stop["lat"], stop["lon"]],
    icon=folium.Icon(color=get_day_color(stop["day"]), icon="cutlery", prefix="fa")
).add_to(m)
</code></code></pre><p><strong>Why </strong><code>prefix="fa"</code><strong>?</strong> It tells Folium to use Font Awesome icons, which gives you a much larger glyph library (cutlery, camera, bed, etc.) than the default set.</p><h2>Understanding Category Icons</h2><p>Color tells you <em>which day</em>. The icon glyph tells you <em>what kind of stop</em> it is. A simple dictionary maps categories to Font Awesome icons:</p><pre><code><code>CATEGORY_ICONS = {
    "food": "cutlery",
    "sightseeing": "camera",
    "hotel": "bed",
    "activity": "star",
    "transport": "plane",
}

def get_category_icon(category):
    # Fallback to a generic marker if the category is unknown
    return CATEGORY_ICONS.get(category, "info-sign")
</code></code></pre><p>Now a blue cutlery marker reads instantly as &#8220;Day 1 food stop.&#8221; This visual encoding is what separates a real map from a pile of identical pins.</p><h2>Understanding Rich HTML Popups</h2><p>A popup can be plain text &#8212; or it can be styled HTML. Folium accepts an HTML string, so you can build a proper info card:</p><pre><code><code>def build_popup_html(stop):
    return f"""
    &lt;div style="width: 220px; font-family: sans-serif;"&gt;
        &lt;h4 style="margin: 0 0 6px 0;"&gt;{stop['name']}&lt;/h4&gt;
        &lt;hr style="margin: 4px 0;"&gt;
        &lt;p style="margin: 4px 0;"&gt;&lt;b&gt;{stop['category'].title()}&lt;/b&gt; &#183; Day {stop['day']}&lt;/p&gt;
        &lt;p style="margin: 4px 0; color: #444;"&gt;{stop['description']}&lt;/p&gt;
    &lt;/div&gt;
    """

popup = folium.Popup(build_popup_html(stop), max_width=250)
folium.Marker(location=[stop["lat"], stop["lon"]], popup=popup).add_to(m)
</code></code></pre><p><strong>Key detail:</strong> wrap the HTML in <code>folium.Popup(..., max_width=250)</code>. Without <code>max_width</code>, long descriptions render as one ugly unwrapped line. The <code>&lt;div&gt;</code> with a fixed width keeps every popup tidy.</p><h2>Understanding Route Lines with PolyLine</h2><p>To connect the stops of a day in order, use <code>folium.PolyLine</code> &#8212; it draws a line through a list of coordinates:</p><pre><code><code>def draw_day_route(day_stops, color, map_obj):
    # day_stops must already be in visiting order
    coordinates = [[stop["lat"], stop["lon"]] for stop in day_stops]

    folium.PolyLine(
        locations=coordinates,
        color=color,
        weight=4,
        opacity=0.7,
        dash_array="8"  # dashed line looks like a travel route
    ).add_to(map_obj)
</code></code></pre><p>We draw <strong>one PolyLine per day</strong>, using that day&#8217;s color. The result: three colored, dashed routes that show the flow of each day, not one tangled line across the whole trip.</p><h2>Understanding Auto-Centering and Auto-Zoom</h2><p>Hardcoding <code>location</code> and <code>zoom_start</code> breaks the moment the trip changes. Instead, let the data decide the view with <code>fit_bounds</code>:</p><pre><code><code>def fit_map_to_stops(map_obj, itinerary):
    lats = [stop["lat"] for stop in itinerary]
    lons = [stop["lon"] for stop in itinerary]

    # Bounding box: [[south, west], [north, east]]
    south_west = [min(lats), min(lons)]
    north_east = [max(lats), max(lons)]

    map_obj.fit_bounds([south_west, north_east])
</code></code></pre><p><code>fit_bounds</code> computes the zoom and center so every stop is visible with a little padding. Now the map works for a walkable city or a sprawling road trip without any manual tuning &#8212; which matters on Day 2, when the AI generates unpredictable destinations.</p><h2>Understanding LayerControl and FeatureGroups</h2><p>To toggle days on and off, each day goes into its own <code>FeatureGroup</code>. A <code>LayerControl</code> then renders the checkboxes:</p><pre><code><code>day_group = folium.FeatureGroup(name="Day 1 &#8212; Historic Center")

# add that day's markers and route to the group instead of the map
marker.add_to(day_group)

day_group.add_to(map_obj)

# add this LAST, after all groups exist
folium.LayerControl(collapsed=False).add_to(map_obj)
</code></code></pre><p><strong>Order matters:</strong> add <code>LayerControl</code> <em>after</em> every <code>FeatureGroup</code>, or some layers won&#8217;t appear in the control.</p><h2>Understanding Map Plugins</h2><p>Folium ships plugins that add real polish in one line each:</p><pre><code><code>from folium.plugins import MiniMap, Fullscreen

# Small overview map in the corner
MiniMap(toggle_display=True).add_to(m)

# Fullscreen button
Fullscreen().add_to(m)
</code></code></pre><p>The <code>MiniMap</code> helps users keep their bearings when zoomed in; <code>Fullscreen</code> makes the shared HTML feel like a proper app. Small additions, big difference in perceived quality.</p><h2>Practical Use Cases</h2><p><strong>1. Personal trip planning:</strong></p><pre><code><code>Hand-build your itinerary, get one map with every stop, route, and note.
</code></code></pre><p><strong>2. Sharing plans with travel companions:</strong></p><pre><code><code>Send the HTML file &#8212; they open it, no install, fully interactive.
</code></code></pre><p><strong>3. Travel blogs and guides:</strong></p><pre><code><code>Embed the map in a post so readers can explore the route themselves.
</code></code></pre><p><strong>4. Tour and event planning:</strong></p><pre><code><code>Color-code by day or group, drop notes in popups, share one link.
</code></code></pre><p><strong>5. Foundation for Day 2:</strong></p><pre><code><code>The exact data shape Gemini will return &#8212; the map is ready before the AI exists.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we add the <strong>AI</strong>. Instead of hand-writing the itinerary, you&#8217;ll describe a trip in plain English (&#8221;3 days in Lisbon, I love food and history&#8221;) and Gemini &#8212; via LangChain v1 &#8212; generates the full structured itinerary, coordinates included, ready to drop straight into today&#8217;s map engine.</p><h2>Skeleton and Solution</h2><p>Below you will find both a downloadable skeleton.py file to help you code the project with comment guides and the downloadable solution.py file containing the correct solution.</p><p>Get the code skeleton here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/view/AxDgTc2XGQHO_yxqM944Iw&quot;,&quot;text&quot;:&quot;View Code Skeleton&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/view/AxDgTc2XGQHO_yxqM944Iw"><span>View Code Skeleton</span></a></p><p></p><p>Get the code solution here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/cpotUXrFcCxJaE4DMYSIzQ&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/cpotUXrFcCxJaE4DMYSIzQ"><span>View Code Solution</span></a></p><p></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Daily Python Projects is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Build a Workout Tracker Suite: Day 3- Complete Workout Dashboard ]]></title><description><![CDATA[Today we&#8217;re creating a complete training dashboard with templates, 1RM calculator, strength standards, and session timers!]]></description><link>https://dailypythonprojects.substack.com/p/build-a-workout-tracker-suite-day-c58</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-workout-tracker-suite-day-c58</guid><pubDate>Thu, 21 May 2026 11:03:49 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/4e76f4bd-9fc1-48be-8771-061c0f2b3b41_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>Workout Tracker Suite</strong> that helps you log lifts, visualize progress, and optimize your training &#8212; perfect for anyone serious about strength training!</p><ul><li><p><strong>Day 1:</strong> Barbell Plate Calculator (Tkinter GUI)</p></li><li><p><strong>Day 2:</strong> Workout Logger with Live Charts</p></li><li><p><strong>Day 3:</strong> Complete Workout Dashboard <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-18">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale!</strong> We&#8217;ve built a plate calculator and workout logger. Today we&#8217;re creating a <strong>complete training dashboard</strong> with templates, 1RM calculator, strength standards, and session timers!</p><p>You&#8217;ll have a professional-grade workout tracking system that rivals commercial fitness apps!</p><h2>Project Task</h2><p>Create a complete workout dashboard with:</p><ul><li><p>Workout templates (Push/Pull/Legs, Upper/Lower, Full Body)</p></li><li><p>1RM (one-rep max) calculator with multiple formulas</p></li><li><p>Strength standards comparison (beginner/intermediate/advanced)</p></li><li><p>Session timer with rest period alerts</p></li><li><p>Progressive overload recommendations</p></li><li><p>Volume tracking with periodization suggestions</p></li><li><p>Export workout reports as text files</p></li><li><p>Enhanced charts with volume and intensity trends</p></li><li><p>All Day 1 &amp; 2 features integrated</p></li></ul><p>This project gives you hands-on practice with advanced GUI design, mathematical formulas, timer functionality, template systems, data analysis, and building production-ready fitness applications &#8212; essential skills for complete software solutions!</p><h2>Expected Output</h2><p>We have gone one step further in this version of the GUI and added tabs. This is a major milestone because it demonstrates one of the most popular techniques to organize a GUI when we are adding more features in the app. With tabs we can organize the various functionalities of the apps as you can see below:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!chk2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!chk2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 424w, https://substackcdn.com/image/fetch/$s_!chk2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 848w, https://substackcdn.com/image/fetch/$s_!chk2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 1272w, https://substackcdn.com/image/fetch/$s_!chk2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!chk2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:870408,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/198272856?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!chk2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 424w, https://substackcdn.com/image/fetch/$s_!chk2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 848w, https://substackcdn.com/image/fetch/$s_!chk2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 1272w, https://substackcdn.com/image/fetch/$s_!chk2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12d9639f-c236-4921-888f-5f4cc0e99fea_2496x1664.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>For example, we have now moved the weight progress to a new &#8220;Analytics&#8221; tab:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!aH4b!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!aH4b!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 424w, https://substackcdn.com/image/fetch/$s_!aH4b!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 848w, https://substackcdn.com/image/fetch/$s_!aH4b!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 1272w, https://substackcdn.com/image/fetch/$s_!aH4b!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!aH4b!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1065833,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/198272856?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!aH4b!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 424w, https://substackcdn.com/image/fetch/$s_!aH4b!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 848w, https://substackcdn.com/image/fetch/$s_!aH4b!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 1272w, https://substackcdn.com/image/fetch/$s_!aH4b!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4f244f7-a00f-473c-9807-8cd15948e6a6_2496x1664.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>And we have also added other tabs as it can be seen in the screenshots. <br></p><h2>Setup Instructions</h2><p><strong>Install Required Packages:</strong></p><pre><code><code>pip install matplotlib pandas
</code></code></pre><p><strong>Run the dashboard:</strong></p><pre><code><code>python workout_dashboard.py
</code></code></pre><p><strong>All features ready to use:</strong></p><ul><li><p>No additional setup needed</p></li><li><p>Data persists in CSV files</p></li><li><p>Templates saved as JSON</p></li><li><p>Reports export as TXT files</p></li></ul><h2>Understanding 1RM Calculations</h2><p><strong>What is 1RM?</strong></p><p>Your <strong>one-rep max</strong> - the maximum weight you can lift for exactly one rep.</p><p><strong>Why calculate it?</strong></p><ul><li><p>Design training programs (% of 1RM)</p></li><li><p>Track true strength gains</p></li><li><p>Compare against standards</p></li><li><p>Set realistic goals</p></li></ul><p><strong>Common formulas:</strong></p><p><strong>1. Epley Formula:</strong></p><pre><code><code>1RM = weight &#215; (1 + reps/30)

# Example: 225 lbs &#215; 8 reps
1RM = 225 &#215; (1 + 8/30)
1RM = 225 &#215; 1.267
1RM = 285 lbs
</code></code></pre><p><strong>2. Brzycki Formula:</strong></p><pre><code><code>1RM = weight &#215; (36 / (37 - reps))

# Example: 225 lbs &#215; 8 reps
1RM = 225 &#215; (36 / (37 - 8))
1RM = 225 &#215; (36 / 29)
1RM = 279 lbs
</code></code></pre><p><strong>3. Lander Formula:</strong></p><pre><code><code>1RM = (100 &#215; weight) / (101.3 - 2.67123 &#215; reps)

# Example: 225 lbs &#215; 8 reps
1RM = (100 &#215; 225) / (101.3 - 2.67123 &#215; 8)
1RM = 22500 / 79.93
1RM = 281 lbs
</code></code></pre><p><strong>Our implementation:</strong></p><pre><code><code>def calculate_1rm(weight, reps):
    # Epley
    epley = weight * (1 + reps/30)
    
    # Brzycki
    brzycki = weight * (36 / (37 - reps))
    
    # Lander
    lander = (100 * weight) / (101.3 - 2.67123 * reps)
    
    # Lombardi
    lombardi = weight * (reps ** 0.10)
    
    # Average
    average = (epley + brzycki + lander + lombardi) / 4
    
    return {
        'epley': round(epley, 1),
        'brzycki': round(brzycki, 1),
        'lander': round(lander, 1),
        'lombardi': round(lombardi, 1),
        'average': round(average, 1)
    }
</code></code></pre><h2>Understanding Strength Standards</h2><p><strong>What are strength standards?</strong></p><p>Benchmarks for comparing your lifts to typical progression levels.</p><p><strong>Standard categories:</strong></p><pre><code><code>STRENGTH_STANDARDS = {
    'Bench Press': {
        'Beginner': 0.75,      # 0.75&#215; bodyweight
        'Novice': 1.0,         # 1.0&#215; bodyweight
        'Intermediate': 1.25,  # 1.25&#215; bodyweight
        'Advanced': 1.5,       # 1.5&#215; bodyweight
        'Elite': 1.75,         # 1.75&#215; bodyweight
    },
    'Squat': {
        'Beginner': 1.0,
        'Novice': 1.5,
        'Intermediate': 2.0,
        'Advanced': 2.5,
        'Elite': 3.0,
    },
    'Deadlift': {
        'Beginner': 1.25,
        'Novice': 1.75,
        'Intermediate': 2.25,
        'Advanced': 2.75,
        'Elite': 3.25,
    }
}
</code></code></pre><p></p><h2>Understanding Workout Templates</h2><p><strong>What are templates?</strong></p><p>Pre-designed workout programs with exercises, sets, reps, and rest times.</p><p><strong>Common splits:</strong></p><p><strong>Push/Pull/Legs (PPL):</strong></p><pre><code><code>TEMPLATES = {
    'Push': [
        {'exercise': 'Bench Press', 'sets': 4, 'reps': 8, 'rest': 180},
        {'exercise': 'Overhead Press', 'sets': 3, 'reps': 10, 'rest': 120},
        {'exercise': 'Incline DB Press', 'sets': 3, 'reps': 12, 'rest': 90},
        {'exercise': 'Lateral Raises', 'sets': 3, 'reps': 15, 'rest': 60},
        {'exercise': 'Tricep Extensions', 'sets': 3, 'reps': 12, 'rest': 60},
    ],
    'Pull': [
        {'exercise': 'Deadlift', 'sets': 3, 'reps': 5, 'rest': 180},
        {'exercise': 'Pull-ups', 'sets': 3, 'reps': 10, 'rest': 120},
        {'exercise': 'Barbell Row', 'sets': 4, 'reps': 8, 'rest': 120},
        {'exercise': 'Face Pulls', 'sets': 3, 'reps': 15, 'rest': 60},
        {'exercise': 'Bicep Curls', 'sets': 3, 'reps': 12, 'rest': 60},
    ],
    'Legs': [
        {'exercise': 'Squat', 'sets': 4, 'reps': 6, 'rest': 180},
        {'exercise': 'Romanian Deadlift', 'sets': 3, 'reps': 10, 'rest': 120},
        {'exercise': 'Leg Press', 'sets': 3, 'reps': 12, 'rest': 90},
        {'exercise': 'Leg Curls', 'sets': 3, 'reps': 15, 'rest': 60},
        {'exercise': 'Calf Raises', 'sets': 4, 'reps': 20, 'rest': 60},
    ]
}
</code></code></pre><p><strong>Loading a template:</strong></p><pre><code><code>def load_template(template_name):
    exercises = TEMPLATES[template_name]
    
    print(f"\n{template_name.upper()} DAY:")
    for i, ex in enumerate(exercises, 1):
        print(f"{i}. {ex['exercise']}")
        print(f"   {ex['sets']}&#215;{ex['reps']}")
        print(f"   Rest: {ex['rest']}s")
</code></code></pre><p>We also have other features such as the timer which the user can use to time your workout sessions.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a <strong>complete workout tracking system</strong>!</p><ul><li><p><strong>Day 1:</strong> Visual plate calculator</p></li><li><p><strong>Day 2:</strong> Workout logger with charts</p></li><li><p><strong>Day 3:</strong> Complete training dashboard</p></li></ul><p><strong>You now have:</strong></p><p>&#9989; <strong>Plate calculator</strong> - Visual barbell loading<br>&#9989; <strong>Workout logging</strong> - Track all exercises<br>&#9989; <strong>Progress charts</strong> - See gains visualized<br>&#9989; <strong>1RM calculator</strong> - Multiple formulas<br>&#9989; <strong>Strength standards</strong> - Compare your lifts<br>&#9989; <strong>Workout templates</strong> - Pre-built programs<br>&#9989; <strong>Session timers</strong> - Track workout &amp; rest<br>&#9989; <strong>Analytics</strong> - Volume &amp; intensity trends<br>&#9989; <strong>Export reports</strong> - Share your progress<br>&#9989; <strong>Professional GUI</strong> - Tabbed interface</p><p><strong>Next steps:</strong></p><ul><li><p>Add exercise video library</p></li><li><p>Integrate with fitness trackers</p></li><li><p>Build mobile version</p></li><li><p>Add nutrition tracking</p></li><li><p>Social features (share workouts)</p></li><li><p>Online coaching integration</p></li></ul><p>You&#8217;ve built the foundation for a <strong>complete fitness platform</strong>! &#128640;</p><h2>View Code Evolution</h2><p>Compare today&#8217;s complete dashboard with earlier versions and see the full progression from simple calculator &#8594; logger &#8594; complete system!</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-a-workout-tracker-suite-day-c58">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a Workout Tracker Suite: Day 2- Workout Logger with Live Charts]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-workout-tracker-suite-day-057</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-workout-tracker-suite-day-057</guid><pubDate>Wed, 20 May 2026 11:51:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/54bbc298-9a93-4227-bce7-d27357716868_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series:</h2><p>This week, we build a <strong>Workout Tracker Suite</strong> that helps you log lifts, visualize progress, and optimize your training &#8212; perfect for anyone serious about strength training!</p><ul><li><p><strong>Day 1:</strong> Barbell Plate Calculator (Tkinter GUI)</p></li><li><p><strong>Day 2:</strong> Workout Logger with Live Charts <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Complete Workout Dashboard</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-18">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we built a plate calculator. Today we&#8217;re adding <strong>workout logging with live progress charts</strong>! Log your sets, track your PRs, and see your strength gains visualized in real-time!</p><p>You&#8217;ll enhance the app into a complete training tracker with embedded matplotlib charts!</p><h2>Project Task</h2><p>Enhance the plate calculator with workout logging:</p><ul><li><p>Log exercises with sets, reps, weight, and RPE</p></li><li><p>Save workouts to CSV with timestamps</p></li><li><p>Live progress chart showing weight over time</p></li><li><p>Personal records (PR) tracker</p></li><li><p>Exercise history viewer</p></li><li><p>Volume calculations (sets &#215; reps &#215; weight)</p></li><li><p>Filter by exercise and date range</p></li><li><p>Embedded matplotlib charts in Tkinter</p></li><li><p>All Day 1 features still work</p></li></ul><p>This project gives you hands-on practice with CSV data storage, pandas data analysis, matplotlib integration in Tkinter, data visualization, progressive overload tracking, and building data-driven fitness tools &#8212; essential skills for analytics applications!</p><h2>Expected Output</h2><p><strong>Running the enhanced workout logger:</strong></p><pre><code><code>python workout_logger.py
</code></code></pre><p><strong>Log a workout:</strong></p><p>In this version the user can log in a workout such as bench press, squat, etc. These can be chosen from the drop down list:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!NYRB!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!NYRB!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 424w, https://substackcdn.com/image/fetch/$s_!NYRB!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 848w, https://substackcdn.com/image/fetch/$s_!NYRB!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 1272w, https://substackcdn.com/image/fetch/$s_!NYRB!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!NYRB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:986242,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/198247786?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!NYRB!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 424w, https://substackcdn.com/image/fetch/$s_!NYRB!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 848w, https://substackcdn.com/image/fetch/$s_!NYRB!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 1272w, https://substackcdn.com/image/fetch/$s_!NYRB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bc14bea-732b-4222-a34d-e8c229c7aea2_2502x1668.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>After some days, you will see a weight progress at the bottom part of the GUI showing how your weights have changed over time:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-YbK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-YbK!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 424w, https://substackcdn.com/image/fetch/$s_!-YbK!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 848w, https://substackcdn.com/image/fetch/$s_!-YbK!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 1272w, https://substackcdn.com/image/fetch/$s_!-YbK!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-YbK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/47066338-87db-4984-809d-dbf57089964b_2796x1864.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1226399,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/198247786?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-YbK!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 424w, https://substackcdn.com/image/fetch/$s_!-YbK!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 848w, https://substackcdn.com/image/fetch/$s_!-YbK!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 1272w, https://substackcdn.com/image/fetch/$s_!-YbK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F47066338-87db-4984-809d-dbf57089964b_2796x1864.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p><strong>CSV file generated: </strong></p><p>Every time you log in a workout, the data are saved in a local <em>workouts.csv</em> <code>file. </code>For the example demonstrated above, here is how the file looks like:</p><pre><code><code>date,time,exercise,sets,reps,weight,rpe,volume
2026-05-13,14:30:00,Bench Press,3,8,225,8,5400
2026-05-13,14:45:00,Squat,4,6,315,9,7560
2026-05-13,15:00:00,Deadlift,3,5,405,9,6075
2026-05-10,15:15:00,Bench Press,4,6,215,7,5160
2026-05-10,15:30:00,Squat,3,8,295,8,7080

</code></code></pre><h2>Setup Instructions</h2><p><strong>Install Required Package:</strong></p><p></p><pre><code><code>pip install matplotlib pandas</code></code></pre><p><strong>Run the logger:</strong></p><pre><code><code>python workout_logger.py
</code></code></pre><p><strong>Data storage:</strong></p><ul><li><p>Workouts saved to <code>workouts.csv</code> automatically</p></li><li><p>PRs tracked automatically</p></li><li><p>Charts update in real-time</p></li></ul><h2>Understanding CSV Workout Storage</h2><p><strong>Why CSV for workouts?</strong></p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-a-workout-tracker-suite-day-057">
              Read more
          </a>
      </p>
   ]]></content:encoded></item></channel></rss>