<script data-pm-proxy="intercept"></script><?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Daily Python Projects]]></title><description><![CDATA[Learn Python by practicing every day with a new project.]]></description><link>https://dailypythonprojects.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!gfXP!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e0e0918-4155-47ff-ba5d-c061609cbc83_1000x1000.png</url><title>Daily Python Projects</title><link>https://dailypythonprojects.substack.com</link></image><generator>Substack</generator><lastBuildDate>Thu, 03 Sep 2026 17:56:51 GMT</lastBuildDate><atom:link href="/__u/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[What “done” actually means: the production readiness checklist nobody teaches]]></title><description><![CDATA[Your app works. But there are about thirty small things standing between &#8220;it works for me&#8221; and &#8220;a stranger can trust it with their password.&#8221; Here they all are.]]></description><link>https://dailypythonprojects.substack.com/p/what-done-actually-means-the-production</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/what-done-actually-means-the-production</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 03 Sep 2026 10:38:36 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!HK0I!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this post, you will get the complete map of what separates a working demo from an app real strangers can actually use and trust. None of this is covered in a normal coding course, and almost none of it is something AI will bring up unless you ask. This is the first post in a series: today is the map, and over the coming weeks we will build each piece.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!HK0I!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!HK0I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2048242f-a41a-4233-8c01-fc36a902dd0c_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;:106706,&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/213988638?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!HK0I!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2048242f-a41a-4233-8c01-fc36a902dd0c_2400x1120.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></p><p>Imagine you build a small app with AI over a weekend. Signup works, login works, the core feature works. You are proud of it, and you should be. Now imagine a real stranger signs up on day one, forgets their password on day two, exactly the way real people do, and clicks &#8220;forgot password&#8221;.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!zUBx!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!zUBx!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_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;:79851,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/213988638?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!zUBx!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b0a0e55-f8ff-4d04-b98e-88cf0cfac947_2400x960.png 1456w" sizes="100vw"></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>Nothing happens. Not because you are a bad developer, but because the demo never needed that button to actually work, so neither you nor the AI ever built it.</p><p>That gap between &#8220;the demo works&#8221; and &#8220;a stranger can trust it&#8221; is the entire subject of this post.</p><h2>The full list, category by category</h2><p>I grouped everything into nine categories, roughly in the order we will build them over the coming weeks. This is the map; the visual has the details.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!XIpi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 424w, /__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 848w, /__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 1272w, /__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!XIpi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png" width="1456" height="1092" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1092,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:310278,&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/213988638?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 424w, /__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 848w, /__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.png 1272w, /__u/substackcdn.com/image/fetch/$s_!XIpi!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2481d726-ec59-4fa8-80bc-5cd22ae2b32e_2400x1800.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><strong>Authentication</strong> is usually the biggest gap, because a coding course happily stops at &#8220;login works&#8221;. Real authentication means a password reset flow, email verification, a Google or GitHub sign-in option because plenty of people refuse to make yet another password, passwords that are actually enforced as strong on your server and not just suggested by the frontend, a limit on how many times someone can guess a password per minute, and sessions that expire instead of lasting forever.</p><p><strong>Email infrastructure</strong> is what makes half of the authentication category possible in the first place. Your own Gmail account was never built to send password resets to strangers at scale, and it will eventually get flagged. You need a real sending service, a template for each type of email, and a sending domain that is actually verified, or your emails quietly land in spam and you will not even know it is happening.</p><p><strong>Database and data safety</strong> stops being optional the moment someone else&#8217;s data lives in your database. Automated backups, migrations tracked in version control instead of manual changes you might forget, and a database for development kept strictly separate from the one real users touch.</p><p><strong>Secrets and configuration</strong> is one rule really: every key and password lives in an environment variable, never in your code, and your <code>.env</code> file is in <code>.gitignore</code> from the very first commit, not added after you almost leaked something.</p><p><strong>Security basics</strong> covers HTTPS everywhere with no exceptions, validating anything a user types before it touches your database, deliberately configuring what is allowed to call your API instead of leaving it open to everyone, and not leaving known vulnerabilities sitting in your dependencies for a year.</p><p><strong>Payments</strong>, if your app has any, means handling the boring but critical edge cases: a subscription renewal, a failed card, and the specific nightmare of a customer who paid successfully while your webhook silently failed to record it.</p><p><strong>Ops and reliability</strong> is how you find out your app broke before an angry user tells you. Error monitoring, uptime monitoring, and logs you can actually read instead of print statements scattered across your codebase.</p><p><strong>Legal and trust</strong> feels like paperwork until you notice that Stripe, Google sign-in, and most app stores simply will not approve you without a privacy policy and terms of service. Add cookie consent if you have visitors in the EU, and a support email that a real human reads.</p><p><strong>Developer workflow</strong> closes the list: commits that make sense, no secrets ever committed to GitHub history, and a way to undo a deploy that breaks production, because eventually one will.</p><h2>Why this list barely existed five years ago</h2><p>Here is the part I find genuinely interesting. A few years ago, this list mattered less, because building the actual app already consumed most of your time and energy, and few solo learners even reached the point of having real users.</p><p>Now AI can build the core app in an afternoon, so this list has quietly become the majority of the real work. Writing the code was never the hard part for very long. Making it trustworthy always was, and it still is.</p><h2>Where we go from here</h2><p>Over the next weeks, we are going to build each of these, one at a time, starting where it hurts first: password resets, then email verification, Google sign-in, and onward through the list in order. Each post will be a complete, working piece you can drop straight into your own project.</p><p>For today, save the checklist image from this post. The next time you or an AI ship something that &#8220;works&#8221;, hold it up against these nine categories, and you will know exactly what is left before a stranger can actually use it.</p><p>If you learned something from this today, consider upgrading for the full experience: every weekly project with full source code, plus the complete archive. Since you read this far, I&#8217;d like to offer you 35% off the annual plan as a token of appreciation. Click below to get the discount:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?coupon=3ffabf6b&quot;,&quot;text&quot;:&quot;Get 20% off: $99 &#8594; $79/year&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="/__u/dailypythonprojects.substack.com/subscribe?coupon=3ffabf6b"><span>Get 20% off: $99 &#8594; $79/year</span></a></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build Your Own Read-It-Later App in Python: Day 2 — The Full Tool]]></title><description><![CDATA[Bookmarklet, full-text search, tags, dark mode, keyboard shortcuts, Markdown export. Yesterday&#8217;s MVP becomes a tool you&#8217;ll actually use every day.]]></description><link>https://dailypythonprojects.substack.com/p/build-your-own-read-it-later-app-969</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-your-own-read-it-later-app-969</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 02 Sep 2026 11:58:10 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/61ffa154-eea5-44d8-a475-49ccff978a49_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday&#8217;s MVP was already usable &#8212; save, extract, read, archive. But two things separated it from a tool a real person would use daily. First: saving still required a &#8220;copy URL, open app, paste, click Save&#8221; dance. Real people don&#8217;t do that four-step flow for every article &#8212; they do it twice, decide it&#8217;s not worth it, and stop using the tool. Second: there was no way to find anything more than a week old. A read-later tool without search is a black hole where articles go to die.</p><p>Today we fix both, plus everything else that separates &#8220;a working script&#8221; from &#8220;a tool I use every day for two years.&#8221; <strong>Bookmarklet</strong> turns saving into a single click from any page in any browser. <strong>Full-text search</strong> (SQLite FTS5 with Porter stemming, so <code>read</code> matches <code>reading</code>) lets you find articles from months ago, with match highlights in the results. <strong>Tags</strong> organize your library. <strong>Dark mode</strong> for night reading. <strong>Keyboard shortcuts</strong> &#8212; <code>j</code>/<code>k</code> to move through the queue, <code>Enter</code> to open, <code>a</code> to archive, <code>/</code> to search &#8212; because every tool you use daily is 4x faster with keyboard shortcuts. <strong>Markdown export</strong> so your library is portable and future-proof, either one article at a time or the whole thing as a zip.</p><p>Everything from Day 1 keeps working. Same database file (<code>~/.readlater/readlater.db</code>), same extraction engine, same reader typography, same graceful failure. On first Day 2 run, the search index is built from any articles you already saved yesterday &#8212; no manual re-indexing.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>Day 1:</strong> Save, extract, and read articles from a local web UI</p><p>Yesterday, we built the working MVP &#8212; Flask server with a save form, <code>trafilatura</code> for content extraction, SQLite storage with normalized URLs (tracking params stripped so duplicates dedupe), a queue view, and a reader view with proper Georgia-serif typography. Graceful failure for JS-heavy sites and paywalls kept the tool usable even when extraction couldn&#8217;t work.</p><p><strong>Day 2:</strong> Bookmarklet, search, tags, dark mode, keyboard shortcuts, export <strong>(Today)</strong></p><p>Save any page with one click. Set up the bookmarklet on the Setup page &#8212; a draggable button that becomes a bookmark on your browser&#8217;s bookmarks bar. </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!aU5k!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 424w, /__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 848w, /__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 1272w, /__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!aU5k!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png" width="1456" height="1206" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1206,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1543502,&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/213844796?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 424w, /__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 848w, /__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.png 1272w, /__u/substackcdn.com/image/fetch/$s_!aU5k!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c31ae3d-53dc-4323-9d69-9d763ee98958_1594x1320.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></p><p>Click that bookmark on any article and a small popup confirms the save and closes itself after a second, so your reading flow doesn&#8217;t get interrupted:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!4TIF!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 424w, /__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 848w, /__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 1272w, /__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!4TIF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png" width="1456" height="901" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.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;:424666,&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/213844796?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 424w, /__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 848w, /__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.png 1272w, /__u/substackcdn.com/image/fetch/$s_!4TIF!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F54fa01e0-05fa-48d7-86bd-4db341564de9_1774x1098.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>Find any article you&#8217;ve ever saved via the search box in the nav bar. Full-text search with Porter stemming and match highlighting &#8212; search for &#8220;ocean&#8221; and find articles about &#8220;oceans&#8221;.</p><p>The matched terms are highlighted in each result&#8217;s snippet so you can tell at a glance which article has what you&#8217;re looking for:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!dgqf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 424w, /__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 848w, /__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!dgqf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png" width="1456" height="972" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:972,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:370492,&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/213844796?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 424w, /__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 848w, /__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dgqf!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbf578d91-10b9-41e7-8ec8-9b4c22b33665_1918x1280.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>Tag articles from the reader view &#8212; type comma-separated tags into the input and hit Add. Small pills appear on queue cards showing an article&#8217;s tags. Click any tag pill to filter your queue to just articles with that tag. The Tags page shows all your tags with article counts:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!UalI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 424w, /__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 848w, /__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 1272w, /__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!UalI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png" width="1456" height="972" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:972,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:346205,&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/213844796?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 424w, /__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 848w, /__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.png 1272w, /__u/substackcdn.com/image/fetch/$s_!UalI!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa0fabe83-4b7f-4a67-9ce0-d8ad3f67f641_1918x1280.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>Dark mode toggles from the moon icon in the nav bar and remembers your choice per browser via localStorage. The theme is set inline in the page <code>&lt;head&gt;</code> before any CSS renders, so there&#8217;s no flash of the wrong theme on page load &#8212; same technique GitHub, Twitter, and every well-built site uses.</p><p>Export any article or your whole library as clean Markdown. Individual articles get a <code>Export .md</code> button on the reader page. The whole library exports as a zip from the Setup page &#8212; perfect for importing into Obsidian, dumping into a git repo, or just having a plain-text backup that will still work in ten years.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-33">View All Projects This Week</a></p><h2>About the stack</h2><p>Same as Day 1, plus one small addition. <strong>markdownify</strong> is a small library that converts HTML to clean Markdown. We use it to convert our stored <code>content_html</code> (the extracted article HTML from <code>trafilatura</code>) into the Markdown that goes into <code>.md</code> exports. Hand-writing HTML&#8594;MD conversion for all the edge cases (nested lists, blockquotes, code blocks, links inside paragraphs) is a rabbit hole; <code>markdownify</code> handles it in one function call.</p><p>Everything else &#8212; Flask, trafilatura, requests, sqlite3, Jinja2 &#8212; is unchanged from yesterday.</p><h2>Setup Instructions</h2><p>Add the new dependency:</p><pre><code><code>pip install markdownify
</code></code></pre><p>Run:</p><pre><code><code>python readlater_full.py                    # start the server on :8770
python readlater_full.py save &lt;URL&gt;         # save from the terminal
python readlater_full.py list               # print the queue
python readlater_full.py search &lt;query&gt;     # search from the terminal (new!)
</code></code></pre><p>Same <code>~/.readlater/readlater.db</code> file as Day 1 &#8212; if you were using yesterday&#8217;s version, all your saved articles carry over. On first Day 2 run, the search index gets built from your existing articles automatically. Takes a few milliseconds even for thousands of articles.</p><p>First thing to do after starting the server: visit <code>/setup</code> and drag the bookmarklet to your browser&#8217;s bookmarks bar. That single action changes the tool from &#8220;occasionally used&#8221; to &#8220;used every day.&#8221;</p><h2>Understanding the Bookmarklet</h2><p>A bookmarklet is a bookmark whose URL is <code>javascript:...</code> instead of </p><p>http://...</p><p>. When you click it, the browser executes the JavaScript in the context of the current page instead of navigating. It&#8217;s one of the oldest and most under-appreciated web features &#8212; dead simple, works in every browser since IE 4, requires no extension install.</p><p>Our bookmarklet is generated by the <code>/setup</code> route, so it uses whatever port your server is running on:</p><pre><code><code>@app.get("/setup")
def setup_page():
    server_url = request.url_root.rstrip("/")
    js = (
        "javascript:(function(){"
        f"window.open('{server_url}/save?close=1&amp;url='"
        "+encodeURIComponent(location.href),"
        "'rl_save','width=380,height=240');"
        "})();"
    )
    return render_template_string(SETUP_HTML, bookmarklet_href=js, ...)
</code></code></pre><p>The JS is wrapped in an IIFE (<code>(function(){...})()</code>) so the bookmarklet&#8217;s variables don&#8217;t leak into the current page&#8217;s namespace. It calls <code>window.open()</code> with a small window size &#8212; the popup appears at ~380x240 pixels, big enough to show &#8220;Saved &#10003;&#8221; and the article title.</p><p>The <code>close=1</code> query parameter is the important trick. Our <code>/save</code> handler treats it as &#8220;this was called by the bookmarklet &#8212; instead of the normal 302 redirect back to the queue, return a tiny self-closing popup HTML page&#8221;:</p><pre><code><code>SAVED_POPUP_HTML = """&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
  &lt;div class="icon"&gt;&#128218; &#10003;&lt;/div&gt;
  &lt;h2&gt;Saved.&lt;/h2&gt;
  &lt;div class="title"&gt;{{ title }}&lt;/div&gt;
  &lt;script&gt;setTimeout(function() { window.close(); }, 1400);&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
"""

@app.route("/save", methods=["GET", "POST"])
def save_url():
    close_after = request.args.get("close") == "1"
    # ... save the article ...
    if close_after:
        return render_template_string(SAVED_POPUP_HTML, ...)
    return redirect(...)
</code></code></pre><p><code>setTimeout</code> gives the user 1.4 seconds to see confirmation, then <code>window.close()</code> closes the popup automatically. <code>window.close()</code> only works on windows opened by JavaScript, but that&#8217;s exactly our case &#8212; the bookmarklet opened this window via <code>window.open()</code>, so the browser lets it close itself.</p><p>One user-experience detail: the bookmarklet on the Setup page is deliberately un-clickable. Clicking a <code>javascript:</code> link in an <code>&lt;a&gt;</code> tag would run the code on the setup page itself (saving the setup page&#8217;s URL, which isn&#8217;t what you want). So we override the click:</p><pre><code><code>&lt;a class="bookmarklet-link" href="javascript:..."
   onclick="alert('Drag this button to your bookmarks bar - don\\'t click it here.'); return false;"&gt;
</code></code></pre><p>Clicking shows a helpful message. Dragging still works and gives you the bookmark. Standard pattern for bookmarklet install pages.</p><h2>Understanding SQLite FTS5</h2><p>SQLite ships with a full-text search extension called <strong>FTS5</strong>. It&#8217;s a virtual table type &#8212; you create it, insert rows into it, and query with special <code>MATCH</code> operators. Under the hood it maintains an inverted index (word &#8594; list of documents containing that word), same data structure Elasticsearch and Lucene use.</p><p>Setup is one CREATE statement:</p><pre><code><code>CREATE VIRTUAL TABLE articles_fts USING fts5(
    title, byline, site_name, content_text,
    tokenize='porter unicode61 remove_diacritics 2'
);
</code></code></pre><p>The tokenizer options matter for real-world quality:</p><p><code>porter</code> enables the Porter stemming algorithm &#8212; a set of rules for reducing English words to their root form. <code>reading</code>, <code>reads</code>, <code>reader</code> all stem to <code>read</code>. When you search for &#8220;read,&#8221; you find all of them. Without stemming, exact-match search fails constantly (&#8221;I searched for &#8216;reading&#8217; but got zero hits &#8212; but I know I saved an article that said &#8216;reading&#8217;&#8221;).</p><p><code>unicode61</code> is the tokenizer that understands Unicode word boundaries. Without it, non-English text tokenizes wrong &#8212; &#8220;resum&#233;&#8221; wouldn&#8217;t split correctly.</p><p><code>remove_diacritics 2</code> strips accents so &#8220;caf&#233;&#8221; matches &#8220;cafe&#8221;. Critical for a personal tool where you might save articles in multiple languages.</p><p>Search queries use the <code>MATCH</code> operator:</p><pre><code><code>conn.execute("""
    SELECT a.*,
           snippet(articles_fts, -1, '&lt;mark&gt;', '&lt;/mark&gt;', '&#8230;', 20) AS matched_snippet
    FROM articles_fts
    JOIN articles a ON a.id = articles_fts.rowid
    WHERE articles_fts MATCH ?
    ORDER BY rank
    LIMIT 100
