<?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>Fri, 24 Jul 2026 14:35:01 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[Webhooks, taught by a script that texts you]]></title><description><![CDATA[Polling is calling the pizza place every two minutes. A webhook is them calling you.]]></description><link>https://dailypythonprojects.substack.com/p/webhooks-taught-by-a-script-that</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/webhooks-taught-by-a-script-that</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 23 Jul 2026 13:49:47 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!Ng-_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>&#128227; Starting today, Thursdays on Daily Python Projects are getting something new. On Tuesdays and Wednesdays we build projects, but I have noticed over the years that most Python learners are missing something else entirely: they can write the code, but nobody ever explained to them how software actually gets built and shipped. What is a server, how do you deploy a script, what is Docker, where does the data live. So every Thursday, we will take one of these topics and demystify it. Today we start with webhooks.&#127881; </em></p><div><hr></div><p>In this post, you will learn what webhooks are and how to use one to send notifications from your scripts straight to your phone. Webhooks are one of the most common building blocks in software development, and once you understand them, a big part of modern automation stops being mysterious.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Ng-_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Ng-_!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 424w, https://substackcdn.com/image/fetch/$s_!Ng-_!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 848w, https://substackcdn.com/image/fetch/$s_!Ng-_!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 1272w, https://substackcdn.com/image/fetch/$s_!Ng-_!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Ng-_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:582,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:74371,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/208186535?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.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_!Ng-_!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 424w, https://substackcdn.com/image/fetch/$s_!Ng-_!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 848w, https://substackcdn.com/image/fetch/$s_!Ng-_!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 1272w, https://substackcdn.com/image/fetch/$s_!Ng-_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 1456w" sizes="100vw" fetchpriority="high"></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 aria-hidden="true" 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><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 have probably created an automation with Python before. Perhaps a script that sends an email, or something that scrapes data from a weather website. Now imagine the script crashed for some reason. You would not know immediately. You might come back days later and see that no data has been scraped for a week.</p><p>So what most people do is add a loop somewhere that keeps checking:</p><pre><code><code>import time

while True:
    if check_if_done():
        break
    time.sleep(10)
</code></code></pre><p>This approach is called polling. Your script keeps asking &#8220;is it done yet?&#8221; over and over. It works, but think about what is actually happening here: your script sends thousands of requests a day just to hear &#8220;no&#8221; almost every time. It is like calling the pizza place every two minutes to ask if your order is ready. They will not enjoy that, and neither will the server you are polling.</p><p>The pizza place solves this differently, of course. You give them your number, and they call you when the order is ready. In software, that pattern is called a webhook.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!bfb_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!bfb_!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 424w, https://substackcdn.com/image/fetch/$s_!bfb_!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 848w, https://substackcdn.com/image/fetch/$s_!bfb_!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 1272w, https://substackcdn.com/image/fetch/$s_!bfb_!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!bfb_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png" width="1456" height="655" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:655,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!bfb_!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 424w, https://substackcdn.com/image/fetch/$s_!bfb_!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 848w, https://substackcdn.com/image/fetch/$s_!bfb_!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 1272w, https://substackcdn.com/image/fetch/$s_!bfb_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.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 aria-hidden="true" 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><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><h2>A webhook is not a technology</h2><p>So what is a webhook exactly? It is an HTTP request that someone else&#8217;s computer sends to a URL of your choice when an event happens. That is really the whole definition. It is not a library you install or a protocol you have to study. It is just an agreement between two computers: when X happens, send a POST request to this address.</p><p>Until now, you have always been the caller. Every <code>requests.get()</code> you have written is your script phoning a server. A webhook is the same thing in the opposite direction: the server phones you.</p><h2>The script that texts you</h2><p>Let me show you the fastest way to experience this. We will make a script send a notification to your phone.</p><p>The tool we will use is ntfy.sh. There is no signup, no API key, and no credit card involved. I have seen enough free services die under my projects to be careful with recommendations, but ntfy has been reliable for me.</p><p>First, install the ntfy app on your phone (free on iOS and Android) and subscribe to a topic. A topic is just a name you invent. Keep in mind that anyone who knows the name can send messages to it, so pick something hard to guess, like <code>da-topic-4821</code>, not <code>test</code>.</p><p>Then run this script:</p><pre><code><code>import requests

requests.post(
    "https://ntfy.sh/da-topic-4821",
    data="Scraper finished. 340 rows."
)
</code></code></pre><p>About one second later, your phone buzzes.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!8c4Y!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!8c4Y!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 424w, https://substackcdn.com/image/fetch/$s_!8c4Y!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 848w, https://substackcdn.com/image/fetch/$s_!8c4Y!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 1272w, https://substackcdn.com/image/fetch/$s_!8c4Y!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!8c4Y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:679,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!8c4Y!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 424w, https://substackcdn.com/image/fetch/$s_!8c4Y!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 848w, https://substackcdn.com/image/fetch/$s_!8c4Y!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 1272w, https://substackcdn.com/image/fetch/$s_!8c4Y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.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 aria-hidden="true" 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><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>That is four lines of Python, and your script can now reach you anywhere.</p><h2>What actually traveled over the wire</h2><p>Let&#8217;s look at what your script actually sent to ntfy&#8217;s server.</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_!oXeP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!oXeP!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 424w, https://substackcdn.com/image/fetch/$s_!oXeP!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 848w, https://substackcdn.com/image/fetch/$s_!oXeP!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 1272w, https://substackcdn.com/image/fetch/$s_!oXeP!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!oXeP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png" width="1456" height="607" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:607,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:125690,&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/207925167?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.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_!oXeP!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 424w, https://substackcdn.com/image/fetch/$s_!oXeP!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 848w, https://substackcdn.com/image/fetch/$s_!oXeP!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 1272w, https://substackcdn.com/image/fetch/$s_!oXeP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.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 aria-hidden="true" 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><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>It is a verb (POST), an address, a couple of headers, and a body with the message. Every webhook in the world has this same shape. When Stripe informs an online shop that a payment succeeded, it sends this exact structure, just with a bigger JSON body. When GitHub tells a build server that someone pushed code, same thing.</p><h2>The other side of the phone call</h2><p>So far you have been the sender. Receiving a webhook is the reverse: you have a URL, and other computers send POST requests to it when something happens.</p><p>You might think you need Flask or FastAPI for this, but you don&#8217;t. Python comes with everything built in:</p><pre><code><code>from http.server import HTTPServer, BaseHTTPRequestHandler

class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers["Content-Length"])
        body = self.rfile.read(length).decode()
        print("Webhook received:", body)
        self.send_response(200)
        self.end_headers()

HTTPServer(("", 8000), Hook).serve_forever()
</code></code></pre><p>Run this and you have a webhook receiver at http://localhost:8000. If you send it a POST request from a second terminal (reuse the four-line script from above and just change the URL), you will see the message printed. You just ran both sides of a webhook on one machine, with the standard library only.</p><p>By the way, if you want to watch a real webhook arrive before writing any code, open webhook.site in your browser. It gives you a temporary URL and shows every request that hits it, live. Five minutes there and webhooks will never feel mysterious again.</p><h2>The catch: localhost is a private address</h2><p>There is one thing that catches almost everyone at this point. Your receiver works when you send to it. However, if you paste http://localhost:8000 into Stripe&#8217;s webhook settings, nothing will ever arrive.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!IOQf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!IOQf!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 424w, https://substackcdn.com/image/fetch/$s_!IOQf!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 848w, https://substackcdn.com/image/fetch/$s_!IOQf!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 1272w, https://substackcdn.com/image/fetch/$s_!IOQf!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!IOQf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png" width="1456" height="558" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:558,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:104411,&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/207925167?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.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_!IOQf!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 424w, https://substackcdn.com/image/fetch/$s_!IOQf!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 848w, https://substackcdn.com/image/fetch/$s_!IOQf!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 1272w, https://substackcdn.com/image/fetch/$s_!IOQf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.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 aria-hidden="true" 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><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>That is because localhost means &#8220;this machine&#8221;. It is a name only your own computer can hear. Your laptop has no public address on the internet. It sits behind your router, and the router has no idea it should forward anything to you.</p><p>There are two solutions. For testing, you can use a tunnel like ngrok, which gives you a temporary public URL that forwards to your laptop. For real applications, you deploy the receiver to a server, which is simply a computer with a public address that stays on. That trip from laptop to server is a topic we will keep coming back to in this publication.</p><h2>The pattern to steal today</h2><p>Here is what I recommend you do with all this. Wrap the sender in a small function and keep it in every project:</p><pre><code><code>import requests

def notify(message):
    requests.post("https://ntfy.sh/da-topic-4821", data=message)