""", (fts_escape(query),))
</code></code></pre><p>Two SQL features doing real work here:</p><p><code>snippet(...)</code> returns an excerpt from the matched text with the search terms wrapped in <code>&lt;mark&gt;</code> and <code>&lt;/mark&gt;</code> tags. Column index <code>-1</code> means &#8220;pick the best column automatically.&#8221; That&#8217;s what gives our results the highlighted &#8220;&#8230;async programming with Python&#8217;s <code>asyncio</code> library&#8230;&#8221; preview with the search terms highlighted. Same technique every serious search UI uses.</p><p><code>ORDER BY rank</code> uses FTS5&#8217;s built-in BM25 ranking &#8212; matches with more occurrences of the search terms rank higher, matches in shorter documents rank higher, matches in the title rank higher than matches in the body. Better relevance than &#8220;most recent&#8221; ordering for open-ended search.</p><p>One footgun: user input can contain characters FTS5 treats as operators (<code>AND</code>, <code>OR</code>, <code>NEAR</code>, <code>/</code>, quotes, etc.). Feeding raw user input into <code>MATCH</code> risks syntax errors or unexpected behavior. Simple fix: quote each word individually so it&#8217;s treated as a literal term:</p><pre><code><code>def _fts_escape(query):
    words = [w for w in re.split(r"\s+", query.strip()) if w]
    return " ".join('"' + w.replace('"', '""') + '"' for w in words)
</code></code></pre><p>That turns <code>python/flask</code> into <code>"python/flask"</code> &#8212; one literal term, no operators. Turns <code>hello world</code> into <code>"hello" "world"</code> &#8212; implicit AND across both terms. Simple, safe, works for any input.</p><h2>Understanding Dark Mode Without the Flash</h2><p>The classic dark-mode bug: user loads the page, sees a bright white flash for 100ms, THEN dark theme kicks in. Ugly, and gives away that dark mode is bolted on rather than built in.</p><p>The fix is: <strong>set the theme BEFORE any CSS renders</strong>. Which means a synchronous inline script at the very top of <code>&lt;head&gt;</code>, before the <code>&lt;style&gt;</code> tag:</p><pre><code><code>&lt;head&gt;
  &lt;script&gt;
    (function() {
      var t = localStorage.getItem('rl-theme') || 'light';
      document.documentElement.setAttribute('data-theme', t);
    })();
  &lt;/script&gt;
  &lt;style&gt;
    :root { --bg: #faf7f2; --text: #1a1a1a; ... }
    :root[data-theme="dark"] { --bg: #14181d; --text: #e6edf3; ... }
    body { background: var(--bg); color: var(--text); }
  &lt;/style&gt;
&lt;/head&gt;
</code></code></pre><p>The script runs synchronously (browsers block rendering on <code>&lt;script&gt;</code> tags without <code>async</code> or <code>defer</code> unless they&#8217;re modules). It reads the saved theme from <code>localStorage</code>, sets <code>data-theme="dark"</code> on the <code>&lt;html&gt;</code> element. Then the browser reads the CSS, sees the <code>[data-theme="dark"]</code> selector matches, applies the dark variables. Result: page renders dark immediately, no flash.</p><p>The CSS variables approach is what makes this clean. Instead of <code>.dark-mode body { background: black; }</code> in every place, we define variables once and reference them:</p><pre><code><code>:root {
  --bg: #faf7f2;
  --text: #1a1a1a;
  --card: #ffffff;
  --muted: #6b6b6b;
  /* ... */
}
:root[data-theme="dark"] {
  --bg: #14181d;
  --text: #e6edf3;
  --card: #1c2128;
  --muted: #8b949e;
  /* ... */
}
</code></code></pre><p>Everything else in our CSS uses <code>var(--bg)</code>, <code>var(--text)</code>, etc. To add a new theme (sepia? high-contrast?), we&#8217;d just add another <code>:root[data-theme="sepia"]</code> block with different variable values. Zero changes to any other CSS.</p><p>The toggle button flips the attribute and saves the choice:</p><pre><code><code>function toggleTheme() {
  var current = document.documentElement.getAttribute('data-theme');
  var next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('rl-theme', next);
}
</code></code></pre><p>Total dark mode implementation: ~40 lines of CSS variables plus 20 lines of JS. Small effort, huge quality-of-experience improvement, especially for reading at night.</p><h2>Understanding the Keyboard Shortcuts</h2><p>Every tool you use daily is 4x faster with keyboard shortcuts. Gmail trained a whole generation of professionals to expect <code>j</code>/<code>k</code> for navigation, <code>Enter</code> to open, <code>a</code> for actions. Reddit and Twitter kept the muscle memory alive. Every keyboard-friendly interface uses the same vocabulary.</p><p>Our shortcuts are one <code>document.addEventListener('keydown', ...)</code>:</p><pre><code><code>document.addEventListener('keydown', function(e) {
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  if (isEditable(e.target)) {
    if (e.key === 'Escape') e.target.blur();
    return;
  }

  if (e.key === '/') { e.preventDefault(); focusSearch(); return; }

  var cards = Array.from(document.querySelectorAll('.article-card'));
  if (cards.length) {
    var focusedIdx = cards.findIndex(c =&gt; c.classList.contains('kb-focus'));
    if (e.key === 'j' || e.key === 'ArrowDown') {
      focusedIdx = Math.min(focusedIdx + 1, cards.length - 1);
      setKbFocus(cards, focusedIdx);
    } else if (e.key === 'k' || e.key === 'ArrowUp') {
      focusedIdx = Math.max(focusedIdx - 1, 0);
      setKbFocus(cards, focusedIdx);
    } else if (e.key === 'Enter' &amp;&amp; focusedIdx &gt;= 0) {
      cards[focusedIdx].click();
    }
  }
});
</code></code></pre><p>Three details worth calling out:</p><p><strong>Skip modifier keys.</strong> If user presses <code>Cmd+K</code> or <code>Ctrl+J</code>, that&#8217;s a browser shortcut &#8212; don&#8217;t hijack it. The first <code>if (e.metaKey || ...)</code> skips all combos with modifiers.</p><p><strong>Skip inputs.</strong> If user is typing in the search box or a tag input, <code>j</code>/<code>k</code>/<code>a</code> should be regular typing, not shortcut activation. The <code>isEditable</code> check bails out early when the event target is an <code>INPUT</code>, <code>TEXTAREA</code>, or contenteditable element. The one exception: <code>Escape</code> inside an input blurs the input, so you can escape the search box back to keyboard-nav mode.</p><p><strong>Use CSS class for focus state.</strong> We track the &#8220;focused&#8221; card via a <code>kb-focus</code> class rather than using the browser&#8217;s native <code>:focus</code>. Reason: browser focus behavior on scroll-into-view is buggy across browsers. Our own class + <code>scrollIntoView({ block: 'nearest' })</code> gives predictable behavior.</p><p>The <code>a</code> shortcut on the reader page finds the archive form and submits it:</p><pre><code><code>if (e.key === 'a') {
  var f = document.querySelector('form[data-shortcut-a]');
  if (f) f.submit();
}
</code></code></pre><p>And the archive form marks itself with <code>data-shortcut-a</code>:</p><pre><code><code>&lt;form method="post" action="/__u/dailypythonprojects.substack.com/article/1/archive" data-shortcut-a&gt;
  &lt;button type="submit" class="primary"&gt;Archive &lt;kbd&gt;a&lt;/kbd&gt;&lt;/button&gt;
&lt;/form&gt;
</code></code></pre><p>Nice separation &#8212; the JS doesn&#8217;t need to know URLs or article IDs. The form is the source of truth. Same pattern works if we add more shortcuts later.</p><h2>Understanding Tags with Many-to-Many</h2><p>Tags is the classic many-to-many database pattern. One article can have many tags. One tag can label many articles. So we need three tables:</p><pre><code><code>CREATE TABLE tags (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE COLLATE NOCASE
);

CREATE TABLE article_tags (
    article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
    tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (article_id, tag_id)
);
</code></code></pre><p>The <code>tags</code> table stores tag definitions (name + id). The <code>article_tags</code> junction table has one row per article-to-tag link. Composite primary key (<code>article_id, tag_id</code>) means an article can&#8217;t be linked to the same tag twice &#8212; deduplication built into the schema.</p><p><code>COLLATE NOCASE</code> on the tag name makes queries case-insensitive at the database level. So <code>WHERE name = 'python'</code> matches <code>Python</code>, <code>PYTHON</code>, and <code>python</code>, without us needing to do string comparisons in Python code. Same behavior as any modern web app&#8217;s tag input.</p><p><code>ON DELETE CASCADE</code> on both foreign keys means: if we delete an article, the corresponding rows in <code>article_tags</code> are auto-deleted. Same for deleting a tag. Prevents orphaned rows without needing manual cleanup code.</p><p>Adding tags to an article is &#8220;get or create&#8221;:</p><pre><code><code>def add_tags_to_article(article_id, raw):
    names = _parse_tag_input(raw)   # lowercase, split by comma, dedupe
    with db_connect() as conn:
        for name in names:
            conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
            tag_row = conn.execute(
                "SELECT id FROM tags WHERE name = ? COLLATE NOCASE", (name,)
            ).fetchone()
            conn.execute(
                "INSERT OR IGNORE INTO article_tags (article_id, tag_id) "
                "VALUES (?, ?)", (article_id, tag_row["id"])
            )
</code></code></pre><p><code>INSERT OR IGNORE</code> handles the &#8220;already exists&#8221; case silently &#8212; either the tag definition already exists, or the article-to-tag link already exists, and we just skip that step.</p><p>Removing a tag from an article optionally garbage-collects the tag definition too:</p><pre><code><code>def remove_tag_from_article(article_id, tag_id):
    with db_connect() as conn:
        conn.execute(
            "DELETE FROM article_tags WHERE article_id = ? AND tag_id = ?",
            (article_id, tag_id),
        )
        conn.execute("""
            DELETE FROM tags WHERE id = ? AND NOT EXISTS (
                SELECT 1 FROM article_tags WHERE tag_id = ?
            )
        """, (tag_id, tag_id))
</code></code></pre><p>The second DELETE runs only if no other article has this tag &#8212; keeps the tags table from filling up with orphaned tag definitions from removed tags.</p><h2>Understanding Markdown Export</h2><p>The export feature is deceptively important. What it really means is: <strong>your library is not held hostage by this tool</strong>. You can export everything to plain text at any time and take it elsewhere &#8212; into Obsidian, into a git repo, into any note-taking app that reads Markdown, into another Read Later clone you build in five years.</p><p>Individual article export is a few lines:</p><pre><code><code>def article_to_markdown(article):
    lines = [f"# {article['title'] or article['url']}", ""]
    meta = []
    if article['byline']: meta.append(f"**{article['byline']}**")
    if article['site_name']: meta.append(article['site_name'])
    if article['published_at']: meta.append(f"Published {article['published_at'][:10]}")
    if meta:
        lines.append(" &#183; ".join(meta))
        lines.append("")
    lines.append(f"[Original]({article['url']})")
    lines.append("")
    lines.append("---")
    lines.append("")
    if article['content_html']:
        lines.append(html_to_md(article['content_html'], heading_style="ATX"))
    return "\n".join(lines) + "\n"
</code></code></pre><p><code>markdownify.markdownify()</code> (imported as <code>html_to_md</code>) converts HTML to Markdown. It handles the annoying edge cases &#8212; nested lists, blockquotes with paragraphs inside, code blocks, links inside emphasis &#8212; that you&#8217;d otherwise have to write ~500 lines of regex to handle badly.</p><p>The whole-library zip export uses <code>zipfile</code> (stdlib) to stream a zip response:</p><pre><code><code>def all_articles_zip():
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        with db_connect() as conn:
            articles = conn.execute("SELECT * FROM articles ORDER BY saved_at DESC").fetchall()
        for a in articles:
            slug = _slugify(a["title"] or f"article-{a['id']}")
            filename = f"{a['id']:04d}-{slug}.md"
            zf.writestr(filename, article_to_markdown(a))
    return buf.getvalue()
</code></code></pre><p><code>io.BytesIO()</code> is an in-memory file. We write the zip to it, then return the bytes as an HTTP response with <code>Content-Type: application/zip</code> and a <code>Content-Disposition: attachment; filename="readlater-YYYY-MM-DD.zip"</code> header. Browser triggers a download instead of trying to display the zip inline.</p><p>Filenames use <code>NNNN-slug</code> format (<code>0042-why-python-is-slow.md</code>) so they sort in save order in any file manager. Zero-padded IDs mean sorting stays correct even at 10,000+ articles.</p><h2>Practical Use Cases</h2><p><strong>Bookmarklet becomes the entire save flow.</strong> Once installed, you never open the Read Later UI to save. You&#8217;re on Hacker News, see a good article, click your bookmarklet, popup confirms &#8220;Saved,&#8221; you&#8217;re back on Hacker News in under a second. That reduction in friction is what makes the tool actually get used daily.</p><p><strong>Search becomes the entire retrieval flow.</strong> &#8220;I read something about SQLite triggers a few months ago&#8221; &#8594; hit <code>/</code>, type &#8220;sqlite trigger,&#8221; hit Enter, find it. The article you would otherwise have re-Googled to find is one keyboard shortcut away.</p><p><strong>Tags for cross-cutting themes.</strong> Not for every article &#8212; that&#8217;s tedious. Just for the recurring themes worth clustering later. <code>python</code>, <code>essays</code>, <code>career</code>, <code>startup-stories</code>, <code>long-reads</code> &#8212; five tags cover most people&#8217;s actual usage. Click a tag pill to filter your queue.</p><p><strong>Dark mode for reading in bed.</strong> The tool sitting on your laptop screen at 11pm is way easier on the eyes in dark mode. Once you toggle it once, it&#8217;s your default from then on because we save it to localStorage.</p><p><strong>Export everything as backup.</strong> Once a month, hit <code>/export.zip</code>, save the file. If your laptop dies, your reading history isn&#8217;t gone &#8212; it&#8217;s in that zip, in plain text, forever.</p><p><strong>Deploy on a home server for cross-device sync.</strong> Everything so far assumes local. But if you have a small always-on machine (Raspberry Pi, home NAS, VPS), run the server there instead of on your laptop. Configure the bookmarklet to point at that machine&#8217;s LAN address. Now the same queue is accessible from every device &#8212; laptop, phone, tablet, work computer. Same $5/month VPS could host this and Focus Timer from Week 32.</p><h2>What&#8217;s Next</h2><p>Thursday&#8217;s post is a standalone tutorial on a specific piece of infrastructure &#8212; free, all subscribers get it. Details Thursday morning.</p><p>Next week we pivot to something new &#8212; details on Tuesday.</p><h2>Solution</h2><p>Below you will find the downloadable solution.py file containing the correct solution.</p><p>Get the code here:</p><p></p>
      <p>
          <a href="/__u/dailypythonprojects.substack.com/p/build-your-own-read-it-later-app-969">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build Your Own Read-It-Later App in Python: Day 1 — The MVP]]></title><description><![CDATA[Pocket is dying, Instapaper feels stale. Save articles, strip the junk, keep them forever -built entirely in Python.]]></description><link>https://dailypythonprojects.substack.com/p/build-your-own-read-it-later-app</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-your-own-read-it-later-app</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 01 Sep 2026 11:56:23 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/73832b50-9c8f-4825-9573-a7584f1e87f7_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p> </p><p>Mozilla is sunsetting Pocket. Instapaper feels frozen in 2013. Every &#8220;read it later&#8221; service in existence is one acquisition away from vanishing, taking your entire reading history with it. And the whole problem &#8212; save a URL, extract just the article, read it later &#8212; is genuinely 600 lines of Python you can write in a weekend and use forever.</p><p>This week we build it. Save any article&#8217;s URL, and the tool fetches the page, strips out the nav / ads / cookie banners / sidebars / &#8220;related articles&#8221; / newsletter popup / footer, and stores a clean reader-view copy in a SQLite database on your machine. Open the tool in your browser, see your queue, click one, read it in beautiful typography with none of the design that made you want to leave the original page. Archive when done. Weeks or months later, when you think &#8220;I read something about X&#8221; &#8212; it&#8217;s still there, still readable, even if the original page has died or gone behind a paywall.</p><p>Day 1 is the working MVP &#8212; save via web form or CLI, extract with <code>trafilatura</code>, store extracted content <strong>plus</strong> the raw HTML for future re-extraction, queue view, reader view, archive/delete actions, and graceful failure for the sites that resist extraction. Day 2 adds the bookmarklet for one-click saving from any page, full-text search across everything you&#8217;ve ever saved, tags, dark mode, keyboard shortcuts, and export to Markdown.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>Day 1:</strong> Save, extract, and read articles from a local web UI <strong>(Today)</strong></p><p>You start the server with <code>python readlater.py</code> and it opens http://127.0.0.1:8770 in your browser. The queue page shows a URL input box at the top and your saved articles beneath. Paste any URL, click Save, and the tool fetches the page, extracts the article, and pushes it to the top of your queue with a title, byline, site name, reading time, and a short excerpt:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!dCD1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 424w, /__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 848w, /__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!dCD1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png" width="1456" height="1361" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1361,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:457002,&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/213690953?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 424w, /__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 848w, /__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dCD1!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861f3338-23cf-45f8-9c94-d6615a853880_1752x1638.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><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></p><p>Click any article and you land on the reader view &#8212; clean serif typography, generous line-height, comfortable reading column, no distractions. The original site&#8217;s URL is one click away if you want it, but you probably don&#8217;t:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!tG5b!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 424w, /__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 848w, /__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 1272w, /__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!tG5b!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png" width="1456" height="1328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1328,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:538817,&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/213690953?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 424w, /__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 848w, /__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.png 1272w, /__u/substackcdn.com/image/fetch/$s_!tG5b!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F646e6fcf-feb9-4b53-906a-41816ef0d165_1846x1684.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>There you have a clean distraction-free view you can read and en joy.</p><p><strong>Day 2:</strong> Bookmarklet, full-text search, tags, dark mode, and export</p><p>Save from ANY page in ANY browser with one click via a bookmarklet you drag to your browser bar &#8212; no more copy-paste-open-app-paste dance. Search across every article you&#8217;ve ever saved with SQLite FTS5, finding phrases from articles you saved months ago. Tag articles (&#8221;python&#8221;, &#8220;essays&#8221;, &#8220;long-reads&#8221;) for organization. Dark mode for night reading. Keyboard shortcuts (j/k to move through the queue, Enter to open, a to archive). Export any article or your whole archive to Markdown for keeping in your notes app or a git repo.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-33">View All Projects This Week</a></p><h2>About the stack</h2><p><strong>Flask</strong> for the web server &#8212; small, mature, single-user local tools don&#8217;t need async. Same choice as Week 32&#8217;s focus dashboard.</p><p><strong>trafilatura</strong> for content extraction. Actively maintained (last update this year), specifically designed for extracting article content from arbitrary web pages, ships a well-tested extraction algorithm with metadata support out of the box. Gives you the article&#8217;s title, author, publication date, and site name for free &#8212; all in one call. It&#8217;s what many news aggregators and research tools use under the hood in 2026.</p><p><strong>requests</strong> for HTTP. Battle-tested, still the gold standard for fetching a URL with sensible defaults.</p><p><strong>sqlite3</strong> (stdlib) for storage. One file on disk at <code>~/.readlater/readlater.db</code>. Perfect for personal-scale &#8212; a few hundred saved articles is under 10 MB, a few thousand is under 100 MB.</p><p><strong>Jinja2</strong> for templates (bundled with Flask). Templates live inside our Python file as strings, loaded via <code>DictLoader</code> &#8212; same pattern we used in Weeks 28 and 32. One file, no separate <code>templates/</code> folder.</p><h2>Setup Instructions</h2><p>Install three packages:</p><pre><code><code>pip install flask trafilatura requests
</code></code></pre><p>Run:</p><pre><code><code>python readlater.py                        # start the web server on :8770
python readlater.py save &lt;URL&gt;             # save from the terminal, no server needed
python readlater.py list                   # print the queue to the terminal
</code></code></pre><p>On first run, the tool creates <code>~/.readlater/</code> (a hidden folder in your home directory) with the SQLite database inside. This is where all your saved articles live. Back it up if you care about them, migrate it between machines to move your library.</p><p>No sudo required &#8212; this tool doesn&#8217;t touch anything system-level. Just saves stuff in your home directory and serves a web page on localhost.</p><h2>Understanding Content Extraction</h2><p>The hardest technical problem this project solves is: <strong>given a web page&#8217;s HTML, return just the article, without the surrounding junk.</strong></p><p>The naive approach &#8212; &#8220;look for a <code>&lt;article&gt;</code> tag&#8221; &#8212; works maybe 30% of the time. Most pages don&#8217;t have semantic HTML. Some do, but the article is wrapped in twenty divs with class names like <code>story-body-inner-wrapper-v3</code>. Others have the article split across multiple divs with ads injected between paragraphs. Cookie consent modals, newsletter signup popups, &#8220;you might also like&#8221; carousels &#8212; the DOM is a battlefield.</p><p>The right approach is a <strong>statistical content extractor</strong> &#8212; an algorithm that scores every text-containing element in the page by likelihood-of-being-content vs likelihood-of-being-chrome, based on things like text density, link density, tag depth, and neighboring element scores. This is what <code>trafilatura</code> does, along with <code>readability-lxml</code>, <code>newspaper3k</code>, and the original Arc90 Readability algorithm that Firefox and Safari&#8217;s reader modes are based on.</p><p>The whole extraction step in our code is one function call:</p><pre><code><code>extracted = trafilatura.extract(
    raw_html,
    output_format="json",
    with_metadata=True,
    include_comments=False,
    include_tables=True,
    include_images=False,
    favor_precision=True,
)
</code></code></pre><p><code>output_format="json"</code> gives us title, author, publication date, site name, and cleaned text in one structured response. <code>favor_precision=True</code> biases toward &#8220;less content but cleaner&#8221; over &#8220;more content but might include some junk&#8221; &#8212; the right trade-off for a reader tool where readability trumps completeness.</p><p>Then we call <code>trafilatura.extract</code> a second time with <code>output_format="html"</code> to get the article as clean HTML with paragraph and heading structure preserved. That&#8217;s what the reader view renders. Text alone would be readable but less pretty &#8212; headings and blockquotes and lists all render better as HTML.</p><h2>Understanding the Raw HTML Archive</h2><p>Notice that we store <em>both</em> the extracted content AND the original raw HTML:</p><pre><code><code>CREATE TABLE articles (
    ...
    content_html TEXT,     -- cleaned reader view
    content_text TEXT,     -- plain text for search
    raw_html TEXT,         -- the full original page bytes
    ...
)
</code></code></pre><p>Storing the raw HTML seems wasteful &#8212; the extracted content is what the user actually reads. Why keep 200 KB of DOM when 20 KB of article text would do?</p><p>Two reasons, both about future-proofing:</p><p><strong>First, extraction algorithms improve.</strong> <code>trafilatura</code> in 2026 is way better than <code>readability</code> was in 2019. In 2030 there&#8217;ll be something better than both. If we only store the extracted version, we&#8217;re frozen at today&#8217;s algorithm quality forever. If we keep the raw HTML, we can re-extract with a better tool later. That&#8217;s the same reason photographers keep RAW files instead of just JPEGs &#8212; the source data is more valuable than any one processed version of it.</p><p><strong>Second, the original page might die.</strong> Blogs shut down. Companies pivot and delete their content marketing. Paywalls appear in front of previously-free articles. Domains expire. When any of these happens, your extracted content still works &#8212; but you can&#8217;t verify it against the source, or notice you extracted a truncated version, without the raw HTML. Having the original bytes means your archive is truly permanent.</p><p>Storage cost is trivial. Even 5,000 saved articles at 200 KB each is 1 GB. That&#8217;s less than one HD movie.</p><h2>Understanding URL Normalization</h2><p>Same article, three different URLs:</p><pre><code><code>https://blog.example.com/why-python?utm_source=twitter&amp;utm_medium=social
https://blog.example.com/why-python?utm_source=newsletter&amp;utm_campaign=weekly
https://blog.example.com/why-python#comments</code></code></pre><p>All three point to the same content. If we treat them as different URLs and don&#8217;t dedupe, saving the article via Twitter, then via the newsletter link, then bookmarking it &#8212; three duplicate entries. That&#8217;s a silly failure mode. Real read-later tools normalize URLs before storing them.</p><p>Our <code>normalize_url</code> function strips ~25 common tracking parameters (all <code>utm_*</code> variants, <code>fbclid</code>, <code>gclid</code>, <code>mc_cid</code>, <code>ref</code>, and friends), lowercases the host, drops the fragment (<code>#section</code> &#8212; that&#8217;s client-side navigation, doesn&#8217;t identify content), and adds <code>https://</code> if the scheme was missing:</p><pre><code><code>TRACKING_PARAMS_TO_STRIP = {
    "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
    "fbclid", "gclid", "mc_cid", "mc_eid", "ref", "ref_src",
    # ...
}

def normalize_url(url):
    parsed = urlparse(url)
    kept = [(k, v) for k, v in parse_qsl(parsed.query)
            if k.lower() not in TRACKING_PARAMS_TO_STRIP]
    return urlunparse((
        parsed.scheme,
        parsed.netloc.lower(),
        parsed.path,
        parsed.params,
        urlencode(kept),
        "",              # drop fragment
    ))
</code></code></pre><p>Then the database has a UNIQUE constraint on <code>url</code>, so re-saving an already-saved article updates the existing row instead of creating a duplicate. And re-saving un-archives it, because saving an article you already read means you probably want it back in your queue for another look.</p><h2>Understanding Graceful Extraction Failure</h2><p>Some websites cannot be extracted. JavaScript-heavy sites (Twitter posts, most SaaS blogs with client-side rendering) return an empty HTML shell that fills in with JS after page load &#8212; <code>trafilatura</code> sees empty divs. Paywalled sites return a login page. Some sites detect our <code>requests</code> fetch as a bot and serve a captcha instead.</p><p>The wrong response to this is: refuse to save the URL. That would break the user&#8217;s mental model. They intended to save the article; the tool failing to extract shouldn&#8217;t discard their intent.</p><p>The right response is: save the URL anyway, mark it as extraction-failed, and show a helpful message in the reader view:</p><pre><code><code>def save_url():
    extraction = fetch_and_extract(url)
    article_id, was_new = save_article(extraction)

    if extraction.extraction_succeeded:
        msg = "Saved."
    else:
        msg = ("Saved the link, but couldn't extract the article content. "
               "You can still click through to the original.")
    return redirect(url_for("queue", flash=msg))
</code></code></pre><p>In the queue, failed articles appear with italic muted titles and a small <code>saved link only</code> tag so users know they&#8217;ll be clicking through to the original site instead of a reader view. In the reader, they get a friendly panel explaining what happened and a prominent &#8220;open original&#8221; link.</p><p>This turns a hard failure into a soft one. The tool becomes a reliable bookmark holder even for content it can&#8217;t perfect. Users trust it more, and the failed cases teach us over time what sites need better extraction (Day 2 will add fallback extractors).</p><h2>Understanding the Reader Typography</h2><p>The whole point of a read-later tool is a better reading experience than the original page. That means the typography has to be genuinely nice, not just &#8220;an HTML page&#8221;:</p><pre><code><code>.article-body {
  font-family: Georgia, "Times New Roman", serif;
  font-size: 1.15rem;
  line-height: 1.7;
  color: #262626;
}
.article-body p { margin: 0 0 1.1em; }
main.wrap { max-width: 780px; margin: 0 auto; }
</code></code></pre><p>A few decisions here are worth understanding:</p><p><strong>Serif for the body.</strong> Georgia is the classic web-safe serif that renders well at any size. Serif fonts are traditionally considered easier to read for long passages of text, though the modern research is mixed. What matters more is that serif <em>signals</em> long-form reading &#8212; the same content in Arial feels like a blog post, in Georgia it feels like a book. That signal changes how you engage with it.</p><p><strong>780px maximum content width.</strong> Line length matters enormously for reading comfort. Below 400px, your eyes have to jump back too often. Above 900px, you lose your place returning to the start of the next line. The sweet spot for readable prose is 50-75 characters per line, which at 18px font size is roughly 600-800px of column width. 780px is a comfortable middle.</p><p><strong>1.7 line-height.</strong> Default browser line-height is around 1.2. That&#8217;s too tight for extended reading &#8212; the lines feel packed together. 1.7 gives the eye breathing room. Every good reader interface (Medium, Instapaper, Kindle) uses something between 1.5 and 1.8.</p><p><strong>Off-white background (</strong><code>#faf7f2</code><strong>).</strong> Pure white can feel harsh in low light. A very slight warm off-white is easier on the eyes without being obviously &#8220;sepia mode&#8221; busy. Same logic as e-readers.</p><p><strong>Sans-serif for UI, serif for content.</strong> The nav bar and buttons are <code>-apple-system</code> (Apple&#8217;s system font, which adapts to whatever the reader&#8217;s OS is). The article body is Georgia. That split makes the article visually distinct from the interface &#8212; you&#8217;re reading content, not clicking through an app.</p><p>These choices took ~50 lines of CSS total. Small effort, huge quality-of-experience win.</p><h2>Practical Use Cases</h2><p><strong>Killing the &#8220;40 open tabs&#8221; problem.</strong> The main use case. See something interesting &#8594; save &#8594; close the tab &#8594; guilt evaporates. Come back to it in a quiet moment.</p><p><strong>Reading during commutes without WiFi.</strong> Since everything is stored locally, articles read fine on a plane, in a subway, or anywhere with bad reception. As long as you saved them before losing signal.</p><p><strong>Building a personal knowledge archive that survives platform deaths.</strong> Everything you save is yours forever, in a plain SQLite file you can back up, migrate, or export from. When (not if) some blog you love goes offline in 2028, you still have the articles you saved.</p><p><strong>Sending yourself long-reads across devices.</strong> Save from your work laptop, read on your phone, archive from your tablet. The database is a single file &#8212; put it on Dropbox or Syncthing or a home NAS and every device sees the same queue.</p><p><strong>Foundation for Day 2.</strong> Every function we built today gets reused tomorrow. <code>fetch_and_extract</code> gets a fallback chain for tough sites. <code>save_article</code> gains a tags table. <code>list_articles</code> grows a search parameter. <code>humanize_time</code> gets keyboard shortcut friends. The engine stays; the interface expands.</p><h2>Coming Wednesday</h2><p>Tomorrow we add the bookmarklet &#8212; one drag to your browser bar, then any page you&#8217;re on gets saved with one click. Full-text search across everything you&#8217;ve ever saved, powered by SQLite FTS5. Tags for organization. Dark mode for night reading. Keyboard shortcuts (<code>j</code>/<code>k</code> to navigate, <code>Enter</code> to open, <code>a</code> to archive, <code>/</code> to search). Export any article or your whole library to Markdown for keeping in your notes app or a git repo.</p><p>If you want the full tool, <a href="/__u/dailypythonprojects.substack.com/subscribe">upgrade here</a> before Wednesday.</p><h2>Solution</h2><p>Below you will find the downloadable solution.py file containing the correct solution.</p><p>Get the code here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/uQvhhQkabi1G2DbHhOo7jQ&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/uQvhhQkabi1G2DbHhOo7jQ"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build a Focus Timer with Real Website Blocking in Python: Day 2 — The Dashboard]]></title><description><![CDATA[Yesterday&#8217;s CLI became a real personal focus system today &#8212; web dashboard, presets, stats, streaks, mandatory breaks.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-focus-timer-with-real-website-98c</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-focus-timer-with-real-website-98c</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 26 Aug 2026 10:31:48 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/6f3f4498-8a19-4707-95cc-119b850bcf0a_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday&#8217;s CLI was one command, one session, quit when done. Genuinely useful, but a focus tool is something you use dozens of times a week, and a CLI you re-invoke from scratch every session leaves half the value on the table. Today the CLI becomes a <strong>personal focus system</strong> &#8212; a pinnable browser tab that runs all day, remembers your favorite presets, tracks your streak of consecutive focused days, and enforces a mandatory rest break after every completed session so you don&#8217;t chain sprints into burnout.</p><p>Same blocking engine underneath. Exact same <code>add_block_entries</code> / <code>remove_block_entries</code> / <code>flush_dns</code> code we wrote yesterday, just wired into a Flask web UI with SQLite-backed stats. That&#8217;s the pedagogical arc of the week: <strong>write the engine on Day 1, wrap it in a product on Day 2</strong>. When your engine is a pure function that operates on a hosts file, you can wrap it in a CLI or a web app or a menu bar app or a Discord bot without changing the engine. The interface layer is the thing that changes; the engine stays the same.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>Day 1:</strong> CLI focus timer with real website blocking</p><p>Yesterday, we built a stdlib-only CLI that starts a focus session for N minutes, edits <code>/etc/hosts</code> (or the Windows equivalent) to make distracting sites unreachable via DNS, shows a live countdown in the terminal, and cleans everything up automatically &#8212; whether the timer completes normally, you Ctrl+C, or the process crashes. Zero external dependencies, three-layer cleanup safety net, marker-based hosts editing so we never touch anything else in the file.</p><p><strong>Day 2:</strong> Web dashboard with presets, stats, streaks, and break enforcement <strong>(Today)</strong></p><p>Run <code>sudo python focus_dashboard.py</code> and it starts a local server on http://127.0.0.1:8765 and auto-opens your browser. The dashboard shows named preset cards &#8212; Pomodoro (25 min), Deep work (90 min), Study (45 min), Quick focus (15 min). Click one and the session starts instantly, the block goes into effect, and the page swaps to a big centered countdown:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!jSa5!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 424w, /__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 848w, /__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 1272w, /__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!jSa5!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png" width="1456" height="918" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:918,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:446941,&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/212827354?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 424w, /__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 848w, /__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.png 1272w, /__u/substackcdn.com/image/fetch/$s_!jSa5!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd5728c4e-1443-4c27-92a7-2d199564e6ae_2120x1336.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></p><p>The countdown ticks smoothly in the browser without hammering the server &#8212; the JavaScript decrements locally every 200 ms and re-syncs with the server every 5 seconds. Below the timer, your daily stat cards show how much you&#8217;ve focused today, this week, your current streak, and your longest streak ever. Tab across to the Stats page for the deep view &#8212; a 30-day bar chart of daily focus minutes plus a table of your last 30 sessions with completion status:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!0mTW!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 424w, /__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 848w, /__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 1272w, /__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!0mTW!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png" width="1456" height="1067" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1067,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:400753,&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/212827354?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 424w, /__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 848w, /__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.png 1272w, /__u/substackcdn.com/image/fetch/$s_!0mTW!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229add-7615-4f7b-848e-c9e32798a5a1_2410x1766.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>Note</strong>: When a session completes, you don&#8217;t get to start another one immediately. The tool imposes a proportional break window &#8212; 20% of the session duration, clamped to 5-20 minutes &#8212; and refuses to start a new session until the break ends. That can be a good thing for users not to trick the program.</p><p>And the Presets page lets you edit the list &#8212; add &#8220;Writing sprint&#8221; (45 min with <code>hackernews.com</code> in the extras), &#8220;Morning deep work&#8221; (2 hours with LinkedIn added), whatever your workflows are. Stored as JSON at <code>~/.focustimer/presets.json</code> so you can hand-edit them too.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-32">View All Projects This Week</a></p><h2>About the stack</h2><p><strong>Flask</strong> is the web framework &#8212; a change of pace after several weeks of FastAPI. Flask has been the classic &#8220;Python web app in one file&#8221; tool for over a decade. Its request/response model is synchronous and straightforward, which is exactly right for a single-user local tool where we don&#8217;t need async concurrency.</p><p><strong>Jinja2</strong> is the templating engine (bundled with Flask). We put templates inside our Python file as strings and load them via <code>DictLoader</code> &#8212; same pattern as the Notes app in Week 28. Full template inheritance, includes, filters, everything works.</p><p><strong>sqlite3</strong> is Python&#8217;s built-in database (stdlib since 2.5). No installation, no server, one file on disk at <code>~/.focustimer/focus.db</code>. Perfect for personal-scale persistence.</p><p><strong>threading</strong> runs the focus session in a background thread alongside the Flask request handlers. A <code>threading.Lock</code> protects the session state from concurrent read/write, and a <code>threading.Event</code> lets the main thread signal &#8220;stop early&#8221; to the session thread.</p><p><strong>Chart.js</strong> from a CDN renders the 30-day bar chart. Battle-tested, works in every browser, one script tag.</p><p><strong>Pico.css</strong> from a CDN styles the whole thing. Classless &#8212; write plain semantic HTML, get a professional look. Same choice we&#8217;ve made every week for a reason: it just works.</p><h2>Setup Instructions</h2><p>Install Flask (the only new dependency on top of Day 1):</p><pre><code><code>pip install flask
</code></code></pre><p>Run:</p><pre><code><code>sudo python focus_dashboard.py                # localhost:8765, auto-opens browser
sudo python focus_dashboard.py --port 8888    # custom port
sudo python focus_dashboard.py --no-browser   # don't auto-open
sudo python focus_dashboard.py --unblock      # emergency unblock, then exit
</code></code></pre><p>The recommended workflow is to leave one terminal tab running the server all day, and pin the dashboard tab in your browser. Ctrl+C in the terminal to shut down cleanly &#8212; the block is removed automatically if any session was active.</p><p>On first run, the tool creates <code>~/.focustimer/</code> (a hidden folder in your home directory) with two files inside: <code>presets.json</code> (editable) and <code>focus.db</code> (SQLite). Both survive across restarts, so your stats and custom presets persist.</p><h2>Understanding the Reusable Engine Pattern</h2><p>The single most important design decision this week: <strong>Day 2 doesn&#8217;t reinvent the blocking engine, it reuses it.</strong> Every function from Day 1 that does the actual work &#8212; <code>add_block_entries</code>, <code>remove_block_entries</code>, <code>flush_dns</code>, <code>is_hosts_active</code>, <code>get_hosts_file</code>, <code>is_admin</code> &#8212; appears again in Day 2, unchanged. Copy-paste from Day 1&#8217;s <code>focus.py</code> into Day 2&#8217;s <code>focus_dashboard.py</code>, no modifications required.</p><p>That copy-paste is intentional. Both files are self-contained &#8212; a subscriber can download either one and run it, without needing the other in the same folder. If you were writing this as a real project you&#8217;d factor the shared code into a <code>focus_core.py</code> module and import from it, but for teaching purposes the redundancy makes each day standalone.</p><p>The lesson underneath: <strong>when your engine is pure logic (takes inputs, mutates a file, returns nothing), it composes into any interface</strong>. CLI, web app, mobile app, cron job, all valid wrappers. The engine has no idea whether a human typed a command or clicked a button. That separation is what lets big software companies ship the same core to a web app and a mobile app and a desktop app.</p><h2>Understanding the Threaded Session</h2><p>The tricky part of Day 2 is: how do you run a long-running timer alongside a web server without either blocking the other?</p><p>The answer is threading. The Flask process runs the HTTP server on the main thread; when the user clicks &#8220;Start Pomodoro&#8221;, the request handler spawns a background thread that runs the countdown and does the eventual unblock:</p><pre><code><code>class FocusSession:
    def start(self, preset):
        with self._lock:
            # ... update state to "active", record preset details ...
            self._stop_event.clear()

        # Do the actual blocking OUTSIDE the lock (DNS flush can be slow)
        add_block_entries(self.hosts_file, self.sites)
        flush_dns()

        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def _run(self):
        end_time = self.started_at + timedelta(seconds=self.duration_seconds)
        while datetime.now() &lt; end_time:
            if self._stop_event.wait(0.5):     # returns True if event was set
                self._finish(completed=False)
                return
        self._finish(completed=True)
</code></code></pre><p>Two important patterns here:</p><p><code>threading.Event</code><strong> for cooperative interruption.</strong> The <code>_stop_event.wait(0.5)</code> sleeps for up to 500 ms but returns immediately if another thread calls <code>set()</code> on the event. So when the &#8220;Stop early&#8221; button posts to <code>/session/stop</code>, that request handler does <code>self._stop_event.set()</code>, and within 500 ms the session thread notices, breaks out of the loop, and unblocks. No busy-waiting, no polling, no killing threads (Python doesn&#8217;t actually let you kill a thread &#8212; cooperation via events is the right pattern).</p><p><code>daemon=True</code><strong> threads.</strong> A daemon thread doesn&#8217;t prevent the process from exiting. If the user Ctrl+Cs the server while a session is running, the daemon thread dies without cleanup. That&#8217;s OK because our <code>atexit</code> handler (registered separately) removes the hosts file entries before the process actually terminates. Same three-layer cleanup pattern as Day 1: explicit unblock in the finish method, <code>atexit</code> for normal exit, signal handlers for Ctrl+C.</p><p><strong>Lock discipline.</strong> State mutations happen inside <code>with self._lock:</code>. External calls (adding block entries, DNS flush) happen outside the lock &#8212; they can take hundreds of milliseconds and holding the lock during them would freeze the API. The rule: <strong>only hold the lock for the duration of the state mutation itself</strong>, never during I/O.</p><h2>Understanding the Live Countdown Without Server Hammering</h2><p>The dashboard shows a smooth ticking countdown. The naive way to do this would be to poll <code>/api/status</code> every 200 ms &#8212; 300 requests per minute per open browser tab. Left open for 8 hours: 144,000 requests. That&#8217;s crazy for a local tool.</p><p>The correct pattern is <strong>client-side interpolation with periodic server sync</strong>:</p><pre><code><code>let state = { state: 'active', remaining: 3547, lastSync: Date.now() };

function render() {
  const elapsed = (Date.now() - state.lastSync) / 1000;
  const remaining = Math.max(0, state.remaining - elapsed);
  activeTime.textContent = mmss(remaining);
}

async function sync() {
  const res = await fetch('/api/status');
  const data = await res.json();
  state = {
    state:     data.state,
    remaining: data.remaining_seconds || 0,
    lastSync:  Date.now(),
    // ...
  };
  render();
}

setInterval(render, 200);   // smooth local tick, no network
setInterval(sync,   5000);  // sync with server every 5 seconds
</code></code></pre><p>The <code>render</code> function reads <code>state.remaining</code> (last known value from server) and subtracts the local elapsed time since that value was received. So the countdown appears to tick every 200 ms even though the actual authoritative value is only refreshed every 5 seconds. Feels smooth to the user, gentle on the server &#8212; 12 requests per minute instead of 300.</p><p>The state-change reload trick handles transitions between <code>active</code> / <code>break</code> / <code>idle</code> &#8212; if the top-level state changes between syncs, we call <code>window.location.reload()</code> to pick up the freshly server-rendered dashboard with the correct view. Cheap and correct.</p><h2>Understanding the Stats Database</h2><p>The <code>sessions</code> table is stupidly simple:</p><pre><code><code>CREATE TABLE sessions (
    id INTEGER PRIMARY KEY,
    preset_name TEXT,
    started_at TEXT,             -- ISO-8601 local time
    duration_seconds INTEGER,
    completed_seconds INTEGER,   -- actual focused time (&lt; duration if stopped early)
    was_completed INTEGER
)
</code></code></pre><p>Every finished session (completed OR interrupted) gets one row. That&#8217;s it. Everything on the stats page is derived from this table via SQL:</p><pre><code><code>def total_seconds_since(start_date):
    row = conn.execute("""
        SELECT COALESCE(SUM(completed_seconds), 0) AS total
        FROM sessions
        WHERE date(started_at) &gt;= ?
    """, (start_date.isoformat(),)).fetchone()
    return int(row["total"])
</code></code></pre><p><code>date(started_at)</code> is SQLite&#8217;s date function &#8212; extracts just the date portion of an ISO-8601 timestamp. So we can group sessions by day trivially. <code>COALESCE(SUM(...), 0)</code> returns 0 when there are no rows, avoiding a null response.</p><p><strong>Streak calculation</strong> is the fun part. Walk backwards from today, day by day, counting how many consecutive days have at least one session:</p><pre><code><code>session_days = {r["d"] for r in conn.execute(
    "SELECT DISTINCT date(started_at) AS d FROM sessions"
).fetchall()}

current_streak = 0
d = today
while d.isoformat() in session_days:
    current_streak += 1
    d -= timedelta(days=1)
</code></code></pre><p>The grace clause: if today has no session yet but yesterday does, the streak continues (haven&#8217;t broken it yet, still time to redeem the day). Same principle as Duolingo&#8217;s &#8220;streak freeze&#8221; &#8212; a small psychological accommodation that keeps people from despairing at 11pm.</p><p><strong>Longest streak</strong> is a scan over all session days sorted ascending, tracking the longest consecutive run:</p><pre><code><code>sorted_days = sorted(date.fromisoformat(d) for d in session_days)
run = 1
longest_streak = 1
for i in range(1, len(sorted_days)):
    if (sorted_days[i] - sorted_days[i - 1]).days == 1:
        run += 1
        longest_streak = max(longest_streak, run)
    else:
        run = 1
</code></code></pre><p>O(N) where N is the total number of days you&#8217;ve ever done a focus session. Fast even after years of use.</p><h2>Understanding Break Enforcement</h2><p>The whole point of a focus tool is not &#8220;maximize hours in front of screen with notifications off.&#8221; The point is sustainable deep work. Chaining focus sprints without breaks reliably leads to burnout &#8212; every productivity researcher will tell you the same thing. So Day 2 enforces breaks structurally:</p><pre><code><code>def _compute_break_seconds(self, session_seconds):
    """20 percent of session, clamped to 5-20 minutes."""
    return int(min(20 * 60, max(5 * 60, session_seconds * 0.20)))
</code></code></pre><p>25-minute pomodoro &#8594; 5-minute break. 45-minute study session &#8594; 9-minute break. 90-minute deep work &#8594; 18-minute break. These proportions come from actual attention-restoration research (Kaplan&#8217;s ART) &#8212; longer focus needs proportionally longer rest.</p><p>When a session completes normally, the state transitions to <code>"break"</code> and <code>break_until</code> is set:</p><pre><code><code>def _finish(self, completed):
    remove_block_entries(self.hosts_file)
    flush_dns()

    with self._lock:
        # ... record in stats DB ...
        if completed:
            break_secs = self._compute_break_seconds(self.duration_seconds)
            self.break_until = datetime.now() + timedelta(seconds=break_secs)
            self.state = "break"
        else:
            self.state = "idle"      # no break if you stopped early
</code></code></pre><p>Only completed sessions earn a break &#8212; if you stop early, you go straight back to idle. That subtle design choice prevents the exploit where you &#8220;start a session, immediately stop, then complain the break is stopping you from starting again.&#8221;</p><p>Attempts to start a new session while on break are rejected with a friendly countdown:</p><pre><code><code>def start(self, preset):
    with self._lock:
        if self.state == "break":
            remaining = int((self.break_until - datetime.now()).total_seconds())
            if remaining &gt; 0:
                m, s = divmod(remaining, 60)
                raise SessionError(
                    f"You're on a break for another {m}m {s:02d}s. "
                    "Rest, then start the next session."
                )
</code></code></pre><p>The <code>SessionError</code> bubbles up to the Flask handler, which redirects back to the dashboard with the message in a flash banner.</p><h2>Understanding Local-Only Binding</h2><p>The whole app binds to <code>127.0.0.1</code>, not <code>0.0.0.0</code>:</p><pre><code><code>app.run(host="127.0.0.1", port=args.port,
        debug=False, threaded=True, use_reloader=False)
</code></code></pre><p>That single-word change from <code>0.0.0.0</code> to <code>127.0.0.1</code> means <strong>the server is only reachable from the same machine</strong>. Other devices on the LAN can&#8217;t see it. Your neighbor at the coffee shop can&#8217;t hit <code>http://your-laptop.local:8765/session/stop</code> to sabotage your focus. This is why we don&#8217;t need any auth &#8212; the OS-level network binding is the auth.</p><p>Contrast with LAN Share from Week 30, which bound to <code>0.0.0.0</code> precisely because we wanted other LAN devices to reach it, and therefore needed a PIN gate. Different tool, different security model, different binding.</p><p><code>threaded=True</code> is essential because the background session thread needs to run alongside HTTP request handlers. <code>use_reloader=False</code> disables Flask&#8217;s dev-mode reloader, which would fork the process and destroy our in-memory session state.</p><h2>Practical Use Cases</h2><p><strong>All-day pinned focus tab.</strong> Pin the dashboard tab in Chrome/Firefox/Safari. When you sit down to work, click a preset. Focus. Break. Click another. Repeat. Because it&#8217;s a browser tab, it&#8217;s always one keyboard shortcut away &#8212; no window management, no menu bar dance.</p><p><strong>Weekly focus reports.</strong> The stats page shows exactly how many hours you focused this week. If you&#8217;re managing your own time as a freelancer, contractor, or founder, this is actually meaningful data &#8212; vastly more honest than &#8220;how did last week feel?&#8221;</p><p><strong>Team focus challenges.</strong> Everyone in a small team runs the same tool, screenshots their weekly stats page every Friday. Not for judgment &#8212; for peer accountability. Turns out most people focus a lot less than they think they do, and seeing &#8220;9h focused this week&#8221; concretely changes behavior.</p><p><strong>Kids doing homework blocks with visible progress.</strong> Parents can start a 45-minute Study session with expanded blocks (add Roblox, Discord, whatever the current addictions are). Kids see the countdown, know when it ends, get their brain break automatically enforced.</p><p><strong>Baseline for a menu bar app.</strong> Everything we built today can be wrapped in a menu bar UI on macOS with <code>rumps</code>, a system tray app on Windows with <code>pystray</code>, or a GNOME extension on Linux. The engine and stats stay the same; the interface changes.</p><h2>Solution</h2><p>Below you will find the downloadable solution.py file containing the correct solution.</p><p>Get the code here:</p>
      <p>
          <a href="/__u/dailypythonprojects.substack.com/p/build-a-focus-timer-with-real-website-98c">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a Focus Timer with Real Website Blocking in Python: Day 1 — The CLI]]></title><description><![CDATA[Focus apps ask you nicely to stop scrolling. This one makes it physically impossible for 25 minutes.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-focus-timer-with-real-website</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-focus-timer-with-real-website</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 25 Aug 2026 21:43:34 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c56ff93d-c802-4631-b839-3f99471721a5_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every focus app on the market gently asks you to please stop scrolling. Freedom, Cold Turkey, Forest, StayFocusd &#8212; they all use some variation of &#8220;hey, you&#8217;re not supposed to be here, do you want to close this?&#8221; Two clicks around the reminder and you&#8217;re back on Reddit. That&#8217;s the flaw. Willpower-plus-a-nag isn&#8217;t willpower.</p><p>This week we build a focus tool that removes the willpower part entirely. During a focus session, distracting sites don&#8217;t load. Not &#8220;muted&#8221;, not &#8220;hidden behind a nag screen&#8221; &#8212; the browser genuinely cannot reach them. Type <code>reddit.com</code>, get &#8220;This site can&#8217;t be reached.&#8221; Every time. For the duration you chose. When the timer ends, everything is unblocked automatically. If you Ctrl+C to give up, everything unblocks. If the script crashes, everything unblocks. The only way to keep sites blocked forever is to unplug your computer, which is another approach entirely.</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>Day 1 is the CLI tool &#8212; one Python file, zero external dependencies, works on macOS, Linux, and Windows. Day 2 wraps the same engine in a web dashboard with charts, streak tracking, per-site rules, and named presets (&#8221;deep-work&#8221;, &#8220;study&#8221;, &#8220;morning&#8221;). Both days build on the same core insight: <strong>you can edit your machine&#8217;s hosts file to make specific domains unreachable at the DNS level</strong>, and that&#8217;s the same technique advertisers, security researchers, and Pi-hole users have been using for years. We&#8217;re just wrapping it in a timer.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>Day 1:</strong> CLI focus timer with real website blocking <strong>(Today)</strong></p><p>When you run the script with a number of minutes, it starts a live countdown right in your terminal. Big centered time, subtle context lines beneath, and behind the scenes your hosts file is now telling every DNS lookup for Reddit, Twitter, YouTube, TikTok, Facebook, Instagram, and Hacker News to go to <code>127.0.0.1</code>. Where nothing is listening. So the browser gives up:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!J5m1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 424w, /__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 848w, /__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 1272w, /__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!J5m1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png" width="1348" height="564" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:564,&quot;width&quot;:1348,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:162988,&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/212764278?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 424w, /__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 848w, /__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.png 1272w, /__u/substackcdn.com/image/fetch/$s_!J5m1!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6eee5188-77a3-41df-af43-50e7aceebe5f_1348x564.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></p><p>Try to open one of the blocked sites during the session and you get this &#8212; the browser genuinely cannot reach it, and no &#8220;unblock&#8221; button anywhere will change that until the timer ends:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!i9VH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 424w, /__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 848w, /__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 1272w, /__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!i9VH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png" width="1456" height="1035" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1035,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:309941,&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/212764278?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 424w, /__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 848w, /__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.png 1272w, /__u/substackcdn.com/image/fetch/$s_!i9VH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3d6e6079-6cdb-401f-a0de-0f985b3f7de4_1488x1058.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><p>The moment the timer hits zero &#8212; or you Ctrl+C early, or the script crashes, or your laptop shuts down and reboots &#8212; the block is removed automatically. Sites come back. No manual cleanup required.</p><p><strong>Day 2:</strong> Web dashboard with charts, streaks, and named presets</p><p>The same blocking engine, wrapped in a web UI you can pin as a browser tab. Named presets (&#8221;deep-work 90 min&#8221;, &#8220;pomodoro 25/5&#8221;, &#8220;study 45 min&#8221;) save your favorite combos of duration + blocklist. Per-site rules let you allow LinkedIn during work hours but block it after 6pm, or unblock YouTube only for tutorials on a whitelist of channels. A dashboard on the front page shows your focus time today, this week, and this month, plus your longest streak of consecutive days with a focus session:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!fu4T!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 424w, /__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 848w, /__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 1272w, /__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!fu4T!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png" width="1456" height="918" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:918,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:341731,&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/212764278?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 424w, /__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 848w, /__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.png 1272w, /__u/substackcdn.com/image/fetch/$s_!fu4T!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76ad9593-6c49-4b04-b08d-f3d70c7bc506_2134x1346.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>Users can set a focus session by clicking on of the items on the dashboard:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!na_G!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 424w, /__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 848w, /__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 1272w, /__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!na_G!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png" width="1456" height="918" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:918,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:316548,&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/212764278?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 424w, /__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 848w, /__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.png 1272w, /__u/substackcdn.com/image/fetch/$s_!na_G!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5948b0fb-2c3d-4ec9-b3dd-b1f8e766b771_2120x1336.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>Break enforcement, too &#8212; after a completed focus session, the app enforces a mandatory break window where refocusing is disabled. Because the way you burn out isn&#8217;t by focusing too hard, it&#8217;s by never letting your brain rest between sprints.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-32">View All Projects This Week</a></p><h2>About the stack</h2><p><strong>Zero external dependencies.</strong> No <code>pip install</code> step. Just save the file and run it. That&#8217;s a deliberate design choice for system-level tools like this &#8212; every extra dependency is another thing that could break in six months when the maintainer decides to change an API, or when Python 3.15 ships and something isn&#8217;t ready. For a tool you want to still work in five years, stdlib-only is a real feature.</p><p><strong>argparse</strong> parses the command-line arguments. Standard library, has been in Python since 3.2, will work forever. Handles <code>--add</code>, <code>--list-sites</code>, <code>--unblock</code>, and the positional <code>minutes</code> argument.</p><p><strong>pathlib</strong> manages paths to the hosts file. Cross-platform out of the box &#8212; <code>/etc/hosts</code> on macOS and Linux, <code>C:\Windows\System32\drivers\etc\hosts</code> on Windows.</p><p><strong>subprocess</strong> runs the DNS-cache flush commands (<code>dscacheutil</code> on macOS, <code>resolvectl</code> on Linux, <code>ipconfig /flushdns</code> on Windows). Same standard library, best-effort &#8212; if the flush command doesn&#8217;t exist, the block still works, just with a few seconds of stale cache before it kicks in.</p><p><strong>signal</strong> and <strong>atexit</strong> are the cleanup safety net. Signal handles Ctrl+C and SIGTERM gracefully. Atexit runs on any process exit, including unhandled exceptions &#8212; so even if the script crashes with a bug, your hosts file gets cleaned up before Python shuts down. Data integrity is critical here: the failure mode of &#8220;sites stay blocked forever because we crashed&#8221; would be a genuinely bad bug.</p><p><strong>ANSI escape codes</strong> for the terminal UI &#8212; colors, cursor positioning, in-place redraws. All modern terminals on macOS, Linux, and Windows Terminal support them natively. Old Windows CMD shows raw codes but the tool still functions.</p><h2>Setup Instructions</h2><p>No installation. Save the script as <code>focus.py</code> and run:</p><pre><code><code>sudo python focus.py 25                      # 25-minute focus session
sudo python focus.py 90 --add twitch.tv      # add extra site to block
sudo python focus.py --unblock               # emergency unblock
python focus.py --list-sites                 # show default blocklist (no sudo)
</code></code></pre><p>On Windows, right-click your terminal (or PowerShell) and pick &#8220;Run as Administrator&#8221; instead of using <code>sudo</code>. Same script, same commands, just the elevation mechanism is different.</p><p>The reason for <code>sudo</code>: editing <code>/etc/hosts</code> requires root, and there&#8217;s no clean way around that. It&#8217;s the whole point &#8212; if any random program could edit your hosts file, malware would use it constantly. The permission gate is a security feature, not a bug.</p><h2>Understanding the hosts File</h2><p>This whole project rests on one piece of infrastructure everyone has and almost nobody thinks about &#8212; the <strong>hosts file</strong>.</p><p>Your operating system resolves domain names through a chain. When your browser wants <code>reddit.com</code>, the OS asks &#8220;who is reddit.com?&#8221; and the first place it checks is a plain text file. On macOS and Linux that file lives at <code>/etc/hosts</code>. On Windows it&#8217;s at <code>C:\Windows\System32\drivers\etc\hosts</code>. The file has been there since roughly forever &#8212; it predates DNS by about a decade.</p><p>The format is dead simple: one line per entry, IP address then hostname:</p><pre><code><code>127.0.0.1 localhost
::1 localhost
127.0.0.1 reddit.com
</code></code></pre><p>The OS reads this file every time it needs to resolve a name, and if the name matches something in the file, it uses that IP and never asks a real DNS server. So <code>127.0.0.1 reddit.com</code> means &#8220;when anything on this machine tries to reach reddit.com, send it to 127.0.0.1 instead.&#8221; <code>127.0.0.1</code> is your own machine, where nothing is listening for reddit&#8217;s traffic, so the browser gets connection refused and shows &#8220;This site can&#8217;t be reached.&#8221;</p><p>This isn&#8217;t a hack. It&#8217;s how the system is designed to work. Advertisers use hosts-file entries to block ad servers. Pi-hole uses network-level DNS blocking based on the same idea. Malware researchers block command-and-control servers this way. We&#8217;re just using it for focus.</p><p>The only catch: <strong>editing the hosts file requires root/admin privileges.</strong> That&#8217;s why the tool needs <code>sudo</code>. It&#8217;s not asking for those privileges to snoop on your system &#8212; it&#8217;s asking for the exact minimum permission needed to add a few lines to one specific file.</p><h2>Understanding Marker-Based Edits</h2><p>Never, ever, ever overwrite the whole hosts file. Your system has important entries in there &#8212; <code>localhost</code>, IPv6 mappings, entries added by Docker, VPN clients, corporate tools. Wiping them and rewriting from scratch would break things you didn&#8217;t know you had.</p><p>The right pattern is <strong>marker-based editing</strong>: wrap our block section in commented markers, and on removal only touch lines between those markers:</p><pre><code><code>BEGIN_MARKER = "# BEGIN focustimer -- do not edit; managed by focus.py"
END_MARKER = "# END focustimer"

def add_block_entries(hosts_file, sites):
    remove_block_entries(hosts_file)   # idempotent: clear old block first
    existing = hosts_file.read_text() if hosts_file.exists() else ""
    if existing and not existing.endswith("\n"):
        existing += "\n"

    block = [BEGIN_MARKER]
    for site in sites:
        block.append(f"127.0.0.1 {site}")
    block.append(END_MARKER)
    block.append("")

    hosts_file.write_text(existing + "\n".join(block))
</code></code></pre><p>Removal is symmetric &#8212; walk the file line by line, and while we&#8217;re between our markers, drop those lines. Everything else stays exactly where it was:</p><pre><code><code>def remove_block_entries(hosts_file):
    if not hosts_file.exists():
        return False
    content = hosts_file.read_text()
    if BEGIN_MARKER not in content:
        return False

    lines = content.splitlines(keepends=True)
    out_lines = []
    inside_block = False
    for line in lines:
        stripped = line.rstrip("\n\r")
        if stripped == BEGIN_MARKER:
            inside_block = True
            continue
        if stripped == END_MARKER:
            inside_block = False
            continue
        if not inside_block:
            out_lines.append(line)

    hosts_file.write_text("".join(out_lines).rstrip() + "\n")
    return True
</code></code></pre><p>Two properties this gives us for free: the function is <strong>idempotent</strong> (calling it twice is safe &#8212; the second call is a no-op), and it&#8217;s <strong>crash-tolerant</strong> (if a previous session died without cleanup, the next run&#8217;s <code>add_block_entries</code> sees the old markers, removes them, and writes fresh ones).</p><p>We tested this by writing a hosts file with several unrelated entries, running the full add/remove cycle, and confirming the file came back identical to how it started. Round-trip preserving.</p><h2>Understanding Cleanup Safety</h2><p>The single most important behavior of this tool is: <strong>at the end of every possible execution path, the hosts file must be clean.</strong></p><p>If Ctrl+C is pressed, sites must unblock. If the process crashes with an unhandled exception, sites must unblock. If the timer completes normally, sites must unblock. There&#8217;s no acceptable outcome where the user&#8217;s hosts file gets left blocked forever because a script died mid-session.</p><p>The way we get all three is a two-layer defense &#8212; signal handlers plus <code>atexit</code>:</p><pre><code><code>def cleanup():
    if remove_block_entries(hosts_file):
        flush_dns()

atexit.register(cleanup)

def _sig_handler(signum, frame):
    print(f"Session interrupted. All sites unblocked.")
    sys.exit(128 + signum)

signal.signal(signal.SIGINT, _sig_handler)
signal.signal(signal.SIGTERM, _sig_handler)
</code></code></pre><p><code>atexit.register</code> runs the callback whenever the Python interpreter exits normally &#8212; including when an unhandled exception propagates out of <code>main</code>. So if we hit a bug during the countdown, the exception unwinds, Python starts shutting down, and atexit runs <code>cleanup</code> before actually terminating. Hosts file gets restored.</p><p><strong>Signal handlers</strong> for SIGINT (Ctrl+C) and SIGTERM (the polite shutdown signal) convert the signal into a normal <code>sys.exit</code>. Because <code>sys.exit</code> triggers <code>atexit</code>, the cleanup runs.</p><p>The one case this doesn&#8217;t cover is SIGKILL (<code>kill -9</code>) or a power outage &#8212; no software can clean up when the OS kills you instantly. That&#8217;s what <code>--unblock</code> is for. If you find yourself unable to reach Reddit and don&#8217;t remember running a focus session, run <code>sudo python focus.py --unblock</code> and you&#8217;re back to normal.</p><h2>Understanding DNS Cache Flushing</h2><p>Editing the hosts file changes the source of truth immediately, but your OS may have cached the old resolution for a domain you&#8217;re blocking. Result: you edit the file, then your browser still loads Reddit for a few seconds via the cached DNS entry.</p><p>The fix is flushing the DNS cache after every hosts change:</p><pre><code><code>def flush_dns():
    system = platform.system()
    try:
        if system == "Darwin":
            subprocess.run(["dscacheutil", "-flushcache"], check=False, capture_output=True)
            subprocess.run(["killall", "-HUP", "mDNSResponder"], check=False, capture_output=True)
        elif system == "Linux":
            r = subprocess.run(["resolvectl", "flush-caches"], check=False, capture_output=True)
            if r.returncode != 0:
                subprocess.run(["systemd-resolve", "--flush-caches"], check=False, capture_output=True)
        elif system == "Windows":
            subprocess.run(["ipconfig", "/flushdns"], check=False, capture_output=True, shell=True)
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
</code></code></pre><p>Every platform has its own command. macOS needs two calls &#8212; one to flush the userspace cache and one to signal the mDNS resolver to reload. Linux switched from <code>systemd-resolve</code> to <code>resolvectl</code> a few years ago so we try both. Windows has had <code>ipconfig /flushdns</code> since Windows 2000 and it just works.</p><p>The <code>capture_output=True</code> and <code>check=False</code> mean the calls are best-effort &#8212; if the flush command isn&#8217;t available or fails, the block still works, just with a few seconds of stale cache before the browser catches up. The tool never crashes over a failed flush.</p><p>Note on browsers: Chrome specifically has its <strong>own</strong> DNS cache separate from the OS. If you find Reddit still loading after starting a session, open <code>chrome://net-internals/#dns</code> and click &#8220;Clear host cache&#8221;. Or just close and reopen the browser. Firefox and Safari respect the OS cache and don&#8217;t have this issue.</p><h2>Understanding the Live Countdown</h2><p>The countdown UI is pure ANSI escape codes &#8212; no <code>rich</code>, no <code>blessed</code>, no external terminal library:</p><pre><code><code>class LiveDisplay:
    def __init__(self):
        self.prev_line_count = 0
        sys.stdout.write("\033[?25l")   # hide cursor
        sys.stdout.flush()

    def render(self, lines):
        if self.prev_line_count &gt; 0:
            sys.stdout.write(f"\033[{self.prev_line_count}A")   # cursor up N
        for line in lines:
            sys.stdout.write("\033[K" + line + "\n")   # clear line, write, newline
        sys.stdout.flush()
        self.prev_line_count = len(lines)
</code></code></pre><p>Every call to <code>render</code> moves the cursor up by the height of the previous frame, then rewrites each line with a leading <code>\033[K</code> (clear-to-end-of-line). Result: smooth in-place redraw, no flicker, no scrollback pollution.</p><p>The countdown loop itself uses <code>time.monotonic()</code> &#8212; not <code>time.time()</code> &#8212; because monotonic time doesn&#8217;t jump if the system clock is adjusted mid-session (NTP sync, timezone change, whatever). Your 25-minute session lasts 25 minutes even if your laptop syncs its clock during it:</p><pre><code><code>start = time.monotonic()
while True:
    elapsed = time.monotonic() - start
    remaining = duration_seconds - elapsed
    if remaining &lt;= 0: break
    display.render(build_countdown_view(int(round(remaining)), ...))
    time.sleep(max(0.05, 1 - (time.monotonic() - start) % 1))
</code></code></pre><p>That last line is a subtle trick: instead of sleeping exactly 1 second per iteration (which would drift over long sessions because computation takes time), we sleep to the next whole-second boundary. Over an hour, drift stays under a second.</p><h2>Practical Use Cases</h2><p><strong>Deep-work sprints for anyone who works from home.</strong> Two 90-minute sessions in the morning, protected from Reddit and Twitter, is a completely different day from six hours of shallow work with distractions. Especially for programmers, writers, students, anyone whose job requires sustained attention.</p><p><strong>Kids doing homework without infinite YouTube tabs.</strong> Parents can start a 45-minute session with an expanded blocklist before homework time. When it ends, YouTube comes back. Beats standing over their shoulder.</p><p><strong>Exam prep and studying.</strong> The Pomodoro technique combined with real enforcement. Most students who try Pomodoro give up in a week because &#8220;just 5 minutes of Reddit&#8221; turns into 40. Real blocking removes the option.</p><p><strong>Recovering from a Twitter/TikTok habit.</strong> Not with a permanent block that feels punishing &#8212; with time-boxed sessions that build a habit of <em>not</em> checking. Twenty-five minutes six times a day teaches your brain &#8220;you can go 25 minutes without seeing what&#8217;s trending.&#8221;</p><p><strong>Foundation for Day 2.</strong> Every function we built today &#8212; <code>add_block_entries</code>, <code>remove_block_entries</code>, <code>flush_dns</code>, the cleanup safety net &#8212; gets reused tomorrow. Day 2 is a web UI wrapping the same engine, with persistent stats and preset management. That&#8217;s the pedagogical arc of the week: minimal working thing today, complete production-quality tool tomorrow.</p><h2>Coming Wednesday</h2><p>Tomorrow we wrap this engine in a proper web dashboard &#8212; named session presets (&#8221;deep-work 90 min&#8221;, &#8220;pomodoro 25/5&#8221;, &#8220;study 45 min&#8221;), per-site rules with time-of-day windows, a stats page showing your focus time this week and your streak of consecutive days with sessions, and mandatory break enforcement so you don&#8217;t burn out chaining focus sprints.</p><p>If you want the full tool, <a href="/__u/dailypythonprojects.substack.com/subscribe">upgrade here</a> before Wednesday.</p><h2>Solution</h2><p>Below you will find the downloadable solution.py file containing the correct solution.</p><p>Get the code here:</p><p></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/EaLhPghyBDERD5E7MHDrmA&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/EaLhPghyBDERD5E7MHDrmA"><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[Why your script dies when you close the laptop]]></title><description><![CDATA[Your script was running fine. You closed the laptop. Now it&#8217;s gone. Here is what actually happens in that moment, and the three ways to stop it.]]></description><link>https://dailypythonprojects.substack.com/p/why-your-script-dies-when-you-close</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/why-your-script-dies-when-you-close</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 20 Aug 2026 11:48:15 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lbgA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this post, you will learn what actually happens to a running script when you close your laptop or lose your connection to a server, and the three ways to keep it alive on purpose. This is one of those things every beginner discovers by accident, usually at the worst possible time.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!lbgA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!lbgA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png" width="1456" height="558" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/376ebd77-753f-462a-a6e4-61082fabf5c3_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;:59742,&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/211990156?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!lbgA!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376ebd77-753f-462a-a6e4-61082fabf5c3_2400x920.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></p><p>Picture this. You start a script that scrapes 900 pages. It is going to take an hour, so you close the laptop and go make coffee. When you come back, the script is gone. Not paused, not resumed. Gone, with maybe forty pages scraped instead of nine hundred.</p><h2>What actually happens when you close the lid</h2><p>Here is the part almost nobody explains. Your script has no idea what a laptop is. It does not know about lids, or WiFi, or coffee breaks. What it does understand is signals, small messages the operating system sends to processes. And when your terminal session ends, the OS sends every process under it a specific signal called SIGHUP, which literally stands for &#8220;hangup&#8221;, a name left over from the days terminals were connected by phone lines.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!egTN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!egTN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png" width="1456" height="631" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:631,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:105488,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/211990156?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!egTN!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F77789525-5fa1-4996-b343-bbd10cc9a508_2400x1040.png 1456w" sizes="100vw"></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>By default, a process that receives SIGHUP simply exits. So your script was not killed by your laptop. It was killed by a decades-old convention, faithfully followed by your operating system, because nobody told your script to ignore it.</p><h2>The fix: tell it to ignore the signal</h2><p>The simplest fix is a command called <code>nohup</code>, and the name tells you exactly what it does: no hangup.</p><pre><code><code>nohup python3 script.py &amp;</code></code></pre><p>I actually ran this on a small test script that logs a line every couple of seconds. Here is the real terminal output:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!pBAH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!pBAH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png" width="1456" height="631" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/be294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:631,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:85423,&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/211990156?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!pBAH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbe294a0f-af5a-48fb-bc57-e8d846bb8daf_2400x1040.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>Notice the process ID printed right after starting, <code>648</code> in this case. That number is how you would check on it or stop it later. And notice that even after the session ends, the log keeps growing, because the process ignored the signal that would normally have ended it. <code>nohup</code> also redirects anything your script would print into a file called <code>nohup.out</code>, so you do not lose your output either.</p><h2>nohup does not stop your laptop from sleeping</h2><p>Here is a question worth asking before you trust <code>nohup</code> completely: does it survive your laptop actually going to sleep, not just the terminal closing? The honest answer is no, and it is a different problem entirely.</p><p>Closing your terminal sends a signal, SIGHUP, which <code>nohup</code> tells your script to ignore. Sleep is not a signal at all. When your laptop sleeps, the whole operating system pauses, your script included, mid-instruction, exactly where it was. Nothing crashes. Time simply stops passing for your script until the laptop wakes up again, at which point it continues from that exact line as if no time had gone by.</p><p>That sounds harmless, and for a pure calculation it usually is. But most scripts worth leaving running involve a network connection, and that is where sleep quietly breaks things. Your WiFi disconnects during sleep, and when the laptop wakes, your script tries to keep talking to a connection that no longer exists. You will not see a dramatic crash. You will see it die on a random request twenty minutes after you opened the laptop again, and it will look like a completely unrelated bug.</p><p>So the honest rule is this: <code>nohup</code> protects you from closing the terminal, not from closing the laptop. If your script needs to survive actual sleep, either turn sleep off for that session, a checkbox in every operating system&#8217;s power settings, or accept that you are really back to the same answer as before: put the script on a server that never sleeps in the first place.</p><h2>Two more tools worth knowing</h2><p><code>nohup</code> is the boring, always-available option, and I use it constantly for that exact reason. But there are two more tools worth having in your pocket, depending on the situation.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!hKso!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!hKso!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f3702e47-91e2-46f9-b13b-95dc491f7503_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;:133353,&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/211990156?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!hKso!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3702e47-91e2-46f9-b13b-95dc491f7503_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></p><p><code>tmux</code> creates a session you can detach from and reattach to later, from the same machine or a different one entirely. Instead of just letting a script survive in the background, you can walk away and come back to literally the same terminal, scrollback and all, as if you never left. It takes a bit longer to learn than <code>nohup</code>, but if you are the kind of person who likes checking on things, it is worth the twenty minutes.</p><p>And then there is the real answer for anything that truly needs to run forever: stop running it from your laptop at all. Deploy it to a server, a computer that stays on by design, and let a process manager restart it automatically if it ever crashes. Your laptop was never meant to be the thing holding your app up, and once you deploy, this entire problem disappears, because there is no lid to close.</p><h2>A quick word on Mac, Linux, and Windows</h2><p>Everything above assumes Mac or Linux, and the good news is that assumption barely matters, because both are built on Unix underneath. <code>nohup</code>, <code>tmux</code>, and SIGHUP itself behave identically on both. If you develop on a Mac, the exact commands in this post already work on your machine.</p><p>Windows is the exception, and it is a real one, not a small syntax difference. Windows does not use SIGHUP at all, closing a window sends a completely different kind of signal, and neither <code>nohup</code> nor <code>tmux</code> exist natively there. My honest advice, and what I use myself when I have to touch Windows, is to install <a href="https://learn.microsoft.com/en-us/windows/wsl/install">WSL, the Windows Subsystem for Linux</a>. It gives you a real Linux environment inside Windows, and once you are inside it, every command in this post works exactly as written. Trying to find native PowerShell equivalents for all of this is possible, but it is a rabbit hole not worth the time when WSL gets you the real thing in ten minutes.</p><h2>Which one for you</h2><p>If a script just needs to survive one afternoon while your terminal closes but the laptop stays awake, <code>nohup</code> is enough, and it costs you nothing to learn. If you find yourself wanting to check progress and jump back in, <code>tmux</code> earns its place, though it has the same blind spot as <code>nohup</code> the moment the laptop itself sleeps. And the moment something needs to run every single day, laptop open, asleep, or on the other side of the room, that is your sign it belongs on a server, not on your desk.</p><p>I used to just leave my laptop open and pray, for years, before I had a name for any of this. If that is you right now, you are in good company, and at least now you have three better options.</p><p>If you learned something from this today, consider upgrading for the full experience: every weekly project with full source code, plus the complete archive. Since you read this far, I&#8217;d like to offer you 35% off the annual plan as a token of appreciation. Click below to get the discount:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?coupon=3ffabf6b&quot;,&quot;text&quot;:&quot;Get 20% off: $99 &#8594; $79/year&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="/__u/dailypythonprojects.substack.com/subscribe?coupon=3ffabf6b"><span>Get 20% off: $99 &#8594; $79/year</span></a></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build a Real Estate Listings Website with Flask: Day 2 - Everyone can post listings]]></title><description><![CDATA[Yesterday, only the owner could publish a listing. Today, anyone can submit one, but nothing goes live until it&#8217;s approved. Here&#8217;s how that changes everything underneath.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-real-estate-listings-website-163</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-real-estate-listings-website-163</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 19 Aug 2026 09:56:43 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/33174ff4-5865-455e-befc-84af1094d1ce_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Projects in this week&#8217;s series</h2><p><strong>Yesterday</strong>, we built the smallest version of a real estate site that actually works: one owner, one password, one door. Anyone could browse, only the owner could publish.</p><p><strong>Today</strong>, we open the doors. Any property owner can create an account and submit their own listings, complete with a photo. But nothing appears on the public site the moment it is submitted. It waits, quietly, until the site owner looks at it and decides.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!fR6O!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 424w, /__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 848w, /__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 1272w, /__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!fR6O!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png" width="1456" height="1347" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1347,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:524768,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 424w, /__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 848w, /__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.png 1272w, /__u/substackcdn.com/image/fetch/$s_!fR6O!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab1d3828-8e18-4473-b19f-40e2b57f1bb2_1920x1776.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><a href="/__u/dailypythonprojects.substack.com/t/week-31">View All Projects This Week</a></p><h2>Why &#8220;anyone can submit&#8221; is a different problem entirely</h2><p>Yesterday&#8217;s whole security model was one sentence: only the owner has the password. That sentence stops working the moment a second person needs to publish something, because now you need a way to let people in without handing them the keys to everything.</p><p>The answer is a queue. Anyone can submit a listing, but a submission is not the same thing as a published listing. It sits in between, in a state we call pending, until a human being with the authority to decide looks at it.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!siOH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 424w, /__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 848w, /__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 1272w, /__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!siOH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp&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;:17542,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 424w, /__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 848w, /__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 1272w, /__u/substackcdn.com/image/fetch/$s_!siOH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9cc2b2ba-40ba-40c1-b5cf-cf2ba2f99d9c_1456x582.webp 1456w" sizes="100vw"></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>This pattern is everywhere once you notice it. Every app store review, every YouTube comment section with moderation, every marketplace where sellers list products, all of them run on the same idea: letting people contribute does not mean letting their contribution appear instantly.</p><h2>Telling owners apart from everyone else</h2><p>Yesterday there was one account. Today there is a table of accounts, and each one carries a single flag: is this person the site owner, or just one of the property owners? That one flag decides everything about what happens after someone submits a listing.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!32Y0!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 424w, /__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 848w, /__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 1272w, /__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!32Y0!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png" width="1456" height="704" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:704,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:107862,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 424w, /__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 848w, /__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.png 1272w, /__u/substackcdn.com/image/fetch/$s_!32Y0!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46b62d2a-ac28-4d1b-ace8-4b033ff3d45f_2400x1160.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>If the site owner submits a listing, it can go live immediately, because they already have full authority. If anyone else submits one, it lands in the pending pile instead, and stays invisible to regular visitors until it gets approved. The homepage itself never had to get more complicated to make this work. It simply only ever asks the database for listings marked approved, so a pending one is not hidden by some special rule, it just never matches the question being asked.</p><h2></h2><h2>Seeing today&#8217;s feature actually work</h2><p>I ran the whole flow for real before writing any of this: a property owner signs up, logs in, and submits a listing with a photo. It does not appear on the public homepage. The site owner logs in, sees it waiting in a pending list, and approves it. Only then does it appear.</p><p>So the flow is:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!xra1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!xra1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0ca020ac-5657-43da-88d1-b30fc49ec105_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;:83927,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!xra1!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ca020ac-5657-43da-88d1-b30fc49ec105_2400x960.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>A property owner or agent can sign up for an account:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!BySo!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 424w, /__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 848w, /__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 1272w, /__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!BySo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png" width="1298" height="876" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:876,&quot;width&quot;:1298,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:214923,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 424w, /__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 848w, /__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.png 1272w, /__u/substackcdn.com/image/fetch/$s_!BySo!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F003f5f30-b8f8-4273-8f73-31bbc12895fb_1298x876.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>And they can add a listing:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!0N9X!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 424w, /__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 848w, /__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 1272w, /__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!0N9X!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png" width="1456" height="1367" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/cce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1367,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:400586,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 424w, /__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 848w, /__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.png 1272w, /__u/substackcdn.com/image/fetch/$s_!0N9X!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcce1d30f-ca2f-4a81-acc5-12957cf628bb_1596x1498.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 admin of the website can see the listings added by owners/agents and approve them:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!dDtE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 424w, /__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 848w, /__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!dDtE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png" width="1456" height="819" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:819,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:384235,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 424w, /__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 848w, /__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dDtE!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F456421a0-d55d-4da7-9e5d-a8559b6266bc_1912x1076.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>Once the website admin approves, the listing shows on the website:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!YCCn!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 424w, /__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 848w, /__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 1272w, /__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!YCCn!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png" width="1456" height="1740" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/af6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1740,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:896389,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 424w, /__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 848w, /__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.png 1272w, /__u/substackcdn.com/image/fetch/$s_!YCCn!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Faf6dcf2a-69ac-42fb-babd-ea40fa527e42_1590x1900.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>What this version can do that yesterday&#8217;s could not</h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!L_fl!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!L_fl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png" width="1456" height="655" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2efcc390-f9b5-41ac-84af-8ea4049f19fc_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;:127580,&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/211725242?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!L_fl!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2efcc390-f9b5-41ac-84af-8ea4049f19fc_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><p></p><h2>Source Code and AI Prompt to create a similar professional real estate web app</h2><p></p><p>The complete, working code for today&#8217;s exact version, all six files including the upload handling, is ready below.</p><p></p>
      <p>
          <a href="/__u/dailypythonprojects.substack.com/p/build-a-real-estate-listings-website-163">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a Real Estate Listings Website with Flask: Day 1 - The MVP]]></title><description><![CDATA[Today we build a working website with Python where the admin can add new real estate listings.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-real-estate-listings-website</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-real-estate-listings-website</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 18 Aug 2026 18:02:06 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/3c578940-e270-49e8-945c-49fa98fad2e4_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Picture a small independent real estate agent. Right now, every property they own gets listed the same way: a spreadsheet, maybe a folder of photos, and a lot of manual work whenever someone wants to see what&#8217;s available. What they actually need is a website. Nothing fancy, just a page where people can browse the properties, and a way for the agent to add a new one whenever a house or apartment comes up for sale.</p><p>That is what we are building this week. And the interesting part is not the website itself, it is the question sitting underneath it: who gets to add a listing, and how do you make sure it stays that way?</p><h2>Projects in this week&#8217;s series</h2><p><strong>Today</strong>, we build the smallest version that actually works. There is exactly one person who can publish a listing: the site owner. Everyone else can look, nobody else can touch.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!b2Rc!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 424w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 848w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 1272w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!b2Rc!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png" width="1456" height="1347" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1347,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:524768,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 424w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 848w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 1272w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.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></p><p><strong>Tomorrow</strong>, paid subscribers get the &#8220;grown-up&#8221; version. Other property owners get their own accounts and can submit listings themselves, but nothing appears on the site until the owner reviews and approves it. We will also look at the specific ways this kind of feature quietly breaks when it is built carelessly, and what it takes to run it for real.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!s86M!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!s86M!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/42eedc66-cc19-41de-ad88-643800adc68d_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;:39723,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!s86M!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42eedc66-cc19-41de-ad88-643800adc68d_2400x960.png 1456w" sizes="100vw"></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><em>Tomorrow&#8217;s project is already posted. Get instant access by upgrading to a paid monthly or annual plan.</em></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe&quot;,&quot;text&quot;:&quot;Unlock Day 2&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="/__u/dailypythonprojects.substack.com/subscribe"><span>Unlock Day 2</span></a></p><p><em>With a paid plan you can:</em></p><ul><li><p><em>Read Day 2 right now instead of waiting until tomorrow</em></p></li><li><p><em>Access to full code of Day 1 and Day 2 project</em></p></li><li><p><em>Access to the full archive of 500+ projects</em></p></li></ul><p></p><h2>The shape of the problem</h2><p>Every listing, once it exists, is really just a few facts: a title, a location, a price, and a description. Simple enough to hold in a single table. The harder question is not what a listing looks like, it is what stops a random visitor from adding fifty fake ones.</p><p>So today&#8217;s whole project comes down to one line of thinking: browsing is public, publishing is not.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!n0PJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 424w, /__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 848w, /__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 1272w, /__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!n0PJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png" width="1456" height="607" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f5276be9-9b1d-4fab-927e-338c1a218f50_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;:93713,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 424w, /__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 848w, /__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_2400x1000.png 1272w, /__u/substackcdn.com/image/fetch/$s_!n0PJ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5276be9-9b1d-4fab-927e-338c1a218f50_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>Any visitor can open the site and see every property, no account needed. But the moment someone tries to add a new one, they hit a locked door, and only the owner has the key. For now, that key is a single password. It is not fancy, and it does not need to be, because there is only one person who should ever hold it.</p><h2>Flask and SQLite, in plain terms</h2><p>If every Python program you have written so far runs once, prints something, and stops, a website might feel like a different universe. It really is not one, and it is worth slowing down on this for a moment, because the rest of the post assumes it.</p><p>A website has to stay running and keep listening, so that the instant someone visits a page, something responds. Flask is a small Python library whose only job is exactly that. You write ordinary Python functions, and Flask makes sure the right one runs the moment someone opens a particular page in their browser. The homepage, the login page, the &#8220;add listing&#8221; form: each one is just a URL with a Python function waiting behind it.</p><p>That handles behavior, but behavior alone forgets everything the second the program restarts, and a listings site obviously cannot forget its listings between visits. That is what SQLite is for. It is a genuine database, yet it lives as a single file sitting right next to your code, nothing separate to install. Every listing gets written there, and it is still there tomorrow, next week, whenever the site starts back up again.</p><p>If you read the post on frontend versus backend a few weeks ago, here is where this project sits on that map: entirely on the backend side. There is no separate frontend framework here. Flask builds plain HTML pages and sends them straight to the browser, and for a site like this one, that is genuinely enough.</p><h2>How the lock actually works</h2><p>Here is the part worth understanding, even if you never open the code. When the owner visits the site and logs in, the server remembers them for as long as their browser stays open, using something called a session. From that point on, every request they make quietly carries proof that they are logged in.</p><p>Anyone else, someone who never logged in, gets redirected the instant they try to reach the &#8220;add listing&#8221; page. They never even see the form.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!55oQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!55oQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/27684801-b29f-43b4-8e38-7c3b1132d78a_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;:108313,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!55oQ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27684801-b29f-43b4-8e38-7c3b1132d78a_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>Notice something about that flow: the check happens before anything else runs. The server does not build the page and then decide whether to show it. It decides first, and only builds the page if the answer is yes. That order matters more than it looks, because it means there is no version of the page that briefly exists for someone who should not see it.</p><p>One more detail, small but worth knowing. The owner&#8217;s password is never stored as plain text anywhere, not even in the one-account version we are building today. It is stored as a hash, a kind of one-way scramble that can confirm a password is correct without ever revealing what it is. It is a habit worth having from the very first line of code, because it is exactly the same habit that will protect real users tomorrow.</p><h2>Seeing it run</h2><p>First install the needed Python libraries with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;fba31cb2-f7c1-4c61-a0a3-e4aab10bc81a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">pip install flask</code></pre></div><p>Starting the site is one command, and a moment later it is live on your own machine.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!nU15!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!nU15!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/43759457-dfdc-4050-abaa-45c777f7fa9c_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;:85406,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!nU15!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F43759457-dfdc-4050-abaa-45c777f7fa9c_2400x960.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>Open it in a browser and you get a clean homepage listing whatever properties exist so far:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!b2Rc!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 424w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 848w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 1272w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!b2Rc!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png" width="1456" height="1347" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1347,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:524768,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 424w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 848w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.png 1272w, /__u/substackcdn.com/image/fetch/$s_!b2Rc!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb31f0d89-89e7-43d2-bc4e-a10f9813995f_1920x1776.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>Try to sneak into the &#8220;add listing&#8221; page directly, without logging in, and the site quietly sends you to the login screen instead. Exactly as promised.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!TwJt!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 424w, /__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 848w, /__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 1272w, /__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!TwJt!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png" width="1456" height="804" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:804,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:197756,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 424w, /__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 848w, /__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.png 1272w, /__u/substackcdn.com/image/fetch/$s_!TwJt!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2cf6112f-4bdb-44d3-99fd-8d9bbf0eeda3_1670x922.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>Log in as the owner, and a simple form appears.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!ucma!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 424w, /__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 848w, /__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 1272w, /__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!ucma!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png" width="1456" height="1281" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1281,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:376745,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 424w, /__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 848w, /__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.png 1272w, /__u/substackcdn.com/image/fetch/$s_!ucma!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F68926a63-5187-4f89-95f1-a33a7c23e2f3_1618x1424.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>Fill it in, publish, and the new property shows up on the homepage immediately, right at the top.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!7CYb!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 424w, /__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 848w, /__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 1272w, /__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!7CYb!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png" width="1456" height="1094" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1094,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:382934,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 424w, /__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 848w, /__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.png 1272w, /__u/substackcdn.com/image/fetch/$s_!7CYb!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88368457-584d-45cb-be6b-71fb1f4a0310_1624x1220.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>That is the whole loop. One person, one password, one door.</p><h2>Coming tomorrow</h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!cRRd!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!cRRd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png" width="1456" height="655" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b3f089f1-8e69-483c-b4eb-1ff3cc392034_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;:127580,&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/211695493?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!cRRd!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb3f089f1-8e69-483c-b4eb-1ff3cc392034_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><p></p><p>Today&#8217;s version is genuinely enough if you are the only person who will ever list a property, which is true for plenty of small agencies and independent landlords. The moment a second person needs to publish their own listings, the whole shape of the problem changes, and that is exactly where tomorrow picks up.</p><p>Tomorrow we add the other half. I<span>f you want the full website, </span><a href="/__u/dailypythonprojects.substack.com/subscribe">upgrade here</a><span> and you can access both projects today.</span></p><h2>Solution</h2><p>The complete, working code for today&#8217;s version is ready for you below, all four files.</p><p>View and download <em>app.py</em> here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/NPL4ShJkk9SRz0SmSXh2AQ&quot;,&quot;text&quot;:&quot;app.py&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://share.pythonanywhere.com/evolution/NPL4ShJkk9SRz0SmSXh2AQ"><span>app.py</span></a></p><p>Download the HTML files here and place them in a folder named <em>templates</em> directory and place the <em>templates folder</em> in the same directory with <em>app.py</em>:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://drive.google.com/file/d/16NBWzrWJH1_1nPPVuletqxaT9hoz5xet/view?usp=sharing&quot;,&quot;text&quot;:&quot;HTML Files&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://drive.google.com/file/d/16NBWzrWJH1_1nPPVuletqxaT9hoz5xet/view?usp=sharing"><span>HTML Files</span></a></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[SQLite, Postgres, or Supabase: the decision AI can’t make for you]]></title><description><![CDATA[AI can write your database code in a minute. It cannot decide where your data should live. Here is the map for that decision.]]></description><link>https://dailypythonprojects.substack.com/p/sqlite-postgres-or-supabase-the-decision</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/sqlite-postgres-or-supabase-the-decision</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 13 Aug 2026 08:43:31 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!Ovnh!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this post, you will learn how to choose a database for your app: SQLite, Postgres running on your machine, or a hosted service like Supabase. We also touch on other databases such as MySQL and why it is less popular than Postgres.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!49Nb!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!49Nb!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png" width="1456" height="558" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/34f5e2e2-555d-4d2a-8310-402fff36ab57_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;:79164,&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/210669670?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!49Nb!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F34f5e2e2-555d-4d2a-8310-402fff36ab57_2400x920.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>Imagine you ask AI to build you a habit tracker. Cursor or Claude produces the whole app in a few minutes, and it works. However, somewhere in those generated files, the AI also made a quiet decision for you: where your data lives. Maybe it picked SQLite. Maybe it wired in Postgres. You nodded along, because the app runs. But that quiet decision determines how you will deploy the app, what happens when your laptop dies, and whether your data can follow you to your phone. The code was AI&#8217;s job. This decision was supposed to be yours.</p><p>So let&#8217;s give you the map. But first, answer this honestly:</p><div class="poll-embed" data-attrs="{&quot;id&quot;:965715}" data-component-name="PollToDOM"></div><p></p><p>If you picked &#8220;honestly, no idea&#8221;, you are in good company, and this post exists exactly for you. Keep your answer in mind, because by the end you will be able to check whether it was right.</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><h2>The three realistic options</h2><p>For a solo builder, there are really three options worth knowing, and they differ mainly in one thing: where the data physically sits.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!Ovnh!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 424w, /__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 848w, /__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!Ovnh!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png" width="1456" height="728" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:728,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:174244,&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/210669670?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 424w, /__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 848w, /__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Ovnh!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4b25cf44-8108-4c24-b030-97ab603a15cb_2400x1200.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><strong>SQLite</strong> is a complete database inside a single file. It comes with Python, so there is nothing to install, no server to run, and no password to configure. This is genuinely all it takes:</p><pre><code><code>import sqlite3

con = sqlite3.connect("app.db")
con.execute("CREATE TABLE habits(name TEXT, done_on TEXT)")
con.execute("INSERT INTO habits VALUES ('stretch', '2026-08-10')")
con.commit()</code></code></pre><p>After running this, there is a file called <code>app.db</code> in your folder, 8 KB in size, holding your data. People will tell you SQLite is a toy. It is not. It runs inside every phone and every browser you own, which makes it one of the most deployed pieces of software in history.</p><p><strong>Local Postgres</strong> is a proper database server running as a separate program on your machine. It is the industry standard, and it is also installation, configuration, users, passwords, and a process that must always be running. It buys you complexity before it buys you anything else, so my honest advice for beginners is to skip it until a real need appears.</p><p><strong>Supabase</strong> is Postgres again, but running on someone else&#8217;s computer, reachable from anywhere. Your data is no longer trapped on one machine: it survives your laptop dying, and ten users on ten devices can all reach it. In exchange, every query travels over the internet, your data lives elsewhere, and you are on a free tier with limits. Take those limits seriously. A few weeks ago, while building the file sharing tool for one of this publication&#8217;s projects, I had three free upload services fail under me in a single afternoon: one started returning JSON errors, its replacement turned out to be disabled for spam, and I finished the day on a third one. Free tiers are real infrastructure until the day they are not.</p><p>At this point you might be wondering about MySQL, because it appears in every tutorial from the last twenty years. MySQL is a server database like Postgres, and it still quietly runs a huge part of the internet, with WordPress alone keeping it very busy. However, among developers starting new projects today, Postgres has clearly won: it has more features, stricter and saner defaults, and it is what modern hosted services like Supabase are built on. So for your decision, MySQL and Postgres occupy the same slot on the map, and when you eventually reach for that slot, my advice is to pick Postgres.</p><h2>One question decides it</h2><p>When people ask me which one to pick, I ask back a single question: who needs to reach this data?</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!HPAM!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!HPAM!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0b08b655-3d53-47ef-8999-b2e41df5f4b6_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;:131484,&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/210669670?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!HPAM!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b08b655-3d53-47ef-8999-b2e41df5f4b6_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></p><p>If the answer is &#8220;just this app, on my machine&#8221;, use SQLite and move on with your life. If the answer is &#8220;just this app, but deployed on a server&#8221;, SQLite still works: the file simply lives on the server next to your code. However, be careful here, because on many hosting platforms the disk gets wiped on every redeploy, and that trap deserves its own post soon. And if the answer is &#8220;many users or devices, from anywhere&#8221;, then the data needs its own home on the internet, and that is when hosted Postgres like Supabase earns its place.</p><p>Notice that local Postgres did not appear in any starting answer. That is not an accident.</p><h2>Building with AI changes one thing</h2><p>Here is the practical part. When you vibe-code an app, tell the AI which database to use, explicitly, in the first prompt. Something like: &#8220;Build me a habit tracker. Use SQLite, one file named app.db.&#8221; If you do not say it, the AI will pick for you, and you will inherit a decision you never made.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!Z2pP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!Z2pP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png" width="1456" height="631" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:631,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:110238,&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/210669670?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Z2pP!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F922f4e66-1fe0-4dc7-9640-6a4971843dd0_2400x1040.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>Then, after it builds the app, do one small thing: find the data file and open its folder. Knowing where your data physically is sounds trivial, yet it is the difference between people who recover from disasters and people who lose everything.</p><p>The good news is that switching databases later is cheaper than it has ever been, because AI can rewrite the code layer in minutes. Moving the data itself, however, is still your problem. So start simple, start with SQLite, and treat moving up as a milestone your app earned, not a mistake you made at the beginning.</p><p>Before you go, one small test of today&#8217;s map. Imagine you are building a recipe app, just for yourself, but you want to use it on both your phone and your laptop. Which database do you pick?</p><div class="poll-embed" data-attrs="{&quot;id&quot;:965726}" data-component-name="PollToDOM"></div><p></p><p>Think about the one question from the decision map before you answer. </p><p>If you learned something from this today, consider upgrading for the full experience: every weekly project with full source code, plus the complete archive. Since you read this far, I&#8217;d like to offer you 35% off the annual plan as a token of appreciation. Click below to get the discount:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/3ffabf6b&quot;,&quot;text&quot;:&quot;Get 20% off: $99 &#8594; $79/year&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="/__u/dailypythonprojects.substack.com/3ffabf6b"><span>Get 20% off: $99 &#8594; $79/year</span></a></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build a LAN File Sharing System: Day 2 - Downloads, drag-and-drop, progress bars, and PIN protection ]]></title><description><![CDATA[Yesterday we could push files onto the laptop. Today the tool becomes a real two-way sharing tool with drag-drop, downloads, progress bars, and PIN protection.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-lan-file-sharing-system-day-9c8</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-lan-file-sharing-system-day-9c8</guid><pubDate>Wed, 12 Aug 2026 09:18:20 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7d19cfd3-32b5-4b02-b8cf-17aad1c8269a_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday you built the upload half &#8212; a phone-scannable server anyone on the LAN could push files onto. Useful, but one-directional. If your friend wants files back off your laptop, they still have to email them or start a video call and hope screen-sharing works. And anyone on the same WiFi with the URL could upload themselves 200 GB of memes to your Downloads folder.</p><p>Today we fix all of that. Downloads work now, so any device can pull files off your laptop, not just push files onto it. Drag-and-drop replaces the tap-to-pick UI &#8212; drop files anywhere on the browser window and they upload in the background. Real progress bars show you exactly how far along that 200 MB video is. And a 4-digit PIN gate protects the server from your coffee-shop neighbor.</p><p>The underlying philosophy: <strong>you don&#8217;t need to install an app or trust a cloud company just to move files between devices on the same physical WiFi network</strong>. A single Python file &#8212; 700 lines total, everything inline &#8212; replaces AirDrop, Snapdrop, LocalSend, WeTransfer, and all the &#8220;send a file to yourself&#8221; workflows people use every day. Once you&#8217;ve built one of these, you&#8217;ll never install a file-transfer app again.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>Day 1:</strong> Upload server with QR code</p><p>Yesterday, we built the upload half &#8212; a FastAPI server that binds to <code>0.0.0.0</code> on the LAN, prints a QR code in the terminal that any phone camera can scan, and accepts uploads from a mobile-first page with <code>&lt;input type="file" multiple&gt;</code>. Files stream to disk 1 MB at a time so a 4 GB video doesn&#8217;t blow up memory. Filename safety via <code>Path.name</code> blocks path traversal, and collisions auto-rename to <code>foo (1).jpg</code>. All in one Python file, ~300 lines, no accounts, no cloud.</p><p><strong>&#128073; Day 2 (Today):</strong> <strong>Downloads, drag-and-drop, progress bars, and PIN protection </strong></p><p>The main page now has two halves: a drop zone up top for uploads, and a gallery below showing every file already uploaded. Drag files from your desktop onto the browser window, or tap the drop zone on mobile to open your photo library &#8212; either way, each file gets its own live progress bar showing exactly how much has uploaded:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!WJ-s!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!WJ-s!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1038359,&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/210591807?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!WJ-s!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7242a387-b628-4bbd-a780-ff3f80d48861_2964x1976.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></p><p>Once uploaded, files appear in the gallery with a thumbnail if it&#8217;s an image, or a document icon otherwise. Each card shows the filename, size, upload time (<code>e.g., Aug 10, 15:00</code>), and two buttons &#8212; Download and Delete. Tap Download and the file lands on your device. Tap Delete and it&#8217;s gone. This is what makes the tool actually two-way:</p><p>Before you see any of this, though, you have to enter the 4-digit PIN. You get the pin from the terminal when you run the .py script and you enter it in the app:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!eOiQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!eOiQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:898665,&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/210591807?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!eOiQ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b75bdfd-c22a-4bd8-89ad-fb9b6152200e_2964x1976.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg 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>Every run, the server prints a fresh PIN in the terminal alongside the URL and QR code. Anyone opening the URL sees a PIN entry page first. After entering it correctly, a signed session cookie remembers them for the rest of the run.</p><p>Every file now is in both your devices. </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!fuzK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 424w, /__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 848w, /__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 1272w, /__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!fuzK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png" width="1008" height="672" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:672,&quot;width&quot;:1008,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:229056,&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/210591807?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 424w, /__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 848w, /__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.png 1272w, /__u/substackcdn.com/image/fetch/$s_!fuzK!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b741c93-c135-4c01-bb31-ab431b9d7cc8_1008x672.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>That way if you fire this up on coffee-shop WiFi, random strangers on the network can&#8217;t find the server and dump files onto your laptop. Only the people you actually tell the PIN to get in.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-30">View All Projects This Week</a></p><h2>About the stack</h2><p>Same base as yesterday &#8212; FastAPI, uvicorn, python-multipart, qrcode, rich. Two additions:</p><p><strong>itsdangerous</strong> signs the session cookies that remember whether a browser has passed the PIN check. Same library we used in Week 28 for the Notes app. Signs data with a secret key, verifies it hasn&#8217;t been tampered with on the way back. If you know the key you can forge cookies; if you don&#8217;t, you can&#8217;t. The secret key gets regenerated on every server restart, which is what we want &#8212; sessions don&#8217;t need to survive across restarts.</p><p><strong>Vanilla JavaScript</strong>, inline in the HTML template. About 80 lines. Handles drag-and-drop, XHR uploads, and progress bars. No React, no Vue, no build step, no <code>node_modules</code>. This is one of those cases where the amount of JS you need is small enough that reaching for a framework would slow things down, not speed them up.</p><h2>Setup Instructions</h2><p>Install the extra dependency on top of Day 1:</p><pre><code><code>pip install itsdangerous
</code></code></pre><p>Everything else you already have from yesterday. Run:</p><pre><code><code>python lanshare_full.py                       # save to ~/lanshare/
python lanshare_full.py ~/Downloads           # save to your Downloads folder
python lanshare_full.py ~/photos --port 9000  # custom folder and port
</code></code></pre><p>The startup banner is the same as Day 1 plus the PIN &#8212; you&#8217;ll see something like <code>PIN: 4712</code> in yellow above the QR code. Read that number to whoever wants access. On subsequent runs the PIN changes because it&#8217;s regenerated on startup &#8212; that&#8217;s a feature, not a bug.</p><h2>Understanding the PIN Gate</h2><p>The whole &#8220;who is allowed in&#8221; system is three functions and a dependency:</p><pre><code><code>signer = URLSafeSerializer(SECRET_KEY)

def make_session_cookie():
    return signer.dumps({"ok": True})

def is_authed(request):
    token = request.cookies.get(SESSION_COOKIE)
    if not token:
        return False
    try:
        return bool(signer.loads(token).get("ok"))
    except BadSignature:
        return False

def require_auth(request):
    if not is_authed(request):
        raise AuthRequired()
</code></code></pre><p>Login is <code>POST /login</code> with the PIN in a form field. If it matches, we set a cookie whose value is <code>signer.dumps({"ok": True})</code> &#8212; a short signed string. Any subsequent request that includes this cookie passes the PIN check. If someone tries to fake the cookie by hand, <code>signer.loads</code> raises <code>BadSignature</code> and they get bounced back to <code>/login</code>.</p><p>The <code>AuthRequired</code> exception + <code>@app.exception_handler</code> pattern is what makes protection clean at the route level. Every protected route just adds one line:</p><pre><code><code>@app.get("/")
async def home(_: None = Depends(require_auth)):
    ...
</code></code></pre><p>If the request is unauthed, <code>require_auth</code> raises <code>AuthRequired</code>, the exception handler catches it and returns a redirect to <code>/login</code>. The route body never runs. This is the exact same pattern from the Notes app in Week 28, extended here from &#8220;who are you&#8221; (username/password) down to &#8220;are you the person I told the PIN to&#8221; (single shared secret).</p><p>A note on brute force: 10,000 possible PINs might sound weak, but on a LAN with 5-10 devices, nobody is running a distributed brute-force attack. If you&#8217;re worried about a hostile actor on the same coffee-shop network trying 10,000 PINs, add a rate limiter &#8212; but for personal use it&#8217;s overkill.</p><h2>Understanding Drag-and-Drop</h2><p>The HTML5 drag-and-drop API is one of those things that sounds complicated but comes down to four event handlers:</p><pre><code><code>const dropzone = document.getElementById('dropzone');

// Prevent the browser opening dropped files in a new tab (default behavior).
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(ev =&gt; {
  document.body.addEventListener(ev, e =&gt; e.preventDefault());
});

dropzone.addEventListener('dragover', () =&gt; dropzone.classList.add('dragover'));
dropzone.addEventListener('dragleave', () =&gt; dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', e =&gt; {
  dropzone.classList.remove('dragover');
  if (e.dataTransfer &amp;&amp; e.dataTransfer.files.length) {
    uploadFiles(e.dataTransfer.files);
  }
});
</code></code></pre><p>The <code>dragover</code> handler adds a CSS class that changes the border color and background &#8212; that&#8217;s the visual feedback showing the drop zone is active. The <code>drop</code> handler reads <code>e.dataTransfer.files</code> (a <code>FileList</code> object identical to what an <code>&lt;input type="file"&gt;</code> gives you), and passes them to the same upload function.</p><p>The one weird thing everyone hits the first time: <strong>you have to preventDefault on those four events on the whole body</strong>, otherwise dropping a file onto the page anywhere-not-the-dropzone causes the browser to <em>navigate</em> to that file. That&#8217;s why we attach preventDefault handlers to the body itself.</p><p>On mobile, drag-and-drop doesn&#8217;t exist as a gesture &#8212; you can&#8217;t drag files out of a file manager. So we also make the dropzone clickable, and clicking it opens the same hidden <code>&lt;input type="file" multiple&gt;</code>:</p><pre><code><code>dropzone.addEventListener('click', e =&gt; {
  if (e.target.tagName !== 'INPUT') fileInput.click();
});
</code></code></pre><p>Same UI element, two interaction modes &#8212; drag-and-drop on desktop, tap-to-pick on mobile. One dropzone works on everything.</p><h2>Understanding Live Progress Bars</h2><p>Standard HTML form submission gives you no upload progress info at all. The browser posts the whole thing and shows you the response when it&#8217;s done. For a 200 MB video that could take a minute, the user sees nothing happening.</p><p>The fix is <code>XMLHttpRequest</code>, specifically its <code>upload.progress</code> event. Modern JavaScript prefers <code>fetch()</code>, but <code>fetch()</code> doesn&#8217;t expose upload progress at all &#8212; the spec still hasn&#8217;t shipped it. So for progress bars we have to reach for the older <code>XHR</code> API:</p><pre><code><code>const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append('files', file, file.name);

xhr.upload.addEventListener('progress', e =&gt; {
  if (!e.lengthComputable) return;
  const pct = Math.round((e.loaded / e.total) * 100);
  bar.value = pct;
  pctText.textContent = pct + '%';
});

xhr.open('POST', '/upload');
xhr.send(fd);
</code></code></pre><p>The <code>upload.progress</code> event fires many times per second while the file is being sent. <code>e.loaded</code> is bytes uploaded so far, <code>e.total</code> is total bytes. Divide, round, done. The <code>&lt;progress&gt;</code> HTML element is a first-class native progress bar &#8212; no CSS gymnastics needed to make it look good.</p><p>Doing multiple files at once? Each file gets its own XHR, its own progress row in the DOM, its own progress event stream. Uploads run in parallel because the browser handles multiple simultaneous XHRs to the same origin. The main constraint is the browser&#8217;s per-origin request limit (usually 6), which is more than enough for a personal file-transfer tool.</p><h2>Understanding the File Gallery</h2><p>The gallery is server-rendered HTML, updated by reloading the page after uploads complete. Every request to <code>/</code> reads the current contents of the upload directory, sorts by modification time, and renders one card per file:</p><pre><code><code>@app.get("/")
async def home(_: None = Depends(require_auth)):
    files = sorted(
        [p for p in upload_dir.iterdir()
         if p.is_file() and not p.name.startswith(".")],
        key=lambda p: p.stat().st_mtime,
        reverse=True,
    )
    return HTMLResponse(MAIN_PAGE.format(
        gallery_html=render_gallery(files),
        ...
    ))
</code></code></pre><p><code>iterdir()</code> is <code>pathlib</code>&#8216;s way of listing a directory. Filter out hidden files (<code>.DS_Store</code>, <code>.thumbs</code>), sort by <code>st_mtime</code> descending, done. Newest first is the right default because that&#8217;s what you just uploaded.</p><p>Each card decides whether to show a thumbnail image or a document icon based on the file extension:</p><pre><code><code>IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic",
                    ".bmp", ".svg", ".avif"}

if is_image(f.name):
    thumb = f'&lt;img src="/__u/dailypythonprojects.substack.com/files/%7Bname_escaped%7D" alt="" loading="lazy"&gt;'
else:
    thumb = '&lt;span class="icon"&gt;&amp;#128196;&lt;/span&gt;'
</code></code></pre><p>For image thumbnails, we use the file itself as the <code>&lt;img src&gt;</code>. The browser downloads it, resizes it via <code>object-fit: cover</code> in CSS, and renders it. For a personal LAN tool with a few dozen files, this is fine. A production version would generate real thumbnails with Pillow, but that adds a dependency for very little UX gain at small scale.</p><p>The <code>loading="lazy"</code> attribute is important &#8212; it tells the browser to only load thumbnails as they scroll into view. Without it, opening a page with 100 images would trigger 100 simultaneous downloads.</p><h3>Understanding Path Traversal on Downloads</h3><p>Day 1 handled filename safety on uploads via <code>Path(name).name</code>. Day 2 adds two more attack surfaces: downloads (<code>GET /files/{name}</code>) and deletes (<code>POST /files/{name}/delete</code>). Both take a filename from the URL, and both could be tricked into escaping the upload directory if we weren&#8217;t careful.</p><p>We reuse the same <code>safe_filename</code> from Day 1, but also add a defense-in-depth check that resolves the final path to absolute form and confirms it&#8217;s still inside the upload directory:</p><pre><code><code>def resolve_upload_path(upload_dir, name):
    candidate = (upload_dir / safe_filename(name)).resolve()
    upload_dir_r = upload_dir.resolve()
    try:
        candidate.relative_to(upload_dir_r)
    except ValueError:
        return None                    # escaped upload_dir - reject
    if not candidate.is_file():
        return None
    return candidate</code></code></pre><p><code>safe_filename</code> alone would probably be enough, but &#8220;probably enough&#8221; is not the right level of confidence for security-sensitive code. </p><p>The same helper protects the delete route. Without it, a malicious request would happily delete files outside the upload directory. With it, you get a 404 and nothing happens.</p><h3>Understanding the JSON Upload Response</h3><p>Day 1&#8217;s upload route returned a 303 redirect back to <code>/?uploaded=N</code> &#8212; the standard Post-Redirect-Get pattern for form submissions. That worked because Day 1 used a plain HTML form.</p><p>Day 2&#8217;s upload runs from JavaScript via XHR, so the response can be JSON instead:</p><p>python</p><pre><code><code>return JSONResponse({"ok": True, "saved": saved})</code></code></pre><p>The JavaScript doesn&#8217;t actually read the response body &#8212; it just checks <code>xhr.status</code> to know if the upload succeeded &#8212; but returning structured JSON is the right idiom for a JavaScript-driven endpoint, and gives us the option to expand the response later (thumbnails, previews, computed metadata) without breaking anything.</p><p>After all in-flight uploads finish, the JS reloads the page:</p><p>javascript</p><pre><code><code>if (inFlight === 0) setTimeout(() =&gt; location.reload(), 600);</code></code></pre><p>The 600ms delay lets the &#8220;Done&#8221; text on the progress rows stay visible for a moment before the page refreshes to show the new gallery. Small polish, feels much better than an instant snap.</p><h3>Practical Use Cases</h3><p><strong>Family photo dump at Christmas.</strong><br>Everyone at the family gathering AirDrops or Google-Drives their photos to one shared album, taking 45 minutes and losing quality. Alternative: someone starts LAN Share on their laptop, projects the QR code on the TV, everyone in the room scans it, uploads their photos in full quality in 30 seconds. Downloadable back to any device afterwards.</p><p><strong>Workshop or conference submissions.</strong><br>Running a two-hour Python workshop and want everyone to submit their solutions? Way faster than a shared Google Drive. Everyone&#8217;s on the venue WiFi, they scan the QR code, PIN is projected on your slide, files land in one folder on your laptop. No Google accounts needed.</p><p><strong>Getting files off a phone with no cable.</strong><br>Phone lost its cable, doesn&#8217;t have iCloud, or the photos are too big for email? Start the server, scan the QR, upload the 4 GB video, downloaded to your laptop in whatever time the WiFi takes. Faster than trying to work around whatever cloud service compresses your file.</p><p><strong>Sharing a big file between two computers on the same network.</strong><br>Two laptops on the same WiFi, want to move a 15 GB dataset between them? USB drives, cloud upload, Ethernet cables all take longer than starting LAN Share and dragging the file. LAN transfer runs at whatever your WiFi supports &#8212; often over 100 MB/s.</p><p><strong>Deploy it to your own VPS with a domain and HTTPS.</strong><br>Point it at a real domain with a Let&#8217;s Encrypt certificate and you have a private file-transfer service accessible from anywhere. Send the link + PIN to anyone. No SaaS, no monthly fee, no size limit, no expiring links, no ads. Total setup cost: $5/month VPS + one weekend of learning nginx and Certbot.</p><h3>What&#8217;s Next</h3><p>Thursday&#8217;s post is a standalone tutorial on a specific piece of infrastructure &#8212; free, all subscribers get it. Details Thursday morning.</p><p>Next week we shift into a different kind of build &#8212; details on Tuesday.</p><h3>Solution</h3><p>Below you will find the downloadable solution.py file containing the correct solution.</p><p>Get the code here:</p>
      <p>
          <a href="/__u/dailypythonprojects.substack.com/p/build-a-lan-file-sharing-system-day-9c8">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a LAN File Sharing System: Day 1 - Upload files with QR code]]></title><description><![CDATA[Get an email the moment your favorite site or your own site goes down, or the free product page you&#8217;ve been refreshing all week.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-lan-file-sharing-system-day</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-lan-file-sharing-system-day</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 11 Aug 2026 09:02:27 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/2804a4c7-dd38-43fb-82a8-49249ec44be8_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cross-device file transfer in 2026 is still miserable. AirDrop works but only if everyone is on Apple. Emailing yourself hits a 25 MB attachment cap and destroys quality. WhatsApp compresses your 4K videos into potato quality. Google Drive means uploading to a corporation, then downloading from that corporation, for a file that never needed to leave your house. Snapdrop is buggy, LocalSend requires an app installed on every device, and PC-to-phone transfer is universally awful.</p><p>This week we build the fix. Three hundred lines of Python that turns your laptop into an AirDrop-for-everyone server. Your phone, your friend&#8217;s Android, your kid&#8217;s iPad, a Windows machine, another laptop &#8212; anything with a browser on the same WiFi can send files to you. No accounts, no cloud, no apps to install anywhere except your laptop. You start the server, it prints a QR code, everyone scans it, everyone drops files, done.</p><p>Day 1 is the upload half &#8212; anyone on your WiFi can send files to your laptop. Day 2 adds the download half, drag-and-drop, PIN protection, and a file browser so it&#8217;s a two-way tool. Both days build on the same core insight: <strong>a laptop running a small web server on the LAN is a better peer-to-peer file transfer solution than anything in the app store</strong>, and modern Python makes it a weekend project.</p><h2>Projects in this week&#8217;s series:</h2><p><strong>&#128073; Day 1 (Today): Upload server with QR code</strong> </p><p>When you run the script, the terminal prints a colored panel with the URL and a QR code your phone camera can scan directly. It also tells you exactly where uploaded files will land and starts logging every upload live:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!YImp!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 424w, /__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 848w, /__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 1272w, /__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!YImp!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.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;:1285370,&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/210576027?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 424w, /__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 848w, /__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.png 1272w, /__u/substackcdn.com/image/fetch/$s_!YImp!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc00e1f07-0e6e-4333-af5e-ec5f763e1f17_1536x1024.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>Your phone opens the URL and sees a simple mobile-first upload page. Tap the file picker, choose photos from your library or take one with the camera, hit Upload, done. The page shows a green success banner after every upload:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!Jrqc!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!Jrqc!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:968528,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/210576027?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Jrqc!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87a0b943-3427-43a0-b3fa-c8f55b511c6f_2964x1976.png 1456w" sizes="100vw"></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>I did use the actual app that I built to transfer the screenshot above to my computer and upload it in this post. Here is how my &#8220;lanshare&#8221; folder looks like in my computer after uploading some files from my phone:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!xnVT!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 424w, /__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 848w, /__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 1272w, /__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!xnVT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png" width="1110" height="740" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:740,&quot;width&quot;:1110,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:231705,&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/210576027?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 424w, /__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 848w, /__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.png 1272w, /__u/substackcdn.com/image/fetch/$s_!xnVT!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5ea1ea3-4367-4870-9e2f-cc419e6cbd9c_1110x740.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>Path traversal attacks are blocked, filename collisions auto-rename (<code>photo.jpg</code> becomes <code>photo (1).jpg</code>), and uploads stream to disk in chunks so a 4 GB video doesn&#8217;t blow up your laptop&#8217;s memory.</p><p><strong>Day 2 (Tomorrow):</strong> Downloads, drag-and-drop, and PIN protection</p><p>Same server, but bidirectional and prettier. Drag files onto the browser window instead of using the picker. Real progress bars during upload so you know your video isn&#8217;t stuck. A file gallery on the page showing everything that&#8217;s been uploaded so far, with a Download button next to each &#8212; so your friend can grab files back off your laptop instead of only sending them:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!vTq3!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!vTq3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:898665,&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/210576027?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 424w, /__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 848w, /__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 1272w, /__u/substackcdn.com/image/fetch/$s_!vTq3!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f8cb9cf-2081-4c1a-98c4-0cb4410adc29_2964x1976.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg 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>Plus a 4-digit PIN shown in the terminal on startup that anyone visiting the page has to enter first. That way if your neighbor is on the same coffee shop WiFi, they can&#8217;t just find your server and upload themselves 200 GB of memes.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-30">View All Projects This Week</a></p><h2>About the stack</h2><p>Four small packages and one CDN link, no JavaScript files, no accounts, no cloud.</p><p><strong>FastAPI</strong> is the web framework, same one we used for the Notes app in Week 28. It handles multipart file uploads out of the box, streams big files through <code>UploadFile.read(chunk_size)</code>, and gives us clean async routes.</p><p><strong>uvicorn</strong> is the ASGI server that actually runs FastAPI. It binds to <code>0.0.0.0</code> so any device on the local network can reach it, and its default settings handle huge file uploads without any tuning.</p><p><strong>python-multipart</strong> is a dependency FastAPI needs for parsing HTML form uploads. It&#8217;s the difference between &#8220;your server sees the multipart data&#8221; and &#8220;your server sees nothing.&#8221; Install it, then forget about it.</p><p><strong>qrcode</strong> generates QR codes. Not a huge library, and importantly it can render straight to the terminal using Unicode half-block characters &#8212; a real, scannable QR code that fits in 33 characters of terminal width. No image file, no screen sharing, just Unicode in your terminal.</p><p><strong>Rich</strong> styles the terminal output &#8212; the startup panel, the colored upload log. Not strictly necessary but the polish makes the tool feel like a real product rather than a script.</p><p><strong>Pico.css</strong> from a CDN gives us a mobile-first, classless styling framework. We write plain semantic HTML like <code>&lt;form&gt;</code>, <code>&lt;input type="file"&gt;</code>, <code>&lt;button&gt;</code>, and it looks like a real product on both desktop and mobile. Zero CSS files in our project.</p><h2>Setup Instructions</h2><p>Install dependencies:</p><pre><code><code>pip install fastapi uvicorn python-multipart qrcode rich
</code></code></pre><p>Five packages. All small, all mature, none likely to break in the next five years.</p><p>Run it:</p><pre><code><code>python lanshare.py                       # save uploads to ~/lanshare/
python lanshare.py ~/Downloads           # save to your Downloads folder
python lanshare.py ~/photos --port 9000  # custom folder and port
</code></code></pre><p>The default target folder is <code>~/lanshare/</code>, auto-created on first run. The default port is 8000. Once running, the terminal prints the URL to open on any device on the same WiFi and a QR code you can scan directly.</p><p>Firewall note: on macOS, the first time you run this you&#8217;ll get a popup asking whether to allow incoming connections. Click Allow. On Windows, Windows Defender Firewall will ask the same thing. On Linux you&#8217;re on your own.</p><h2>Understanding LAN IP Discovery</h2><p>The single most useful trick in this entire project is finding your own LAN IP so you can print it. Sounds easy &#8212; you have an IP, right? &#8212; but it&#8217;s actually one of the classic gotchas of network programming.</p><p>The obvious way, <code>socket.gethostbyname(socket.gethostname())</code>, doesn&#8217;t work reliably. On many machines it returns <code>127.0.0.1</code> (localhost), which is useless for LAN sharing. On some machines it returns an IP from a virtual network interface (Docker, VMware, VirtualBox) that no other real device can reach. On multi-homed machines with multiple network interfaces, it might return the wrong one entirely.</p><p>The elegant trick that actually works is this:</p><pre><code><code>def get_lan_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("8.8.8.8", 80))
        return s.getsockname()[0]
    finally:
        s.close()
</code></code></pre><p>We open a UDP socket and &#8220;connect&#8221; it to Google&#8217;s public DNS server (<code>8.8.8.8</code>). But UDP is connectionless &#8212; no actual network traffic happens here. What happens is the operating system&#8217;s kernel picks the network interface that <em>would</em> be used if we were to send a packet to <code>8.8.8.8</code>, and the socket now has that interface&#8217;s IP bound to it. We read that back with <code>getsockname()</code>, close the socket, and we&#8217;re done.</p><p>This works because the kernel&#8217;s routing table is the ground truth for &#8220;what IP would other devices see if I sent them a packet.&#8221; Whether you&#8217;re on WiFi, Ethernet, or a hotspot from your phone, the kernel knows which interface has internet access and returns its IP. It works on macOS, Linux, and Windows without changes. And crucially, it ignores virtual network adapters like Docker&#8217;s <code>docker0</code> which would otherwise confuse things.</p><p>The <code>8.8.8.8</code> address is arbitrary &#8212; any routable public IP works. We use Google&#8217;s DNS because it&#8217;s globally famous and unlikely to disappear, but <code>1.1.1.1</code> or literally any public IP would work equally well.</p><h2>Understanding FastAPI File Uploads</h2><p>FastAPI handles file uploads through the <code>UploadFile</code> type, which is a Starlette wrapper around a <code>SpooledTemporaryFile</code>. The magic is that FastAPI never loads the whole file into memory unless you make it. You get to control the streaming yourself:</p><pre><code><code>from fastapi import FastAPI, File, UploadFile
from typing import List

@app.post("/upload")
async def upload(files: List[UploadFile] = File(...)):
    for f in files:
        size = 0
        with open(target_path, "wb") as out:
            while True:
                chunk = await f.read(1024 * 1024)   # 1 MB at a time
                if not chunk:
                    break
                out.write(chunk)
                size += len(chunk)
</code></code></pre><p>The critical part is that <code>await f.read(1024 * 1024)</code> reads at most 1 MB of the upload at a time. We write it to disk immediately, then loop back for the next chunk. Total memory used: about 1 MB per file being uploaded, no matter how big the file is. A 4 GB video and a 4 KB text file both use the same amount of RAM.</p><p>Contrast this with the naive <code>contents = await f.read()</code> which reads the entire file into a single bytes object. Send a 4 GB video that way and your server tries to allocate 4 GB of RAM. Bad.</p><p>The <code>List[UploadFile]</code> type combined with <code>&lt;input type="file" multiple&gt;</code> in the HTML lets users pick multiple files at once and we handle them all in a single request. Each file goes through the same streaming loop.</p><h2>Understanding Collision Handling</h2><p>If two people upload files called <code>photo.jpg</code>, only one of them can win &#8212; unless we auto-rename on collision. The pattern that every operating system&#8217;s file manager uses is <code>foo.jpg</code>, <code>foo (1).jpg</code>, <code>foo (2).jpg</code>, and so on:</p><pre><code><code>def unique_path(folder, name):
    p = folder / name
    if not p.exists():
        return p
    stem, suffix = p.stem, p.suffix
    for i in range(1, 10_000):
        candidate = folder / f"{stem} ({i}){suffix}"
        if not candidate.exists():
            return candidate
    # Fallback if someone somehow has 10k files with the same name
    return folder / f"{stem}-{datetime.now():%Y%m%d-%H%M%S}{suffix}"
</code></code></pre><p><code>pathlib</code> gives us <code>stem</code> (filename without extension) and <code>suffix</code> (extension including the dot) for free. Loop up to some large number, and if we somehow hit 10,000 files with the same base name, fall back to appending a timestamp. That fallback will basically never trigger in practice but it keeps the function total &#8212; always returns a valid path.</p><p>There&#8217;s a subtle race condition here: between checking <code>not candidate.exists()</code> and actually opening the file for writing, another concurrent request could theoretically create that filename first. For a LAN tool with a handful of concurrent uploaders it&#8217;s not a real problem, but a truly bulletproof version would use <code>os.open</code> with <code>O_EXCL</code> flag to atomically create-if-not-exists. Overkill for our use case.</p><h2>Understanding QR Codes in the Terminal</h2><p>The QR code lives in the terminal, not in a browser or an image file. The <code>qrcode</code> library can do this directly:</p><pre><code><code>qr = qrcode.QRCode(border=1)
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
</code></code></pre><p><code>print_ascii(invert=True)</code> renders the QR using Unicode half-block characters (<code>&#9600;</code>, <code>&#9604;</code>, <code>&#9608;</code>) which pack two &#8220;pixels&#8221; of QR data into every character cell. That&#8217;s why the whole QR fits in 33 columns instead of 66. <code>invert=True</code> means &#8220;light QR on dark background&#8221; which reads correctly in modern dark terminals like iTerm, Terminator, or Windows Terminal.</p><p>Any modern phone camera app opens QR codes automatically. iPhone Camera, Android Camera, most third-party barcode scanners &#8212; they all see the QR, recognize the URL, and offer to open it in the browser with a single tap. The whole &#8220;print QR in terminal, phone scans it, browser opens&#8221; flow takes about three seconds.</p><h2>Understanding the Mobile-First HTML</h2><p>The upload page is one Jinja-free HTML string, styled entirely by Pico.css from a CDN. The whole payload is 1.5 KB and renders identically on desktop browsers and mobile ones:</p><pre><code><code>&lt;form method="post" action="/__u/dailypythonprojects.substack.com/upload" enctype="multipart/form-data"&gt;
  &lt;div class="upload-box"&gt;
    &lt;input type="file" name="files" multiple required&gt;
  &lt;/div&gt;
  &lt;button type="submit"&gt;Upload&lt;/button&gt;
&lt;/form&gt;
</code></code></pre><p>Three tricks worth pointing out:</p><p><code>enctype="multipart/form-data"</code> is required for file uploads. Without it, the browser sends just the filename as a text field and your file goes nowhere. Miss this one attribute and you spend an hour debugging.</p><p><code>multiple</code> on the file input lets users pick multiple files at once. On iPhone, this makes the photo picker allow multi-select. On Android, same thing. On desktop, Cmd/Ctrl-click in the file picker works. One attribute, huge UX win.</p><p>No <code>accept</code> attribute means the picker accepts any file type. On iPhone this shows a menu with &#8220;Photo Library&#8221;, &#8220;Take Photo or Video&#8221;, and &#8220;Choose Files&#8221;. On Android, &#8220;Camera&#8221;, &#8220;Files&#8221;, and &#8220;Photos&#8221;. On desktop, the normal file picker. The whole platform-specific file selection UI comes free with <code>&lt;input type="file"&gt;</code>.</p><h2>Practical Use Cases</h2><p><strong>Airdropping to Windows or Android friends.</strong> This solves the &#8220;I have an iPhone but my friend/partner/kid has an Android&#8221; problem forever. Start the server, share the QR code (screenshot it, or just show them your terminal), and both devices can send files to your laptop.</p><p><strong>Getting photos off a phone at a party or event.</strong> Instead of everyone AirDropping photos to one person and then that person figuring out how to share them, everyone uploads to one laptop over the venue WiFi. All photos land in a shared folder in real time. Then you sync them to a shared album later.</p><p><strong>Team file drops during a workshop or meeting.</strong> Running a workshop and want everyone to submit their exercise files? Way faster than a shared Google Drive folder and doesn&#8217;t require anyone to have a Google account. Start the server, project the QR code, everyone submits.</p><p><strong>Foundation for Day 2.</strong> Every function we built today gets reused tomorrow. <code>safe_filename</code> and <code>unique_path</code> become the basis for a file gallery view. The Rich terminal logging pattern gets extended with download events. The Pico.css upload page evolves into a drag-and-drop UI. That&#8217;s the pedagogical arc of the week: minimal working thing today, complete production-quality tool tomorrow.</p><h2>Coming Wednesday</h2><p>Tomorrow we add the other half &#8212; downloads, so your friend can pull files off your laptop instead of only sending them. Drag-and-drop upload with real progress bars during transfer. A file gallery on the page showing everything that&#8217;s been uploaded, with a Download button next to each file. And a 4-digit PIN protection layer so your neighbor at the coffee shop can&#8217;t upload themselves onto your laptop.</p><p>If you want the full tool, <a href="/__u/dailypythonprojects.substack.com/subscribe">upgrade here</a> before Wednesday.</p><h2>Solution</h2><p>Below you will find the downloadable solution.py file containing the correct solution.</p><p>Get the code here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/-4kmZfR2Wj0_ce9mPa7WoA&quot;,&quot;text&quot;:&quot;View Code Solution&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://share.pythonanywhere.com/evolution/-4kmZfR2Wj0_ce9mPa7WoA"><span>View Code Solution</span></a></p>]]></content:encoded></item><item><title><![CDATA[Frontend vs backend: where Python stops]]></title><description><![CDATA[Good news: you may never need to write frontend code. But the backend is a different story, and here is where the line between them sits.]]></description><link>https://dailypythonprojects.substack.com/p/frontend-vs-backend-where-python</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/frontend-vs-backend-where-python</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 06 Aug 2026 13:06:11 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!JqVH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this post, you will learn the difference between frontend and backend, and where exactly Python fits into a web application. This split is the basic map of how every website and app is built, and once you can see it, a whole category of confusing questions answers itself.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!JqVH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!JqVH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png" width="1456" height="558" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ba70b3b7-3bf2-4916-8071-01e9b577ecf9_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;:51850,&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/210005495?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!JqVH!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fba70b3b7-3bf2-4916-8071-01e9b577ecf9_2400x920.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><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>Last week you learned how to <a href="/__u/dailypythonprojects.substack.com/p/turn-your-script-into-an-api">turn a function into an API</a>, and when you opened it in the browser, you got something like <code>{"password": "NlDRymnljPanqFp2"}</code>. Perhaps a part of you was slightly disappointed by that. Real websites do not look like raw JSON. They have buttons, colors, input fields, animations. So where does all of that come from, and why did your Python produce none of it?</p><p>The answer is that every web application is actually two programs, running on two different computers.</p><h2>The two halves</h2><p>One program runs in the browser, on the visitor&#8217;s machine. It is written in HTML, CSS, and JavaScript, and its job is everything you can see and touch. This half is called the frontend.</p><p>The other program runs on a server, which as we have said before is simply a computer that stays on. Its job is the data and the logic. This half is called the backend, and this is where your Python lives.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!dYFN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!dYFN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/178789e1-35ee-488b-a81b-6f258148d597_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;:119414,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/210005495?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!dYFN!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F178789e1-35ee-488b-a81b-6f258148d597_2400x1120.png 1456w" sizes="100vw"></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>Here is the part that surprises most beginners: Python never runs inside the browser. Browsers only run one programming language, and that language is JavaScript. So when you wonder why your Python script cannot change a button color on a page, the answer is that your Python is not there. It is on the other side of the border, on the server, and the only thing that crosses that border is HTTP requests going one way and data coming back the other way.</p><h2>What actually happens when you click a button</h2><p>Let&#8217;s make this concrete with a scenario. Imagine a weather website. You type &#8220;Lagos&#8221; into a search box and click Search. Here is the full trip:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!is6F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 424w, /__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 848w, /__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 1272w, /__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!is6F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png" width="1456" height="752" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:752,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:127231,&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/210005495?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 424w, /__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 848w, /__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.png 1272w, /__u/substackcdn.com/image/fetch/$s_!is6F!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F42e967ed-7f46-46a2-bcb4-7e0a24b372bf_2400x1240.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>JavaScript in your browser notices the click and sends a request, something like <code>GET /weather?city=Lagos</code>. If that shape looks familiar, it should, because it is exactly how you called your own password API last week. The request travels to the server, where the Python backend runs: it might check a database, or call an external weather service using an API key. Then it sends back JSON, something like <code>{"temp": 24, "sky": "clear"}</code>. Finally, JavaScript receives that JSON and turns it into pixels: a big 24&#176;C and a sun icon.</p><p>So the ugly JSON you saw last week was not a limitation. It is the handshake between the two halves. Backends speak data, and frontends turn data into things you can look at.</p><h2>Who does what, and why it matters</h2><p>The division of labor is fairly clean. The browser handles everything that needs to feel instant: layouts, hover effects, reacting while you type. The server handles everything that involves data and trust: the real logic, the database, and the secrets.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!Ccdr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!Ccdr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png" width="1456" height="631" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:631,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:106070,&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/210005495?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Ccdr!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74985c91-7d10-40c0-9aa8-1359562583ae_2400x1040.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 word &#8220;trust&#8221; deserves a moment. Everything in frontend code is visible to every visitor. Anyone can right-click your page, choose View Source, and read all of it. So if you place an API key in JavaScript, you have published it to the internet. This is one of the most common beginner mistakes out there, and it is why secrets always stay on the backend, where visitors cannot see them.</p><h2>Do you need to learn frontend now?</h2><p>My answer is no, and the reason is different than it would have been three years ago. Today you can let AI write your frontend. Describe the page you want, and tools like Claude or Cursor will produce the HTML, CSS, and JavaScript for you in a minute. Frontend is also where AI shines the most, because you can immediately judge the result with your own eyes: <strong>the button is either blue or it is not.</strong></p><p><strong>The backend is a different story</strong>. AI can write that code too, and I encourage you to let it. However, the backend is where the logic of your app lives: what gets saved, who is allowed to do what, what happens when a payment fails. Those are decisions, not code, and AI cannot make them for you. If you do not understand your backend, you do not understand your app. That is why you are learning Python, and not CSS.</p><p>This is also why I believe there has never been a better time to learn Python. The frontend used to be a wall: you either learned a second craft or hired a frontend developer. Now AI fills that gap for free, and one person with Python and a clear picture of the logic can ship a complete app alone. The map from this post is what lets you direct the AI, because you know which half a feature belongs to, and which half a bug lives in.</p><p>One more thing: this map is not just for websites. Every app on your phone works the same way. The app itself is a frontend, and somewhere a server is running someone&#8217;s backend code. Two programs, one handshake. You now have the whole picture.</p><p>If you learned something from this today, consider upgrading for the full experience: every weekly project with full source code, plus the complete archive. Since you read this far, I&#8217;d like to offer you 35% off the annual plan as a token of appreciation. Click below to get the discount:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/3ffabf6b&quot;,&quot;text&quot;:&quot;Get 20% off: $99 &#8594; $79/year&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="/__u/dailypythonprojects.substack.com/3ffabf6b"><span>Get 20% off: $99 &#8594; $79/year</span></a></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[Visualizing the 2026 Eclipse with Python: Day 2 - Interactive Streamlit web map]]></title><description><![CDATA[Yesterday you built the astronomy engine. Today you wrap it in a world map that anyone can click.]]></description><link>https://dailypythonprojects.substack.com/p/visualizing-the-2026-eclipse-with-c95</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/visualizing-the-2026-eclipse-with-c95</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 05 Aug 2026 09:20:31 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c5c16637-4030-42b4-a48f-883e2152bb9b_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p></p><p>Yesterday you built the astronomy engine &#8212; a Python script that takes a location and returns the precise eclipse timing for that spot, computed from the same JPL ephemerides NASA uses. Impressive as a CLI, but a terminal report isn&#8217;t something you share with friends, students, or your family WhatsApp group.</p><p>Today we take the same engine and wrap it in a live, interactive world map. Full eclipse path drawn as a bold red line across the North Atlantic. Cities in the path pre-marked as green dots, cities near it as orange or blue depending on coverage. <strong>Click anywhere on Earth</strong> and the sidebar populates with what you&#8217;ll see from <em>that</em> exact point &#8212; start time, maximum, end time in local timezone, coverage percentage, whether you&#8217;re in the path of totality. Search box for city lookup, live countdown to the moment of greatest eclipse. All in one Python file, wrapping yesterday&#8217;s script.</p><p>The lesson underneath: <strong>your astronomy engine should be reusable.</strong> Written well, <code>compute_eclipse(lat, lon)</code> is a pure function that doesn&#8217;t care whether it&#8217;s being called from a terminal script or a web app. Day 1&#8217;s file becomes an importable module for Day 2, and Day 2 is short because the hard work is already done. That separation between engine and interface is one of the most important design habits a working Python developer can build.</p><h2>Projects in this week&#8217;s series:</h2><h3><strong>Day 1:</strong> Command-line eclipse info tool (Yesterday)</h3><p>Yesterday, we built a Python CLI script that computes precise eclipse timing for any location on Earth &#8212; start, maximum, and end times in local timezone, coverage percentage, whether you're inside the path of totality, and if so how long totality lasts. The astronomy runs off Skyfield with JPL DE421 ephemerides, accurate to within seconds of NASA's published numbers.</p><h3><strong>&#128073; Day 2:</strong> Interactive Streamlit web map <strong>(Today)</strong></h3><p>The same astronomy engine, wrapped in Streamlit + Folium. The full eclipse path is drawn as a bold red line arcing from Siberia across the North Pole, down through Iceland, across the Atlantic, into northern Spain, exiting over the Balearic Sea. Featured cities appear as colored dots: green for cities inside the path of totality, orange for cities with over 90% coverage, blue for moderate partials:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!9TJe!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 424w, /__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 848w, /__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 1272w, /__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!9TJe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c38689e0-104c-4c59-b3bc-817476f11050_2454x1636.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;:2070372,&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/209899266?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 424w, /__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 848w, /__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.png 1272w, /__u/substackcdn.com/image/fetch/$s_!9TJe!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc38689e0-104c-4c59-b3bc-817476f11050_2454x1636.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>Click anywhere on the map &#8212; Boston, Almada, Tokyo, wherever &#8212; and the left sidebar populates with the local eclipse info for that exact point, complete with local time conversion. There&#8217;s a search box that geocodes any city name in the world, a live countdown to the eclipse, and popups on every featured city. All computed live from the same <code>compute_eclipse()</code> function we built yesterday.</p><p>Here the map shows that Boston will have a 16.01% partial eclipse:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!izrE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 424w, /__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 848w, /__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 1272w, /__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!izrE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.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;:2405072,&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/209899266?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 424w, /__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 848w, /__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.png 1272w, /__u/substackcdn.com/image/fetch/$s_!izrE!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3623de26-759f-402e-a3d8-20c31f2d8cff_2508x1672.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>Zero JavaScript files. Zero HTML files. Zero CSS files. About 420 lines of Python that read like a normal script.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-29">View All Projects This Week</a></p><h2>About the stack</h2><p>Three new packages, each doing one clean thing on top of Day 1&#8217;s foundation.</p><p><strong>Streamlit</strong> is Python&#8217;s most popular framework for turning scripts into web apps. Its whole philosophy is &#8220;write a normal Python script; get a web app for free.&#8221; No routes, no HTML templates, no request handlers. Every widget (button, text input, map) is a Python function call. <code>st.title("Hello")</code> puts a title on the page. <code>st.button("Click me")</code> adds a button and returns <code>True</code> when clicked. Then the whole script re-runs and your logic reacts.</p><p>Streamlit&#8217;s re-run model is the one thing that surprises people. Every widget interaction &#8212; every click, every keystroke, every zoom on the map &#8212; re-runs your entire script from top to bottom. That sounds inefficient, but combined with <code>@st.cache_data</code> for expensive computations and <code>st.session_state</code> for persisting values across re-runs, it&#8217;s actually a delightful mental model: no callbacks, no event handlers, just linear code that runs whenever anything changes.</p><p><strong>Folium</strong> is a Python wrapper around Leaflet.js &#8212; the same JavaScript library that powers OpenStreetMap, Airbnb, and most of the interactive maps you&#8217;ve ever used online. Folium lets you build a map object in pure Python (<code>folium.Map()</code>, <code>folium.PolyLine()</code>, <code>folium.CircleMarker()</code>) and renders it as an HTML/JS widget. Nice API, zero JS to write.</p><p><strong>streamlit-folium</strong> is the bridge. Folium alone gives you a beautiful map but it&#8217;s one-way &#8212; you can display it but users can&#8217;t send anything back to Python. <code>streamlit-folium</code> adds <code>st_folium()</code>, a component that renders a Folium map <em>and</em> returns click coordinates, current center, zoom level, and marker interactions as a Python dict. That&#8217;s what makes the map bidirectional &#8212; clicks flow back to your script, which reruns with the new click, which populates the sidebar. Without this component, our whole &#8220;click anywhere to see local eclipse info&#8221; flow wouldn&#8217;t work.</p><h2>Understanding the Reusable Engine Pattern</h2><p>Day 1&#8217;s <code>eclipse.py</code> was written as a script &#8212; it has a <code>main()</code> function, CLI parsing, colored terminal output. But look at the actual astronomy: <code>compute_eclipse(lat, lon)</code> takes two floats and returns a dict. Pure function. No CLI dependencies, no printing, no side effects other than reading the ephemeris (which is loaded once at module level).</p><p>That&#8217;s what makes Day 2 short. The first non-import line of <code>eclipse_map.py</code> is:</p><pre><code><code>from eclipse import CITIES, compute_eclipse, format_duration
</code></code></pre><p>Everything else &#8212; Streamlit widgets, Folium map construction, click handling &#8212; is UI code around that pure function. If tomorrow we wanted a REST API version, we&#8217;d write a FastAPI file that imports the same three names. If we wanted a Discord bot, same thing. <strong>The engine doesn&#8217;t care about the interface.</strong></p><p>This split &#8212; pure engine, thin interface &#8212; is one of the most valuable habits a Python developer can build. Every time you catch yourself hardcoding <code>print()</code> calls or hardcoding CLI arguments deep inside computational code, you&#8217;re gluing the engine and the interface together, and future-you (or future-teammate) will pay for it.</p><h2>Understanding Streamlit&#8217;s Re-run Model</h2><p>The Streamlit mental model is genuinely different from anything else in web development, and getting it wrong is the fastest way to end up with a broken app. Here&#8217;s the rule: <strong>every widget interaction re-runs the entire script from top to bottom.</strong></p><p>Type a character in the search box? Whole script reruns. Click a button? Whole script reruns. Pan the map by one pixel? Whole script reruns. Nothing survives between runs by default.</p><p>This sounds catastrophic &#8212; surely computing the eclipse for 12 cities on every keystroke would be unbearably slow? &#8212; but Streamlit gives you two tools that make it work.</p><p><code>@st.cache_data</code> memoizes expensive functions by their arguments. Wrap <code>compute_eclipse</code> in a cached version and it runs once per unique lat/lon:</p><pre><code><code>@st.cache_data(show_spinner=False)
def cached_eclipse(lat: float, lon: float) -&gt; dict:
    return compute_eclipse(round(lat, 3), round(lon, 3))
</code></code></pre><p>The <code>round(..., 3)</code> trick is worth calling out. Rounding to 3 decimal places means about 100 meters of precision &#8212; way finer than eclipse timing needs &#8212; but crucially it makes the cache key stable across nearby clicks. Click one pixel apart on the map, the cache still hits. Without the round, every micro-click would be a cache miss.</p><p><code>st.session_state</code> is a dict-like object that persists across reruns for the current user&#8217;s session. Store anything you need to remember:</p><pre><code><code>st.session_state.setdefault("selected", None)
st.session_state.setdefault("map_center", [55.0, -10.0])
st.session_state.setdefault("map_zoom", 3)
</code></code></pre><p>Now <code>st.session_state.selected</code> survives across every rerun. When the user clicks the map, we update it; when the script reruns, we read it and populate the sidebar accordingly.</p><h2>Understanding the Bidirectional Map</h2><p>The whole app hinges on <code>st_folium()</code> returning click data back to Python. Look at how it&#8217;s called:</p><pre><code><code>map_state = st_folium(
    fmap,
    height=600,
    returned_objects=["last_clicked", "last_object_clicked", "center", "zoom"],
    key="eclipse-map",
)
</code></code></pre><p><code>returned_objects</code> is the key argument. It tells the component: &#8220;here are the things I care about; give them to me on every rerun.&#8221; Then <code>map_state</code> is a Python dict with keys like:</p><pre><code><code>{
    "last_clicked": {"lat": 43.263, "lng": -2.935},   # or None
    "last_object_clicked": None,                       # or {"lat", "lng"} of clicked marker
    "center": {"lat": 55.0, "lng": -10.0},
    "zoom": 3,
    ...
}
</code></code></pre><p>We use these three pieces:</p><pre><code><code># Preserve pan/zoom so it doesn't reset every rerun
if map_state.get("center"):
    st.session_state.map_center = [map_state["center"]["lat"], 
                                    map_state["center"]["lng"]]
if map_state.get("zoom") is not None:
    st.session_state.map_zoom = map_state["zoom"]

# React to new clicks
click = map_state.get("last_clicked") or map_state.get("last_object_clicked")
if click is not None:
    new_selected = (click["lat"], click["lng"],
                    f"({click['lat']:.3f}, {click['lng']:.3f})")
    if new_selected != st.session_state.selected:
        st.session_state.selected = new_selected
        st.rerun()
</code></code></pre><p>The <code>st.rerun()</code> at the bottom forces a fresh script run &#8212; needed because we changed session state after the map rendered. That fresh run picks up the new selected lat/lon and rebuilds the sidebar with the new eclipse info.</p><h2>Understanding Folium PolyLines and Markers</h2><p>Drawing the eclipse path is one line:</p><pre><code><code>folium.PolyLine(
    locations=CENTERLINE,   # list of (lat, lon) tuples
    color="#dc2626",        # bold red
    weight=4,
    opacity=0.85,
    tooltip="Eclipse centerline",
).add_to(fmap)
</code></code></pre><p>The <code>CENTERLINE</code> list is 21 precomputed (lat, lon) points along the umbra&#8217;s track. I computed them once by finding, at each 5-minute step, the (lat, lon) on Earth&#8217;s surface where the Sun and Moon appear at exactly zero angular separation. That&#8217;s the definition of the shadow&#8217;s center. The optimization runs in <code>scipy.optimize.minimize</code>, takes about a second per point, and the output is baked into <code>eclipse_map.py</code> as a Python list &#8212; no runtime cost, works offline.</p><p>The featured cities are <code>CircleMarker</code> objects colored by coverage:</p><pre><code><code>folium.CircleMarker(
    location=[lat, lon],
    radius=7,
    color=color,
    weight=2,
    fill=True,
    fillOpacity=0.75,
    popup=folium.Popup(popup_html, max_width=220),
    tooltip=name,
).add_to(fmap)
</code></code></pre><p>Popups are HTML strings &#8212; Folium accepts any HTML you throw at them. For each city, we compute its eclipse info once (cached forever), and stuff the coverage percentage and totality duration straight into the popup body.</p><h2>Understanding the Sidebar Rendering</h2><p>Streamlit has a built-in sidebar accessible via <code>st.sidebar</code>. Everything you&#8217;d normally do with <code>st.something(...)</code> you can do with <code>st.sidebar.something(...)</code>:</p><pre><code><code>def render_sidebar(lat, lon, label):
    st.sidebar.markdown(f"### &#128205; {label}")
    st.sidebar.caption(f"{lat:.3f}, {lon:.3f}")

    res = cached_eclipse(lat, lon)

    if not res["visible"]:
        st.sidebar.error("No eclipse visible &#8212; Sun below horizon here.")
        return

    pct = res["max_obscuration"] * 100
    if res["in_totality"]:
        st.sidebar.success(
            f"### &#127761; **TOTAL ECLIPSE**\n"
            f"**{format_duration(res['totality_seconds'])}** of totality"
        )
    elif pct &gt;= 90:
        st.sidebar.warning(f"### &#127767; **{pct:.2f}%** partial eclipse")
    else:
        st.sidebar.info(f"### &#127768; **{pct:.2f}%** partial eclipse")
    ...
</code></code></pre><p><code>st.success</code>, <code>st.warning</code>, <code>st.info</code>, <code>st.error</code> are colored callout boxes &#8212; perfect for status messages. Green for totality, yellow for deep partials, blue for moderate, red for &#8220;not visible.&#8221; Zero CSS written.</p><h2>Understanding the Live Countdown</h2><p>The countdown line updates every time Streamlit re-runs (which is often, given re-runs happen on every widget interaction). No JavaScript setInterval, no WebSocket &#8212; just a normal Python function reading <code>datetime.now()</code>:</p><pre><code><code>GREATEST_ECLIPSE_UTC = datetime(2026, 8, 12, 17, 46, tzinfo=timezone.utc)

def countdown_line():
    delta = GREATEST_ECLIPSE_UTC - datetime.now(timezone.utc)
    if delta.total_seconds() &lt;= 0:
        return "&#127762; The 2026 eclipse has already happened."
    days = delta.days
    hours, rem = divmod(delta.seconds, 3600)
    minutes, _ = divmod(rem, 60)
    return f"&#9201; **{days}d {hours}h {minutes}m** until greatest eclipse"
</code></code></pre><p>Between reruns the countdown stays frozen, but the moment anyone interacts with anything &#8212; even hovering a marker triggers a re-run &#8212; the countdown ticks. Good enough for a friendly display; if you needed literal per-second precision you&#8217;d use a small JS component, but that&#8217;s out of scope.</p><h2>Understanding Layout and Ordering</h2><p>Streamlit renders in the order you call widgets. That&#8217;s why we call <code>render_sidebar()</code> before <code>st_folium()</code> &#8212; even though visually the sidebar is on the left and the map is on the right, we want the sidebar to reflect the <em>current</em> <code>st.session_state.selected</code>, and the map click handling that might change <code>selected</code> happens after. So the flow is:</p><ol><li><p>Read <code>st.session_state.selected</code></p></li><li><p>Render sidebar based on it (frozen for this rerun)</p></li><li><p>Render the map</p></li><li><p>Handle map clicks &#8212; if there&#8217;s a new one, update session state and <code>st.rerun()</code></p></li><li><p>Next rerun starts at step 1 with the new selection</p></li></ol><p>If we rendered the sidebar <em>after</em> the map, the sidebar would always be showing the previous click, one step behind. Small ordering thing, big UX difference.</p><h2>Setup Instructions</h2><p><strong>Install dependencies:</strong></p><pre><code><code>pip install streamlit folium streamlit-folium
</code></code></pre><p>Three new packages on top of Day 1&#8217;s stack. If you skipped Day 1, also install:</p><pre><code><code>pip install skyfield skyfield-data rich geopy timezonefinder numpy
</code></code></pre><p><strong>Run it:</strong></p><pre><code><code>streamlit run eclipse_map.py
</code></code></pre><p>Streamlit opens the app in your browser automatically at </p><p>http://localhost:8501</p><p>. First load takes about 6 seconds &#8212; most of that is loading the JPL ephemeris and computing eclipse info for the 12 featured cities (all cached after that, so subsequent interactions are instant). Any changes to <code>eclipse_map.py</code> while the server runs get detected and Streamlit prompts you to reload.</p><p><strong>Important: both files must be in the same folder.</strong> <code>eclipse_map.py</code> imports from <code>eclipse.py</code>. Put them together, <code>cd</code> into that folder, then run.</p><h2>Practical Use Cases</h2><p><strong>Ship it publicly for eclipse day.</strong> Streamlit Community Cloud hosts Streamlit apps for free directly from a public GitHub repo. Push these two files plus a <code>requirements.txt</code>, connect the repo, and you have a live URL you can share on social media, WhatsApp, or a personal newsletter. Total setup: about ten minutes. Free tier is more than enough for a novelty app that gets shared for a week.</p><p><strong>Template for any point-on-map computation.</strong> Any problem shaped like &#8220;user picks a location on Earth &#8594; I compute something for that location&#8221; fits this exact pattern: weather forecasts, elevation lookups, travel-time calculations, real estate comparisons, air-quality checks. Swap <code>compute_eclipse</code> for <code>compute_weather</code> and you have a weather app.</p><p><strong>Real portfolio project.</strong> Two files, ~830 lines total, real astronomy, real interactive UI, deployable in ten minutes. Put it on your CV under Projects with a link to the live version &#8212; dramatically stronger than &#8220;made a chatbot with the OpenAI API&#8221; like every other candidate.</p><h2>What&#8217;s Next</h2><p>Thursday&#8217;s post is a standalone deep-dive on a specific piece of infrastructure. Coming this week: <strong>why your app should never talk to your database on port 5432</strong>, and the three-line change that makes it 10x faster. Free post, all subscribers get it.</p><p>Next week we shift gears again &#8212; details on Tuesday.</p><h2>Solution</h2><p>Click below to find the complete source code that produces the interactive map with the eclipse path and other data:</p>
      <p>
          <a href="/__u/dailypythonprojects.substack.com/p/visualizing-the-2026-eclipse-with-c95">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Visualizing the 2026 Eclipse with Python: Day 1 - Command-line eclipse info tool]]></title><description><![CDATA[Eight days from now mainland Europe gets its first total solar eclipse since 1999. Here&#8217;s the Python astronomy that tells you exactly what your city will see.]]></description><link>https://dailypythonprojects.substack.com/p/visualizing-the-2026-eclipse-with</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/visualizing-the-2026-eclipse-with</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 04 Aug 2026 10:53:55 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/b76c9cd6-a740-4a48-bb6b-08055c68c738_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On August 12, the Moon&#8217;s shadow will sweep across Greenland, Iceland, and northern Spain &#8212; the first time mainland Europe has seen a total solar eclipse since 1999. Whether you&#8217;re in the path of totality, catching a 94% partial from Lisbon, or getting a thin 9% sliver in New York, exactly what you see depends on precise Sun-Moon geometry from your specific spot on Earth. And that geometry is computable &#8212; in Python &#8212; with the same JPL ephemeris data NASA uses.</p><p>This week we build a real astronomy tool. Day 1 is a Python CLI script that answers &#8220;what will I see from <em>my</em> location?&#8221; for any place on Earth &#8212; eclipse start, maximum, end, percentage of Sun covered, whether you&#8217;re in the path of totality. Day 2 wraps the astronomy in an interactive Streamlit web map: click anywhere on the world, get instant local eclipse timing. The astronomy is the hard part, and we do it once &#8212; Day 2 imports the same functions we build today.</p><h2>Projects in this week&#8217;s series:</h2><h3><strong>&#128073; Day 1:</strong> Command-line eclipse info tool <strong>(Today)</strong></h3><p>When the user runs the script with no arguments, it prints a colored summary table of all the major cities in the eclipse path &#8212; Reykjavik, Bilbao, Burgos, Zaragoza, Palma, plus nearby cities like Barcelona, Madrid, and Lisbon that get deep partials:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!bxxn!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 424w, /__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 848w, /__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 1272w, /__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!bxxn!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png" width="1452" height="968" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:968,&quot;width&quot;:1452,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:689611,&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/209760682?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 424w, /__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 848w, /__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.png 1272w, /__u/substackcdn.com/image/fetch/$s_!bxxn!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d3fdad0-e710-47fd-9a7b-7b69d32a6acb_1452x968.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></p><p>When they run it with a city name, for example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;6143d2f9-1b1f-43f8-a941-bc5890877c43&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">python eclipse.py Madrid</code></pre></div><p> or coordinates: </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;c8b76fde-c512-4bcd-9c83-55f9c4aad97d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">python eclipse.py "43.26, -2.94" </code></pre></div><p>they get a detailed report &#8212; start/max/end times in local timezone plus UTC, coverage percentage, magnitude, and totality duration if applicable:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!K2IM!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 424w, /__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 848w, /__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 1272w, /__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!K2IM!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.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;:642117,&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/209760682?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 424w, /__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 848w, /__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.png 1272w, /__u/substackcdn.com/image/fetch/$s_!K2IM!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F18b4dfcf-a8fd-48fd-9a25-1700c06b271c_1614x1076.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>Everything is computed live from JPL ephemerides. Accurate to within seconds of NASA&#8217;s published numbers.</p><h3><strong>Day 2 (Tomorrow):</strong> Interactive Streamlit web map of the eclipse</h3><p>Tomorrow we will use the astronomy engine, but wrapped in Streamlit + Folium. Full eclipse path drawn on an interactive world map &#8212; centerline and totality band. Click anywhere on Earth and the sidebar populates with local eclipse info computed live for that exact point. City search box (type &#8220;Rome&#8221; or &#8220;Boston&#8221; instead of clicking). Real-time countdown to the eclipse. Major cities pre-marked with popups:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!kyxy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 424w, /__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 848w, /__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 1272w, /__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!kyxy!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.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;:1857514,&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/209760682?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 424w, /__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 848w, /__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.png 1272w, /__u/substackcdn.com/image/fetch/$s_!kyxy!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3635f56-56fb-4d4a-9f5e-e193b9491e1a_2760x1840.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>The map is the payoff of the week &#8212; turning terminal output into something you can share with your family, your students, or a WhatsApp group.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-29">View All Projects This Week</a></p><h2>About the stack</h2><p>Today&#8217;s script uses five packages, each doing one specific thing:</p><p><strong>Skyfield</strong> is Python&#8217;s modern astronomy library. It reads JPL planetary ephemeris files &#8212; the same tables NASA uses to navigate spacecraft &#8212; and gives you sub-second-accurate positions of the Sun, Moon, planets, and satellites from any point on Earth at any time. This is real, professional-grade astronomy code. When you run it and get an eclipse timing, that timing is correct to within a second or two.</p><p><strong>skyfield-data</strong> is a companion package that bundles the DE421 ephemeris file (about 17 MB of JPL data covering 1900&#8211;2050) as a Python package. Normally Skyfield downloads the file from JPL on first run; using <code>skyfield-data</code> means it just works offline, no download step, no waiting.</p><p><strong>Rich</strong> is <em>the</em> library for beautiful terminal output in Python. Colored text, formatted tables, spinners, progress bars &#8212; one import and your CLI script suddenly looks professional instead of like a homework submission. The tables it produces are Unicode box-drawing characters that render perfectly in any modern terminal.</p><p><strong>geopy</strong> does geocoding &#8212; city name to lat/lon. It&#8217;s a thin wrapper around Nominatim (OpenStreetMap&#8217;s free geocoder), so <code>geopy.geocode("Bilbao")</code> returns <code>(43.263, -2.935)</code> and works for any place OpenStreetMap knows about.</p><p><strong>timezonefinder</strong> does the opposite of geocoding for time &#8212; given a lat/lon, it tells you the timezone. Purely offline, backed by polygon data for every timezone on Earth. We use this to convert Skyfield&#8217;s UTC timestamps into local time at the user&#8217;s chosen location, so &#8220;eclipse maximum at 18:27:20 UT&#8221; becomes &#8220;20:27:20 CEST&#8221; for a Spanish city and &#8220;17:48:00 GMT&#8221; for Reykjavik automatically.</p><h2>The single-file philosophy stays</h2><p>Everything &#8212; imports, constants, city list, astronomy math, geometry, formatting, CLI parsing &#8212; lives in one <code>eclipse.py</code> file. Around 400 lines total, organized in clearly-labeled sections. No config file, no separate modules, no folder hopping.</p><h2>Setup Instructions</h2><p><strong>Install dependencies:</strong></p><pre><code><code>pip install skyfield skyfield-data rich geopy timezonefinder numpy
</code></code></pre><p>Six packages. The one to know about is <code>skyfield-data</code> &#8212; without it, Skyfield will download the JPL ephemeris file on first run (~17 MB, one-time). With it, the file lives inside the pip package and everything works offline.</p><p><strong>Run it in one of the following ways:</strong></p><pre><code><code>python eclipse.py                       # summary table for cities in the path
python eclipse.py Bilbao                # detailed report for one city
python eclipse.py "Palma de Mallorca"   # quote names with spaces
python eclipse.py "43.26, -2.94"        # or pass raw coordinates
</code></code></pre><p>First run takes a couple of seconds to load the ephemeris and Rich; subsequent runs are near-instant.</p><h2>Understanding the Astronomy</h2><p>The whole eclipse question &#8212; from your specific location on Earth &#8212; reduces to one geometric question repeated over time: <strong>how much do the Sun&#8217;s and Moon&#8217;s apparent disks overlap as seen from your standing point?</strong></p><p>Each disk has an angular size in the sky. The Sun&#8217;s angular radius from Earth is about 15.8 arcminutes (roughly a quarter of a degree). The Moon&#8217;s is <em>almost identical</em> &#8212; around 15.5 to 16.7 arcminutes depending on where the Moon is in its elliptical orbit. That near-perfect size match is why total solar eclipses exist at all: if the Moon were noticeably smaller in the sky, we&#8217;d only ever get annular eclipses; if noticeably larger, no &#8220;diamond ring&#8221; moment. It&#8217;s a coincidence of two body sizes and orbital distances that will end in about 600 million years as the Moon slowly drifts away.</p><p>For an eclipse from <em>your</em> location, the question is what fraction of the Sun&#8217;s disk area is covered by the Moon&#8217;s disk at each moment. When that fraction is 0, no eclipse. When it&#8217;s 1 and the disks are perfectly overlapping, totality. Everything between is a partial. Simple in principle. In practice, we need to know the <em>exact</em> angular positions of Sun and Moon as seen from your specific lat/lon at every moment during the event.</p><p>That&#8217;s what Skyfield gives us.</p><h2>Understanding Skyfield</h2><p>Skyfield is the professional-grade astronomy library for Python. Under the hood, it reads <strong>JPL DE421</strong> &#8212; a giant table of planetary positions computed by NASA&#8217;s Jet Propulsion Laboratory for the years 1900 to 2050, accurate to sub-arcsecond. Every time we ask &#8220;where is the Moon right now?&#8221;, Skyfield interpolates through this table.</p><p>Three fundamental objects:</p><pre><code><code>from skyfield.api import load, load_file, wgs84
from skyfield_data import get_skyfield_data_path
import os

# The ephemeris (planetary positions table).
bsp = os.path.join(get_skyfield_data_path(), 'de421.bsp')
ts = load.timescale()          # for building Time objects
eph = load_file(bsp)           # the JPL ephemeris

sun = eph['sun']
moon = eph['moon']
earth = eph['earth']
</code></code></pre><p>Then we build an <strong>observer</strong> &#8212; a point on Earth&#8217;s surface with a lat/lon:</p><pre><code><code>observer = earth + wgs84.latlon(43.263, -2.935)   # Bilbao
</code></code></pre><p>Now we can ask &#8220;what does the observer see?&#8221; at any time:</p><pre><code><code>t = ts.utc(2026, 8, 12, 18, 27)              # Aug 12, 2026, 18:27 UT
sun_app = observer.at(t).observe(sun).apparent()
moon_app = observer.at(t).observe(moon).apparent()
</code></code></pre><p>The <code>.apparent()</code> at the end applies all the corrections that make astronomy hard: light-travel time (the Sun&#8217;s photons left it 8 minutes ago, the Moon&#8217;s about 1.3 seconds ago), aberration of light due to Earth&#8217;s motion, and the deflection of light passing near the Sun. Real, textbook astronomy.</p><p>From these, we can extract everything we need:</p><pre><code><code>separation = sun_app.separation_from(moon_app)       # angular gap
sun_dist_km = sun_app.distance().km                  # for angular radius
sun_alt_deg = sun_app.altaz()[0].degrees             # is Sun above horizon?
</code></code></pre><h2>Understanding Angular Radii</h2><p>Physical radii are known constants &#8212; the Sun is about 695,700 km in radius, the Moon 1,737.4 km. Angular radius depends on how far away the object is at that moment. The formula:</p><pre><code><code>sun_rad = np.arcsin(R_SUN_KM / sun_dist_km)     # radians
moon_rad = np.arcsin(R_MOON_KM / moon_dist_km)  # radians
</code></code></pre><p>Simple trigonometry. The Sun&#8217;s distance varies by about 3% over the year (Earth&#8217;s slightly elliptical orbit). The Moon&#8217;s distance varies by about 12% each month (much more elliptical orbit). These small variations are exactly what determines whether a given eclipse is total or annular &#8212; and <em>what fraction</em> of the Sun&#8217;s disk gets covered at maximum in a given place.</p><h2>Understanding Vectorized Time</h2><p>Naive approach: loop through 5040 times (7 hours &#215; 720 samples/hour at 5s resolution), computing Sun and Moon positions at each. That&#8217;s slow.</p><p>Better approach: <strong>pass all 5040 times to Skyfield at once</strong>, as a numpy array:</p><pre><code><code>minutes = np.arange(0, 7*60, 5/60)   # every 5 seconds for 7 hours
times = ts.utc(2026, 8, 12, 14, minutes)   # ONE Time object containing 5040 timestamps

sun_app = observer.at(times).observe(sun).apparent()      # 5040-point result
moon_app = observer.at(times).observe(moon).apparent()    # 5040-point result

seps = sun_app.separation_from(moon_app).radians          # numpy array
sun_dist_km = sun_app.distance().km                       # numpy array
</code></code></pre><p>Skyfield handles vectorized time natively &#8212; internally, everything is numpy arrays. Total runtime for computing the eclipse at one city: about 0.4 seconds. For all 12 cities in the table: about 5 seconds. Fast enough that you can compute it live in a web app (which is what we&#8217;ll do on Day 2).</p><h2>Understanding Circle-Circle Intersection</h2><p>Once we have angular separation <code>d</code> and angular radii <code>R</code> and <code>r</code>, we need the intersection area of two circles. This is a classic geometry problem with a well-known closed-form solution:</p><pre><code><code>def circle_intersection_area(d, R, r):
    """Works elementwise on numpy arrays. Three cases:"""

    # Case 1: no overlap
    if d &gt;= R + r: return 0

    # Case 2: one disk fully contains the other
    if d &lt;= abs(R - r): return np.pi * min(R, r)**2

    # Case 3: partial overlap - standard formula
    part1 = r**2 * np.arccos((d**2 + r**2 - R**2) / (2*d*r))
    part2 = R**2 * np.arccos((d**2 + R**2 - r**2) / (2*d*R))
    part3 = 0.5 * np.sqrt((-d+r+R) * (d+r-R) * (d-r+R) * (d+r+R))
    return part1 + part2 - part3
</code></code></pre><p>The math looks intimidating but the shape is intuitive. <code>part1</code> is the area of the moon&#8217;s disk chord cut by the sun&#8217;s disk. <code>part2</code> is the same for the sun cut by the moon. <code>part3</code> corrects for the overlap of those two chords (which would otherwise be double-counted). The intersection area is <code>part1 + part2 - part3</code>. It&#8217;s the same formula every graphics engine uses for lens-flare rendering and collision detection.</p><p>Divide the intersection area by the sun&#8217;s area, and you have <strong>obscuration</strong> &#8212; the fraction of the Sun&#8217;s disk covered by the Moon at any given moment. Multiply by 100, you have the percentage.</p><h2>Understanding the Horizon Check</h2><p>Geometry can happily tell you the Sun and Moon are perfectly aligned at 3 AM local time &#8212; but no eclipse is visible then because the Sun is on the other side of the Earth. Every eclipse calculation has to check whether the Sun is actually above the horizon:</p><pre><code><code>sun_alt_deg = sun_app.altaz()[0].degrees   # array of altitudes
above_horizon = sun_alt_deg &gt; 0
obscuration = np.where(above_horizon, obscuration, 0.0)
</code></code></pre><p><code>altaz()</code> returns <code>(altitude, azimuth, distance)</code> &#8212; altitude is how high above the horizon, azimuth is compass direction. Above 0 means visible, below 0 means the Sun has set (or hasn&#8217;t risen yet). Any moment when the Sun is below the horizon has effective obscuration of zero &#8212; the eclipse might be &#8220;happening&#8221; geometrically, but it&#8217;s happening on the far side of the world.</p><p>This is exactly why the eclipse table shows different results for Sydney (Sun already set, no eclipse visible) versus NYC (Sun at 62&#176; altitude, small partial eclipse visible) versus Bilbao (Sun at 8&#176; altitude &#8212; near sunset but still up, gets full totality).</p><h2>Understanding Timezone Conversion</h2><p>Skyfield returns everything in UTC. But nobody thinks in UTC. When we report to the user, we want their local time. Two libraries:</p><p><code>timezonefinder</code> takes a lat/lon and returns a timezone name:</p><pre><code><code>from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tf.timezone_at(lat=43.263, lng=-2.935)   # 'Europe/Madrid'
tf.timezone_at(lat=64.147, lng=-21.940)  # 'Atlantic/Reykjavik'
</code></code></pre><p>It works entirely offline using polygon data of every timezone on Earth. Fast and reliable.</p><p><code>zoneinfo</code> (Python stdlib since 3.9) converts UTC datetimes into that timezone:</p><pre><code><code>from zoneinfo import ZoneInfo
local = utc_dt.astimezone(ZoneInfo('Europe/Madrid'))
</code></code></pre><p>Together, they let us take any <code>(lat, lon, utc_datetime)</code> triple and produce a properly-localized display like <code>20:27:20 CEST</code>. Handles daylight saving automatically because that&#8217;s built into the timezone data.</p><h2>Understanding Path of Totality</h2><p>The path of totality is where the Moon&#8217;s disk <em>completely covers</em> the Sun&#8217;s disk. Geometrically: <code>separation + sun_radius &lt;= moon_radius</code>. That is, even at the farthest edge of the Sun from the Moon&#8217;s center, the Sun&#8217;s edge is still inside the Moon&#8217;s disk.</p><pre><code><code>total_mask = (seps + sun_rad &lt;= moon_rad) &amp; above_horizon
in_totality = bool(total_mask.any())

if in_totality:
    first_total = np.argmax(total_mask)
    last_total = len(total_mask) - 1 - np.argmax(total_mask[::-1])
    totality_seconds = (last_total - first_total) * SAMPLE_STEP_SECONDS
</code></code></pre><p>For Burgos, our tool reports 1m 45s of totality. NASA&#8217;s official prediction is 1m 42s. That 3-second discrepancy is our sampling resolution (5 seconds), not an astronomy error &#8212; the underlying JPL data is accurate to milliseconds. If you needed sub-second precision, you&#8217;d interpolate between grid points; for a human-readable report, 3 seconds is fine.</p><h2>Practical Use Cases</h2><p><strong>Trip planning right now.</strong> If you&#8217;re anywhere in Europe on August 12 and want to know exactly what you&#8217;ll see from your Airbnb, run this script with your coordinates. Get the exact times, plan your dinner around them.</p><p><strong>Teaching astronomy.</strong> This is one of the most compact &#8220;real astronomy&#8221; projects you can build in Python. Under 400 lines and it produces genuinely correct results. Assignable to students at any level: middle-schoolers can play with cities, undergrads can dig into the geometry, grad students can extend it to future eclipses (there&#8217;s a total in North America in August 2044).</p><p><strong>Foundation for Day 2.</strong> Every function we built today gets reused tomorrow in the web map. <code>compute_eclipse(lat, lon)</code> becomes the callback when someone clicks the map. That&#8217;s the pedagogical arc of the week: build the engine on Day 1, wrap it in something beautiful on Day 2.</p><h2>Coming Wednesday</h2><p>Tomorrow we take today&#8217;s astronomy engine and wrap it in an interactive Streamlit web app with a Folium/Leaflet map. Full eclipse path drawn as centerline and totality band. Click anywhere on the world and the sidebar populates with local eclipse info computed live. City search box, countdown timer, popups on major cities. The full &#8220;wow&#8221; of the week.</p><p>If you want the map, <a href="/__u/dailypythonprojects.substack.com/subscribe">upgrade here</a> before Wednesday.</p><h2>Solution</h2><p>Below you will find the source code of this project which will produce the same output as shown in the screenshots in this post.</p><p>Get the source code here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/lT74gvrUR0u5LAinnlBOWw&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/lT74gvrUR0u5LAinnlBOWw"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Turn your script into an API]]></title><description><![CDATA[Your Python function works on your laptop, but only there. Here is how it gets its own URL, so a phone, a friend, or another script can call it.]]></description><link>https://dailypythonprojects.substack.com/p/turn-your-script-into-an-api</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/turn-your-script-into-an-api</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Thu, 30 Jul 2026 09:22:34 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!ucJu!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this post, you will learn how to give a Python function its own URL, turning it into an API that other programs can call. This is one of the most important transitions in software development, because it is the moment your code stops being a private script and becomes a service.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!ucJu!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!ucJu!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png" width="1456" height="558" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/28ab29a8-81c9-4d47-bf69-157b913a30fd_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;:57174,&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/209090731?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!ucJu!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28ab29a8-81c9-4d47-bf69-157b913a30fd_2400x920.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 written a function you are a bit proud of. Perhaps it generates strong passwords, or cleans up messy text, or calculates something useful for you. It works fine on your laptop. However, there is a limitation you might not have thought about: the only way to use that function is to open your laptop, activate the environment, and run the script yourself. Your friend cannot use it. Your phone cannot use it. Even another one of your own scripts, running on a different machine, cannot use it.</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><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!SFqg!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!SFqg!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png" width="1456" height="631" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:631,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:88933,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/209090731?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!SFqg!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffedf836c-8ff4-43ce-b0ff-8dd8d73c7ac7_2400x1040.png 1456w" sizes="100vw"></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>The fix for that is called an API.</strong> You have probably used APIs before, at least as the caller/client: you send a request to a URL and you get data back instead of a web page. So today we do the other side. We take your function and give it a URL.</p><h2>Five lines around your function</h2><p>For this we will use FastAPI. Install it together with uvicorn, the program that will run your API:</p><pre><code><code>pip install fastapi uvicorn</code></code></pre><p>Now say this is your function, saved in a file called <code>main.py</code>:</p><pre><code><code>import secrets
import string

def generate_password(length):
    chars = string.ascii_letters + string.digits
    return "".join(secrets.choice(chars) for _ in range(length))
</code></code></pre><p>To turn it into an API, you add this around it:</p><pre><code><code>from fastapi import FastAPI

app = FastAPI()

@app.get("/password")
def password(length: int = 16):
    return {"password": generate_password(length)}
</code></code></pre><p>The line that does the magic is the decorator. <code>@app.get("/password")</code> tells FastAPI: when someone visits <code>/password</code>, run this function and send back whatever it returns.</p><h2>Starting the server</h2><p>Now run it from the terminal:</p><pre><code><code>uvicorn main:app</code></code></pre><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!mC4y!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!mC4y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png" width="1456" height="679" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/83c7aa74-3669-44e5-b03b-120ee46b51aa_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;:116666,&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/209090731?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!mC4y!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83c7aa74-3669-44e5-b03b-120ee46b51aa_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>Your function is now listening at http://127.0.0.1:8000. </p><p>Open <code>http://127.0.0.1:8000/password</code> in your browser and you will see something like <code>{"password":"NlDRymnljPanqFp2"}</code>. Refresh, and you get a new one. Then try adding <code>?length=24</code> to the end of the URL, and you will get a longer password back.</p><p>Notice what happened there. You typed <code>?length=24</code> in a browser, and FastAPI passed that value to your function as the <code>length</code> argument. The URL became a function call.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!sxrn!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!sxrn!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png" width="1456" height="655" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b90d5a6f-d1cc-46f7-8670-27ca0fbab229_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;:107739,&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/209090731?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!sxrn!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb90d5a6f-d1cc-46f7-8670-27ca0fbab229_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><p></p><p>That mapping is the entire idea of an API, and it is worth staring at for a minute. The path picks the function, the query string fills its arguments, and the return value travels back as JSON.</p><h2>Two things you got for free</h2><p>There are two more things FastAPI did without asking you.</p><p>First, open <code>http://127.0.0.1:8000/docs</code> in your browser. You will find a documentation page for your API, generated from your function&#8217;s signature. It lists your endpoint, its parameters, and even has a button to try it out. You wrote no documentation, yet your API has some.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!jgYG!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!jgYG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png" width="1456" height="631" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:631,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:82150,&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/209090731?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 424w, /__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 848w, /__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.png 1272w, /__u/substackcdn.com/image/fetch/$s_!jgYG!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fef267f8c-511a-4ae1-913a-39244c0d22fc_2400x1040.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>Second, try calling your API with <code>?length=abc</code>. Instead of crashing, it responds with a clear message saying that <code>length</code> should be a valid integer. FastAPI read the <code>length: int</code> hint in your function and built the validation for you. When I first saw this, I had written type checks by hand for years, so this felt slightly unfair.</p><h2>Before you send the URL to a friend</h2><p>One warning, and if you read last week&#8217;s post about webhooks you already know what is coming. If you send <code>http://127.0.0.1:8000/password</code> to a friend, it will not work. That address means &#8220;this machine&#8221;, so your API is real but only reachable from your own computer. To let the world call it, you deploy it to a server, which is a computer with a public address that stays on. That trip from laptop to server is a topic we keep circling back to in this publication, because it is the trip that matters most.</p><h2>The bigger picture</h2><p>Here is the way I want you to think about all this. Every API you have ever used, whether it was a weather API, a payment API, or an AI API, is exactly what you built today: someone&#8217;s function, wrapped with a decorator, running on a computer that stays on. There is no magic in between.</p><p>And it also means any useful function you have ever written is about five lines away from becoming a service.</p><p>If you learned something from this today, consider upgrading for the full experience: every weekly project with full source code, plus the complete archive. Since you read this far, I&#8217;d like to offer you 35% off the annual plan as a token of appreciation. Click below to get the discount:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://dailypythonprojects.substack.com/subscribe?coupon=3ffabf6b&quot;,&quot;text&quot;:&quot;Get 20% off: $99 &#8594; $79/year&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="/__u/dailypythonprojects.substack.com/subscribe?coupon=3ffabf6b"><span>Get 20% off: $99 &#8594; $79/year</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build a Note Taking App with FastAPI and HTMX - Day 2: Wiki-links between notes + full-text search]]></title><description><![CDATA[Type [[groceries]] in one note and it becomes a live link. That&#8217;s how Notion, Obsidian, and Roam work under the hood.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-note-taking-app-with-fastapi-908</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-note-taking-app-with-fastapi-908</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Wed, 29 Jul 2026 13:36:32 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c79eb42e-c073-48dd-9e8b-2c8c5688c87a_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday you built a proper multi-user web app: signup, login, notes with real security. Impressive on its own &#8212; but by the end of the post, a plain list of notes is still just a list of notes. Today we add the two features that turn it into something that feels like Notion, Obsidian, or Roam.</p><h3>Projects in this week&#8217;s series:</h3><p><strong>Day 1 (Yesterday):</strong> Sign up, log in, and take notes</p><p>When the user visits the site, they see a clean signup / login page. After creating an account, they land in their notes dashboard, with a form to add notes and a list of all their existing ones:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!TxCJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 424w, /__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 848w, /__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 1272w, /__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!TxCJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bb4573c8-851e-497f-a02c-ca5d90950856_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;:511320,&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/208900221?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 424w, /__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 848w, /__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.png 1272w, /__u/substackcdn.com/image/fetch/$s_!TxCJ!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbb4573c8-851e-497f-a02c-ca5d90950856_2610x1740.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></p><p>Each note has a delete button that removes it instantly, without a full page reload. Logging out clears the session, and trying to visit <code>/notes</code> while logged out bounces you back to the login page. Every user only sees their own notes &#8212; I can&#8217;t peek at yours, you can&#8217;t peek at mine.</p><p><strong>&#128073; Day 2 (Today):</strong> <strong>Wiki-links between notes + full-text search </strong></p><p>Type <code>[[computer tricks]]</code> in one note: </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!DeT0!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 424w, /__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 848w, /__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 1272w, /__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!DeT0!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/08733111-9199-4501-8f98-052b73cea9e5_2556x1704.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;:590328,&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/208900221?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 424w, /__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 848w, /__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.png 1272w, /__u/substackcdn.com/image/fetch/$s_!DeT0!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F08733111-9199-4501-8f98-052b73cea9e5_2556x1704.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>And &#8220;computer tricks&#8221; automatically becomes a separate note and a clickable hyperlink in the first note:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!RCAC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 424w, /__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 848w, /__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 1272w, /__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!RCAC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.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;:1218609,&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/208900221?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 424w, /__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 848w, /__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.png 1272w, /__u/substackcdn.com/image/fetch/$s_!RCAC!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffcb4c1bf-5280-4a17-a825-5e390eb9021a_2574x1716.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>Click it, and you&#8217;re on the &#8220;computer tricks&#8221; note where you can enter more details about that new note:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!Jhez!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 424w, /__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 848w, /__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!Jhez!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.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;:214129,&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/208900221?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 424w, /__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 848w, /__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Jhez!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5162b712-ec09-45c1-88ea-34a2c7c91608_2532x1688.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>The app also shows a backlinks section at the bottom listing every other note that mentions this one. Same pattern as Notion, Obsidian, and Roam Research. </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!_geF!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 424w, /__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 848w, /__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 1272w, /__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!_geF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6451ae53-3474-407e-acb6-658a4a223306_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;:613274,&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/208900221?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 424w, /__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 848w, /__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_2580x1720.png 1272w, /__u/substackcdn.com/image/fetch/$s_!_geF!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6451ae53-3474-407e-acb6-658a4a223306_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>Plus a search bar on the notes list that finds any word across all your notes as you type &#8212; powered by SQLite&#8217;s built-in FTS5 full-text search extension, no external dependencies. Type a word, wait 250ms, and the notes grid updates in place with matching notes:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!wihr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 424w, /__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 848w, /__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 1272w, /__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!wihr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.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;:721148,&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/208900221?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="/__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 424w, /__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 848w, /__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.png 1272w, /__u/substackcdn.com/image/fetch/$s_!wihr!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6708715c-3bce-4b5f-be83-97137141a0e8_2646x1764.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><a href="/__u/dailypythonprojects.substack.com/t/week-28">View All Projects This Week</a></p><p></p><h3>Setup Instructions</h3><p>Same dependencies as yesterday, no new packages:</p><pre><code><code>pip install fastapi uvicorn jinja2 sqlalchemy bcrypt itsdangerous python-multipart</code></code></pre><p>Everything today uses stuff that&#8217;s already installed: <code>re</code> for parsing, <code>sqlalchemy.text</code> for the raw SQL that creates the FTS5 virtual table, <code>markupsafe.Markup</code> to tell Jinja &#8220;I&#8217;ve already escaped this HTML, don&#8217;t double-escape it.&#8221;</p><p><strong>Run it:</strong></p><pre><code><code>uvicorn notes_app_wiki:app --reload</code></code></pre><p>Same as yesterday. The <code>notes.db</code> file from Day 1 works &#8212; the new tables (<code>wiki_links</code> and <code>notes_fts</code>) get created on startup if they don&#8217;t exist. Your existing notes stay, but they won&#8217;t be indexed for search or wiki-links until you edit-save them once (because indexing happens on write, not on read).</p><h3>Understanding Wiki-Links as a Graph</h3><p><strong>A note is not just text &#8212; it&#8217;s a node in a graph.</strong> Every <code>[[link]]</code> inside a note&#8217;s content is a directed edge to another note. Once you have edges, you get all the things graphs give you: neighborhoods, paths, backlinks, connected components.</p><p>The technical decision that unlocks this: <strong>store the link target as a string, not as a foreign key.</strong></p><pre><code><code>class WikiLink(Base):
    __tablename__ = "wiki_links"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    source_note_id = Column(Integer, ForeignKey("notes.id"), nullable=False, index=True)
    target_title = Column(String, nullable=False, index=True)</code></code></pre><p>Note that <code>target_title</code> is a plain string, not a <code>ForeignKey(notes.id)</code>. This is deliberate. If it were a foreign key, you couldn&#8217;t create the link before the target note existed &#8212; which would ruin the Obsidian pattern of writing <code>[[future recipe]]</code> first and creating the actual note later. Storing the title as text lets you write &#8220;forward references&#8221; that resolve automatically when the target gets created.</p><p>The trade-off: renaming a note breaks all links pointing to its old name. Same as Obsidian. Most systems handle this by finding-and-updating all wiki-links on rename; we skip that for now.</p><h3>Understanding the Wiki-Link Parser</h3><p>Two functions do the work: <code>extract_wiki_links</code> (for indexing) and <code>render_wiki_links</code> (for display):</p><pre><code><code>import re

WIKI_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")

def extract_wiki_links(content: str) -&gt; list[str]:
    """Return lowercased target titles from all [[links]] in text."""
    return [m.group(1).strip().lower()
            for m in WIKI_LINK_RE.finditer(content)]</code></code></pre><p>The regex is intentionally simple: two brackets, one or more characters that aren&#8217;t brackets, two brackets. No nesting, no multi-line links. Fewer edge cases, fewer surprises.</p><p>We lowercase the target title on the way out because wiki-link lookups should be case-insensitive &#8212; <code>[[Groceries]]</code> and <code>[[groceries]]</code> should point to the same note. Store lowercase, query lowercase, display whatever the user typed.</p><h3>Understanding Safe HTML Rendering</h3><p>This part is subtle. When we render a note&#8217;s content, we&#8217;re generating HTML from user input, which is where XSS vulnerabilities usually live. The naive &#8220;just escape everything at the end&#8221; doesn&#8217;t work &#8212; we&#8217;re intentionally producing HTML for the wiki-links, and Jinja&#8217;s autoescape would treat our generated <code>&lt;a&gt;</code> tags as text.</p><p>The right approach is to <strong>tokenize the content into pieces and escape each piece separately</strong> as we go:</p><pre><code><code>from html import escape as html_escape
from urllib.parse import quote
from markupsafe import Markup

def render_wiki_links(text, notes_by_title):
    parts = []
    last_end = 0

    for match in WIKI_LINK_RE.finditer(text):
        # Regular text before this link - escape it as HTML
        parts.append(html_escape(text[last_end:match.start()]))

        raw_title = match.group(1).strip()
        safe_display = html_escape(raw_title)     # for display in the link
        target = notes_by_title.get(raw_title.lower())

        if target is not None:
            parts.append(
                f'&lt;a href="/__u/dailypythonprojects.substack.com/notes/%7Btarget.id%7D" class="wiki-link"&gt;'
                f'{safe_display}&lt;/a&gt;'
            )
        else:
            safe_url = quote(raw_title, safe="")   # for the URL
            parts.append(
                f'&lt;a href="/__u/dailypythonprojects.substack.com/notes/new?title={safe_url}" '
                f'class="wiki-link broken"&gt;{safe_display}&lt;/a&gt;'
            )
        last_end = match.end()

    parts.append(html_escape(text[last_end:]))
    return Markup("".join(parts))</code></code></pre><p>Two things happen safely:</p><ol><li><p><strong>Every piece of user content &#8212; the text between links, and the link titles themselves &#8212; goes through </strong><code>html_escape</code><strong> before being placed in the HTML.</strong> A note that contains <code>&lt;script&gt;</code> gets rendered as literal <code>&amp;lt;script&amp;gt;</code>.</p></li><li><p><strong>We return </strong><code>Markup(...)</code> at the end, which tells Jinja &#8220;trust this string, don&#8217;t escape it again.&#8221; Otherwise Jinja&#8217;s autoescape would turn our <code>&lt;a&gt;</code> tags into <code>&amp;lt;a&amp;gt;</code> and destroy the links.</p></li></ol><p>The URL title gets <code>quote()</code> instead of <code>html_escape</code> because it&#8217;s going into a URL, not into HTML. <code>quote()</code> handles URL-encoding (spaces &#8594; <code>%20</code>, special chars percent-encoded). Same principle: <strong>escape for the context you&#8217;re inserting into.</strong></p><h3>Understanding the Sync Pattern</h3><p>The wiki-links table doesn&#8217;t magically populate itself &#8212; you have to keep it in sync with the notes table by hand. Every time a note is created, edited, or deleted, the wiki-links index has to be updated:</p><pre><code><code>def sync_wiki_links(db: Session, note: Note) -&gt; None:
    """Rebuild the wiki_links rows for one note. Call after create or edit."""
    db.query(WikiLink).filter(WikiLink.source_note_id == note.id).delete()
    for target_title in extract_wiki_links(note.content):
        db.add(WikiLink(
            user_id=note.user_id,
            source_note_id=note.id,
            target_title=target_title,
        ))</code></code></pre><p>Simple pattern: <strong>delete all wiki-links FROM this note, re-parse the content, insert the new set.</strong> No fancy diff-and-update logic. For hundreds of links per note it might matter, but for typical note content it&#8217;s fine.</p><p>Then the create/edit routes call this after saving the note:</p><pre><code><code>@app.post("/notes")
async def create_note(title, content, user, db):
    note = Note(user_id=user.id, title=title.strip(), content=content)
    db.add(note)
    db.flush()                    # need note.id for the derived indices
    sync_wiki_links(db, note)     # update the wiki-links table
    fts_insert(db, note)          # update the FTS5 index
    db.commit()
    return RedirectResponse(f"/notes/{note.id}", status_code=303)</code></code></pre><p>Note <code>db.flush()</code> &#8212; this sends the INSERT to SQLite so <code>note.id</code> gets populated, but doesn&#8217;t commit yet. We need the ID to link the derived tables. Then everything commits together as one atomic transaction.</p><p><strong>This is the pattern for every derived index in every real app.</strong> Materialized views, cache layers, search indexes, activity feeds &#8212; they all follow this shape. Source of truth (your notes) gets a write, then N derived indexes get updated in the same transaction. If any step fails, everything rolls back and stays consistent.</p><h3>Understanding Backlinks</h3><p>Once wiki-links are indexed, backlinks are trivial &#8212; just query the same table in the other direction:</p><pre><code><code>def get_backlinks(db: Session, note: Note) -&gt; list[Note]:
    """Notes (of the same user) that link TO this note via [[title]]."""
    rows = (db.query(WikiLink.source_note_id)
              .filter(WikiLink.user_id == note.user_id,
                      WikiLink.target_title == note.title.lower(),
                      WikiLink.source_note_id != note.id)
              .distinct()
              .all())
    ids = [row[0] for row in rows]
    if not ids:
        return []
    return (db.query(Note)
              .filter(Note.id.in_(ids))
              .order_by(Note.updated_at.desc())
              .all())</code></code></pre><p>Two queries, two SQL round-trips: find the note IDs, then hydrate them. Indexed on both <code>user_id</code> and <code>target_title</code>, so this stays fast even with thousands of notes.</p><p>The <code>source_note_id != note.id</code> filter avoids showing a note as its own backlink if it happens to contain <code>[[itself]]</code>. Small polish; you&#8217;d hit this bug in production without it.</p><h3>Understanding SQLite FTS5</h3><p>Here&#8217;s a fact that surprises most people: <strong>SQLite has a built-in full-text search engine.</strong> It&#8217;s called FTS5, it&#8217;s compiled into virtually every SQLite install since 2015, and it&#8217;s genuinely production-grade. Big tech uses it internally for shipping products. You don&#8217;t need Elasticsearch, Meilisearch, Algolia, or even PostgreSQL&#8217;s <code>tsvector</code> &#8212; just a <code>CREATE VIRTUAL TABLE</code> statement:</p><pre><code><code>def init_fts(engine):
    with engine.connect() as conn:
        conn.execute(text("""
            CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
                note_id UNINDEXED,
                user_id UNINDEXED,
                title,
                content,
                tokenize = 'porter unicode61'
            )
        """))
        conn.commit()</code></code></pre><p>A few important pieces:</p><ul><li><p><code>USING fts5(...)</code> &#8212; this is a <strong>virtual table</strong>. It doesn&#8217;t work like a normal table; it&#8217;s backed by an inverted index. You still <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> into it, but the storage under the hood is optimized for fast text search.</p></li><li><p><code>UNINDEXED</code> &#8212; these columns are stored in the FTS5 table but not searchable. <code>note_id</code> and <code>user_id</code> are just tags we use to filter results and link back to the notes table. Marking them UNINDEXED skips building an index for them and saves disk space.</p></li><li><p><code>title</code> and <code>content</code> &#8212; these are the searchable columns. FTS5 tokenizes them and builds an inverted index.</p></li><li><p><code>tokenize = 'porter unicode61'</code> &#8212; this stacks two tokenizers. <code>unicode61</code> handles Unicode text properly (Chinese, Arabic, accented Latin, emoji). <code>porter</code> applies English stemming &#8212; so <code>running</code> matches <code>run</code>, <code>flies</code> matches <code>fly</code>. If you&#8217;re not making an English notes app, drop <code>porter</code>.</p></li></ul><p>The FTS5 virtual table lives entirely inside your existing <code>notes.db</code> SQLite file. No new database process, no new port, no new dependency. That&#8217;s a real production-grade full-text search engine embedded in a single file.</p><h3></h3><h3>Understanding HTMX Search-as-You-Type</h3><p>The search input on the notes list has four HTMX attributes:</p><pre><code><code>&lt;input type="search"
       name="q"
       placeholder="Search your notes..."
       hx-get="/notes/search"
       hx-trigger="input changed delay:250ms, search"
       hx-target="#notes-grid"
       hx-swap="innerHTML"&gt;</code></code></pre><ul><li><p><code>hx-get="/notes/search"</code> &#8212; send a GET request to this URL</p></li><li><p><code>hx-trigger="input changed delay:250ms, search"</code> &#8212; fire on input changes with a 250ms debounce, and also on the browser&#8217;s native &#8220;search&#8221; event (fired when the user clears the input via the &#215; button on the search input)</p></li><li><p><code>hx-target="#notes-grid"</code> &#8212; put the response into the element with this ID</p></li><li><p><code>hx-swap="innerHTML"</code> &#8212; replace the target&#8217;s inner HTML with the response</p></li></ul><p>The <strong>250ms debounce</strong> is critical. Without it, every keystroke fires a request &#8212; a user typing &#8220;groceries&#8221; would fire 9 requests. With the debounce, HTMX waits until the user has been idle for 250ms before firing one request. Instant-feeling for humans, gentle on the server.</p><h3></h3><h3>What&#8217;s Next</h3><p>Thursday&#8217;s post is a standalone deep-dive on a specific piece of infrastructure. Coming this week: <strong>why your app should never talk to your database on port 5432</strong>, and the three-line change that makes it 10x faster. Free post, all subscribers get it.</p><p>Next week I&#8217;m switching gears into a different kind of build &#8212; details on Tuesday.</p><h3>Solution</h3><p>Below you will find the source code of this project which will produce the same output as shown in the screenshots in this post.</p><p>Get the source code here:</p>
      <p>
          <a href="/__u/dailypythonprojects.substack.com/p/build-a-note-taking-app-with-fastapi-908">
              Read more
          </a>
      </p>
   ]]></content:encoded></item><item><title><![CDATA[Build a Note Taking App with FastAPI and HTMX - Day 1: Sign up, log in, and take notes]]></title><description><![CDATA[Every Python course teaches you to build tools for yourself. This week you build one other people log into.]]></description><link>https://dailypythonprojects.substack.com/p/build-a-note-taking-app-with-fastapi</link><guid isPermaLink="false">https://dailypythonprojects.substack.com/p/build-a-note-taking-app-with-fastapi</guid><dc:creator><![CDATA[Ardit Sulce]]></dc:creator><pubDate>Tue, 28 Jul 2026 23:04:24 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/a8e64ccf-3639-4b52-86a3-0493939b1c6c_1160x785.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Today we will build an app that lets users sign up, log in and create and save notes. Each user has their own account. This is a great app you can use for yourself and customize it as you wish.</p><p>Two days, one file, zero JavaScript. You&#8217;ll build a working note-taking web app with real authentication &#8212; sign up, log in, protected pages, sessions, password hashing. Wednesday adds the innovative feature that makes it feel like Notion or Obsidian instead of a class project.</p><h2>Projects in this week&#8217;s series:</h2><h4><strong>&#128073; Day 1 (Today): Sign up, log in, and take notes</strong></h4><p>When the user visits the site, they see a clean signup / login page:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!x_GL!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 424w, /__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 848w, /__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 1272w, /__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!x_GL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.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;:890484,&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/208899723?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 424w, /__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 848w, /__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.png 1272w, /__u/substackcdn.com/image/fetch/$s_!x_GL!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8253df1-a379-46c8-9ef6-331347e62b5c_2562x1708.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></p><p>After creating an account, they land in their notes dashboard, with a form to add a note:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!LZGP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 424w, /__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 848w, /__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 1272w, /__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!LZGP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/91b5e681-bb84-4d26-9237-9fc9bb976f38_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;:511320,&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;:false,&quot;internalRedirect&quot;:&quot;https://dailypythonprojects.substack.com/i/208899723?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 424w, /__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 848w, /__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 1272w, /__u/substackcdn.com/image/fetch/$s_!LZGP!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F91b5e681-bb84-4d26-9237-9fc9bb976f38_2610x1740.png 1456w" sizes="100vw"></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 some notes added they will see a list of all their existing ones:</p><p></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!o6F3!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 424w, /__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 848w, /__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 1272w, /__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!o6F3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.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;:1499289,&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/208899723?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 424w, /__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 848w, /__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.png 1272w, /__u/substackcdn.com/image/fetch/$s_!o6F3!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd874642f-76e9-485f-9025-1955ea03a9d5_3132x2088.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 note has a delete button that removes it instantly, without a full page reload. Logging out clears the session, and trying to visit <code>/notes</code> while logged out bounces you back to the login page. Every user only sees their own notes &#8212; I can&#8217;t peek at yours, you can&#8217;t peek at mine.</p><h4><strong>Day 2 (Tomorrow):</strong> Wiki-links between notes + full-text search</h4><p>Type <code>[[groceries]]</code> in one note and it becomes a live hyperlink. </p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!k-Zv!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 424w, /__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 848w, /__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 1272w, /__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!k-Zv!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.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;:590328,&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/208899723?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 424w, /__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 848w, /__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.png 1272w, /__u/substackcdn.com/image/fetch/$s_!k-Zv!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F595ae99f-abdd-4ac2-9994-bed3f1915f6a_2556x1704.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>Click it, and you&#8217;re on the &#8220;groceries&#8221; note &#8212; with a backlinks section at the bottom showing every other note that mentions this one. Same pattern as Notion, Obsidian, and Roam Research:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="/__u/substackcdn.com/image/fetch/$s_!OtFL!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="/__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 424w, /__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 848w, /__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 1272w, /__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 1456w" sizes="100vw"><img src="/__u/substackcdn.com/image/fetch/$s_!OtFL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/512b7c32-7a34-4af9-ac4f-6a580bef950b_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;:613274,&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/208899723?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="/__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 424w, /__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 848w, /__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_2580x1720.png 1272w, /__u/substackcdn.com/image/fetch/$s_!OtFL!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F512b7c32-7a34-4af9-ac4f-6a580bef950b_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>Plus a search bar in the nav that finds any word across all your notes as you type &#8212; powered by SQLite&#8217;s FTS5 full-text search extension, no external dependencies. Two novel features that turn a plain notes list into a real personal wiki.</p><p><a href="/__u/dailypythonprojects.substack.com/t/week-28">View All Projects This Week</a></p><h2>Today&#8217;s Project</h2><p>A working multi-user notes app with real authentication.</p><p>Users can sign up with a username and password. They can log in and log out. The password is stored hashed &#8212; even you as the app owner can&#8217;t read it. When they&#8217;re logged in, they see their own notes. When they&#8217;re not, they get bounced to the login page. And user A can never, ever see user B&#8217;s notes, no matter what URLs they try to hack.</p><p><strong>Everything lives in one Python file.</strong> Routes, database models, HTML templates, security helpers &#8212; all in <code>notes_app.py</code>. Templates are strings inside the Python file, loaded through Jinja2&#8217;s <code>DictLoader</code>. CSS and interactivity come from CDN links &#8212; Pico.css handles styling, HTMX handles the delete-without-reload button. No <code>.html</code> files, no <code>.css</code> files, no <code>.js</code> files.</p><p>This is deliberate. Real web apps eventually get split into folders and modules, but that split hides the shape of the whole system from students learning it for the first time. Seeing every route, every model, every template in one file is worth more than &#8220;good practice&#8221; for now.</p><h2>About the stack</h2><p>We&#8217;re using a modern, deliberately-chosen stack. Not the biggest one, not the trendiest one &#8212; the one that gets us to a working web app fastest while teaching real concepts.</p><p><strong>FastAPI</strong> is Python&#8217;s most popular modern web framework. Async by default, tiny footprint, gets out of your way. It surpassed Flask in weekly downloads in early 2024 and hasn&#8217;t looked back.</p><p><strong>Jinja2</strong> is the templating engine &#8212; it&#8217;s what Flask uses too. Templates look like HTML with <code>{{ variables }}</code> and <code>{% if conditions %}</code>. We put them inside our Python file as strings using Jinja2&#8217;s <code>DictLoader</code>, so the whole app is one file.</p><p><strong>SQLAlchemy</strong> is Python&#8217;s most-used database toolkit. We use its ORM (Object-Relational Mapper) so we can write Python classes that map to database tables. Backend is SQLite &#8212; one file, no separate database server.</p><p><strong>bcrypt</strong> hashes passwords. This is the standard for password storage &#8212; slow by design so brute-force attacks are impractical, and includes a random salt so identical passwords hash to different values.</p><p><strong>itsdangerous</strong> signs session cookies. When a user logs in, we put their user ID in a cookie signed with a secret key. If anyone tampers with the cookie, the signature won&#8217;t verify and we reject it.</p><p><strong>Pico.css</strong> is a <em>classless</em> CSS framework. That means you don&#8217;t have to write <code>class="btn btn-primary"</code> &#8212; you just write a plain <code>&lt;button&gt;</code> and it looks professional automatically. One CDN link in the HTML <code>&lt;head&gt;</code> and everything looks like a real product.</p><p><strong>HTMX</strong> is the &#8220;modern web app without JavaScript&#8221; library. It adds attributes to HTML (<code>hx-post="/notes/1/delete"</code>) that fetch responses and swap them into the page &#8212; like React, but written as HTML attributes. Zero JavaScript files in our project.</p><h2>Project Task</h2><p>Build a single-file FastAPI app that:</p><ul><li><p>Has a signup page &#8212; validates the username (3-30 chars, not already taken) and password (min 6 chars)</p></li><li><p>Has a login page &#8212; verifies credentials against the hashed password</p></li><li><p>Sets a <strong>signed session cookie</strong> on successful signup/login</p></li><li><p>Has a <code>/logout</code> route that clears the session cookie</p></li><li><p>Bounces logged-out users away from protected pages</p></li><li><p>Shows the logged-in user&#8217;s notes (only theirs &#8212; never anyone else&#8217;s)</p></li><li><p>Lets users create a new note (title + content)</p></li><li><p>Lets users view a single note</p></li><li><p>Lets users <strong>delete a note without a page reload</strong>, using HTMX</p></li><li><p>Stores everything in a local SQLite database via SQLAlchemy</p></li><li><p>Puts all HTML templates inside the Python file (Jinja2 <code>DictLoader</code>)</p></li><li><p>Loads CSS and JavaScript from CDN &#8212; no local static files</p></li></ul><h2>Setup Instructions</h2><p><strong>Install dependencies:</strong></p><pre><code><code>pip install fastapi uvicorn jinja2 sqlalchemy bcrypt itsdangerous python-multipart
</code></code></pre><p>Seven packages. <code>uvicorn</code> is the web server that runs our FastAPI app. <code>python-multipart</code> is required by FastAPI to parse HTML form submissions (signup, login, new note). The rest we&#8217;ve discussed.</p><p><strong>Run it:</strong></p><pre><code><code>uvicorn notes_app:app --reload
</code></code></pre><p>The <code>--reload</code> flag restarts the server automatically whenever you save the file. Open http://127.0.0.1:8000 in your browser. You&#8217;ll see the login page. Click &#8220;Sign up&#8221;, create an account, and you&#8217;re in.</p><p><strong>A note about the database:</strong> the first time you run the app, it creates a file called <code>notes.db</code> in the current directory. That&#8217;s your SQLite database &#8212; every user, every note lives inside it. Delete the file to start fresh; back it up if you don&#8217;t want to lose your notes.</p><h2>Understanding Password Hashing</h2><p>The single most important idea in this project.</p><p><strong>Never store passwords in plain text.</strong> Even if you promise yourself the database is safe, you&#8217;re one leaked backup, one misconfigured cloud bucket, one disgruntled employee away from disaster. Every major breach in the news is either &#8220;they stored plain-text passwords&#8221; or &#8220;they stored badly hashed passwords.&#8221;</p><p>The right approach: store a <strong>cryptographic hash</strong> of the password. A hash is a one-way function &#8212; you can compute it from the password, but you cannot recover the password from it. When a user logs in, you hash whatever they typed and compare it to the stored hash. Match = correct. No match = wrong.</p><p>But not just any hash. Fast hashes like SHA-256 are the <em>wrong</em> choice for passwords &#8212; an attacker who steals your database can compute billions of hashes per second and brute-force weak passwords in minutes. You need a <strong>slow hash</strong>, deliberately designed to be expensive to compute.</p><p><strong>bcrypt</strong> is that hash. It&#8217;s slow by design &#8212; takes ~100ms per hash, which is invisible to a legitimate user but makes brute-forcing impractical. It also includes a random <strong>salt</strong> per password, so even two users with the same password get different hashes.</p><p>Our code:</p><pre><code><code>import bcrypt

def hash_password(password: str) -&gt; str:
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

def verify_password(password: str, hashed: str) -&gt; bool:
    return bcrypt.checkpw(password.encode(), hashed.encode())
</code></code></pre><p>Three details worth noting:</p><ul><li><p><code>bcrypt.gensalt()</code> generates a random salt every call. That&#8217;s why two identical passwords produce different hashes. Never reuse a salt.</p></li><li><p><code>.encode()</code><strong> on input, </strong><code>.decode()</code><strong> on output</strong> &#8212; bcrypt works in bytes, we work in strings.</p></li><li><p><code>bcrypt.checkpw</code> is <strong>constant-time</strong>. It compares in a way that doesn&#8217;t leak <em>how much</em> of the hash matched &#8212; otherwise an attacker could time your responses and gradually learn parts of the hash.</p></li></ul><p>There&#8217;s one bcrypt quirk worth knowing: <strong>it silently ignores anything past the first 72 bytes</strong>. So <code>bcrypt.hashpw("A" * 100)</code> and <code>bcrypt.hashpw("A" * 72)</code> produce the same hash. Newer versions of the <code>bcrypt</code> library raise an error instead of silently truncating &#8212; we defensively truncate to 72 bytes ourselves:</p><pre><code><code>BCRYPT_MAX_BYTES = 72

def _prepare_password(password: str) -&gt; bytes:
    """Encode as UTF-8 and truncate to bcrypt's 72-byte limit safely."""
    encoded = password.encode("utf-8")
    return encoded[:BCRYPT_MAX_BYTES]
</code></code></pre><p>Truncating on the byte boundary (not the character boundary) handles emoji and non-Latin scripts safely &#8212; a multi-byte character never gets split in the middle.</p><h2>Understanding Session Cookies</h2><p>Once a user logs in, how do we remember them for the next request? HTTP is stateless &#8212; every request is independent, the server has no memory of who&#8217;s who. We need to give the browser something to send with every subsequent request.</p><p>The universal answer: <strong>cookies</strong>. When the user logs in, we set a cookie in their browser. Every future request from that browser includes the cookie, and we use it to identify the user.</p><p>But cookies live in the browser, which means anyone &#8212; including malicious extensions, XSS attacks, or the user themselves &#8212; can read and modify them. We can&#8217;t just put <code>user_id=1</code> in a cookie and trust it. Anyone could edit that to <code>user_id=2</code> and impersonate any user.</p><p><strong>The fix is signing.</strong> We store the user ID in the cookie, but we sign it with a secret key that only the server knows. When the cookie comes back, we verify the signature. If it&#8217;s been tampered with, the signature won&#8217;t match and we reject it.</p><p><code>itsdangerous</code> is the standard Python library for this. Two functions:</p><pre><code><code>from itsdangerous import URLSafeSerializer, BadSignature

signer = URLSafeSerializer(SECRET_KEY)