</code></code></pre><pre><code><code>notify("Backup complete: 2.3 GB")
notify(f"Script crashed: {error}")
notify("Price dropped below 40 EUR")
</code></code></pre><p>My personal rule is that any script running longer than a coffee gets a <code>notify()</code> call at the end and one in the error handler. It changes how you work, because your scripts stop being things you watch and start being things that report to you.</p><p>And once you know the shape, you will notice webhooks everywhere: GitHub triggering tests, Stripe unlocking accounts, Telegram bots replying to messages.</p><p>In a future post, we will take the next step: giving your own function a public URL, so that anything on the internet can call your code.</p><p>-Ardit</p>]]></content:encoded></item><item><title><![CDATA[Build a "Whisper Flow" Voice Dictation Clone: Day 2 - “Whisper Flow” mode —keyboard key to record anywhere]]></title><description><![CDATA[Press a key, dictate, text appears in whichever app you&#8217;re in.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-whisper-flow-voice-dictation-cfa</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-whisper-flow-voice-dictation-cfa</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 22 Jul 2026 09:31:32 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/dbad9127-017a-431c-b6e2-f43069df32a8_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday&#8217;s script worked &#8212; but only if you were sitting in the terminal. Every voice note meant Cmd+Tab to the terminal, wait, copy the transcript, Cmd+Tab back to your target app, paste. Real dictation apps don&#8217;t make you do that. Whisper Flow and Superwhisper both run <em>invisibly</em>: no window, no terminal, no context switch. Press a key anywhere on your system, speak, release, and the text lands directly in whichever app you had focused.</p><p>Today we build that.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>Day 1 (Yesterday):</strong> CLI recorder + local Whisper transcription</p><p>When the user runs the Day 1 script, they get prompted to press Enter, speak, and press Enter again. The transcript appears in the terminal and the audio + text are saved to <code>~/VoiceNotes/</code>:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!VNhC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!VNhC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!VNhC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.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;:744610,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/207897477?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.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_!VNhC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1456w" sizes="100vw" fetchpriority="high"></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 aria-hidden="true" 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><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>Everything runs locally &#8212; the transcript appears within a second or two of hitting Enter to stop. It works, but you have to be in the terminal.</p><p><strong>&#128073; Day 2 (Today):</strong> &#8220;Whisper Flow&#8221; mode &#8212; press Right Option anywhere, dictate anything </p><p>When the user presses and holds Right Option (or Right Alt on Windows) inside any app on their computer, the voice notes daemon starts recording. On release, the transcript is typed directly into whichever app has focus. Here&#8217;s it landing in a Slack message input:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!tnd-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!tnd-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!tnd-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.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;:676265,&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/207938971?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.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_!tnd-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.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 aria-hidden="true" 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><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>No window. No terminal to switch to. Just dictate, release, done. Same experience as the commercial dictation apps, running fully offline on your own hardware.</p><p><a href="https://dailypythonprojects.substack.com/t/week-27">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>The Day 1 script had two things wrong for real daily use:</p><ol><li><p><strong>You had to be in the terminal.</strong> No app you actually use fires up in the terminal &#8212; email, Slack, docs, code editors all live in their own windows.</p></li><li><p><strong>The transcript went to a file.</strong> You still had to open that file, copy the text, tab back to your target app, paste. More clicks than just typing.</p></li></ol><p>The fix is a background daemon that listens for a <strong>global hotkey</strong>. When you press it, the app records &#8212; no matter which window you&#8217;re in. When you release, the app transcribes and types the text into whichever window has keyboard focus. Nothing visible, no context switching, no copy-paste.</p><p>The daemon reuses Day 1&#8217;s <code>record_until_stopped()</code> and <code>transcribe()</code> functions unchanged. What changes is the trigger (a hotkey instead of ENTER) and the destination (a keystroke stream instead of a file). Same underlying pipeline; radically different UX.</p><h2>Project Task</h2><p>Build a background daemon that:</p><ul><li><p>Loads a <code>faster-whisper</code> model on startup and prints a &#8220;ready&#8221; banner</p></li><li><p>Uses <code>pynput</code> to listen for a <strong>global hotkey</strong> (default: Right Option / Right Alt)</p></li><li><p>On key press: starts recording from the microphone in a background thread</p></li><li><p>On key release: stops recording, transcribes, and <strong>types the result</strong> into whichever window has focus</p></li><li><p>Runs forever until Ctrl+C</p></li><li><p>Filters out accidental quick taps (recordings under 0.3s)</p></li><li><p>Archives each note to <code>~/VoiceNotes/</code> for later reference</p></li></ul><p>This project teaches you <code>pynput</code> (global hotkeys + typing), how threading events coordinate press-and-release-driven state, and &#8212; critically &#8212; the macOS Accessibility permission dance that trips up everyone the first time.</p><h2>Setup Instructions</h2><p>This one&#8217;s more involved than Day 1 because we&#8217;re doing operating-system-level things &#8212; listening to keyboard events across all apps, injecting keystrokes into whichever app has focus. macOS in particular has security requirements that need explicit setup. <strong>Read this section carefully. Half the errors you&#8217;ll hit are permissions, not code.</strong></p><p><strong>Python dependencies (all platforms):</strong></p><pre><code><code>pip install sounddevice soundfile numpy faster-whisper pynput
</code></code></pre><p>If Day 1 already installed the first four, only <code>pynput</code> is new.</p><h3>macOS setup &#8212; the Accessibility permission</h3><p>You need to grant <strong>Accessibility permission</strong> to whichever app runs the script. This is macOS&#8217;s security model for anything that observes or injects keyboard events &#8212; Python needs it, Chrome needs it, Zoom needs it. Without it, <code>pynput</code> still runs, but macOS feeds it garbage events. The daemon will appear to record and stop on its own, in an infinite loop, without you touching anything. If that happens to you, permission is the problem, not the code.</p><p>The app that needs permission is <strong>the one running python</strong> &#8212; usually Terminal, iTerm2, Warp, or your IDE. Not Python itself.</p><p>To grant it:</p><ol><li><p>Open <strong>System Settings &#8594; Privacy &amp; Security &#8594; Accessibility</strong></p></li><li><p>Click the <strong>+</strong> button</p></li><li><p>Add Terminal (path: <code>/System/Applications/Utilities/Terminal.app</code>), or whichever terminal you actually use</p></li><li><p>Toggle the switch next to it <strong>on</strong></p></li><li><p><strong>Fully quit Terminal with Cmd+Q</strong> (not just close the window) and reopen it</p></li></ol><p>That last step is non-negotiable. macOS only checks the permission list at process startup. A running Terminal keeps behaving as unpermitted even after you flip the toggle, until you fully quit and relaunch.</p><h3>macOS setup &#8212; the SSL cert gotcha</h3><p>If you get an SSL certificate error the first time faster-whisper tries to download the model &#8212; something like <code>FileNotFoundError: [Errno 2] No such file or directory</code> deep in the traceback, pointing at a <code>.pem</code> file &#8212; you&#8217;ve been bitten by a stale <code>SSL_CERT_FILE</code> environment variable. Fix it with:</p><pre><code><code>unset SSL_CERT_FILE
python3 voice_notes_daemon.py
</code></code></pre><p>If that works, find where the variable is being set and remove it permanently:</p><pre><code><code>grep -H SSL_CERT_FILE ~/.zshrc ~/.zprofile ~/.bash_profile 2&gt;/dev/null
</code></code></pre><p>Delete the offending line from whichever file grep finds it in. This isn&#8217;t a problem with your code or with faster-whisper &#8212; it&#8217;s a common misconfiguration on Macs that had older Python setups or Anaconda installed at some point.</p><h3>Windows setup</h3><p><code>pynput</code> works out of the box on Windows. No permissions, no config. Just install and run.</p><p>One note: some antivirus software flags apps that install global keyboard hooks, which is what <code>pynput</code> does under the hood. This is a legitimate flag in general &#8212; apps that watch keystrokes globally <em>could</em> be keyloggers &#8212; but our code only reads press/release events for the hotkey and never captures other keys&#8217; values. Whitelist the script or the Python interpreter if your antivirus balks.</p><h3>Linux setup</h3><p><code>pynput</code> needs an X server. If you&#8217;re on desktop Linux, you already have one. Wayland users get &#8220;failed to acquire X connection&#8221; errors &#8212; either switch to an X11 session or install <code>xwayland</code>. <code>pynput</code>&#8216;s Wayland support is limited as of writing.</p><h3>Choosing your hotkey</h3><p>The default hotkey is <strong>Right Option</strong> on Mac, called <strong>Right Alt</strong> on Windows keyboards. In <code>pynput</code> it&#8217;s <code>keyboard.Key.alt_r</code> &#8212; one code, both platforms.</p><p>Why not CAPS LOCK, which is what commercial apps seem to use?</p><p>Because CAPS LOCK on macOS is a broken abstraction for userspace keyboard libraries. Unlike every other key, CAPS LOCK doesn&#8217;t send normal press-and-release events at the OS level &#8212; it&#8217;s a hardware <em>toggle</em>, and macOS emits weird 0-millisecond press-plus-release event pairs that <code>pynput</code> can&#8217;t reliably interpret. You end up with the daemon firing &#8220;recording started / recording stopped&#8221; on its own, in a loop, without touching the keyboard. I spent longer than I&#8217;d like to admit figuring this out.</p><p>Commercial dictation apps that use CAPS LOCK reach for lower-level APIs than Python has access to from userspace. The nearest equivalent you can build in plain Python is <strong>Right Option</strong>, which sits on the same row as CAPS LOCK, is rarely used for anything else, and behaves like a normal key.</p><p><strong>If you really want CAPS LOCK on Mac:</strong> install <a href="https://karabiner-elements.pqrs.org/">Karabiner Elements</a> &#8212; free, well-maintained, the standard tool for Mac keyboard remapping &#8212; and remap Caps Lock &#8594; F13. Then set the daemon&#8217;s hotkey to <code>keyboard.Key.f13</code>. Karabiner handles the CAPS LOCK weirdness at the hardware event level, and <code>pynput</code> never sees anything unusual. But honestly? Right Option works great and requires zero extra software. Don&#8217;t add complexity for a wart.</p><p><strong>Windows users don&#8217;t have a &#8220;Right Option&#8221; key</strong> &#8212; the same physical key is labeled &#8220;Right Alt&#8221; or &#8220;AltGr&#8221; on international keyboards. Same <code>pynput</code> code (<code>Key.alt_r</code>), different physical label. No remapping needed.</p><h3>Running it</h3><pre><code><code>python voice_notes_daemon.py
</code></code></pre><p>You should see this in the terminal:</p><pre><code><code>&#127897;&#65039;  Voice Notes daemon
    Model: base (int8)
    Hotkey: Key.alt_r
    Archive: /Users/you/VoiceNotes

Loading Whisper model...
&#10003; Model ready.

&#128994; Ready. Hold Key.alt_r anywhere on your system to dictate.
</code></code></pre><p>Then the daemon goes quiet. Silence is correct. <strong>If &#8220;Recording...&#8221; messages appear immediately without you pressing anything</strong>, that means macOS Accessibility permission isn&#8217;t granted, or the terminal wasn&#8217;t fully quit after granting it. Go back to the Accessibility section above.</p><p>Once it&#8217;s idle at the &#8220;&#128994; Ready&#8221; line: open any other app, put your cursor in a text field, hold Right Option, speak a sentence, release. The daemon prints a transcription line, and the text appears in the app you had focused.</p><h2>Understanding Global Hotkeys with pynput</h2><p>Yesterday&#8217;s script used <code>input()</code> &#8212; a built-in that reads from stdin. It only fires when the <em>terminal window</em> has focus. That&#8217;s the opposite of what we want.</p><p>We want to listen for key presses <em>anywhere</em> on the system, regardless of which app has focus. That&#8217;s called a <strong>global hotkey</strong>, and it requires operating-system-level integration:</p><ul><li><p>On macOS: the Quartz event system</p></li><li><p>On Windows: the SetWindowsHookEx API</p></li><li><p>On Linux: the X server or evdev</p></li></ul><p><code>pynput</code> wraps all three behind one Python API. You never touch the platform-specific code:</p><pre><code><code>from pynput import keyboard

def on_press(key):
    print(f"pressed: {key}")

def on_release(key):
    print(f"released: {key}")

with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
    listener.join()
</code></code></pre><p>The <code>Listener</code> runs in its own thread, calling your callbacks whenever a key event happens <em>anywhere on the system</em>. <code>listener.join()</code> blocks the main thread until the listener stops.</p><p>This is the entire core of what commercial dictation apps do. Everything else is polish.</p><h2>Understanding the Press-Release State Machine</h2><p>Our daemon has three states:</p><ul><li><p><strong>Idle</strong> &#8212; no key held, no recording</p></li><li><p><strong>Recording</strong> &#8212; hotkey pressed, audio flowing</p></li><li><p><strong>Transcribing</strong> &#8212; hotkey released, waiting for Whisper</p></li></ul><p>The state transitions are triggered by press/release events, not by ENTER. Two subtleties:</p><p><strong>The listener callback thread must not block.</strong> If we started the actual recording synchronously in <code>on_press</code>, the listener would freeze &#8212; meaning it would miss the <em>release</em> event. Instead, we hand recording off to a new thread and use a <code>threading.Event</code> from Day 1 to signal stop:</p><pre><code><code>recording = False
stop_event = threading.Event()

def on_press(key):
    global recording
    if key == HOTKEY and not recording:
        recording = True
        stop_event.clear()
        threading.Thread(target=record_and_transcribe, daemon=True).start()

def on_release(key):
    global recording
    if key == HOTKEY and recording:
        stop_event.set()
        recording = False
</code></code></pre><p><strong>The </strong><code>recording</code><strong> flag prevents double-starts</strong> when a key auto-repeats. On some keyboards, holding a key down makes the OS fire &#8220;press&#8221; events repeatedly. Without the flag, each auto-repeat would spawn a fresh recording thread &#8212; chaos. The flag ensures one press = one recording session, no matter how many repeat events arrive during the hold.</p><p>Three threads coordinating: the main thread runs the listener, the listener thread calls our callbacks, and each recording session spawns a worker thread. Sounds like a lot, but it maps cleanly: one thread per concern, no thread ever blocking another.</p><h2>Understanding Typing Into the Focused Window</h2><p>Once we have transcribed text, we inject it into whichever app has keyboard focus. <code>pynput</code> handles this with a <code>Controller</code>:</p><pre><code><code>from pynput.keyboard import Controller

kb = Controller()
kb.type("hello world")
</code></code></pre><p>That&#8217;s it. <code>type()</code> iterates through each character and synthesizes press+release events for it. From the OS&#8217;s perspective, it&#8217;s indistinguishable from a human typing very fast. The text lands in whichever field currently has focus &#8212; which is exactly what we want, because the user&#8217;s cursor was <em>there</em> before they held the hotkey.</p><p>There&#8217;s one gotcha worth flagging: <code>type()</code> can be slow for long text because it&#8217;s really simulating individual keystrokes. For a 200-word paragraph you might see a subtle typing animation. That&#8217;s authentic UX (matches what commercial apps do) and rarely a problem, but if you want instant paste-style insertion, put the text on the clipboard with <code>pyperclip</code> and simulate <code>Cmd+V</code> / <code>Ctrl+V</code> instead. Left as an exercise.</p><h2>Understanding the Accessibility Permission Under the Hood</h2><p>macOS-specific, but worth explaining what&#8217;s actually going on because the failure mode is confusing.</p><p>macOS enforces <strong>TCC</strong> (Transparency, Consent, and Control) &#8212; a database of which apps can do which sensitive things. Reading global keyboard events is one of the sensitive things. Every app that wants to observe keyboard events across other apps must be individually approved in System Settings &#8594; Accessibility.</p><p>The reason <code>pynput</code> doesn&#8217;t just <em>crash</em> without permission is that macOS deliberately lets the process create the event listener &#8212; it just feeds it garbage. This is a security design: apps that don&#8217;t have permission are silently sandboxed from the real event stream, so a malicious keylogger can&#8217;t tell whether the user knows about it. From the app&#8217;s point of view, everything looks fine; it&#8217;s just receiving fake events.</p><p>The practical implication for us: when the terminal doesn&#8217;t have Accessibility permission, <code>pynput</code> sees phantom events &#8212; 0ms-duration press-plus-release pairs, ghost keystrokes &#8212; that trigger the daemon&#8217;s callbacks. That&#8217;s why the symptom of missing permission is &#8220;recording started / recording stopped fires by itself&#8221; instead of a clean error message. It&#8217;s not the code being buggy. It&#8217;s macOS feeding fake data as a security measure.</p><p>After granting permission and fully quitting the terminal, the event stream becomes real. Press a key, <code>pynput</code> sees a real press. Release, it sees a real release. Silence when idle.</p><h2>Understanding Why This Composes with Any App</h2><p>The reason the daemon works in Slack, VS Code, Google Docs, terminal, email, and every other app is that <strong>we&#8217;re not integrating with any of them.</strong> We&#8217;re operating at the OS level, one layer below the applications. macOS, Windows, and Linux each own their own keyboard focus model. Whichever app currently has focus receives the keystrokes we generate &#8212; the app has no idea whether they came from a human, a Bluetooth keyboard, or a Python daemon.</p><p>This is the architectural insight behind commercial dictation apps. They don&#8217;t have Slack integrations. They don&#8217;t have VS Code integrations. They just type into the OS&#8217;s keystroke stream, and every app that receives keystrokes normally receives their keystrokes too. <strong>Universal by construction.</strong></p><p>The trade-off: we can&#8217;t do app-specific behavior. We can&#8217;t detect that we&#8217;re in Slack and format the text as a reply-to-thread. We can&#8217;t notice we&#8217;re in VS Code and put the text in a comment vs. code. But 95% of the daily value is just &#8220;make the text appear where my cursor is&#8221; &#8212; and that&#8217;s what we do, in every app, forever, with no integrations to maintain.</p><h2>Practical Use Cases</h2><p><strong>Meeting notes on the fly:</strong> Cursor in your notes app, dictate a bullet, release. Cursor in your Slack channel, dictate a message, release. No app switching, no re-focusing.</p><p><strong>Writing code comments:</strong> Cursor in a code file. Hold, describe what the function does, release. The comment types itself. This is how you get comments on functions you&#8217;d normally skip because typing them is friction.</p><p><strong>Dictating emails:</strong> The hardest part of email is starting. Voice makes the first draft trivial. Cursor in Gmail&#8217;s compose box, hold hotkey, ramble, release, edit. Ships faster.</p><p><strong>Language learning:</strong> Whisper is multilingual. Dictate in Spanish, get Spanish text. Dictate in French, get French text. Detects the language automatically per recording.</p><p><strong>Accessibility:</strong> For anyone whose typing is limited by RSI, tremor, or vision. Fully offline means no cloud dependency, no privacy trade-off, works on airplanes.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>Two days ago, you had no dictation tool. Now you have one that&#8217;s competitive with $12/month commercial apps, running fully on your machine, forever.</p><p><strong>You&#8217;ve learned:</strong></p><ul><li><p>Real-time audio recording with <code>sounddevice</code> and thread-safe queues</p></li><li><p>Local AI transcription with <code>faster-whisper</code> &#8212; no API keys, no data leaves your machine</p></li><li><p>Global keyboard event capture with <code>pynput</code></p></li><li><p>Programmatic keystroke injection into the OS event stream</p></li><li><p>How macOS Accessibility permission actually works under the hood</p></li><li><p>Why CAPS LOCK is a landmine on Mac for userspace apps, and how to route around it</p></li></ul><p><strong>The tool is useful daily</strong>, and you own every line of the code. Modify the hotkey. Add auto-save to a specific folder per app. Trigger different behavior when you double-tap the hotkey. Change the model size for accuracy vs. speed. This isn&#8217;t a toy tutorial &#8212; it&#8217;s a real personal productivity tool that will still work five years from now, because it depends on nothing except your local machine.</p><p><strong>Next steps you might explore:</strong></p><ul><li><p>Add sound feedback (a subtle beep on recording start/stop) using <code>simpleaudio</code> or <code>playsound</code></p></li><li><p>Add a menu bar icon (macOS) or system tray icon (Windows) using <code>rumps</code> or <code>pystray</code> so you can see the daemon status at a glance</p></li><li><p>Swap <code>type()</code> for clipboard-paste (<code>pyperclip</code> + <code>Cmd+V</code>) for instant insertion instead of simulated typing</p></li><li><p>Add language selection &#8212; press the hotkey once for English, twice quickly for Spanish, etc.</p></li><li><p>Swap the <code>base</code> model for <code>small</code> or <code>medium</code> if you want more accuracy and can spare the extra CPU</p></li></ul><p>That&#8217;s the finish line for this week. See you next Tuesday.</p><h2>Solution</h2><p>The complete daemon code &#8212; hotkey listener, threading choreography, and the type-into-focused-window integration &#8212; is below. Save it as <code>voice_notes_daemon.py</code>, install the dependencies, grant Accessibility permission (macOS), and run.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/build-a-whisper-flow-voice-dictation-cfa">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a "Whisper Flow" Voice Dictation Clone: Day 1: CLI recorder + local Whisper transcription ]]></title><description><![CDATA[Press a key, speak, watch text appear in whatever app you&#8217;re focused on &#8212; Slack, VS Code, terminal, Google Docs, anywhere.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-whisper-flow-voice-dictation</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-whisper-flow-voice-dictation</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 21 Jul 2026 17:20:42 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/0a4784dd-22ac-4a73-bf08-05d521a5c0d0_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Whisper Flow costs $12/month. Superwhisper is $9. Both do the same thing: press a key, speak, watch text appear in whatever app you&#8217;re focused on &#8212; Slack, VS Code, terminal, Google Docs, anywhere. Millions of people pay for this.</p><p>This week we build it in Python. Running fully on your machine. No cloud. No API keys. No subscription &#8212; ever.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>&#128073; Day 1 (Today):</strong> CLI recorder + local Whisper transcription </p><p>When the user runs the program, they get asked to press Enter to record their voice:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!VNhC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!VNhC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!VNhC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.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;:744610,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/207897477?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.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_!VNhC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, https://substackcdn.com/image/fetch/$s_!VNhC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1456w" sizes="100vw" fetchpriority="high"></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 pressing Enter again, the program will transcribe their voice, print out the transcribed text in the terminal and also indicate where the saved audio and transcribed file were saved.</p><p><strong>Day 2 (Tomorrow): </strong> &#8220;Whisper Flow&#8221; mode &#8212; press Right Option anywhere, dictate anything </p><p>When the user presses and holds the Right Option / Right Alt key on their keyboard inside any app on their computer, the voice notes daemon starts recording on the background. On release of the button, the transcript is typed directly into whichever app has focus &#8212; here shown landing in a Slack message input as an example:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!tnd-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!tnd-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!tnd-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.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;:676265,&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/207938971?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.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_!tnd-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, https://substackcdn.com/image/fetch/$s_!tnd-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.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>No terminal to switch to. No copy-paste. The exact same experience as Whisper Flow and Superwhisper, running fully offline on the user&#8217;s machine.</p><p><a href="https://dailypythonprojects.substack.com/t/week-27">View All Projects This Week</a></p><h2>By Wednesday, you&#8217;ll have this:</h2><p>You&#8217;re in Slack. Cursor in the message box. Press <strong>Right Key/ Right Option</strong>. Say <em>&#8220;hey team, running 5 minutes late for standup.&#8221;</em> Release. The words appear in the message box, typed by the app. No browser tab open, no cloud upload, no monthly fee.</p><p>Same in VS Code &#8212; you&#8217;re writing a code comment. Right Key / Right Option, dictate, release. Text lands in the file. Same in Google Docs, Notes, terminal, email &#8212; anywhere your cursor is. Nothing on your screen changes except the words appearing.</p><p>That&#8217;s Wednesday. Today we build the foundation that makes it possible: recording and transcribing audio locally, without touching the cloud.</p><h2>Today&#8217;s Project</h2><p>The CLI voice recorder.</p><p>Run the script. Press ENTER. Speak. Press ENTER again. Watch the transcript appear. The audio and text are auto-saved to <code>~/VoiceNotes/</code> for later reference.</p><p>Simple, useful, works entirely offline after the first run. And it teaches the full pipeline that Wednesday&#8217;s magic depends on:</p><ol><li><p>Stream audio from the microphone into a numpy array</p></li><li><p>Save the array as a WAV file</p></li><li><p>Feed it to Whisper for transcription</p></li><li><p>Print + save the result</p></li></ol><p>Wednesday just changes <em>how</em> those steps are triggered &#8212; from an ENTER prompt to a system-wide hotkey &#8212; and <em>where</em> the transcript goes: not to a file, but typed directly into whichever app has focus.</p><h2>About Whisper (and Why &#8220;Faster&#8221;)</h2><p>Whisper is an open-source speech recognition model from OpenAI, released in 2022. Trained on 680,000 hours of multilingual audio, it&#8217;s genuinely excellent &#8212; accurate, robust to accents, handles background noise, and works in 99 languages.</p><p>The original <code>whisper</code> library from OpenAI uses PyTorch and is somewhat slow on CPU. <code>faster-whisper</code> is a re-implementation using CTranslate2 &#8212; the <em>same model</em>, but 4x faster on CPU and using less memory. Same accuracy, better performance. It&#8217;s what nearly every real-world Whisper app uses today.</p><p>Model sizes:</p><ul><li><p><code>tiny</code> &#8212; 75 MB, fastest, less accurate</p></li><li><p><code>base</code> &#8212; 150 MB, great balance for voice notes &#8592; <em>our default</em></p></li><li><p><code>small</code> &#8212; 500 MB, more accurate</p></li><li><p><code>medium</code> &#8212; 1.5 GB, close to human parity</p></li><li><p><code>large-v3</code> &#8212; 3 GB, state of the art</p></li></ul><p>For personal voice notes on a laptop, <code>base</code> is the sweet spot &#8212; fast enough to transcribe a 30-second note in ~1 second on a modern CPU, accurate enough for casual dictation.</p><h2>Project Task</h2><p>Build a CLI script that:</p><ul><li><p>Loads a <code>faster-whisper</code> model on startup</p></li><li><p>Loops: prompts the user, records audio, transcribes, saves, repeats</p></li><li><p>Uses <code>sounddevice</code> to stream from the microphone into a <code>numpy</code> array</p></li><li><p>Streams audio through a <strong>thread-safe queue</strong> so recording keeps working while the main thread waits for input</p></li><li><p>Uses a <code>threading.Event</code> to signal &#8220;stop recording&#8221; from the main thread</p></li><li><p>Saves each note as a <strong>timestamped pair</strong> of <code>.wav</code> + <code>.txt</code> files in <code>~/VoiceNotes/</code></p></li><li><p>Handles Ctrl+C cleanly</p></li><li><p>Filters out accidental too-short recordings (under 0.3s)</p></li></ul><p>This project gives you hands-on practice with <code>sounddevice</code>, thread-safe programming, <code>threading.Event</code>, <code>queue.Queue</code>, <code>faster-whisper</code>, and <code>soundfile</code>.</p><h2>Setup Instructions</h2><p><strong>System dependencies (macOS + Linux):</strong></p><p>macOS:</p><pre><code><code>brew install portaudio
</code></code></pre><p>Linux:</p><pre><code><code>sudo apt install portaudio19-dev
</code></code></pre><p>Windows: no system dependencies needed &#8212; <code>sounddevice</code> bundles PortAudio.</p><p><strong>Python dependencies:</strong></p><pre><code><code>pip install sounddevice soundfile numpy faster-whisper
</code></code></pre><p><strong>Run it:</strong></p><pre><code><code>python voice_notes.py
</code></code></pre><p>First run takes ~30 seconds to download the Whisper model. After that, model load is 2-3 seconds and everything works offline. You&#8217;ll see a startup banner, then a &#8220;Press ENTER to start recording&#8221; prompt. Speak, press ENTER again, and the transcript appears in the terminal &#8212; matching the screenshot at the top of this post.</p><p><strong>A note on microphone permissions:</strong> on macOS, the first time you run this, macOS will pop up a dialog asking to grant microphone permission to whichever terminal you&#8217;re using. Grant it, then restart the script. This is a one-time setup.</p><h2>Understanding sounddevice and Streaming Audio</h2><p>Recording audio in real time is fundamentally different from reading a file. A file has a fixed size; a live audio stream has <em>no end</em> until you tell it to stop. So instead of &#8220;read all the audio,&#8221; we say &#8220;start a stream, and call this function every time a chunk of audio is available.&#8221;</p><p><code>sounddevice</code> gives us exactly that &#8212; the <code>InputStream</code> with a <code>callback</code>:</p><pre><code><code>import sounddevice as sd

def my_callback(indata, frames_count, time_info, status):
    # Called every ~10ms with a fresh chunk of audio.
    # indata is a numpy array of shape (frames, channels)
    print(f"Got {frames_count} frames")

with sd.InputStream(samplerate=16000, channels=1,
                    dtype="float32", callback=my_callback):
    time.sleep(3.0)  # let it run for 3 seconds
</code></code></pre><p>Three things worth internalizing:</p><ul><li><p><strong>The callback runs on a separate thread</strong>, managed by PortAudio internally. It fires at whatever rate the audio hardware ticks (usually every 10-30ms).</p></li><li><p><code>indata</code><strong> is a view into a stream-owned buffer</strong> that will be overwritten as soon as the callback returns. If you want to keep the data, you MUST <code>.copy()</code> it. Failing to do this is the #1 rookie sounddevice bug.</p></li><li><p><strong>The </strong><code>with</code><strong> block is what starts and stops the stream.</strong> As soon as you enter it, audio starts flowing; as soon as you exit, it stops. Clean lifetime management.</p></li></ul><h2>Understanding queue.Queue for Cross-Thread Data</h2><p>Now we have a problem: the audio callback is running on the audio thread. We want to process the audio on the main thread. How do we get data safely from one thread to the other?</p><p><code>queue.Queue</code><strong> is the answer.</strong> It&#8217;s a thread-safe FIFO buffer. One thread puts items in, another thread takes them out. All the locking is handled internally.</p><pre><code><code>import queue

audio_queue = queue.Queue()

def audio_callback(indata, frames_count, time_info, status):
    audio_queue.put(indata.copy())    # audio thread: producer

# Main thread: consumer
while not stop_event.is_set():
    try:
        block = audio_queue.get(timeout=0.1)
        frames.append(block)
    except queue.Empty:
        continue
</code></code></pre><p>Two habits worth forming:</p><ul><li><p><code>.copy()</code><strong> before putting into the queue.</strong> Otherwise the audio thread will overwrite what you queued.</p></li><li><p><code>.get(timeout=0.1)</code> instead of <code>.get()</code> &#8212; the timeout lets the main thread wake up periodically and check <code>stop_event</code>, instead of blocking forever on an empty queue. 100ms is a good compromise between responsiveness and CPU wake-ups.</p></li></ul><p>The producer-consumer pattern with a queue is <em>foundational</em> for any streaming-data work. Once you&#8217;ve done it with audio, you know how to do it with sensor data, live network feeds, real-time file monitoring, and everything in between.</p><h2>Understanding threading.Event for Stop Signals</h2><p>We need a clean way for the main thread to tell the recording thread &#8220;stop now, wrap it up.&#8221; A boolean flag would work but has subtle threading issues. <code>threading.Event</code><strong> is the idiomatic answer</strong>:</p><pre><code><code>import threading

stop_event = threading.Event()   # starts unset

# In recording thread:
while not stop_event.is_set():
    # ... record ...

# In main thread:
stop_event.set()                 # signal stop
recording_thread.join()          # wait for it to actually stop
</code></code></pre><p>An <code>Event</code> is essentially a thread-safe boolean with three operations:</p><ul><li><p><code>.set()</code> &#8212; turn it on</p></li><li><p><code>.is_set()</code> &#8212; check its state</p></li><li><p><code>.wait(timeout)</code> &#8212; block until it turns on</p></li></ul><p>This is <em>the</em> pattern for graceful shutdown in any threaded Python program: web servers, background workers, real-time processors. Learn it once, use it forever.</p><h2>Understanding the Main Loop Choreography</h2><p>Now let&#8217;s zoom out and see how ENTER, the event, and the thread all coordinate:</p><pre><code><code>while True:
    input("Press ENTER to start recording...")   # blocks main thread

    stop_event = threading.Event()               # fresh event per note
    result = {}                                  # place for thread's output

    def worker():
        result["audio"] = record_until_stopped(stop_event)

    recording_thread = threading.Thread(target=worker, daemon=True)
    recording_thread.start()                     # off it goes

    print("Recording... Press ENTER to stop.")
    input()                                       # blocks main thread again
    stop_event.set()                              # signal the worker
    recording_thread.join()                       # wait for actual shutdown

    audio = result["audio"]                       # retrieve results
    # ... transcribe, save, loop ...
</code></code></pre><p>Key ideas:</p><ul><li><p><strong>The </strong><code>result = {}</code><strong> dict pattern</strong> &#8212; since Python functions can&#8217;t easily return values from a <code>threading.Thread(target=...)</code>, we use a dict as a shared &#8220;result box.&#8221; The thread writes into it; the main thread reads after <code>.join()</code>.</p></li><li><p><code>daemon=True</code> &#8212; daemon threads die automatically when the main program exits. If the user hits Ctrl+C during recording, the recording thread won&#8217;t linger.</p></li><li><p><strong>The two </strong><code>input()</code><strong> calls block the main thread</strong> &#8212; but <em>only</em> the main thread. The recording thread keeps happily filling the queue during both waits. This is why threading is essential here: without it, ENTER would never work while recording was happening.</p></li></ul><p>Read this section twice if it&#8217;s new. Understanding this dance is the difference between &#8220;I can use threading&#8221; and &#8220;I can <em>design</em> threaded programs.&#8221;</p><h2>Understanding faster-whisper</h2><p><code>faster-whisper</code> is dead simple to use:</p><pre><code><code>from faster_whisper import WhisperModel

# Load once. This takes 2-3 seconds and downloads the model on first run.
model = WhisperModel("base", device="cpu", compute_type="int8")

# Transcribe. audio is a 1-D float32 numpy array at 16 kHz.
segments, info = model.transcribe(audio, beam_size=5, vad_filter=True)

# segments is a generator. Consume it to get the text.
text = " ".join(seg.text.strip() for seg in segments)
</code></code></pre><p>Three parameters worth knowing:</p><ul><li><p><code>compute_type="int8"</code> &#8212; the fastest CPU inference mode. Slight accuracy loss vs. float32, but ~4x faster and half the RAM. Perfect for laptops.</p></li><li><p><code>beam_size=5</code> &#8212; how many candidate sequences the model considers. Bigger = more accurate, slower. 5 is a great default; you can drop to 1 for speed or bump to 10 for the last few percent of accuracy.</p></li><li><p><code>vad_filter=True</code> &#8212; Voice Activity Detection. Whisper&#8217;s biggest annoyance is that it &#8220;hallucinates&#8221; transcriptions in silent audio (&#8221;thank you for watching&#8221; is a common one, from YouTube training data). VAD drops silent segments <em>before</em> transcription, dramatically reducing this problem.</p></li></ul><p>The <code>info</code> object tells you what language Whisper detected and how confident it was &#8212; a handy little UX detail.</p><h2>Understanding Saving with soundfile</h2><p><code>soundfile</code> is our WAV writer. One function does everything:</p><pre><code><code>import soundfile as sf

sf.write(wav_path, audio, SAMPLE_RATE, subtype="PCM_16")
</code></code></pre><ul><li><p><code>subtype="PCM_16"</code> &#8212; 16-bit signed integer WAV format. Widely compatible, ~half the size of float32 WAV. <code>soundfile</code> handles the float&#8594;int conversion automatically.</p></li><li><p>The path can be a string or <code>pathlib.Path</code> &#8212; both work.</p></li></ul><p>If you ever need to <em>read</em> an audio file, it&#8217;s the mirror:</p><pre><code><code>audio, sample_rate = sf.read(wav_path)
</code></code></pre><p>Both <code>soundfile</code> and <code>sounddevice</code> use <code>numpy</code> arrays as their common language &#8212; no format juggling, no encoding conversions.</p><h2>Understanding the Auto-Save Pattern</h2><p>We save every note automatically with a timestamped filename:</p><pre><code><code>from datetime import datetime

stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
wav_path = NOTES_DIR / f"note_{stamp}.wav"
txt_path = NOTES_DIR / f"note_{stamp}.txt"
</code></code></pre><p>Two design choices worth explaining:</p><ul><li><p><strong>Timestamp format </strong><code>YYYYMMDD_HHMMSS</code> &#8212; sorts lexically the same as chronologically. <code>note_20260721_143000.wav</code> sorts before <code>note_20260721_143500.wav</code>. Both <code>ls</code> and file explorers show them in order automatically.</p></li><li><p><strong>Paired WAV + TXT</strong> with the same stem &#8212; makes it trivial to find &#8220;the audio for this transcript&#8221; or &#8220;the transcript for this audio.&#8221;</p></li></ul><p><strong>Never save with just </strong><code>datetime.now().isoformat()</code> &#8212; that produces strings like <code>2026-07-21T14:30:00.123456+00:00</code>, which contain colons and special characters that break on Windows filenames. <code>strftime</code> with only safe characters is the right call.</p><h2>Practical Use Cases</h2><p><strong>Personal dictation:</strong> Record thoughts on a walk, transcribe them later, paste into your journal.</p><p><strong>Meeting notes:</strong> Record short &#8220;action item&#8221; reminders after a call. The transcripts feed right into your task list.</p><p><strong>Language learning:</strong> Whisper is multilingual &#8212; record yourself speaking Spanish, get back the transcript to verify pronunciation.</p><p><strong>Accessibility:</strong> Transcription for anyone who prefers speaking over typing. Fully local means it works on airplanes.</p><p><strong>Foundation for Wednesday:</strong> The record + transcribe + save pipeline stays. Wednesday just changes what triggers it (a system-wide hotkey) and where the transcript goes (into whatever app you&#8217;re using).</p><h2>Coming Wednesday</h2><p>Wednesday: no ENTER prompt. No terminal you have to switch to. No copy-paste.</p><p>The app runs invisibly in the background. You press <strong>Right Key / Right Option </strong>anywhere on your system &#8212; inside Slack, inside VS Code, inside a Google Doc, inside your email. You speak. You release Right Key / Right Option. The transcription lands directly in whichever app you&#8217;re focused on, character by character, like you typed it yourself.</p><p>It&#8217;s the same experience as Whisper Flow and Superwhisper. It costs nothing. It runs on your laptop offline. And you&#8217;ll own every line of the code.</p><p>Wednesday&#8217;s the paid post &#8212; <a href="https://dailypythonprojects.substack.com/subscribe">upgrade here</a> if you&#8217;re not already a subscriber.</p><h2>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/4wVhOyZ1f5SbFyIkYkKwYA&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/4wVhOyZ1f5SbFyIkYkKwYA"><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/CJde4Sg0ODhhXHbzOZCF6Q&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/CJde4Sg0ODhhXHbzOZCF6Q"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Weather Climatology Dashboard: Day 3 - Multi-city comparison (WeatherSpark style)]]></title><description><![CDATA[Build a multi-city comparison weather dashboard where users see which city is colder/warmer.]]></description><link>https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day-529</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day-529</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 16 Jul 2026 08:03:38 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c41b6c88-a69a-4ec3-9781-cd2d10053c06_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>Climate Dashboard</strong> &#8212; a Streamlit web app that fetches historical weather data from a free public API and turns it into beautiful climatology charts.</p><ul><li><p><strong>Day 1:</strong> Single city, monthly averages</p></li><li><p><strong>Day 2:</strong> Daily climatology with filled temperature bands</p></li><li><p><strong>Day 3:</strong> Multi-city comparison (WeatherSpark style) <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-26">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale &#8212; the recreated WeatherSpark chart.</strong></p><p>Days 1 and 2 built the foundation: fetch, aggregate, smooth, chart. But a climatology chart of a single city can only answer questions about that city in isolation. The really interesting questions are <em>comparative</em>:</p><ul><li><p>Is Vienna colder than Tirana in January?</p></li><li><p>When during the year is the gap the biggest?</p></li><li><p>Which city has a more extreme annual swing?</p></li><li><p>Are there any months where they&#8217;re at the same temperature?</p></li></ul><p>Today we build the app that answers all of that. Two (or three) cities&#8217; curves on the same chart. Translucent filled bands that show where they overlap. Floating labels on each curve. Prose insights beneath the chart that read the data for you.</p><p>Same UI shell as Days 1 and 2. Dramatically more powerful chart.</p><h2>Project Task</h2><p>Build the multi-city comparison view:</p><ul><li><p>Two text inputs (plus a third, optional) for city names</p></li><li><p>Load and process each city using the pipeline from Days 1 &amp; 2</p></li><li><p>Overlay all cities&#8217; max/min curves on a single Plotly chart</p></li><li><p>Each city gets its own <strong>color palette</strong> and <strong>translucent filled band</strong></p></li><li><p>Add <strong>floating city-name labels</strong> near each curve&#8217;s peak</p></li><li><p>Add a <strong>Today</strong> marker (like Day 2)</p></li><li><p>Below the chart: a <strong>prose insights</strong> section &#8212; &#8220;Right now, Tirana is 6&#176;C warmer&#8221;, &#8220;Biggest gap in October&#8221;, &#8220;Curves never cross&#8221;</p></li><li><p>Below that: a <strong>monthly comparison table</strong> showing exact numbers</p></li><li><p>And at the bottom: <strong>city info cards</strong> styled in each city&#8217;s color</p></li></ul><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>streamlit run climatology_compare.py
</code></code></pre><p>Type <strong>Vienna</strong> and <strong>Tirana</strong>. Here&#8217;s what you get:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!IGrp!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!IGrp!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 424w, https://substackcdn.com/image/fetch/$s_!IGrp!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 848w, https://substackcdn.com/image/fetch/$s_!IGrp!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 1272w, https://substackcdn.com/image/fetch/$s_!IGrp!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!IGrp!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.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;:508683,&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/207160351?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.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_!IGrp!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 424w, https://substackcdn.com/image/fetch/$s_!IGrp!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 848w, https://substackcdn.com/image/fetch/$s_!IGrp!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.png 1272w, https://substackcdn.com/image/fetch/$s_!IGrp!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58fb74f1-b474-473c-b56a-6c00b6403e10_2610x1740.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 aria-hidden="true" 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><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>Two smooth curves per city, showing the lowest and highest daily average temperature. Vienna&#8217;s high/low in purple, Tirana&#8217;s in green. This is a great way to have a feeling of the weather of a city at a certain month by comparing it with your own city.</p><p>We also show some more insights below the chart area:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!3dpd!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!3dpd!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 424w, https://substackcdn.com/image/fetch/$s_!3dpd!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 848w, https://substackcdn.com/image/fetch/$s_!3dpd!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 1272w, https://substackcdn.com/image/fetch/$s_!3dpd!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!3dpd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.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;:577571,&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/207160351?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.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_!3dpd!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 424w, https://substackcdn.com/image/fetch/$s_!3dpd!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 848w, https://substackcdn.com/image/fetch/$s_!3dpd!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.png 1272w, https://substackcdn.com/image/fetch/$s_!3dpd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7f7fab58-9868-48d9-87a3-57e45bff444f_2688x1792.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 aria-hidden="true" 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><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>Same dependencies as Days 1 &amp; 2:</p><pre><code><code>pip install streamlit plotly requests
streamlit run climatology_compare.py
</code></code></pre><h2>Understanding Reusing the Day 2 Pipeline</h2><p>The whole point of the &#8220;same UI, growing intelligence&#8221; pattern is that we can <em>call our old code with different arguments</em> instead of rewriting anything. Day 3&#8217;s Load-A-City helper is literally the Day 1/2 pipeline in a single function:</p><pre><code><code>def load_city_climatology(city_name, years):
    """Fetch + aggregate + smooth for one city."""
    city = geocode_city(city_name.strip())
    if city is None:
        return None

    today = date.today()
    end_year = today.year - 1
    start_year = end_year - years + 1
    start_date = f"{start_year}-01-01"
    end_date = f"{end_year}-12-31"

    daily = fetch_daily_history(
        city["latitude"], city["longitude"],
        start_date, end_date,
        city.get("timezone", "auto"),
    )

    raw_max, raw_min = daily_climatology(daily)
    avg_max = rolling_mean(raw_max, SMOOTHING_WINDOW)
    avg_min = rolling_mean(raw_min, SMOOTHING_WINDOW)

    return {
        "city": city,
        "avg_max": avg_max,
        "avg_min": avg_min,
        "years_range": (start_year, end_year),
    }
</code></code></pre><p>Every function called here &#8212; <code>geocode_city</code>, <code>fetch_daily_history</code>, <code>daily_climatology</code>, <code>rolling_mean</code> &#8212; is unchanged from Day 2. We just wrapped them in a function that returns a self-contained &#8220;city bundle&#8221; dict. Then we call it once per city:</p><pre><code><code>bundles = []
for name in [city_a, city_b, city_c]:
    bundles.append(load_city_climatology(name, years))
</code></code></pre><p><strong>This is the &#8220;bundle&#8221; pattern in action.</strong> Each bundle contains everything downstream code needs (the chart, insights, table) to work with that city. Downstream code doesn&#8217;t care how many cities there are &#8212; it just iterates the bundle list.</p><h2>Understanding Overlapping Traces with Individual Fills</h2><p>Each city needs its own max/min pair with its own filled band. Since we have 2-3 cities, that means 4-6 traces in total. The pattern:</p><pre><code><code>for bundle, palette in zip(bundles, CITY_PALETTE):
    # Max curve for this city
    fig.add_trace(go.Scatter(
        x=x_labels, y=bundle["avg_max"],
        line=dict(color=palette["high"], width=2.5),
        legendgroup=bundle["city"]["name"],
    ))

    # Min curve WITH fill='tonexty' - fills to the previous trace (the max above)
    fig.add_trace(go.Scatter(
        x=x_labels, y=bundle["avg_min"],
        line=dict(color=palette["low"], width=2.5),
        fill="tonexty",
        fillcolor=palette["fill"],
        legendgroup=bundle["city"]["name"],
    ))
</code></code></pre><p>Two important tricks:</p><ul><li><p><strong>Trace order matters (same as Day 2).</strong> Max first, then min with <code>fill="tonexty"</code>. The fill fills to the <em>previous trace</em>, which is the max curve above.</p></li><li><p><code>legendgroup</code> groups both traces of one city together in the legend. Click &#8220;Vienna&#8221; in the legend and <em>both</em> the max and min curves toggle at the same time. Nice UX detail.</p></li></ul><p>The color palette provides three hues per city &#8212; a bold <code>high</code> color, a slightly lighter <code>low</code> color, and a translucent <code>fill</code> color:</p><pre><code><code>CITY_PALETTE = [
    {"high": "#7c3aed", "low": "#a78bfa", "fill": "rgba(124, 58, 237, 0.15)"},  # purple
    {"high": "#059669", "low": "#34d399", "fill": "rgba(5, 150, 105, 0.15)"},    # green
    {"high": "#ea580c", "low": "#fb923c", "fill": "rgba(234, 88, 12, 0.15)"},    # orange
]
</code></code></pre><p><strong>The fill alpha (0.15) is crucial.</strong> Too dark and the cities&#8217; bands hide each other where they overlap. At 0.15, they blend transparently &#8212; you can see both cities&#8217; territory even in the shared middle.</p><h2>Understanding Floating City Labels</h2><p>Legends are helpful but require the eye to jump back and forth. Real chart craftsmanship puts labels <em>directly on the data</em>. WeatherSpark does this &#8212; so do we, using <code>add_annotation</code>:</p><pre><code><code>for bundle, palette in zip(bundles, CITY_PALETTE):
    avg_max = bundle["avg_max"]
    # Find the day-of-year where this city's max is highest
    peak_i = max(range(365), key=lambda i: avg_max[i] if avg_max[i] is not None else -999)

    # Offset each city's label by a few days so multi-city labels don't overlap
    offset = bundles.index(bundle) * 8
    label_i = min(364, peak_i + offset)

    fig.add_annotation(
        x=x_labels[label_i],
        y=avg_max[label_i],
        text=f"&lt;b&gt;{bundle['city']['name']}&lt;/b&gt;",
        showarrow=False,
        font=dict(color=palette["high"], size=13),
        xanchor="left", yanchor="bottom",
        xshift=6,
    )
</code></code></pre><p>Two concepts worth internalizing:</p><ul><li><p><code>showarrow=False</code> &#8212; a bare annotation, no arrow pointing anywhere. Just text at a coordinate.</p></li><li><p><code>xshift=6</code> &#8212; nudge the label 6 pixels right so it doesn&#8217;t sit <em>on</em> the data point. Small visual polish that makes a huge difference.</p></li></ul><p>The <code>offset = bundles.index(bundle) * 8</code> line prevents label collision when multiple cities peak in the same month. Without it, &#8220;Vienna&#8221; and &#8220;Tirana&#8221; (both peaking mid-July) would sit on top of each other.</p><h2>Understanding the Smart Insights</h2><p>The chart shows <em>what</em> the data says. The insights section explains it in prose. This is where Day 3 stops being &#8220;just a chart&#8221; and becomes a <em>report</em>.</p><p>Each insight is generated by comparing two cities&#8217; arrays and finding the answer to a specific question:</p><pre><code><code>def build_insights(bundles):
    insights = []
    a, b = bundles[0], bundles[1]

    # Right now: today's typical difference
    today_i = today_doy() - 1
    diff = a["avg_max"][today_i] - b["avg_max"][today_i]
    warmer = a["city"]["name"] if diff &gt; 0 else b["city"]["name"]
    insights.append(
        f"&#128205; Right now, **{warmer}** is typically warmer by "
        f"**{abs(diff):.1f}&#176;C**."
    )

    # July vs January comparison
    a_monthly = monthly_averages_from_daily(a["avg_max"])
    b_monthly = monthly_averages_from_daily(b["avg_max"])
    jul_diff = a_monthly[6] - b_monthly[6]
    # ...
</code></code></pre><p>Each insight is a few lines of pure data-summary logic. The chart shows <em>what</em> &#8212; the insights answer <em>specific questions</em> the user probably had in mind.</p><h2></h2><h2>Understanding the Monthly Comparison Table</h2><p>The insights tell you the story; the table gives you the numbers. <code>st.dataframe</code> accepts a list of dicts and renders them as an interactive table:</p><pre><code><code>def build_comparison_table(bundles):
    rows = []
    monthly_pairs = [(b, monthly_averages_from_daily(b["avg_max"]))
                     for b in bundles]

    for m_i, month_name in enumerate(MONTH_NAMES):
        row = {"Month": month_name}
        for bundle, monthly in monthly_pairs:
            row[bundle["city"]["name"]] = round(monthly[m_i], 1)
        rows.append(row)

    return rows

st.dataframe(build_comparison_table(bundles),
             use_container_width=True, hide_index=True)
</code></code></pre><p>The dataframe is <em>interactive</em> &#8212; click a column header to sort, drag corners to resize. Every real dashboard should have a table below its chart. The chart shows the shape; the table gives you the exact values.</p><p><strong>A design principle worth adopting:</strong> always pair a visualization with a data table. The chart is for pattern recognition; the table is for verification. Users use both, at different moments.</p><h2>Understanding What You&#8217;ve Built</h2><p>Let&#8217;s zoom out. Compare Day 1 with Day 3:</p><p><strong>Day 1:</strong> One city, 12 monthly points. Simple, but limited.</p><p><strong>Day 3:</strong> Two or three cities compared side-by-side, with 365-day smooth curves, filled bands, floating labels, generated prose insights, monthly comparison table, color-coded city cards. <strong>The pipeline was extended twice &#8212; once for daily granularity, once for multi-city.</strong> Nothing was thrown away.</p><p>That&#8217;s the shape of every real data app you&#8217;ll ever build:</p><ul><li><p><strong>Fetch layer:</strong> cache-once functions per API</p></li><li><p><strong>Transform layer:</strong> small pure functions that turn raw data into usable structures</p></li><li><p><strong>Bundle layer:</strong> self-contained data envelopes with everything a view needs</p></li><li><p><strong>View layer:</strong> widgets that render bundles into UI</p></li></ul><p>Change the fetch API tomorrow and only the fetch layer needs updating. Add a fourth visualization next week and only the view layer changes. This separation is what makes Streamlit apps <em>maintainable</em>.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>climate comparison dashboard</strong>:</p><ul><li><p><strong>Day 1:</strong> Two-API pipeline, monthly aggregation, Plotly basics</p></li><li><p><strong>Day 2:</strong> 365-day climatology, rolling smoothing with wraparound, filled bands</p></li><li><p><strong>Day 3:</strong> Multi-city comparison, prose insights, comparison tables, color-coded cards</p></li></ul><p><strong>You now have:</strong></p><ul><li><p><strong>Two-API chaining</strong> &#8212; geocode + main data, the shape of every location app </p></li><li><p><strong>Time-series aggregation</strong> &#8212; the universal groupby-and-average pattern </p></li><li><p><strong>Rolling means for noise reduction</strong> &#8212; with wraparound for cyclical data </p></li><li><p><strong>Plotly for interactive charts</strong> &#8212; line traces, filled areas, annotations, custom ticks </p></li><li><p><strong>Streamlit at intermediate level</strong> &#8212; text inputs, sliders, spinner, metrics, dataframes, styled markdown </p></li><li><p><strong>The &#8220;bundle&#8221; pattern</strong> &#8212; one data structure per entity, downstream code stays clean </p></li><li><p><strong>Prose insights generation</strong> &#8212; reading data programmatically to write sentences</p></li></ul><p><strong>Real-world applications:</strong></p><ul><li><p><strong>Any climatology / seasonality analysis</strong> &#8212; retail sales by day-of-year, energy consumption by hour-of-day, website traffic by day-of-week</p></li><li><p><strong>Comparative dashboards</strong> &#8212; two products&#8217; revenue over time, two competitors&#8217; stock prices, two cities&#8217; any-metric</p></li><li><p><strong>Any location-aware data app</strong> &#8212; real estate prices, air quality, restaurant density</p></li><li><p><strong>Time-series smoothing</strong> &#8212; the rolling-mean trick applies to any noisy signal</p></li><li><p><strong>Portfolio piece</strong> &#8212; deploy to Streamlit Cloud in 5 minutes, share the URL</p></li></ul><p><strong>Next steps:</strong></p><ul><li><p>Add <strong>precipitation</strong> as a second chart tab</p></li><li><p>Try other data types from Open-Meteo: <strong>humidity, wind, sunshine hours</strong></p></li><li><p>Build a <strong>&#8220;best time to visit&#8221;</strong> view combining temperature, rain, and daylight</p></li><li><p>Add a <strong>city autocomplete</strong> using the geocoding API&#8217;s <code>count=5</code> param</p></li><li><p>Deploy to <strong>Streamlit Community Cloud</strong> &#8212; free, one-click GitHub deploy</p></li></ul><p>You&#8217;ve built a fully-functional, deployable climate comparison tool. That&#8217;s a real portfolio piece. &#128640;</p><h2>View Code Evolution</h2><p>Compare Day 3&#8217;s comparison view with Day 2&#8217;s single-city climatology and Day 1&#8217;s monthly averages &#8212; same fetch, same aggregate, same smooth. The chart and insights got smarter; the pipeline never changed. That&#8217;s the design lesson of the week.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day-529">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Weather Climatology Dashboard: Day 2 - Daily climatology with filled temperature bands]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day-18e</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day-18e</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 15 Jul 2026 14:17:46 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/e7c08115-c235-4784-ba6b-f1352a5db2ed_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>Climate Dashboard</strong> &#8212; a Streamlit web app that fetches historical weather data from a free public API and turns it into beautiful climatology charts, culminating in a WeatherSpark-style multi-city comparison by Friday.</p><ul><li><p><strong>Day 1:</strong> Single city, monthly averages</p></li><li><p><strong>Day 2:</strong> Daily climatology with filled temperature bands <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Multi-city comparison (WeatherSpark style)</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-26">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday we drew 12 points per line &#8212; one per month. Useful, but <em>chunky</em>. Real climate visualization sites like <a href="https://weatherspark.com/">WeatherSpark</a> draw <strong>smooth curves</strong> &#8212; one point for every single day of the year, forming a beautiful sinusoidal wave that shows the year&#8217;s temperature cycle in glorious detail.</p><p>Today we go from 12 points to <strong>365</strong>. The trick: for each calendar day (Jan 1, Jan 2, ... Dec 31), we average that day&#8217;s temperature across many years. So the &#8220;Jan 15&#8221; value becomes the <em>typical</em> Jan 15 temperature for this city &#8212; averaged over a decade of data.</p><p>Then we add three visual upgrades:</p><ul><li><p><strong>A filled band</strong> between the max and min curves (the classic WeatherSpark look)</p></li><li><p><strong>A vertical &#8220;Today&#8221; marker</strong> so you can see where the current date sits in the annual cycle</p></li><li><p><strong>A 7-day rolling smooth</strong> so the curves are silky-smooth instead of noisy</p></li></ul><p>Same UI shell as Day 1. Dramatically better chart.</p><h2>What&#8217;s Changing</h2><p>The pipeline stays the same:</p><ol><li><p>Geocode the city</p></li><li><p>Fetch daily temperatures from the archive</p></li><li><p>Aggregate</p></li><li><p>Chart</p></li></ol><p>The <strong>aggregation step</strong> is what levels up. Instead of grouping by month (12 buckets), we group by <strong>day-of-year</strong> (365 buckets). Instead of a chunky monthly line, we get a smooth daily curve.</p><p>Same data, ~30&#215; more detail.</p><h2>Project Task</h2><p>Build the daily climatology view on top of Day 1&#8217;s foundation:</p><ul><li><p>Reuse Day 1&#8217;s geocoding + fetch functions unchanged</p></li><li><p>Group daily observations by <strong>day-of-year</strong> (1..365) instead of by month</p></li><li><p><strong>Collapse Feb 29</strong> into Feb 28 (leap-year handling)</p></li><li><p>Compute a raw 365-day average from 10 years of history</p></li><li><p><strong>Smooth</strong> the raw curve with a 7-day rolling mean (with year-boundary wraparound)</p></li><li><p>Chart it as two curves with <code>fill='tonexty'</code> for the shaded band between them</p></li><li><p>Add a <strong>vertical &#8220;Today&#8221; line</strong> using Plotly&#8217;s <code>add_vline</code></p></li><li><p>Update the metric cards to show today&#8217;s typical high/low</p></li><li><p>Reduce the x-axis clutter (365 tick labels would be unreadable &#8212; show ~12)</p></li></ul><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>streamlit run climatology_daily.py
</code></code></pre><p>Type a city (default Vienna, 10 years of history) and you will get these metrics:</p><ul><li><p>Warmest day of the year</p></li><li><p>Coldest day of the year</p></li><li><p>Typical hight temperature today</p></li><li><p>Elevation</p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!7vgR!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!7vgR!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 424w, https://substackcdn.com/image/fetch/$s_!7vgR!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 848w, https://substackcdn.com/image/fetch/$s_!7vgR!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 1272w, https://substackcdn.com/image/fetch/$s_!7vgR!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!7vgR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.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;:525639,&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/207157948?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.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_!7vgR!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 424w, https://substackcdn.com/image/fetch/$s_!7vgR!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 848w, https://substackcdn.com/image/fetch/$s_!7vgR!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.png 1272w, https://substackcdn.com/image/fetch/$s_!7vgR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3eaa36fe-fb0e-4c3d-8a94-fe0bd15c44bd_2586x1724.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 &#8220;Typical high today&#8221; card is new &#8212; it tells you exactly what a <em>normal</em> mid-July day looks like in Vienna based on 10 years of data. Answers questions like &#8220;am I hotter than usual today?&#8221; at a glance.</p><p>For Vienna, you see the classic seasonal cycle: a smooth sine-like wave peaking in mid-July around 26&#176;C and bottoming in mid-January around -4&#176;C. For Cairo, you see a flatter, higher curve peaking in August around 35&#176;C. For Sydney, the curve is <em>inverted</em> (peaks in January, valleys in July) because it&#8217;s the southern hemisphere.</p><h2>Setup Instructions</h2><p>Same dependencies as Day 1:</p><pre><code><code>pip install streamlit plotly requests
streamlit run climatology_daily.py
</code></code></pre><h2>Understanding Grouping by Day-of-Year</h2><p>Yesterday we grouped by month (12 buckets). Today we group by <strong>day-of-year</strong> &#8212; the integer position of each date within its calendar year, from 1 (Jan 1) to 365 (Dec 31).</p><p>The idea is beautifully simple: <strong>every Jan 15 across every year goes into the same bucket</strong>. Same for Feb 3, and July 14, and December 25. After 10 years of data, each bucket has ~10 observations. We average within each bucket &#8594; get the &#8220;typical&#8221; temperature for that calendar day.</p><pre><code><code>buckets_max = defaultdict(list)
buckets_min = defaultdict(list)

for date_str, tmax, tmin in zip(daily["dates"], daily["max"], daily["min"]):
    doy = day_of_year_no_leap(date_str)      # "2020-07-14" -&gt; 195
    if tmax is not None:
        buckets_max[doy].append(tmax)
    if tmin is not None:
        buckets_min[doy].append(tmin)

avg_max = [_avg(buckets_max[d]) for d in range(1, 366)]
avg_min = [_avg(buckets_min[d]) for d in range(1, 366)]
</code></code></pre><p>Same <code>defaultdict(list)</code> pattern as yesterday &#8212; just with 365 buckets instead of 12. The universality of the &#8220;bucket-and-average&#8221; pattern is worth appreciating: it&#8217;s the same shape whether you&#8217;re aggregating temperatures by day, sales by category, or website visits by hour of day.</p><h2>Understanding the Feb 29 Problem</h2><p>Leap years have 366 days. Non-leap years have 365. So Feb 29 exists in some years but not others.</p><p>If we naively used the &#8220;true&#8221; day-of-year:</p><ul><li><p>Mar 1 would be day 60 in non-leap years and day 61 in leap years</p></li><li><p>The bucket for &#8220;day 61&#8221; would mix Mar 1 (leap) with Mar 2 (non-leap) &#8212; polluted data</p></li><li><p>Feb 29 would have ~2 data points (from leap years only) while other days have ~10 &#8212; massively unbalanced</p></li></ul><p>The cleanest fix is to <strong>use a 365-day calendar and collapse Feb 29 into Feb 28</strong>:</p><pre><code><code>def day_of_year_no_leap(date_str):
    y, m, d = int(date_str[0:4]), int(date_str[5:7]), int(date_str[8:10])

    # Handle the leap day: treat Feb 29 the same as Feb 28.
    if m == 2 and d == 29:
        d = 28

    days_before_month = [0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
    return days_before_month[m] + d
</code></code></pre><p>Now every calendar day gets ~the same number of data points, Mar 1 is always day 60, and Feb 29&#8217;s 2 or 3 observations quietly merge into Feb 28&#8217;s bucket. Data integrity preserved.</p><p><strong>This is what &#8220;handling edge cases correctly&#8221; looks like in practice.</strong> Not fancy &#8212; just knowing your data well enough to spot the trap before it bites you.</p><h2>Understanding the Rolling Mean</h2><p>Even with 10 years of data per bucket, our daily curve is <em>still</em> noisy. A random cold snap in July 2019 will make July 14 look colder than July 13 in the raw average. Look at any real climate data closely and you&#8217;ll see this daily jitter &#8212; it&#8217;s not signal, it&#8217;s noise.</p><p>The fix is a <strong>rolling mean</strong> &#8212; replace each day&#8217;s value with the average of it and its neighbors. Our version uses a 7-day window:</p><pre><code><code>def rolling_mean(values, window):
    n = len(values)
    half = window // 2
    smoothed = []
    for i in range(n):
        window_vals = []
        for offset in range(-half, half + 1):
            j = (i + offset) % n         # wrap Dec 31 &lt;-&gt; Jan 1
            v = values[j]
            if v is not None:
                window_vals.append(v)
        if window_vals:
            smoothed.append(sum(window_vals) / len(window_vals))
        else:
            smoothed.append(None)
    return smoothed
</code></code></pre><p>Three ideas worth understanding:</p><ul><li><p><strong>The window size matters.</strong> 3 days = still noisy; 30 days = over-smoothed (real seasonal transitions get flattened). 7 days is the sweet spot for daily climate data &#8212; one week worth of neighbors on each side.</p></li><li><p><code>(i + offset) % n</code> &#8212; the wraparound trick. December 30&#8217;s smoothed value legitimately includes Jan 1&#8217;s data because climate is <em>cyclical</em>. Without the modulo, Dec 30 would have half a window (only left neighbors) and look artificially bumpy. <strong>This little detail is what makes the curve seamlessly continuous.</strong></p></li><li><p><strong>Skip None values in the window</strong> &#8212; even if some day has no data, the rolling mean fills in gracefully using its neighbors.</p></li></ul><p>In our tests: the raw curve had a day-to-day wobble of 1.05&#176;C. After smoothing, it dropped to 0.20&#176;C &#8212; <strong>an 81% reduction in noise, with the seasonal shape completely preserved</strong>.</p><h2>Understanding the &#8220;Today&#8221; Marker</h2><p>Adding a vertical line at today&#8217;s date is one Plotly call:</p><pre><code><code>today_i = today_doy() - 1
today_label = doy_to_display_date(today_i + 1)

fig.add_vline(
    x=today_label,
    line_width=2,
    line_dash="dash",
    line_color="#f97316",
    annotation_text="Today",
    annotation_position="top right",
    annotation_font_color="#f97316",
)
</code></code></pre><p><code>add_vline</code> draws a full-height vertical line at the given x-position, with an optional annotation. <code>line_dash="dash"</code> makes it dashed &#8212; visually distinct from the temperature curves. The annotation floats near the top of the chart.</p><p>This is the <em>contextualization</em> trick that makes the chart feel alive &#8212; without the &#8220;Today&#8221; marker, you&#8217;d have to eyeball where you are in the year. With it, the current moment is obvious at a glance.</p><h2>Understanding X-Axis Tick Reduction</h2><p>If we told Plotly to show every one of our 365 x-axis labels, they&#8217;d overlap into an illegible blur. We manually pick ~12 evenly-spaced positions and only label those:</p><pre><code><code>tick_positions = [(m - 1) * 30 for m in range(1, 13)]      # ~monthly
tick_text = [doy_to_display_date(p + 1) for p in tick_positions]

fig.update_layout(
    xaxis=dict(
        tickmode="array",
        tickvals=[x_labels[i] for i in tick_positions],
        ticktext=tick_text,
    ),
)
</code></code></pre><p><code>tickmode="array"</code> tells Plotly &#8220;use exactly these positions and labels, ignore automatic ticking.&#8221; Perfect for cases where the data density on the x-axis is way higher than the labels can accommodate.</p><p><strong>The lesson:</strong> in charts with many x-values, labels are for orientation, not for identification. 12 monthly labels give the reader &#8220;roughly where in the year&#8221; without cluttering the picture. The unified-hover tooltip gives them the exact day when they want it.</p><h2>Understanding the Combined Effect</h2><p>Look at the full transformation from Day 1 to Day 2:</p><p><strong>Day 1 chart:</strong></p><ul><li><p>12 discrete monthly points connected by straight-ish segments</p></li><li><p>Chunky, looks like a bar chart&#8217;s spiritual cousin</p></li><li><p>Answers &#8220;roughly, what does this month look like?&#8221;</p></li></ul><p><strong>Day 2 chart:</strong></p><ul><li><p>365 smooth points forming a continuous curve</p></li><li><p>Shaded band showing the daily temperature range</p></li><li><p>Today marker for temporal context</p></li><li><p>Answers &#8220;what&#8217;s the typical Vienna weather on any given day of the year?&#8221;</p></li></ul><p><strong>All of Day 2&#8217;s added intelligence lives in the aggregation step.</strong> The API fetching is unchanged. The Streamlit layout is nearly identical. Only the middle &#8212; how we bucket, how we smooth, how we plot &#8212; grew smarter.</p><p>This is the same &#8220;stable UI, growing intelligence underneath&#8221; pattern that ran through Week 25&#8217;s predictor. It&#8217;s a <em>design principle</em> worth internalizing: <strong>your users shouldn&#8217;t have to relearn the app because you upgraded the math</strong>.<br></p><h2>Coming Tomorrow</h2><p>Tomorrow is the finale &#8212; <strong>multi-city comparison</strong>. Type Vienna, then type Tirana, and watch both cities&#8217; climate curves appear on the same chart with different colors. Which city is hotter in July? By how much? At what point in the year do their temperatures cross? The comparison chart answers all of these visually. This is what WeatherSpark does &#8212; and we&#8217;ll build it from scratch.</p><h2>View Code Evolution</h2><p>Compare Day 2&#8217;s daily climatology with Day 1&#8217;s monthly averages &#8212; same fetch pipeline, same UI shell, but the aggregation step levels up dramatically. That&#8217;s how real data apps evolve: not by rewriting from scratch, but by refining one piece at a time.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day-18e">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Weather Climatology Dashboard: Day 1 - Single city, monthly averages]]></title><description><![CDATA[Build a data dashboard showing the annual climate profile of a any city in the world.]]></description><link>https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/weather-climatology-dashboard-day</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 14 Jul 2026 17:44:32 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/aa3946d4-df2f-4d02-94ba-e93d8dd14b34_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>Climate Dashboard</strong> &#8212; a Streamlit web app that fetches historical weather data from a free public API and turns it into beautiful climatology charts, culminating in a WeatherSpark-style multi-city comparison by Friday.</p><p><strong>Why build this?</strong> Because climate data is one of the most rewarding kinds of data to visualize &#8212; the seasonal cycle is <em>right there</em> in the numbers, but you need the right chart to see it. And this week teaches the exact &#8220;same UI, growing sophistication&#8221; pattern that shows up in real-world data apps every day.</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"></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 how to consume two APIs together (geocoding + weather), aggregate time-series data by different time buckets, and build progressively better charts with Plotly. By Friday, you&#8217;ll be able to compare the climate of any two cities side-by-side in a single interactive chart.</p><p><strong>Why this matters:</strong> Climate visualization is the exact shape of dozens of real data problems &#8212; sales seasonality, website traffic patterns, energy consumption cycles, stock market seasonality. Master this pattern and you master a huge class of dashboards.</p><ul><li><p><strong>Day 1:</strong> Single city, monthly averages <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Daily climatology with filled temperature bands</p></li><li><p><strong>Day 3:</strong> Multi-city comparison (WeatherSpark style)</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-26">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We start with the foundation: <strong>the annual climate profile of a single city</strong>, shown as a 12-month line chart of average high and low temperatures.</p><p>Type &#8220;Vienna&#8221; &#8212; see Vienna&#8217;s climate. Type &#8220;Tokyo&#8221; &#8212; see Tokyo&#8217;s. Type &#8220;Cairo&#8221; &#8212; see Cairo&#8217;s. The app fetches five years of daily data, averages it by month, and plots the seasonal cycle.</p><p>By the end of today you&#8217;ll have real climate data on screen for anywhere in the world, working from two free public APIs with no API key required.</p><h2>About the Data</h2><p>Two free, public APIs from <strong><a href="https://open-meteo.com/">Open-Meteo</a></strong> &#8212; both need no signup, no API key, no credit card:</p><p><strong>Historical Weather API:</strong> <code>archive-api.open-meteo.com</code></p><ul><li><p>ERA5 reanalysis data from <strong>1940 to today</strong></p></li><li><p>Global coverage (works for any lat/lng on Earth)</p></li><li><p>Daily aggregates: max temp, min temp, precipitation, wind, humidity, and more</p></li><li><p>CC BY 4.0 license</p></li><li><p>Free tier: ~10,000 requests/day (way more than we&#8217;ll need)</p></li></ul><p><strong>Geocoding API:</strong> <code>geocoding-api.open-meteo.com</code></p><ul><li><p>Turns &#8220;Vienna&#8221; into <code>latitude: 48.21, longitude: 16.37</code></p></li><li><p>Returns country, timezone, elevation, population as bonuses</p></li><li><p>Also free, also no key</p></li></ul><p>Together they let us build a location-agnostic climate app in under 300 lines.</p><h2>Project Task</h2><p>Build a Streamlit app that:</p><ul><li><p>Takes a <strong>city name</strong> as text input</p></li><li><p><strong>Geocodes</strong> the city into lat/lng using Open-Meteo&#8217;s geocoding API</p></li><li><p><strong>Fetches</strong> N years of daily historical temperatures for that location</p></li><li><p><strong>Aggregates</strong> the daily data into 12 monthly average high and low temperatures</p></li><li><p><strong>Displays</strong> the result as a Plotly line chart with two traces (high and low)</p></li><li><p>Shows a <strong>summary strip</strong> with 4 metric cards: hottest month, coldest month, annual range, elevation</p></li><li><p>Includes a small <strong>map</strong> showing the city location</p></li><li><p>Caches API results so repeat interactions are instant</p></li></ul><p>This project gives you hands-on practice with <code>requests</code> + query parameters, JSON parsing with parallel arrays, dict-based bucketing, <code>defaultdict</code>, Plotly <code>graph_objects</code>, Streamlit&#8217;s <code>metric</code> / <code>columns</code> / <code>plotly_chart</code> widgets, and <code>@st.cache_data</code> for performance.</p><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>streamlit run climatology.py
</code></code></pre><p>Type a city name at the top (e.g., Viena). You get:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!tnjA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!tnjA!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 424w, https://substackcdn.com/image/fetch/$s_!tnjA!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 848w, https://substackcdn.com/image/fetch/$s_!tnjA!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 1272w, https://substackcdn.com/image/fetch/$s_!tnjA!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!tnjA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.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;:337724,&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/207052397?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.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_!tnjA!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 424w, https://substackcdn.com/image/fetch/$s_!tnjA!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 848w, https://substackcdn.com/image/fetch/$s_!tnjA!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.png 1272w, https://substackcdn.com/image/fetch/$s_!tnjA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0201c696-ceba-45c8-864d-56d0987b95c6_1710x1140.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 aria-hidden="true" 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><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 dependencies:</strong></p><pre><code><code>pip install streamlit plotly requests
</code></code></pre><p><strong>Run:</strong></p><pre><code><code>streamlit run climatology.py
</code></code></pre><p>Your browser opens automatically at http://localhost:8501.</p><h2>Understanding Two APIs Working Together</h2><p>A common real-world pattern is that <strong>the API you actually want to hit needs data the user doesn&#8217;t have</strong>. The user knows the city name &#8220;Vienna&#8221; &#8212; but the historical weather API needs precise coordinates. That means two API calls, chained.</p><pre><code><code>User types "Vienna"
       &#8595;
Geocoding API &#8594; { latitude: 48.21, longitude: 16.37, ... }
       &#8595;
Historical API &#8594; { daily: { time: [...], temperature_2m_max: [...], ... } }
       &#8595;
Chart
</code></code></pre><p>Both calls happen in the backend of your Streamlit app. The user just sees a text box and a chart &#8212; the two-hop journey is invisible.</p><p>This is <em>the</em> pattern for any location-aware data app. Weather, air quality, timezone lookups, restaurant search, real-estate prices &#8212; all of them start with &#8220;turn this address/city into coordinates first.&#8221;</p><h2>Understanding requests with Query Parameters</h2><p>Both APIs use standard HTTP GET with query parameters. Our helper wraps <code>requests.get</code> cleanly:</p><pre><code><code>@st.cache_data(ttl=86400)
def geocode_city(name):
    response = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": name, "count": 1, "language": "en", "format": "json"},
        timeout=10,
    )
    response.raise_for_status()
    data = response.json()
    if not data.get("results"):
        return None
    return data["results"][0]
</code></code></pre><p>The <code>params={...}</code> argument tells <code>requests</code> to add these as query parameters &#8212; it will URL-encode them properly, so we don&#8217;t have to think about escaping. <strong>Never manually build a URL by concatenating strings.</strong> <code>?name=New%20York%20City</code> becomes just <code>params={"name": "New York City"}</code> &#8212; Python handles the encoding.</p><p><code>response.raise_for_status()</code> throws immediately on 4xx/5xx errors &#8212; better than silently getting a broken response and confusing yourself later.</p><p><code>data.get("results")</code> safely returns None if the key is missing, instead of crashing. Different APIs are inconsistent about their &#8220;no results&#8221; behavior &#8212; some return an empty array, some omit the key entirely. <code>.get()</code> handles both.</p><h2>Understanding the Archive API Response Shape</h2><p>The Historical Weather API returns something like:</p><pre><code><code>{
  "latitude": 48.2,
  "longitude": 16.37,
  "elevation": 190.0,
  "timezone": "Europe/Vienna",
  "daily": {
    "time":              ["2020-01-01", "2020-01-02", "2020-01-03", ...],
    "temperature_2m_max": [5.4,         6.1,         3.8,          ...],
    "temperature_2m_min": [-1.2,        0.8,         -2.4,         ...]
  }
}
</code></code></pre><p>Notice something specific: <code>daily</code><strong> contains three parallel arrays, not a list of daily record objects.</strong> Element <code>i</code> in each array belongs to the same day.</p><p>This is a common shape for time-series APIs &#8212; it&#8217;s more compact than an array of objects (saving bandwidth on repeated key names), but requires you to iterate them together with <code>zip()</code>:</p><pre><code><code>for date_str, tmax, tmin in zip(daily["time"],
                                daily["temperature_2m_max"],
                                daily["temperature_2m_min"]):
    # date_str = "2020-01-01", tmax = 5.4, tmin = -1.2
    ...