def create_session_token(user_id: int) -&gt; str:
    return signer.dumps({"user_id": user_id})

def read_session_token(token: str) -&gt; Optional[int]:
    try:
        return signer.loads(token)["user_id"]
    except (BadSignature, KeyError, TypeError):
        return None
</code></code></pre><p><code>signer.dumps(data)</code> returns a signed string &#8212; the data is <em>not encrypted</em>, just signed. Anyone can read what&#8217;s in it, but nobody can change it without knowing the secret key.</p><p><code>signer.loads(token)</code> verifies the signature and returns the data if valid. If someone tampered with the cookie, it raises <code>BadSignature</code> and we return <code>None</code> &#8212; the user is treated as not logged in.</p><p><strong>The security of the whole app depends on </strong><code>SECRET_KEY</code><strong> staying secret.</strong> Anyone who knows it can forge cookies for any user. In production, load it from an environment variable and never commit it to git.</p><h2>Understanding Protected Routes</h2><p>Some routes should only work if the user is logged in &#8212; <code>/notes</code>, <code>/notes/new</code>, etc. FastAPI&#8217;s <strong>dependency injection</strong> makes this clean:</p><pre><code><code>def get_current_user(
    request: Request,
    db: Session = Depends(get_db),
) -&gt; Optional[User]:
    token = request.cookies.get(SESSION_COOKIE)
    if not token:
        return None
    user_id = read_session_token(token)
    if not user_id:
        return None
    return db.query(User).filter(User.id == user_id).first()


class AuthRequired(Exception):
    pass


def require_user(user: Optional[User] = Depends(get_current_user)) -&gt; User:
    if not user:
        raise AuthRequired()
    return user


@app.exception_handler(AuthRequired)
async def auth_required_handler(request: Request, exc: AuthRequired):
    return RedirectResponse("/login", status_code=302)
</code></code></pre><p>Then any route that requires login just adds <code>user: User = Depends(require_user)</code> to its signature:</p><pre><code><code>@app.get("/notes")
async def notes_list(user: User = Depends(require_user), ...):
    # If we got here, user is guaranteed to be logged in.
    ...
</code></code></pre><p>The pattern: <code>require_user</code> runs before the route body. If there&#8217;s no session, it raises <code>AuthRequired</code>. The exception handler catches it and redirects to <code>/login</code>. Neat separation of concerns &#8212; the routes never have to check <code>if user is None</code>.</p><h2>Understanding HTMX for the Delete Button</h2><p>The delete button on the notes list uses HTMX to remove the note without reloading the whole page. In the template:</p><pre><code><code>&lt;button
  hx-post="/notes/{{ note.id }}/delete"
  hx-target="#note-{{ note.id }}"
  hx-swap="outerHTML swap:0.2s"
  hx-confirm="Delete this note? This cannot be undone."&gt;
  Delete