</code></code></pre><p><code>zip</code><strong> is the tool for parallel arrays.</strong> Any time you see this shape, <code>zip</code> is what you want.</p><h2>Understanding the Aggregation Idea</h2><p>The core teaching point of today: <strong>grouping daily data into monthly buckets</strong>.</p><p>We fetch ~1,800 daily observations (5 years &#215; ~365 days). We need 12 monthly averages. The pattern:</p><pre><code><code>from collections import defaultdict

buckets_max = defaultdict(list)
buckets_min = defaultdict(list)

for date_str, tmax, tmin in zip(daily["dates"], daily["max"], daily["min"]):
    month = int(date_str[5:7])          # "2020-07-14" -&gt; 7
    if tmax is not None:
        buckets_max[month].append(tmax)
    if tmin is not None:
        buckets_min[month].append(tmin)

avg_max = [sum(buckets_max[m]) / len(buckets_max[m]) for m in range(1, 13)]
avg_min = [sum(buckets_min[m]) / len(buckets_min[m]) for m in range(1, 13)]
</code></code></pre><p>Three concepts worth internalizing:</p><ul><li><p><code>defaultdict(list)</code> &#8212; a dict where any missing key auto-initializes to an empty list. No <code>if month not in buckets: buckets[month] = []</code> clutter. It just works.</p></li><li><p><code>date_str[5:7]</code> &#8212; string slicing to grab the month digits directly. Since Open-Meteo returns dates in <code>YYYY-MM-DD</code> format, positions 5 and 6 are always the month. Fast and no <code>datetime.strptime</code> overhead needed.</p></li><li><p><strong>The pattern</strong> &#8212; &#8220;group by X, aggregate within each group&#8221; is the most common data pattern in the world. This is <code>pandas.groupby</code> under the hood; this is SQL&#8217;s <code>GROUP BY</code>; this is what MapReduce is doing at scale.</p></li></ul><p>Once you see this pattern once, you see it everywhere.</p><h2>Understanding Safe Aggregation with None Values</h2><p>Open-Meteo occasionally returns <code>null</code> for a day where the reanalysis is missing (this is very rare, but real). If we don&#8217;t guard for it, a <code>None</code> value slipping into a bucket crashes the average calculation.</p><p>Two guards handle this:</p><pre><code><code># Skip Nones when appending
if tmax is not None:
    buckets_max[month].append(tmax)

# Guard against empty buckets (e.g. very short date ranges)
def _avg(bucket):
    return sum(bucket) / len(bucket) if bucket else None

avg_max = [_avg(buckets_max[m]) for m in range(1, 13)]
</code></code></pre><p>If a whole month happens to be empty (say, the user picks a very narrow date range), we return <code>None</code> for that slot. Plotly happily renders a gap in the line instead of crashing.</p><p><strong>This is the difference between fragile code and robust code.</strong> Fragile code assumes the data is perfect. Robust code assumes gaps exist and handles them gracefully. Real-world APIs <em>always</em> have gaps.</p><h2>Understanding Plotly Chart Basics</h2><p>We use <code>plotly.graph_objects.Figure</code> for maximum control. The pattern:</p><pre><code><code>import plotly.graph_objects as go

fig = go.Figure()

fig.add_trace(go.Scatter(
    x=MONTHS,
    y=avg_max,
    mode="lines+markers",
    name="Average High",
    line=dict(color="#e11d48", width=3),
    marker=dict(size=8),
    hovertemplate="%{x}: &lt;b&gt;%{y:.1f}&#176;C&lt;/b&gt;&lt;extra&gt;&lt;/extra&gt;",
))

fig.add_trace(go.Scatter(
    x=MONTHS,
    y=avg_min,
    mode="lines+markers",
    name="Average Low",
    line=dict(color="#2563eb", width=3),
    marker=dict(size=8),
    hovertemplate="%{x}: &lt;b&gt;%{y:.1f}&#176;C&lt;/b&gt;&lt;extra&gt;&lt;/extra&gt;",
))

fig.update_layout(
    title=f"Monthly Average Temperature - {city_label}",
    xaxis_title="Month",
    yaxis_title="Temperature (&#176;C)",
    hovermode="x unified",
    height=500,
)
</code></code></pre><p>A few Plotly ideas to know:</p><ul><li><p><code>add_trace(go.Scatter(...))</code> &#8212; each trace is one line/series. Add multiple to overlay.</p></li><li><p><code>mode="lines+markers"</code> &#8212; shows both the connected line and a dot at each data point.</p></li><li><p><code>hovertemplate</code> &#8212; customize what shows up on hover. <code>%{x}</code> and <code>%{y}</code> are the data values; <code>&lt;extra&gt;&lt;/extra&gt;</code> hides the trace name from the tooltip.</p></li><li><p><code>hovermode="x unified"</code> &#8212; the whole reason interactive charts feel great. Hover anywhere on the chart and <em>both</em> traces show their values for that x-position in a single tooltip.</p></li></ul><h2>Understanding Streamlit Caching for API Calls</h2><p>API calls are expensive. If we re-fetched every time the user tweaked the years slider, we&#8217;d hit the API 5+ times per interaction. Streamlit&#8217;s <code>@st.cache_data</code> fixes this in one line:</p><pre><code><code>@st.cache_data(ttl=86400)      # 86,400 seconds = 1 day
def fetch_daily_history(latitude, longitude, start_date, end_date, timezone):
    ...
</code></code></pre><p>Streamlit hashes the input arguments and caches the return value. Next time the function is called with the same arguments, the cached result is returned instantly &#8212; no HTTP call.</p><p>The <code>ttl=86400</code> means &#8220;invalidate after 1 day&#8221; &#8212; perfect for historical data that only changes slightly. For live/current data you&#8217;d use a much smaller TTL.</p><p><strong>Cache the API layer, not the UI.</strong> Historical weather doesn&#8217;t change; the same lat/lng + date range should return the same result forever. This is exactly the right layer to cache.</p><h2>Understanding End-Date Choice</h2><p>There&#8217;s a subtle bug you could easily make: fetch data through <em>today&#8217;s date</em>. Do that and December 2026 gets averaged in when the user picks a range including 2026, but only using days that happened so far. Then your December average is skewed &#8212; it only has data through July 14, missing the coldest half of the month.</p><p>The fix: end at the <strong>last completed year</strong>:</p><pre><code><code>today = date.today()
end_year = today.year - 1
start_year = end_year - years + 1
</code></code></pre><p>If today is July 14, 2026, we ask for data through Dec 31, 2025. All months are complete. All averages are fair.</p><p><strong>Little details like this are what separate good data apps from misleading ones.</strong> Whenever you&#8217;re averaging over a time bucket, ask yourself: &#8220;do all the buckets have equal coverage?&#8221; If not, fix it before the users trust bad numbers.</p><h2>Understanding <code>st.metric</code> for Summary Cards</h2><p>Streamlit&#8217;s <code>st.metric</code> is one of its best components &#8212; a big-number card with an optional caption underneath:</p><pre><code><code>c1, c2, c3, c4 = st.columns(4)
c1.metric("Hottest month",
          stats["hottest_month"],
          f"{stats['hottest_high']:.1f}&#176;C avg high")
c2.metric("Coldest month",
          stats["coldest_month"],
          f"{stats['coldest_low']:.1f}&#176;C avg low")
</code></code></pre><p>Three args: label (small text above), value (big text), delta (small caption below). The delta is normally used for &#8220;changed by X&#8221; arrows, but works nicely as a supplementary caption too.</p><p>Four <code>st.metric</code> cards in a <code>st.columns(4)</code> row is <em>the</em> dashboard-header pattern. Almost every data app should start with a row of these.</p><h2>Practical Use Cases</h2><p><strong>1. The two-API pattern:</strong></p><pre><code><code>User input &#8594; geocoding &#8594; main API &#8594; chart. Works for weather, air quality, restaurants, real estate.
</code></code></pre><p><strong>2. Time-series bucketing:</strong></p><pre><code><code>Daily &#8594; monthly, hourly &#8594; daily, minute &#8594; hourly. Same defaultdict pattern for any resampling.
</code></code></pre><p><strong>3. Streamlit-plus-Plotly:</strong></p><pre><code><code>Nearly the smallest way to ship a real interactive data app. Plotly gives you interactivity for free.
</code></code></pre><p><strong>4. Handling messy real-world data:</strong></p><pre><code><code>None checks, empty-bucket guards, incomplete-year handling. Real data is always messy.
</code></code></pre><p><strong>5. Foundation for the rest of the week:</strong></p><pre><code><code>Tomorrow's daily climatology reuses fetch + geocoding, changes only the aggregation. Friday's multi-city just adds another fetch.
</code></code></pre><h2>Coming Tomorrow</h2><p>Tomorrow we go <strong>daily</strong>. Instead of 12 monthly points, we compute the full <strong>365-day climatology</strong> &#8212; average across years for every single day of the year. Then we plot the two curves with a <strong>filled area between them</strong> (the classic WeatherSpark look) and add a vertical <strong>&#8220;Today&#8221; marker</strong>. Same UI, dramatically smoother chart.</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/zk0L9MFW6mAHZi6GLw8N1w&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/zk0L9MFW6mAHZi6GLw8N1w"><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/xO7JHrymvWfePaj2cUgqpw&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/xO7JHrymvWfePaj2cUgqpw"><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[World Cup 2026 Match Predictor: Day 3- Ensemble + Interactive Analysis]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day-9fc</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day-9fc</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 09 Jul 2026 15:47:41 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ebc07ae5-3841-44a3-b499-2b1af2ca084a_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 Match Predictor</strong> &#8212; a Streamlit web app that predicts upcoming matches using real football-analytics techniques.</p><ul><li><p><strong>Day 1:</strong> Elo-based Predictor</p></li><li><p><strong>Day 2:</strong> Poisson Goal Model</p></li><li><p><strong>Day 3:</strong> Ensemble + Interactive Analysis <strong>(Today)</strong></p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-25">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p><strong>Welcome to the finale &#8212; the models learn to talk to each other.</strong></p><p>Yesterday, our Poisson model surprised us: France vs Morocco came out as <em>Morocco favored 51%</em>, driven by Morocco&#8217;s elite defensive record over the last 3 years. That reveal was uncomfortable &#8212; most football fans would pick France without hesitation.</p><p>Here&#8217;s what we didn&#8217;t show yesterday: <strong>Elo, our Day 1 model, disagrees with Poisson on that match</strong>. Elo (trained on 150 years of history) picks France at 56%. Poisson picks Morocco at 51%. Which one is right?</p><p><strong>Both. And neither.</strong> They&#8217;re capturing different signals &#8212; long-term class vs. recent form &#8212; and the truth almost always lies between them.</p><p>Today we combine them into an <strong>ensemble</strong> &#8212; a weighted average of both models&#8217; probabilities. We also add:</p><ul><li><p><strong>Confidence indicator</strong> &#8212; &#128994; High when models agree, &#128308; Low when they don&#8217;t</p></li><li><p><strong>Click-to-expand analysis</strong> &#8212; see both models side-by-side, recent form badges (W-W-D-L-W), head-to-head record</p></li></ul><p>Same UI shell as Days 1 and 2. Smarter, more honest predictions underneath.</p><h2>The Big Idea Behind Ensembles</h2><p><strong>Ensembling</strong> is one of the most important ideas in modern statistics and machine learning: combining multiple models produces predictions that are more accurate than any single model.</p><p>Why does this work? Because different models make different kinds of mistakes. If model A tends to overrate historically strong teams and model B tends to overrate recent form, then averaging them cancels out both biases. The classic academic result is that <strong>ensembles reduce variance without adding bias</strong> &#8212; as long as the individual models are reasonably good and <em>independent</em> in their errors, the average almost always beats each one.</p><p>Random forests do this by averaging hundreds of decision trees. Gradient boosting stacks weak models. Even the Netflix Prize was won by an ensemble of dozens of models. <strong>The idea is universal.</strong></p><p>For our app: Elo and Poisson are wonderfully independent. Elo bakes in every result since 1872 and moves slowly; Poisson only sees the last 3 years. When they agree on a match, we should be very confident. When they disagree, we should express uncertainty. That&#8217;s what today&#8217;s app does.</p><h2>Project Task</h2><p>Extend yesterday&#8217;s app so it:</p><ul><li><p>Combines Elo and Poisson predictions into a weighted-average <strong>ensemble</strong></p></li><li><p>Adds a <strong>third outcome</strong> (draw probability) to the Elo model so the two are directly comparable</p></li><li><p>Computes an <strong>agreement score</strong> between the two models (total-variation distance)</p></li><li><p>Shows a <strong>confidence indicator</strong> (&#128994;/&#128993;/&#128308;) with a plain-English description</p></li><li><p>Adds a <strong>click-to-expand section</strong> on each match card showing:</p><ul><li><p>Both models&#8217; individual predictions in a table</p></li><li><p>Recent form as <strong>W-D-L colored badges</strong> for each team&#8217;s last 5 matches</p></li><li><p><strong>Head-to-head</strong> history between the two teams</p></li></ul></li><li><p>Ranks the sidebar by a combined <strong>ensemble rank</strong> (average of Elo rank + attack rank)</p></li></ul><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>streamlit run predictor_ensemble.py
</code></code></pre><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!38XH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!38XH!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 424w, https://substackcdn.com/image/fetch/$s_!38XH!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 848w, https://substackcdn.com/image/fetch/$s_!38XH!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 1272w, https://substackcdn.com/image/fetch/$s_!38XH!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!38XH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3602a544-c094-4189-b943-3712327f2439_2958x1972.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;:797061,&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/206310635?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.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_!38XH!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 424w, https://substackcdn.com/image/fetch/$s_!38XH!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 848w, https://substackcdn.com/image/fetch/$s_!38XH!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.png 1272w, https://substackcdn.com/image/fetch/$s_!38XH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3602a544-c094-4189-b943-3712327f2439_2958x1972.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 aria-hidden="true" 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><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>Now the user has the full picture. <strong>France has won 5 straight</strong>, but <strong>Morocco has drawn Brazil and the Netherlands</strong> &#8212; both models&#8217; predictions are defensible; the ensemble captures the genuine uncertainty; the confidence indicator communicates that honestly.</p><p>Compare with a different match &#8212; <strong>Norway vs England:</strong></p><pre><code><code>&#128994; High confidence &#8212; The two models strongly agree.
</code></code></pre><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!CbFd!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!CbFd!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 424w, https://substackcdn.com/image/fetch/$s_!CbFd!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 848w, https://substackcdn.com/image/fetch/$s_!CbFd!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 1272w, https://substackcdn.com/image/fetch/$s_!CbFd!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!CbFd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.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;:417220,&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/206310635?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.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_!CbFd!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 424w, https://substackcdn.com/image/fetch/$s_!CbFd!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 848w, https://substackcdn.com/image/fetch/$s_!CbFd!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.png 1272w, https://substackcdn.com/image/fetch/$s_!CbFd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7794c123-dcff-4cb7-80b8-811a8c0dccc4_2112x1408.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 aria-hidden="true" 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><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>Both models pick England at ~51%. Agreement 96%. The app tells you when to trust it and when to hedge.</p><h2>Setup Instructions</h2><p>No new dependencies:</p><pre><code><code>pip install streamlit requests
streamlit run predictor_ensemble.py
</code></code></pre><h2>Understanding Adding Draws to Elo</h2><p>Yesterday&#8217;s Elo model gave us just two numbers: P(team1 wins), P(team2 wins). No draws. That&#8217;s a real limitation &#8212; international football sees roughly <strong>25-30% draws</strong>, so pretending they don&#8217;t exist inflates the win probabilities.</p><p>Adding draws to Elo turns out to be surprisingly simple with a trick: <strong>draw probability grows as the teams get closer in rating</strong>. Two teams of equal Elo? Around 28% chance of a draw. Two teams with a huge gap? Almost no chance of a draw (the stronger team just wins).</p><pre><code><code>def elo_predict(ratings, team1, team2, neutral=True):
    r1 = ratings.get(team1, INITIAL_ELO) + (0 if neutral else HOME_ADVANTAGE)
    r2 = ratings.get(team2, INITIAL_ELO)

    p1_raw = expected_score(r1, r2)          # standard Elo win probability
    p2_raw = 1.0 - p1_raw

    # How close are the teams? 1.0 = identical, 0.0 = 100% vs 0%
    closeness = 1.0 - abs(p1_raw - 0.5) * 2
    p_draw = 0.28 * closeness

    # Scale down win probabilities to make room for the draw
    p_win1 = p1_raw * (1.0 - p_draw)
    p_win2 = p2_raw * (1.0 - p_draw)

    return p_win1, p_draw, p_win2
</code></code></pre><p>Three moves worth understanding:</p><ul><li><p><code>closeness = 1.0 - abs(p1_raw - 0.5) * 2</code> &#8212; a clean formula that returns 1.0 when the teams are perfectly balanced (p_raw = 0.5) and 0.0 when it&#8217;s a total mismatch (p_raw = 0 or 1). Perfect for scaling draw probability.</p></li><li><p><code>p_draw = 0.28 * closeness</code> &#8212; the 0.28 is the &#8220;peak draw rate&#8221; for perfectly balanced teams. It&#8217;s an empirical constant from international football.</p></li><li><p><strong>Scaling </strong><code>p_win1 *= (1 - p_draw)</code> &#8212; we&#8217;re not adding a draw; we&#8217;re <em>reallocating</em> probability from wins to draws. That way our three numbers still sum to exactly 1.0.</p></li></ul><p>The three probabilities sum to <code>p1_raw * (1-p_draw) + p_draw + p2_raw * (1-p_draw)</code> = <code>(1-p_draw)(p1_raw + p2_raw) + p_draw</code> = <code>(1-p_draw) + p_draw</code> = 1.0. &#10003;</p><h2>Understanding the Ensemble Combination</h2><p>The ensemble is the simplest possible thing: a weighted average of the two models&#8217; probabilities:</p><pre><code><code>def ensemble_predict(elo_probs, poisson_probs, w_elo=0.5, w_poi=0.5):
    p1 = w_elo * elo_probs[0] + w_poi * poisson_probs[0]
    pd = w_elo * elo_probs[1] + w_poi * poisson_probs[1]
    p2 = w_elo * elo_probs[2] + w_poi * poisson_probs[2]
    return p1, pd, p2
</code></code></pre><p>Because both inputs sum to 1.0, and the weights sum to 1.0, the ensemble automatically sums to 1.0 too. No normalization needed. Beautiful.</p><p><strong>Choosing the weights matters.</strong> 50/50 is a good default &#8212; treat both models as equally valid. But you could bias toward one:</p><ul><li><p><strong>Weight Elo higher</strong> if you trust long-term class more (0.7 / 0.3 &#8594; Elo-dominant)</p></li><li><p><strong>Weight Poisson higher</strong> during a hot tournament run when recent form matters more (0.3 / 0.7)</p></li><li><p><strong>Learn the weights</strong> by testing on past tournaments and picking whatever gave the highest accuracy &#8212; the classic ML approach</p></li></ul><p>For teaching purposes, 50/50 is honest: &#8220;we don&#8217;t know which model is right; we&#8217;ll trust them equally.&#8221;</p><h2>Understanding Model Agreement</h2><p>To measure how much the two models agree, we use <strong>total variation distance</strong> &#8212; a standard measure between two probability distributions:</p><pre><code><code>def model_agreement(elo_probs, poisson_probs):
    tv = 0.5 * sum(abs(e - p) for e, p in zip(elo_probs, poisson_probs))
    return 1.0 - tv
</code></code></pre><p>Total variation distance (TV) is <code>half the sum of absolute differences</code>. It ranges from 0 (identical) to 1 (totally opposed). We flip it (<code>1 - tv</code>) so higher = more agreement &#8212; reads intuitively.</p><p><strong>In practice:</strong></p><ul><li><p>Both models pick France at 55% &#8594; TV &#8776; 0.05 &#8594; agreement &#8776; 95% &#8594; &#128994; High confidence</p></li><li><p>Elo picks France, Poisson picks Morocco &#8594; TV &#8776; 0.35 &#8594; agreement &#8776; 65% &#8594; &#128308; Low confidence</p></li></ul><p>The threshold values (0.90, 0.75) that separate green/yellow/red are chosen by feel &#8212; the goal is honest communication of uncertainty, not statistical precision.</p><h2>Understanding Recent Form Badges</h2><p>To show a team&#8217;s last 5 matches, we compute a W/D/L character per match:</p><pre><code><code>def recent_form(team, form_index, n=5):
    matches = form_index.get(team, [])[:n]
    results = []
    for m in matches:
        is_home = m["home_team"] == team
        my_score = int(m["home_score"] if is_home else m["away_score"])
        opp_score = int(m["away_score"] if is_home else m["home_score"])
        opp = m["away_team"] if is_home else m["home_team"]

        if my_score &gt; opp_score: r = "W"
        elif my_score &lt; opp_score: r = "L"
        else: r = "D"

        results.append((r, opp, f"{my_score}-{opp_score}", m["date"]))
    return results
</code></code></pre><p>The trick is that <strong>each match has a home team and away team</strong>, but the team we&#8217;re asking about could be either. The <code>is_home = m["home_team"] == team</code> check normalizes: we always want <em>our</em> team&#8217;s score and <em>their</em> opponent&#8217;s score, regardless of who was nominally at home.</p><p>Then we render each result as a colored badge (green W, yellow D, red L) using inline HTML:</p><pre><code><code>colors = {"W": "#22c55e", "D": "#eab308", "L": "#ef4444"}

badges = []
for r, opp, score, dt in form_results:
    badges.append(
        f"&lt;span style='background:{colors[r]}; color:white; "
        f"padding:2px 8px; border-radius:4px; font-weight:bold; "
        f"margin-right:4px;' title='vs {opp} ({score}) on {dt}'&gt;{r}&lt;/span&gt;"
    )
</code></code></pre><p>The <code>title="..."</code> attribute means users can hover any badge to see the match details. Small UX detail, big polish.</p><h2>Understanding Building the Recent Form Index</h2><p>Looking up a team&#8217;s recent matches quickly requires an <strong>index</strong> &#8212; a precomputed dict of team &#8594; list of matches, sorted newest first:</p><pre><code><code>@st.cache_data(ttl=3600)
def build_recent_form_index(matches):
    idx = defaultdict(list)
    for m in matches:
        if m["date"] &lt; FORM_WINDOW_START:
            continue
        idx[m["home_team"]].append(m)
        idx[m["away_team"]].append(m)
    for team in idx:
        idx[team].sort(key=lambda m: m["date"], reverse=True)
    return dict(idx)
</code></code></pre><p>We iterate once through history, adding each match to <em>both</em> teams&#8217; lists. Then sort each list by date descending. Lookup is <code>O(1)</code> &#8212; no more scanning through 49,000 matches for every card. Same &#8220;walk-once, index, then instant-lookup&#8221; pattern that shows up in <em>every</em> real data application.</p><h2></h2><h2>Understanding the Ensemble Ranking Sidebar</h2><p>The sidebar now ranks by a <strong>combined score</strong> &#8212; average of Elo rank and attack strength rank:</p><pre><code><code>by_elo = sorted(wc_teams_list, key=lambda t: -ratings[t])
by_atk = sorted(wc_teams_list, key=lambda t: -strength[t]["attack"])

elo_rank = {t: i for i, t in enumerate(by_elo)}
atk_rank = {t: i for i, t in enumerate(by_atk)}

combined = sorted(wc_teams_list,
                  key=lambda t: elo_rank[t] + atk_rank[t])
</code></code></pre><p><strong>Ranking by rank</strong> (rather than by raw value) is a subtle but important choice. Elo values range from ~1400 to ~2200; attack strengths range from ~0.5 to ~2.0. Averaging them directly would let Elo dominate purely due to scale.</p><p><strong>Averaging ranks avoids this entirely.</strong> A team ranked 3rd by Elo and 7th by attack has a combined score of 10. A team ranked 1st by Elo and 20th by attack scores 21. Rank-based combinations are dimensionless &#8212; no scale bias, no magic normalization constants. Same trick works for any leaderboard that combines fundamentally different metrics.</p><h2>Understanding Why This Is the Right Finale</h2><p>Look at the arc of the week:</p><ul><li><p><strong>Day 1</strong> built the Elo engine &#8212; the classic rating system.</p></li><li><p><strong>Day 2</strong> added a Poisson goal model &#8212; a fundamentally different lens.</p></li><li><p><strong>Day 3</strong> combines them into an ensemble with honest uncertainty.</p></li></ul><p>Each layer added information without discarding the previous one. The Day 1 code still runs. The Day 2 code still runs. Day 3 is just a coordinator that talks to both.</p><p><strong>This is the shape of every real ML system.</strong> You don&#8217;t build one model; you build several, and combine them. You don&#8217;t discard old models; you keep them running for comparison. You don&#8217;t hide model disagreement from users; you <em>show</em> it, because that&#8217;s what honest communication of uncertainty looks like.</p><h2>What You&#8217;ve Accomplished This Week</h2><p>&#127881; <strong>Congratulations!</strong> You&#8217;ve built a complete <strong>World Cup Match Predictor</strong>:</p><ul><li><p><strong>Day 1:</strong> Elo rating system trained on 49,000+ historical matches</p></li><li><p><strong>Day 2:</strong> Poisson goal model with expected goals and W/D/L splits</p></li><li><p><strong>Day 3:</strong> Ensemble prediction with confidence indicators and click-to-expand analysis</p><p></p></li></ul><p>You&#8217;ve built a real, working <strong>ensemble ML system</strong>. That&#8217;s not a beginner project. &#128640;</p><h2>View Code Evolution</h2><p>Compare today&#8217;s ensemble with Day 1&#8217;s Elo predictor and Day 2&#8217;s Poisson model &#8212; and see how each layer added a new perspective without discarding the previous one. Same UI, three genuinely different models, one honest prediction.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day-9fc">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[World Cup 2026 Match Predictor: Day 2- Poisson Goal Model]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day-1d2</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day-1d2</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 08 Jul 2026 12:20:37 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7807650a-8059-4d2f-bda8-d1a2b3d2642c_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 Match Predictor</strong> &#8212; a Streamlit web app that predicts upcoming matches using real football-analytics techniques. Each day the same interface stays, but the model behind it gets smarter.</p><ul><li><p><strong>Day 1:</strong> Elo-based Predictor</p></li><li><p><strong>Day 2:</strong> Poisson Goal Model <strong>(Today)</strong></p></li><li><p><strong>Day 3:</strong> Ensemble + Interactive Analysis</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-25">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>Yesterday&#8217;s Elo model was good at one thing: telling us <strong>who</strong> is likely to win. But football fans care about more than that &#8212; they want to know <strong>how</strong> it&#8217;ll go. Will it be a rout or a close game? What&#8217;s the most likely scoreline? What&#8217;s the chance of a draw?</p><p>Today we add all of that. The <strong>Poisson goal model</strong> treats each team&#8217;s goals as random events drawn from a probability distribution, with rates estimated from recent form. From that we get:</p><ul><li><p><strong>Expected goals (&#955;)</strong> for each team &#8212; the rate parameter</p></li><li><p><strong>A full grid</strong> of scoreline probabilities</p></li><li><p><strong>W / D / L split</strong> &#8212; draws now have their own line</p></li><li><p><strong>The most likely exact scoreline</strong></p></li></ul><p>Same Streamlit UI, better answers.</p><h2>The Big Idea Behind Poisson</h2><p>Football goals aren&#8217;t uniformly distributed. Teams don&#8217;t score exactly 1.5 goals per match &#8212; they score 0, 1, 2, sometimes 5, occasionally none. But the <em>average</em> rate over many matches is measurable, and the distribution of <em>individual match totals</em> around that average follows a very specific pattern.</p><p>That pattern is the <strong>Poisson distribution</strong> &#8212; the standard model for &#8220;count of independent events over a fixed period.&#8221; Radioactive decay, phone calls per hour to a call center, and <em>football goals per team per match</em> all follow the same shape.</p><p>The Poisson distribution says: if a team&#8217;s average scoring rate is &#955; (lambda), the probability they score exactly <em>k</em> goals in one match is:</p><p>$$P(k) = \frac{e^{-\lambda} \lambda^k}{k!}$$</p><p>For a team averaging 1.5 goals per match:</p><ul><li><p>Probability of 0 goals: 22%</p></li><li><p>Probability of 1 goal: 33%</p></li><li><p>Probability of 2 goals: 25%</p></li><li><p>Probability of 3 goals: 13%</p></li><li><p>Probability of 4 goals: 5%</p></li></ul><p>Sums to essentially 100% (the tail is tiny). That&#8217;s it &#8212; one number (&#955;) fully describes a team&#8217;s scoring profile.</p><h2>Project Task</h2><p>Extend yesterday&#8217;s app so it:</p><ul><li><p>Estimates each team&#8217;s <strong>attack strength</strong> (goals scored vs league average) and <strong>defense strength</strong> (goals conceded vs league average) from recent matches</p></li><li><p>Computes <strong>expected goals</strong> (&#955;) for each team in a matchup</p></li><li><p>Builds a <strong>scoreline probability matrix</strong> using two independent Poisson distributions</p></li><li><p>Sums the matrix into <strong>W / D / L</strong> probabilities</p></li><li><p>Finds the <strong>most likely exact scoreline</strong></p></li><li><p>Displays all of the above in the same card-based Streamlit UI as Day 1</p></li><li><p>Shows a sidebar of top-20 WC 2026 teams by attack strength</p></li></ul><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>streamlit run predictor_poisson.py
</code></code></pre><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!kYjI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!kYjI!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 424w, https://substackcdn.com/image/fetch/$s_!kYjI!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 848w, https://substackcdn.com/image/fetch/$s_!kYjI!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 1272w, https://substackcdn.com/image/fetch/$s_!kYjI!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!kYjI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.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;:603536,&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/206033498?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.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_!kYjI!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 424w, https://substackcdn.com/image/fetch/$s_!kYjI!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 848w, https://substackcdn.com/image/fetch/$s_!kYjI!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.png 1272w, https://substackcdn.com/image/fetch/$s_!kYjI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ca77450-e28e-4950-a84a-72313b25fefc_2628x1752.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 aria-hidden="true" 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><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>Why is Morocco predicted to win here?</strong></p><p>The screenshot above shows France vs Morocco in the Quarter-final. The app predicts <strong>Morocco to win at 50.5%</strong>, with a most likely scoreline of <strong>0&#8211;1</strong>. If you&#8217;re a football fan you&#8217;re probably surprised &#8212; France is clearly the &#8220;bigger&#8221; team, right? So what&#8217;s going on?</p><p>The Poisson model looks at <strong>each team&#8217;s recent scoring form since 2023</strong>, not their historical prestige. And when you look at the actual numbers:</p><ul><li><p><strong>France</strong> has an attack strength of 1.63 (63% above the international average of 1.4 goals/match) and a defense strength of 0.59.</p></li><li><p><strong>Morocco</strong> has an attack strength of 1.53 &#8212; very close to France &#8212; but a <strong>defense strength of 0.29</strong>. They&#8217;ve conceded just 23 goals in 56 matches since 2023.</p></li></ul><p>That defense number is the whole story. Morocco has been <em>exceptionally</em> stingy defensively &#8212; including drawing Brazil 1-1 and Netherlands 1-1 in the group stage, and beating Canada 3-0 in the Round of 16. When we multiply France&#8217;s attack &#215; Morocco&#8217;s tiny defense number, France&#8217;s expected goals collapses to 0.67. Meanwhile Morocco&#8217;s attack &#215; France&#8217;s more permissive defense gives Morocco 1.26 expected goals.</p><p><strong>The prediction reflects a specific truth: over the last 3 years, Morocco&#8217;s defensive record is elite, and France&#8217;s attack has been less prolific than reputation suggests.</strong> Whether that translates to a real Morocco win is up to Wednesday &#8212; the model captures a real signal but ignores things like tournament pressure and historical class.</p><p>This tension between &#8220;recent form&#8221; and &#8220;long-term class&#8221; is exactly why we&#8217;re building an ensemble on Day 3. Yesterday&#8217;s Elo model, which uses every match since 1872, still favors France at 68%. When the two models disagree like this, the truth usually lies in the middle.</p><h2>Setup Instructions</h2><p>Nothing new to install &#8212; same dependencies as Day 1.</p><pre><code><code>pip install streamlit requests
streamlit run predictor_poisson.py
</code></code></pre><h2>Understanding Team Attack and Defense Strength</h2><p>Before the Poisson math can happen, we need to estimate each team&#8217;s <strong>scoring rate</strong>. But teams don&#8217;t play in a vacuum &#8212; a 3-0 win is impressive against a strong opponent, meaningless against a minnow. The trick is to normalize by opponent strength using <strong>multiplicative factors</strong>:</p><pre><code><code>def compute_team_strength(matches):
    recent = [m for m in matches if m["date"] &gt;= FORM_WINDOW_START]

    scored = defaultdict(list)
    conceded = defaultdict(list)

    for m in recent:
        hg, ag = int(m["home_score"]), int(m["away_score"])
        home, away = m["home_team"], m["away_team"]
        scored[home].append(hg)
        scored[away].append(ag)
        conceded[home].append(ag)
        conceded[away].append(hg)

    strength = {}
    for team in scored:
        avg_scored = sum(scored[team]) / len(scored[team])
        avg_conceded = sum(conceded[team]) / len(conceded[team])
        strength[team] = {
            "attack":  avg_scored / LEAGUE_AVG_GOALS,
            "defense": avg_conceded / LEAGUE_AVG_GOALS,
        }

    return strength
</code></code></pre><p>The output is two multipliers per team:</p><ul><li><p><code>attack = 1.0</code> &#8212; you score at league average.</p></li><li><p><code>attack = 1.5</code> &#8212; you score 50% more than league average.</p></li><li><p><code>defense = 0.7</code> &#8212; you concede 30% less than league average (defense good; the <em>lower</em> the number, the tighter the defense).</p></li></ul><p>Real numbers from live data: <strong>Japan attack 2.02, defense 0.53</strong> (dominant scoring, stingy defense). <strong>Spain attack 1.82, defense 0.53</strong> (elite). These are <em>relative</em> to the international average of 1.4 goals per team per match.</p><h2>Understanding the Sample Size Guard</h2><p>There&#8217;s a classic gotcha here that will bite you the first time you build a ranking like this: <strong>teams with very few matches will dominate the top of the list</strong>. If you rank the whole world of national teams by attack strength, the top 20 gets flooded with tiny CONIFA teams &#8212; Isle of Man, Tamil Eelam, Jersey, S&#225;pmi &#8212; all of whom play maybe 6-10 matches every few years against similarly small teams and end up with wildly inflated attack numbers.</p><p>The naive fix is a minimum-matches filter (<code>s["matches"] &gt;= 30</code>). But we can do something cleaner and more relevant to <em>our specific app</em>: <strong>only show teams that are actually at the World Cup 2026</strong>.</p><pre><code><code>def wc_teams(fixtures):
    """The set of real (non-placeholder) team names in the WC 2026 fixtures."""
    teams = set()
    for m in fixtures["matches"]:
        for name in (m["team1"], m["team2"]):
            if not is_placeholder(name):
                teams.add(name)
    return teams

# In the sidebar:
qualifying = {t: s for t, s in strength.items() if t in wc_team_set}
top = sorted(qualifying.items(), key=lambda x: -x[1]["attack"])[:20]
</code></code></pre><p>Two lines. But this is a <em>much</em> better solution than a raw sample-size filter for two reasons:</p><ul><li><p><strong>It&#8217;s semantically correct.</strong> This is a World Cup app; only WC teams belong in the sidebar. Isle of Man&#8217;s attack strength is irrelevant to anyone using this tool.</p></li><li><p><strong>It solves the noise problem automatically.</strong> Every WC team has played ~40+ international matches since 2023 (they had to qualify, they play friendlies, they&#8217;re in continental competitions). The 48 WC teams are <em>automatically</em> the sample-size-clean subset.</p></li></ul><p><strong>The lesson:</strong> the right filter is often a domain filter, not a technical one. Instead of &#8220;teams with 30+ matches&#8221; (arbitrary threshold), we filter by &#8220;teams that matter for this specific view&#8221; (natural, defensible boundary). Any time you&#8217;re about to reach for a magic number to filter noise, ask first if there&#8217;s a domain fact that does the same job cleanly.</p><p>We still compute strengths for <em>every</em> team (Morocco&#8217;s defense number needs their whole history, not just their WC record) &#8212; we&#8217;re just choosing not to <em>display</em> the ones that aren&#8217;t relevant.</p><h2>Understanding the Recent-Form Window</h2><p>We only look at matches since a cutoff date:</p><pre><code><code>FORM_WINDOW_START = "2023-01-01"
</code></code></pre><p>Why? Because a team&#8217;s 2010 attack strength doesn&#8217;t tell us much about their 2026 form. Coaches change, players retire, tactics evolve. Three years is the sweet spot for international football &#8212; enough matches to be meaningful (~40-50 per top team), recent enough to reflect current reality.</p><p>Compare this with Day 1&#8217;s Elo, which used <em>every match ever</em>. That&#8217;s the right choice for Elo &#8212; the rating evolves gradually with each result, so the current rating already captures recent form. For Poisson, we need a <em>fresh</em> per-team average, so a rolling window works better.</p><p><strong>Different models want different data.</strong> That&#8217;s the meta-lesson of doing two models.</p><h2>Understanding Expected Goals (Lambda)</h2><p>Given both teams&#8217; attack and defense multipliers, the expected goals for a matchup is a three-way product:</p><pre><code><code>def expected_goals(team1, team2, strength):
    s1 = strength[team1]
    s2 = strength[team2]
    lambda1 = s1["attack"] * s2["defense"] * LEAGUE_AVG_GOALS
    lambda2 = s2["attack"] * s1["defense"] * LEAGUE_AVG_GOALS
    return lambda1, lambda2
</code></code></pre><p>The formula in plain English:</p><blockquote><p>Team1&#8217;s expected goals = (how well they score) &#215; (how easy team2 is to score against) &#215; (the average goals in a match).</p></blockquote><p>For <strong>France vs Morocco:</strong> France&#8217;s expected goals = France&#8217;s attack (1.63) &#215; Morocco&#8217;s defense (0.29) &#215; 1.4 = <strong>0.67</strong> (very low, because Morocco is so hard to score against). Morocco&#8217;s expected goals = Morocco&#8217;s attack (1.53) &#215; France&#8217;s defense (0.59) &#215; 1.4 = <strong>1.26</strong>. Morocco is the Poisson favorite despite being lower-Elo.</p><p>This is why the models disagree. Elo bakes in the whole history; Poisson listens to what&#8217;s happened <em>recently</em>. Both perspectives are legitimate.</p><h2>Understanding the Poisson PMF</h2><p>The probability mass function for a Poisson distribution:</p><pre><code><code>from math import exp, factorial

def poisson_pmf(k, lam):
    """Probability of exactly k events when the rate is lam."""
    return exp(-lam) * (lam ** k) / factorial(k)
</code></code></pre><p>That&#8217;s the whole math. Three built-in Python operations: <code>exp</code>, <code>**</code>, <code>factorial</code>. No NumPy, no SciPy, no dependencies beyond stdlib.</p><p>For <strong>&#955; = 1.5</strong>:</p><ul><li><p><code>poisson_pmf(0, 1.5)</code> = 0.223 &#8594; 22% chance of 0 goals</p></li><li><p><code>poisson_pmf(1, 1.5)</code> = 0.335 &#8594; 33% chance of 1 goal</p></li><li><p><code>poisson_pmf(2, 1.5)</code> = 0.251 &#8594; 25% chance of 2 goals</p></li><li><p><code>poisson_pmf(3, 1.5)</code> = 0.126 &#8594; 13% chance of 3 goals</p></li></ul><p>Sum: 93% by k=3, essentially 100% by k=5. Football scores are dominated by low numbers, and Poisson captures that shape naturally.</p><h2>Understanding the Scoreline Matrix</h2><p>To predict a match outcome, we build a <strong>joint probability grid</strong> &#8212; every possible scoreline gets a probability:</p><pre><code><code>def score_matrix(lambda1, lambda2, max_goals=8):
    matrix = {}
    for g1 in range(max_goals + 1):
        p1 = poisson_pmf(g1, lambda1)
        for g2 in range(max_goals + 1):
            p2 = poisson_pmf(g2, lambda2)
            matrix[(g1, g2)] = p1 * p2
    return matrix
</code></code></pre><p>The critical assumption: <strong>team1&#8217;s goals and team2&#8217;s goals are independent</strong>. So <code>P(1-0)</code> = <code>P(team1 scores 1) &#215; P(team2 scores 0)</code>.</p><p>For most matches this assumption holds well enough. Real matches have some correlation (a team that&#8217;s winning big might ease off; a team that&#8217;s losing might risk more) but the independence assumption is a reasonable first-order model. Dixon and Coles&#8217; famous 1997 improvement adjusted for exactly this correlation in 0-0 and 1-1 outcomes; you can bolt it on later.</p><p><code>max_goals=8</code> is our practical upper bound. The probability of any score beyond 8-8 is astronomically tiny; ignoring it costs us less than 0.01% of the total probability.</p><h2></h2><h2>Understanding the Most-Likely Scoreline</h2><p>Finding the single most-probable scoreline is a one-liner using <code>max</code> with a key:</p><pre><code><code>def most_likely_score(matrix):
    return max(matrix, key=matrix.get)
</code></code></pre><p><code>matrix.get</code> is the accessor that returns the probability for a given key; <code>max(..., key=fn)</code> returns the <em>key</em> whose function-value is largest. Python at its most elegant.</p><p>For <strong>Argentina vs Egypt</strong> (Argentina expected goals 1.30, Egypt 0.50), the top 5 scorelines are:</p><p>Score Probability 1-0 21.4% 0-0 16.5% 2-0 13.9% 1-1 10.8% 0-1 8.3%</p><p>The most likely single score is 1-0, but even the <em>most</em> likely score only happens 21% of the time &#8212; football is inherently high-variance. That&#8217;s why we care about <em>probability distributions</em> and not just point predictions.</p><h2></h2><h2>Understanding the Model&#8217;s Limitations</h2><p>Every model has weaknesses. Poisson&#8217;s are worth naming &#8212; and are exactly why the France vs Morocco prediction feels off:</p><ol><li><p><strong>Independence assumption:</strong> teams&#8217; goals aren&#8217;t perfectly independent. Real matches have correlation &#8212; winning teams ease off, losing teams take risks. Dixon-Coles (1997) fixes this by adjusting the corners of the matrix.</p></li><li><p><strong>Opponent quality isn&#8217;t weighted:</strong> we compute attack as raw goals scored / league average, without weighting by <em>who</em> was defended against. So a team that mostly plays weaker opponents will look artificially strong. Morocco&#8217;s 5-0 friendly rout of Burundi counts the same as their 1-1 vs Brazil.</p></li><li><p><strong>Match importance isn&#8217;t weighted:</strong> friendlies count the same as World Cup matches. A team that runs up scores in exhibitions inflates its attack rating.</p></li><li><p><strong>No context:</strong> injuries, tournament pressure, weather &#8212; none of it&#8217;s in the model.</p></li></ol><p>Every one of these is fixable with more work. But even this vanilla Poisson model produces ~62% match-outcome accuracy in the football-analytics literature. Good baseline. The interesting question is what happens when we combine it with Elo &#8212; which has <em>different</em> blind spots.</p><h2>Understanding Comparing the Models</h2><p>Here&#8217;s what makes tomorrow interesting. Look at these live matchups from right now:</p><p>Match Elo says Poisson says France vs Morocco France 68% Morocco 51% Norway vs England England 64% England 51% Spain vs Belgium Spain 72% Spain 45% / Draw 25% / Belgium 30% Argentina vs Switzerland Argentina 78% Argentina 63%</p><p>Elo is confident. Poisson is more uncertain. And sometimes they <em>disagree entirely</em> on the favorite.</p><p>Which one is right? Both, and neither. <strong>Elo sees the long game</strong> (decades of results); Poisson <strong>listens to recent form</strong> (this year&#8217;s goals). When they agree, we can be confident. When they disagree, we should probably say so.</p><p>That&#8217;s the whole motivation for tomorrow&#8217;s <strong>ensemble</strong> &#8212; combining the two models into one prediction, and <em>showing</em> the user when the models are aligned vs when they&#8217;re arguing.</p><h2>Coming Tomorrow</h2><p>Tomorrow the two models meet. We build an <strong>ensemble</strong> &#8212; a weighted average of Elo and Poisson probabilities &#8212; that outperforms either alone. And we add interactivity: click any match card and expand it to see <em>both</em> model&#8217;s answers side-by-side, the recent form of each team (W-W-D-L-W last 5), and a <strong>confidence indicator</strong> that tells you when the models agree vs when they&#8217;re arguing. The finale is where it all clicks.</p><h2>View Code Evolution</h2><p>Compare today&#8217;s Poisson predictor with Day 1&#8217;s Elo predictor and see how a fundamentally different mathematical model plugs into the same UI shell &#8212; because we designed the UI to be <em>model-agnostic</em> from day one.</p>
      <p>
          <a href="https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day-1d2">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[World Cup 2026 Match Predictor: Day 1 - Elo-based Predictor]]></title><description><![CDATA[Today we build an app that fetches 49,000+ international matches since 1872, computes an Elo rating for every national team, then displays upcoming World Cup matches with a &#8220;who wins %&#8221; prediction.]]></description><link>https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/world-cup-2026-match-predictor-day</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 07 Jul 2026 15:43:04 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/d0792b7c-a091-4a5e-99de-0bd01e888736_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 Match Predictor</strong> &#8212; a Streamlit web app that predicts upcoming matches using real football-analytics techniques. Each day the same interface stays, but the model behind it gets smarter. Day 1 is Elo, Day 2 adds a Poisson goal model, Day 3 combines them into an ensemble with interactive analysis.</p><p><strong>Why build this?</strong> Because the World Cup 2026 is happening <em>right now</em> &#8212; Round of 16 starts July 4. This is the perfect moment to build a real prediction app that answers questions your friends actually care about. Plus the techniques (Elo, Poisson, ensembles) show up in far more places than football: chess ratings, video-game matchmaking, insurance risk, sports betting, any pairwise-competition domain.</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 Streamlit web apps, the Elo rating system, Poisson distributions applied to goal modeling, ensemble modeling, working with two data sources at once, and turning statistical models into a user-facing interface.</p><p><strong>Why this matters:</strong> By Friday, you&#8217;ll have a real prediction tool that pulls live data and produces defensible predictions &#8212; with a UI polished enough to send to friends.</p><ul><li><p><strong>Day 1:</strong> Elo-based Predictor <strong>(Today)</strong></p></li><li><p><strong>Day 2:</strong> Poisson Goal Model</p></li><li><p><strong>Day 3:</strong> Ensemble + Interactive Analysis</p></li></ul><p><a href="https://dailypythonprojects.substack.com/t/week-25">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>We start with <strong>Elo ratings</strong> &#8212; the same system Arpad Elo invented in the 1950s for chess, now widely used in football analytics. Elo is beautiful because it&#8217;s simple: every team has a number, higher = better, and after each match the winner takes a bit of the loser&#8217;s rating.</p><p>Our app fetches <strong>49,000+ international matches since 1872</strong>, computes an Elo rating for every national team, then displays upcoming World Cup 2026 matches with a &#8220;who wins %&#8221; prediction based on the rating difference.</p><h2>About the Data</h2><p>Two free, public data sources &#8212; both on GitHub, no API keys.</p><p><strong>Historical results:</strong> <code>martj42/international_results</code></p><ul><li><p>A CSV with 49,000+ international matches from 1872 to today</p></li><li><p>Columns: <code>date</code>, <code>home_team</code>, <code>away_team</code>, <code>home_score</code>, <code>away_score</code>, <code>tournament</code>, <code>city</code>, <code>country</code>, <code>neutral</code></p></li><li><p>Includes friendlies, qualifiers, Copa Am&#233;rica, Euros, World Cups &#8212; everything</p></li></ul><p><strong>Upcoming fixtures:</strong> <code>openfootball/worldcup.json</code> <em>(same source as Week 24)</em></p><ul><li><p>Live World Cup 2026 fixtures and results</p></li><li><p>Placeholder codes resolve to real teams as the bracket fills in</p></li></ul><p>Both are public domain, updated daily by hand, and used by dozens of real projects.</p><h2>Project Task</h2><p>Build a Streamlit app that:</p><ul><li><p>Fetches historical international match results</p></li><li><p>Fetches the live World Cup 2026 fixture list</p></li><li><p>Computes an Elo rating for every national team from scratch</p></li><li><p>Filters the fixture list to just upcoming matches with resolved teams</p></li><li><p>Displays each upcoming match as a card with team names, Elo ratings, and prediction bars</p></li><li><p>Shows the top 20 teams by Elo rating in a sidebar</p></li><li><p>Uses Streamlit&#8217;s caching so the 49k-match walk only happens once per session</p></li></ul><p>This project gives you hands-on practice with the Elo rating algorithm, Streamlit&#8217;s card/column/progress components, <code>@st.cache_data</code>, and the classic &#8220;walk historical data to build a state&#8221; pattern that shows up everywhere in analytics.</p><h2>Expected Output</h2><p><strong>Running the app:</strong></p><pre><code><code>streamlit run predictor_elo.py
</code></code></pre><p>The app opens in your browser and you will see all the upcoming matches:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!hd-e!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!hd-e!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 424w, https://substackcdn.com/image/fetch/$s_!hd-e!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 848w, https://substackcdn.com/image/fetch/$s_!hd-e!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 1272w, https://substackcdn.com/image/fetch/$s_!hd-e!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!hd-e!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/acbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.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;:761926,&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/205786280?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.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_!hd-e!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 424w, https://substackcdn.com/image/fetch/$s_!hd-e!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 848w, https://substackcdn.com/image/fetch/$s_!hd-e!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.png 1272w, https://substackcdn.com/image/fetch/$s_!hd-e!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Facbd8c1c-c754-4467-bd02-fbc01673dcd7_2988x1992.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 aria-hidden="true" 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><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 dependencies:</strong></p><pre><code><code>pip install streamlit requests
</code></code></pre><p><strong>Run it:</strong></p><pre><code><code>streamlit run predictor_elo.py</code></code></pre><p>Your browser opens automatically at http://localhost:8501.</p><p>First run downloads ~3.6MB of historical data. Subsequent runs (or interactions) use Streamlit&#8217;s cache &#8212; instant.</p><h2>Understanding the Elo Rating System</h2><p>Elo is the simplest useful rating system you&#8217;ll ever meet. Every team has a rating (starting at 1500 by convention). After each match, the winner&#8217;s rating goes up, the loser&#8217;s goes down. The magnitude depends on:</p><ul><li><p><strong>The rating gap.</strong> Beating a much stronger team = large gain. Beating a much weaker team = tiny gain.</p></li><li><p><strong>The margin.</strong> A 4-1 win moves your rating more than a 1-0 win.</p></li><li><p><strong>Match importance.</strong> World Cups matter more than friendlies.</p></li></ul><p>The core formula gives you the <em>expected</em> result of a matchup:</p><pre><code><code>def expected_score(rating_a, rating_b):
    return 1.0 / (1.0 + 10.0 ** ((rating_b - rating_a) / 400.0))
</code></code></pre><p>That&#8217;s it. If A and B are equal, the formula returns 0.5. If A is 400 points ahead, A&#8217;s expected score is ~91%. Every 400 Elo points = 10x more likely to win. This is <em>the</em> formula behind chess ratings, video-game matchmaking, and half the football-analytics world.</p><h2>Understanding the Update Rule</h2><p>After the match, we compare what actually happened to what Elo expected:</p><pre><code><code>actual_home = 1.0 if home_won else 0.0 if away_won else 0.5
expected_home = expected_score(home_rating, away_rating)

change = K_FACTOR * (actual_home - expected_home)
ratings[home] += change
ratings[away] -= change
</code></code></pre><p>Three key ideas:</p><ul><li><p><code>actual - expected</code> is the <em>surprise</em>. If a strong team beats a weak team as predicted, surprise is small, change is small. If a weak team wins as an underdog, surprise is huge, change is big.</p></li><li><p><strong>The K-factor</strong> controls speed. K=30 is standard for football. Smaller = ratings move slowly (more stable), larger = ratings react faster (more responsive).</p></li><li><p><strong>What one team gains, the other loses</strong> &#8212; Elo is a zero-sum system. Total rating across all teams stays constant.<br></p></li></ul><h2>Understanding Walking the Historical Data</h2><p>The whole ratings dict is built by <em>replaying history</em> &#8212; walking every match in chronological order, updating ratings match-by-match:</p><pre><code><code>def compute_ratings(matches):
    ratings = defaultdict(lambda: INITIAL_ELO)

    for m in matches:
        home, away = m["home_team"], m["away_team"]
        hg, ag = int(m["home_score"]), int(m["away_score"])
        neutral = m["neutral"] == "TRUE"

        home_adj = ratings[home] + (0 if neutral else HOME_ADVANTAGE)
        actual_home = 1.0 if hg &gt; ag else 0.0 if hg &lt; ag else 0.5
        expected_home = expected_score(home_adj, ratings[away])
        g = goal_diff_multiplier(hg - ag)

        change = K_FACTOR * g * (actual_home - expected_home)
        ratings[home] += change
        ratings[away] -= change

    return dict(ratings)
</code></code></pre><p>Two clean patterns:</p><ul><li><p><code>defaultdict(lambda: INITIAL_ELO)</code> &#8212; any team we haven&#8217;t seen before automatically gets a starting rating of 1500. No pre-initialization, no <code>if team in ratings</code> checks.</p></li><li><p><strong>CSV order = chronological.</strong> The <code>martj42</code> CSV is already sorted by date. Just walk it top to bottom and history unfolds naturally. <strong>This &#8220;replay events to build state&#8221; pattern is at the heart of every event-sourced system in the software world</strong> &#8212; banking ledgers, git commit histories, database write-ahead logs. All the same shape.</p></li></ul><h2>Understanding Making the Prediction</h2><p>Once we have the ratings dict, predicting a match is one call to <code>expected_score</code>:</p><pre><code><code>def predict_match(ratings, team1, team2, neutral=True):
    r1 = ratings.get(team1, INITIAL_ELO)
    r2 = ratings.get(team2, INITIAL_ELO)
    if not neutral:
        r1 += HOME_ADVANTAGE
    p1 = expected_score(r1, r2)
    return p1, 1.0 - p1
</code></code></pre><p>Note we use <code>.get(team, INITIAL_ELO)</code> &#8212; if a team somehow isn&#8217;t in our history (very rare), it gets a neutral 1500. Never crash, always return a defensible number.</p><h2></h2><h2>Understanding Streamlit Cards, Columns, Progress Bars</h2><p>The whole prediction card is a few widget calls:</p><pre><code><code>with st.container(border=True):
    st.caption(f"**{match['round']}**  &#8226;  {match['date']}  &#8226;  {match['ground']}")

    col1, col2, col3 = st.columns([3, 1, 3])
    with col1:
        st.markdown(f"### {team1}")
        st.caption(f"Elo: **{r1:.0f}**")
    with col2:
        st.markdown("&lt;div style='text-align:center;padding-top:20px;'&gt;&lt;b&gt;vs&lt;/b&gt;&lt;/div&gt;",
                    unsafe_allow_html=True)
    with col3:
        st.markdown(f"### {team2}")
        st.caption(f"Elo: **{r2:.0f}**")

    st.markdown(f"**{team1}**: {p1*100:.1f}%")
    st.progress(p1)
    st.markdown(f"**{team2}**: {p2*100:.1f}%")
    st.progress(p2)
</code></code></pre><p>Three concepts:</p><ul><li><p><code>st.container(border=True)</code> &#8212; draws an outlined card. Everything inside is a group.</p></li><li><p><code>st.columns([3, 1, 3])</code> &#8212; a row split into three columns with proportions 3:1:3.</p></li><li><p><code>st.progress(value)</code> &#8212; a filled progress bar, <code>value</code> is 0.0&#8211;1.0.</p></li></ul><p>Streamlit&#8217;s superpower: this whole UI is written <em>in Python</em>, no HTML/CSS needed. And it looks polished by default. That&#8217;s what makes it perfect for prototyping data apps.</p><h2>Understanding Filtering Upcoming Matches</h2><p>We only want to predict matches that (1) haven&#8217;t been played and (2) have real team names (not <code>"W73"</code> bracket placeholders):</p><pre><code><code>def upcoming_matches(all_matches):
    today_str = date.today().isoformat()
    upcoming = []
    for m in all_matches:
        if "score" in m:                                 # already played
            continue
        if m.get("date", "9999") &lt; today_str:            # past-and-unplayed data lag
            continue
        if is_placeholder(m["team1"]) or is_placeholder(m["team2"]):
            continue
        upcoming.append(m)

    upcoming.sort(key=lambda m: (m.get("date", ""), m.get("time", "")))
    return upcoming
</code></code></pre><p>Three filters, each catching a real edge case:</p><ul><li><p><strong>Skip played matches</strong> &#8212; no point predicting what we already know.</p></li><li><p><strong>Skip past-dated unplayed matches</strong> &#8212; sometimes the openfootball data lags for a day.</p></li><li><p><strong>Skip bracket placeholders</strong> &#8212; no point predicting <code>"Winner of match 74"</code> vs <code>"Winner of match 75"</code>. Wait for those to resolve.</p></li></ul><p>As Round of 16 finishes, placeholder slots in Quarter-finals fill in, and new match cards appear in the app. <strong>The prediction set grows organically as the tournament progresses.</strong> No manual updates.</p><h2>Understanding the Data Flow</h2><p>The whole app is a small pipeline:</p><ol><li><p><strong>Fetch</strong> &#8212; cached HTTP calls to two GitHub URLs</p></li><li><p><strong>Compute</strong> &#8212; walk 49,000 matches, build the ratings dict</p></li><li><p><strong>Filter</strong> &#8212; narrow WC 2026 fixtures to upcoming-with-real-teams</p></li><li><p><strong>Render</strong> &#8212; one card per fixture, one bar per team</p></li></ol><p>Everything downstream of the ratings dict is <em>just presentation</em>. The intelligence is in step 2. Day 2 and Day 3 will change <em>only that step</em> &#8212; same UI, same filtering, but a smarter model behind the scenes.</p><h2>Coming Tomorrow</h2><p>Tomorrow the model gets smarter. Elo tells us <em>who</em> is likely to win but not <em>by how much</em> &#8212; a 4-1 rout has the same win probability as a 1-0 nail-biter. <strong>Poisson distributions</strong> fix this by modeling goals as random events with team-specific rates. By Wednesday, our app predicts the most likely scoreline, expected goals for each team, and a full W / D / L breakdown (not just win-or-lose).</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/j8WdpIgLYZZBbsJvuWIIYQ&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/j8WdpIgLYZZBbsJvuWIIYQ"><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/obqOQhHJ5y1jIrgnzWqfkQ&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/obqOQhHJ5y1jIrgnzWqfkQ"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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 aria-hidden="true" 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><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></channel></rss>