&lt;/button&gt;
</code></code></pre><p>Four attributes:</p><ul><li><p><code>hx-post</code> &#8212; send a POST request to this URL when clicked</p></li><li><p><code>hx-target</code> &#8212; find this element on the page (the note&#8217;s article tag)</p></li><li><p><code>hx-swap</code> &#8212; replace the target with the server&#8217;s response (with a 0.2s fade)</p></li><li><p><code>hx-confirm</code> &#8212; show a browser confirmation dialog first</p></li></ul><p>Server side, we detect HTMX requests by checking a header they include:</p><pre><code><code>@app.post("/notes/{note_id}/delete")
async def delete_note(request: Request, note_id: int, ...):
    # ... delete the note ...

    if request.headers.get("HX-Request") == "true":
        # HTMX call: return empty HTML - it replaces the note card with nothing.
        return HTMLResponse("")
    else:
        # Regular form submit (from the detail page): redirect back to the list.
        return RedirectResponse("/notes", status_code=303)
</code></code></pre><p>Same route, two behaviors, cleanly separated. This &#8220;progressive enhancement&#8221; pattern is HTMX&#8217;s superpower &#8212; the app works fine without JavaScript, and gets nicer with it.</p><h2>Understanding the Post-Redirect-Get Pattern</h2><p>Notice we return <code>status_code=303</code> after form submissions. This is a hard-won piece of web app wisdom called <strong>PRG</strong> (Post-Redirect-Get):</p><ul><li><p>User submits a POST form (signup, create note, login, delete)</p></li><li><p>Server processes it, then <strong>redirects</strong> to a GET route</p></li><li><p>Browser shows the GET page</p></li></ul><p>If we returned HTML directly from the POST route, and the user hit refresh, the browser would ask &#8220;Resubmit form?&#8221; and re-run the POST. Duplicate signups, duplicate notes, duplicate everything. PRG makes refresh safe &#8212; the browser only ever refreshes the GET.</p><p>Use <code>303 See Other</code> specifically for POST &#8594; GET redirects. It&#8217;s the HTTP status code designed for exactly this pattern. <code>302 Found</code> also works but has slightly different semantics for some HTTP clients.</p><h2>Coming Wednesday</h2><p>Type <code>[[groceries]]</code> in a note, and it magically becomes a hyperlink to your &#8220;groceries&#8221; note. Click through &#8212; and at the bottom of that note, a <strong>Backlinks</strong> section shows every other note that references it. Same pattern as Notion, Obsidian, and Roam Research. It&#8217;s what turns a pile of notes into a personal knowledge base.</p><p>Plus a search bar in the nav that finds any word across all your notes as you type, using SQLite&#8217;s built-in FTS5 full-text search extension (yes, SQLite has one &#8212; most people don&#8217;t know).</p><p>Wednesday&#8217;s the paid post &#8212; <a href="/__u/dailypythonprojects.substack.com/subscribe">upgrade here</a> if you&#8217;re not already a subscriber.</p><h2>Solution</h2><p>Below you will find the source code of this project which will produce the same output as shown in the screenshots in this post.</p><p>Get the source code here:</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://share.pythonanywhere.com/evolution/G60nrqE1oWGxr6C4rr8oYA&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/G60nrqE1oWGxr6C4rr8oYA"><span>View Code Solution</span></a></p><p></p>]]></content:encoded></item><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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 424w, /__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 848w, /__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9becdad5-f680-407f-a7a0-1df2dd95b6f5_2400x960.png 1272w, /__u/substackcdn.com/image/fetch/$s_!Ng-_!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 424w, /__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 848w, /__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8a552541-6a06-4b76-ba76-d19bac154c7e_2400x1080.png 1272w, /__u/substackcdn.com/image/fetch/$s_!bfb_!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 424w, /__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 848w, /__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32fb53c5-e808-4cba-aec0-a1c161abc65e_2400x1120.png 1272w, /__u/substackcdn.com/image/fetch/$s_!8c4Y!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 424w, /__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 848w, /__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 1272w, /__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 424w, /__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 848w, /__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F79e603ef-b3ef-4753-9b03-bc555b6ae202_2400x1000.png 1272w, /__u/substackcdn.com/image/fetch/$s_!oXeP!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 424w, /__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 848w, /__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03d21dc2-d454-4068-961f-34cb39dd2d77_2400x920.png 1272w, /__u/substackcdn.com/image/fetch/$s_!IOQf!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 424w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 848w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6c2d478d-af1b-4840-9b01-bd13ddb112a8_1734x1156.png 1272w, /__u/substackcdn.com/image/fetch/$s_!VNhC!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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>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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_webp, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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="/__u/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="/__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_424, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 424w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_848, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 848w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1272, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700ef52e-1c2a-42a1-9247-ed683dafd571_2352x1568.png 1272w, /__u/substackcdn.com/image/fetch/$s_!tnd-!, /__u/dailypythonprojects.substack.com/w_1456, /__u/dailypythonprojects.substack.com/c_limit, /__u/dailypythonprojects.substack.com/f_auto, /__u/dailypythonprojects.substack.com/q_auto:good, /__u/dailypythonprojects.substack.com/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>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="/__u/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="/__u/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></channel></rss>