<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Bob Belderbos | Automation consulting and developer coaching - python</title>
    <subtitle>I help companies turn manual processes into software that runs: a fixed-price audit, then build, ship and support. And I coach developers in Python, Rust, and AI.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://belderbos.dev/tags/python/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://belderbos.dev/tags/python/"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-10-15T00:00:00+00:00</updated>
    <id>https://belderbos.dev/tags/python/atom.xml</id>
    <entry xml:lang="en">
        <title>My bookkeeping runs on code now. AI only reads the PDFs.</title>
        <published>2026-10-15T00:00:00+00:00</published>
        <updated>2026-10-15T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/replaced-my-bookkeeping-tool-with-python/"/>
        <id>https://belderbos.dev/blog/replaced-my-bookkeeping-tool-with-python/</id>
        <summary type="html">&lt;p&gt;I paid for a popular bookkeeping tool and it worked well. But the UI kept getting slower, and I wasn&#x27;t using its reporting, so I had no real view of my numbers.&lt;&#x2F;p&gt;
&lt;p&gt;So I did what I usually do: I scoped a solution, &lt;a href=&quot;&#x2F;blog&#x2F;build-the-simplest-thing-that-works&#x2F;&quot;&gt;built the smallest thing that worked&lt;&#x2F;a&gt; and tested it.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I paid for a popular bookkeeping tool and it worked well. But the UI kept getting slower, and I wasn&#x27;t using its reporting, so I had no real view of my numbers.&lt;&#x2F;p&gt;
&lt;p&gt;So I did what I usually do: I scoped a solution, &lt;a href=&quot;&#x2F;blog&#x2F;build-the-simplest-thing-that-works&#x2F;&quot;&gt;built the smallest thing that worked&lt;&#x2F;a&gt; and tested it.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Result: about 2K lines of Python, 45 tests, my own invoice scanner with a human in the loop, and a couple of &lt;code&gt;make&lt;&#x2F;code&gt; commands to close the month and regenerate an Excel report (synced to Drive) for my accountant.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-closing-a-month-of-bookkeeping-involves&quot;&gt;What closing a month of bookkeeping involves&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;m an autónomo (freelancer) in Spain. Each month I issue invoices to clients, collect supplier invoices in a mix of currencies, and hand the close to my asesor (accountant).&lt;&#x2F;p&gt;
&lt;p&gt;On paper the paid tool covered all of that, and maybe a higher tier had the report I wanted. But I live in the terminal, and a command-line workflow beats a nicer dashboard. It&#x27;s the same lesson I teach clients: build around how people work, not around how a vendor decided things should work.&lt;&#x2F;p&gt;
&lt;p&gt;Before writing any code, I mapped the workflow and split the steps into rules and interpretation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;only-one-step-needed-ai&quot;&gt;Only one step needed AI&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s tempting to let AI loose on everything, but the map showed only one step where an LLM added value: extracting fields from supplier PDFs with varying layouts. I use Claude by default, but the model call is one function, so swapping in a local model is a small change.&lt;&#x2F;p&gt;
&lt;p&gt;Everything else is arithmetic (numbering, VAT, IRPF, totals, FX), which belongs in plain code with tests.&lt;&#x2F;p&gt;
&lt;p&gt;For example, the code converts currencies at the ECB rate via &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;frankfurter.dev&quot;&gt;frankfurter.dev&lt;&#x2F;a&gt;, and keeps the original amount and rate in the description: &lt;code&gt;(USD 20.00 @ 0.88511 ECB 2026-10-01)&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Most of the work in adding AI was the &lt;em&gt;control layer&lt;&#x2F;em&gt; around it. The model proposes fields. Python computes, converts, assigns ids and validates. I fix and approve. Tests and logs record what happened. See also: &lt;a href=&quot;&#x2F;blog&#x2F;control-layer-is-the-product&#x2F;&quot;&gt;the control layer is the product&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-ai-proposes-i-append&quot;&gt;The AI proposes, I append&lt;&#x2F;h2&gt;
&lt;p&gt;Nothing touches the ledger until I press &lt;code&gt;a&lt;&#x2F;code&gt; (append):&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;bookkeeping-scan.png&quot; alt=&quot;make scan proposing two expense rows from sample invoices, each waiting for append, edit, skip or quit&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Each proposed row gets the next &lt;code&gt;EXP-YYYY-NNN&lt;&#x2F;code&gt; id and the extracted fields, with VAT and gross computed by code. Append is blocked while validation errors exist:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;choice&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; input&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;  [a]ppend &#x2F; [e]dit &#x2F; [s]kip &#x2F; [q]uit: &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).strip().lower()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; choice&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;a&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; _errors(exp):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;        print&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;  fix the errors first (edit), or skip.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        continue&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; exp&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Extraction misses things, like a receipt without a printed tax ID or a description too vague for my asesor. The edit step catches those:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;bookkeeping-scan-edit.png&quot; alt=&quot;editing a proposed expense before appending it: adding the supplier tax ID and a more specific description&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;On approval, the PDF is renamed to &lt;code&gt;EXP-YYYY-NNN_Supplier.pdf&lt;&#x2F;code&gt;. That rename is the whole &lt;em&gt;state machine&lt;&#x2F;em&gt;, no database needed. The rename happens before the row is written. If it crashes in between, the next run proposes that PDF again under the same id, so nothing is lost or recorded twice.&lt;&#x2F;p&gt;
&lt;p&gt;I also fenced off the AI that helped me write the code: reads and edits to my issued invoices and invoice ledger are denied, and every shell command requires my approval.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;boring-by-design&quot;&gt;Boring by design&lt;&#x2F;h2&gt;
&lt;p&gt;I keep most of the system immutable (a habit &lt;a href=&quot;&#x2F;blog&#x2F;10-days-rust-python-developers-recap&#x2F;&quot;&gt;Rust is reinforcing&lt;&#x2F;a&gt;). The CSV files are the source of truth for invoices and expenses. Issued invoice PDFs are immutable (overwriting one needs &lt;code&gt;--force&lt;&#x2F;code&gt;). Excel is regenerated, never edited.&lt;&#x2F;p&gt;
&lt;p&gt;Every total in the workbook is a formula pointing back to its rows, not a pasted number, so my asesor can click any figure and see where it came from without asking me.&lt;&#x2F;p&gt;
&lt;p&gt;The month close is easy now: drop the PDFs in a folder, &lt;code&gt;make scan&lt;&#x2F;code&gt;, &lt;code&gt;make report&lt;&#x2F;code&gt; (which validates first).&lt;&#x2F;p&gt;
&lt;p&gt;When my asesor confirmed mid-build that the VAT on my expenses wasn&#x27;t deductible in my case, it had to move into cost. The tests pinned the totals, so it was one commit.&lt;&#x2F;p&gt;
&lt;p&gt;There is one tradeoff: I now need to maintain this system. There&#x27;s no vendor support, the FX lookup needs a network connection, and tax rules might change. For a one-person business with tech skills, that&#x27;s worth it: I control the process, I dropped a subscription, and the month close is faster.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;illustrations&#x2F;workflow-first.webp&quot; alt=&quot;a one-week Scope First Audit maps a workflow from email to done: most steps are plain code, one step uses AI, and a person approves before anything is final&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The transferable part is the order of operations:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Map the workflow.&lt;&#x2F;li&gt;
&lt;li&gt;Give the model the one step that needs judgment.&lt;&#x2F;li&gt;
&lt;li&gt;Code the rules.&lt;&#x2F;li&gt;
&lt;li&gt;Put a human at the critical decision points.&lt;&#x2F;li&gt;
&lt;li&gt;Make the results traceable.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;If a process in your business is slow, error-prone or lives in one person&#x27;s head, that&#x27;s what my &lt;a href=&quot;&#x2F;consulting&#x2F;&quot;&gt;Scope First Audit&lt;&#x2F;a&gt; maps out.&lt;&#x2F;p&gt;
&lt;p&gt;P.S. The &lt;code&gt;a&#x2F;e&#x2F;s&#x2F;q&lt;&#x2F;code&gt; prompt treats every field the same, so I extracted the scanner into &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;invoice-scanner&quot;&gt;invoice-scanner&lt;&#x2F;a&gt;. It asks only about low-confidence fields, shows the source text and logs corrections. More on that in an upcoming post.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Ship Your First Python App: a Free Course on the Local Dev Workflow</title>
        <published>2026-09-19T00:00:00+00:00</published>
        <updated>2026-09-19T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/free-python-foundations-course-local-dev-workflow/"/>
        <id>https://belderbos.dev/blog/free-python-foundations-course-local-dev-workflow/</id>
        <summary type="html">&lt;p&gt;I keep hearing this a lot: &lt;em&gt;the Python syntax I picked up in an afternoon, but shipping a real project meant learning ten more things.&lt;&#x2F;em&gt; There is a stark difference between writing some code in Python vs building an app that someone else can run on their system. These skills are taught, but rarely together in one place. So I am building a free course to teach this.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I keep hearing this a lot: &lt;em&gt;the Python syntax I picked up in an afternoon, but shipping a real project meant learning ten more things.&lt;&#x2F;em&gt; There is a stark difference between writing some code in Python vs building an app that someone else can run on their system. These skills are taught, but rarely together in one place. So I am building a free course to teach this.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-gap-i-want-to-help-close&quot;&gt;The gap I want to help close&lt;&#x2F;h2&gt;
&lt;p&gt;I spotted a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;learnpython&#x2F;comments&#x2F;1wbhg81&#x2F;looking_for_a_free_course_on_the_practical_python&#x2F;&quot;&gt;post on r&#x2F;learnpython&lt;&#x2F;a&gt; recently of someone who had done several courses, worked with pandas, NumPy, and scikit-learn, and understood the language well, but had never learned the complete development cycle. The confusion:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Installing Python&lt;&#x2F;li&gt;
&lt;li&gt;Using the terminal&lt;&#x2F;li&gt;
&lt;li&gt;Installing packages with pip&lt;&#x2F;li&gt;
&lt;li&gt;Virtual environments&lt;&#x2F;li&gt;
&lt;li&gt;Choosing&#x2F;configuring the correct Python interpreter&lt;&#x2F;li&gt;
&lt;li&gt;Organizing a project&lt;&#x2F;li&gt;
&lt;li&gt;Saving and reusing dependencies&lt;&#x2F;li&gt;
&lt;li&gt;Running and debugging code&lt;&#x2F;li&gt;
&lt;li&gt;Git and GitHub&lt;&#x2F;li&gt;
&lt;li&gt;Understanding how to share a project with someone else&lt;&#x2F;li&gt;
&lt;li&gt;Understanding what I actually need to install versus what belongs inside my project&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;And the framing was spot on:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&#x27;m not looking for another beginner Python programming course. [...] What I need is something that teaches me how to actually work as a Python developer on my own computer.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;None of that is Python the language. It&#x27;s the workflows around it, and it is genuinely the harder part to learn, because a. it&#x27;s often skipped over or set up for you, and b. it&#x27;s a lot of moving parts touching adjacent domains: the OS, the shell, the editor, the package manager, and git &#x2F; GitHub.&lt;&#x2F;p&gt;
&lt;p&gt;You can learn Python without ever learning any of that (e.g. using it in a notebook), but building a real shared project without these skills is not possible.&lt;&#x2F;p&gt;
&lt;p&gt;Fortunately things timed well. One of the developers I&#x27;m currently coaching started mentoring beginners at her company, and she confirmed this gap: getting Python and package management working locally is where a lot of people still get stuck, often before they write a single line of code.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-course-6-weeks-one-small-app&quot;&gt;The course: 6 weeks, one small app&lt;&#x2F;h2&gt;
&lt;p&gt;So I&#x27;m building &lt;a href=&quot;&#x2F;foundations&#x2F;&quot;&gt;Ship Your First Python App&lt;&#x2F;a&gt;, a 6-week course that teaches the local development workflow from scratch by building a small command-line app: a dev journal.&lt;&#x2F;p&gt;
&lt;p&gt;The first week is live, and every Friday a new week is released. The course is free, self-paced, and right here on this site. You can start it right now.&lt;&#x2F;p&gt;
&lt;p&gt;This is the course that person on Reddit was looking for.&lt;&#x2F;p&gt;
&lt;p&gt;Here is what you&#x27;ll learn each week:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Week 1) Project Setup &amp;amp; Data Model&lt;&#x2F;strong&gt; &lt;em&gt;(live now)&lt;&#x2F;em&gt;: the terminal, Git, SSH keys and your first pull request, starting a project with &lt;code&gt;uv&lt;&#x2F;code&gt;, and modeling data with a dataclass serialized to JSON.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Week 2) CLI &amp;amp; Type Safety&lt;&#x2F;strong&gt; &lt;em&gt;(Sep 25)&lt;&#x2F;em&gt;: wrap it in a real command-line interface using the Typer library, add type hints, and handle errors so users get a clean message instead of a traceback.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Week 3) Features &amp;amp; Search&lt;&#x2F;strong&gt; &lt;em&gt;(Oct 2)&lt;&#x2F;em&gt;: case-insensitive search, tag filtering, and limiting to only see the most recent entries.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Week 4) Code Organization &amp;amp; Testing&lt;&#x2F;strong&gt; &lt;em&gt;(Oct 9)&lt;&#x2F;em&gt;: split one file into modules, making a proper package, group persistence into a class, and share test setup with pytest fixtures.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Week 5) Test Coverage &amp;amp; Polish&lt;&#x2F;strong&gt; &lt;em&gt;(Oct 16)&lt;&#x2F;em&gt;: using the Rich library to nicely format an output table, and measuring coverage with pytest-cov to find untested code.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Week 6) Ship It&lt;&#x2F;strong&gt; &lt;em&gt;(Oct 23)&lt;&#x2F;em&gt;: write a README, do the final verification pass, merge, and optionally publish the package to PyPI or add a (web) frontend.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Each week you&#x27;re handed a failing test suite and write the code to make it pass, so you are always coding against a clear spec and reading real pytest along the way. You also make a new branch and open a pull request each week, so you practice the full cycle of a real project. This mirrors how I work in my &lt;a href=&quot;&#x2F;coaching&#x2F;signoff&#x2F;&quot;&gt;Production, Not Prototype&lt;&#x2F;a&gt;, &lt;a href=&quot;&#x2F;coaching&#x2F;systems-rust&#x2F;&quot;&gt;Rust&lt;&#x2F;a&gt; and &lt;a href=&quot;&#x2F;coaching&#x2F;agentic-ai&#x2F;&quot;&gt;Agentic AI&lt;&#x2F;a&gt; cohort programs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;who-it-is-for-feedback-appreciated&quot;&gt;Who it is for + feedback appreciated&lt;&#x2F;h2&gt;
&lt;p&gt;You can read and understand basic Python code, but you struggle with all the pieces around it required to ship an app. Or you&#x27;ve done that but you want a refresher on the newer tooling like uv and ruff. You have scripted in Python but want to ship a small project end-to-end using the proper workflows.&lt;&#x2F;p&gt;
&lt;p&gt;You do not need prior experience with CLIs, packaging, or git. That&#x27;s what this course will teach. I hope you&#x27;re excited to dive in and learn this critical piece of Python software development with me. You&#x27;ll see the weeks unlock as we go. It&#x27;s very important to me to get feedback to make this as useful, practical and pleasant as possible.&lt;&#x2F;p&gt;
&lt;p&gt;Don&#x27;t hesitate to hit me up on &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;x.com&#x2F;bbelderbos&quot;&gt;X&lt;&#x2F;a&gt;, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;belderbos&#x2F;&quot;&gt;LinkedIn&lt;&#x2F;a&gt; or &lt;a href=&quot;mailto:bob@belderbos.dev&quot;&gt;email me&lt;&#x2F;a&gt; with your thoughts.&lt;&#x2F;p&gt;
&lt;p&gt;Have fun and learn a lot! 🚀&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Protocol or ABC? Designing a pluggable provider interface</title>
        <published>2026-09-18T00:00:00+00:00</published>
        <updated>2026-09-18T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/protocol-vs-abc-provider-interface/"/>
        <id>https://belderbos.dev/blog/protocol-vs-abc-provider-interface/</id>
        <summary type="html">&lt;p&gt;I was designing the provider boundary with a developer for a CLI tool that talks to two different image-generation backends.&lt;&#x2F;p&gt;
&lt;p&gt;Same inputs from the user, two different SDKs underneath. We were figuring out how best to define the shared contract: an abstract base class, or a &lt;code&gt;typing.Protocol&lt;&#x2F;code&gt;? This article has the answer.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I was designing the provider boundary with a developer for a CLI tool that talks to two different image-generation backends.&lt;&#x2F;p&gt;
&lt;p&gt;Same inputs from the user, two different SDKs underneath. We were figuring out how best to define the shared contract: an abstract base class, or a &lt;code&gt;typing.Protocol&lt;&#x2F;code&gt;? This article has the answer.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-setup-one-contract-two-backends&quot;&gt;The setup: one contract, two backends&lt;&#x2F;h2&gt;
&lt;p&gt;The CLI should not know which provider it&#x27;s talking to. It calls something like &lt;code&gt;submit(request)&lt;&#x2F;code&gt; and gets a typed result back. Each provider translates that into its own SDK calls. This is the classic case for a shared interface.&lt;&#x2F;p&gt;
&lt;p&gt;My reflex is to reach for abstract base classes (ABCs) here. You write a base class with &lt;code&gt;@abstractmethod&lt;&#x2F;code&gt;s, every provider inherits from it, and Python refuses to instantiate a subclass that does not implement one or more abstract methods at runtime.&lt;&#x2F;p&gt;
&lt;p&gt;It works. I&#x27;ve used it for &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;PyBites-Open-Source&#x2F;search&quot;&gt;Pybites search&lt;&#x2F;a&gt; and as part of &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;the repository pattern&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;However, there is a downside: required inheritance. When the backends are &lt;em&gt;pluggable&lt;&#x2F;em&gt;, I don&#x27;t own every implementation. A third-party provider shouldn&#x27;t have to import my base class just to count as a valid provider. Enter protocols.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-protocol-fits-better-here&quot;&gt;Why Protocol fits better here&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;typing.Protocol&lt;&#x2F;code&gt; uses structural typing. A class satisfies the interface by having the right methods with the right signatures, not by inheriting from anything.&lt;&#x2F;p&gt;
&lt;p&gt;Quick example:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Protocol&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pixgen.models&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; GenRequest, GenResult&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # your Pydantic models&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Provider&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; submit&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, request: GenRequest) -&amp;gt; GenResult:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; OpenAIProvider&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # note: no inheritance&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; submit&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, request: GenRequest) -&amp;gt; GenResult:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;        ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; run&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(provider: Provider, request: GenRequest) -&amp;gt; GenResult:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; provider.submit(request)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;OpenAIProvider&lt;&#x2F;code&gt; does not inherit from &lt;code&gt;Provider&lt;&#x2F;code&gt; (like an ABC would require), yet the type checker (&lt;code&gt;ty&lt;&#x2F;code&gt;, &lt;code&gt;mypy&lt;&#x2F;code&gt;, &lt;code&gt;pyrefly&lt;&#x2F;code&gt;) will accept it wherever a &lt;code&gt;Provider&lt;&#x2F;code&gt; is expected.&lt;&#x2F;p&gt;
&lt;p&gt;If you miss a method, get the signature wrong, or return the wrong type, a static type checker flags it before you run the code. Python itself won&#x27;t enforce the protocol at runtime, so the checker is doing the work here. No forced inheritance, and you catch mistakes before you ship.&lt;&#x2F;p&gt;
&lt;p&gt;Three things this buys me:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No coupling for outside implementers.&lt;&#x2F;strong&gt; A plugin author writes a class with a &lt;code&gt;submit&lt;&#x2F;code&gt; method. They don&#x27;t import my package to prove conformance. The contract lives in the shape, not the family tree.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Providers evolve independently.&lt;&#x2F;strong&gt; As long as a class keeps the right method shape, its internals can change freely, and it never inherits methods it doesn&#x27;t use. An ABC with several abstract methods forces every provider to implement all of them, even as empty stubs.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;It reads as composition, not hierarchy.&lt;&#x2F;strong&gt; The provider is a value I pass into functions.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Related articles: &lt;a href=&quot;&#x2F;blog&#x2F;ai-agent-architecture-python&#x2F;&quot;&gt;How an AI expense agent is actually structured&lt;&#x2F;a&gt; and &lt;a href=&quot;&#x2F;blog&#x2F;rust-import-traits-to-use-methods&quot;&gt;Why Rust makes you import a trait to use its methods&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-i-d-still-go-with-abcs&quot;&gt;When I&#x27;d still go with ABCs&lt;&#x2F;h2&gt;
&lt;p&gt;If the Protocol is only describing the provider contract, it&#x27;s a great fit. The moment providers need to &lt;em&gt;share&lt;&#x2F;em&gt; concrete behavior, an ABC is the better tool:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; abc&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ABC&lt;&#x2F;span&gt;&lt;span&gt;, abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; BaseProvider&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;ABC&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; submit&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, request: GenRequest) -&amp;gt; GenResult:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._validate(request)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;          # shared, concrete&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._call_api(request)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # provider-specific&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _call_api&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, request: GenRequest) -&amp;gt; GenResult:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If every provider runs similar logic and only differs in the actual API call, inheritance is the right tool for shared implementation. Protocol only describes shape, not shared behavior.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-not-use-both&quot;&gt;Why not use both?&lt;&#x2F;h2&gt;
&lt;p&gt;In practice ABCs and protocols aren&#x27;t mutually exclusive. You can use &lt;code&gt;Protocol&lt;&#x2F;code&gt; for the public plugin boundary and an internal &lt;code&gt;ABC&lt;&#x2F;code&gt; that providers share.&lt;&#x2F;p&gt;
&lt;p&gt;A plugin ecosystem doesn&#x27;t force everyone to depend on your base class. The contract lives in the method shape, not the class hierarchy. A Protocol defines the contract, an ABC on the other hand, is just an implementation detail my providers happen to reuse.&lt;&#x2F;p&gt;
&lt;p&gt;This is also the split we&#x27;ve architected in &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;our agentic AI cohort&lt;&#x2F;a&gt;. The expense-tracker app defines an &lt;code&gt;Assistant&lt;&#x2F;code&gt; &lt;strong&gt;Protocol&lt;&#x2F;strong&gt; for LLM providers, so &lt;code&gt;OpenAIAssistant&lt;&#x2F;code&gt; and &lt;code&gt;GroqAssistant&lt;&#x2F;code&gt; conform by shape without inheriting from anything. Swapping in a new SDK never touches the base. Storage on the other hand uses an ABC and the repository pattern (&lt;code&gt;ExpenseRepository&lt;&#x2F;code&gt;), because here we want the in-memory and database versions to use the exact same contract.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-to-decide-which-pattern-to-use&quot;&gt;How to decide which pattern to use&lt;&#x2F;h2&gt;
&lt;p&gt;Default to &lt;code&gt;Protocol&lt;&#x2F;code&gt;: it&#x27;s the lighter contract and it doesn&#x27;t force inheritance on anyone. Switch to &lt;code&gt;ABC&lt;&#x2F;code&gt; when providers need shared implementation, or when you want Python itself to enforce the abstract methods at instantiation time.&lt;&#x2F;p&gt;
&lt;p&gt;For a plugin &quot;conform by shape&quot; is often a better match than &quot;conform by inheritance&quot;. I wrote up the fuller typing.Protocol walkthrough a while back on &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pybit.es&#x2F;articles&#x2F;typing-protocol-abc-alternative&#x2F;&quot;&gt;Pybites&#x27; blog&lt;&#x2F;a&gt;. There you can read more in detail how the type checker enforces protocols, how to enhance them with &lt;code&gt;@typing.runtime_checkable&lt;&#x2F;code&gt;, and a bit more on Python&#x27;s &lt;em&gt;duck typing&lt;&#x2F;em&gt; philosophy.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Why Rust makes you import a trait to use its methods</title>
        <published>2026-09-17T00:00:00+00:00</published>
        <updated>2026-09-17T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/rust-import-traits-to-use-methods/"/>
        <id>https://belderbos.dev/blog/rust-import-traits-to-use-methods/</id>
        <summary type="html">&lt;p&gt;An interesting challenge for a new developer came up in our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;scriptertorust.com&quot;&gt;Rust cohort&lt;&#x2F;a&gt;: they opened a file and wanted to read its contents from disk, but the compiler refused to let them call &lt;code&gt;read_to_string&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;An interesting challenge for a new developer came up in our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;scriptertorust.com&quot;&gt;Rust cohort&lt;&#x2F;a&gt;: they opened a file and wanted to read its contents from disk, but the compiler refused to let them call &lt;code&gt;read_to_string&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;This is what triggered a great discussion:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-storage&quot;&gt;let mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; File&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;open&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;data.txt&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-storage&quot;&gt;let mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; contents&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; String&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;read_to_string&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; contents)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  &#x2F;&#x2F; Error! no method named `read_to_string`&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The fix was one extra line at the top:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;use&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; std&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;io&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Read&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-storage&quot;&gt;let mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; File&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;open&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;data.txt&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-storage&quot;&gt;let mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; contents&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; String&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;read_to_string&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; contents)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But the developer, like most Pythonistas, was confused:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;My thought is that if we are to use the file, it would be &lt;code&gt;std::io::File&lt;&#x2F;code&gt;? The more confusing part is that we import &lt;code&gt;Read&lt;&#x2F;code&gt; but it&#x27;s not being used anywhere.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Exactly. If you&#x27;re coming from Python, this feels backwards.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-does-a-method-only-work-if-you-import-a-trait&quot;&gt;Why does a method only work if you import a trait?&lt;&#x2F;h2&gt;
&lt;p&gt;What do you mean, &quot;no method named &lt;code&gt;read_to_string&lt;&#x2F;code&gt;&quot;? It&#x27;s right there on the type.&lt;&#x2F;p&gt;
&lt;p&gt;Well, not quite.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;read_to_string&lt;&#x2F;code&gt; isn&#x27;t an inherent method defined directly on &lt;code&gt;File&lt;&#x2F;code&gt;. It&#x27;s a method provided by the &lt;code&gt;Read&lt;&#x2F;code&gt; trait, which &lt;code&gt;File&lt;&#x2F;code&gt; implements.&lt;&#x2F;p&gt;
&lt;p&gt;So Rust&#x27;s method lookup needs to consider that trait.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s what this does:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;use&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; std&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;io&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Read&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You&#x27;re not importing a function called &lt;code&gt;read_to_string&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You&#x27;re bringing the &lt;code&gt;Read&lt;&#x2F;code&gt; trait into scope so its methods can participate in method lookup.&lt;&#x2F;p&gt;
&lt;p&gt;This is a subtle distinction, and coming from Python, this confused me at first.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-isn-t-the-method-simply-on-file&quot;&gt;Why isn&#x27;t the method simply on &lt;code&gt;File&lt;&#x2F;code&gt;?&lt;&#x2F;h2&gt;
&lt;p&gt;A trait is roughly Rust&#x27;s version of an interface: it describes behavior that different types can implement.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;Read&lt;&#x2F;code&gt; defines methods such as:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;read&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;read_to_string&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;read_exact&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;File&lt;&#x2F;code&gt; implements &lt;code&gt;Read&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;But &lt;code&gt;File&lt;&#x2F;code&gt; isn&#x27;t the only type that does. All of the following implement the same &lt;code&gt;Read&lt;&#x2F;code&gt; interface and therefore provide the same behavior:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;TcpStream&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Cursor&amp;lt;Vec&amp;lt;u8&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Stdin&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is one of the powerful things about traits: the behavior is defined separately from the concrete type.&lt;&#x2F;p&gt;
&lt;p&gt;So when you write:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;read_to_string&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; contents)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Rust can conceptually follow this path:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;file&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;implements Read&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Read provides read_to_string()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But the &lt;code&gt;Read&lt;&#x2F;code&gt; trait needs to be in scope for Rust to use that trait in method resolution.&lt;&#x2F;p&gt;
&lt;p&gt;So:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;use&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; std&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;io&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Read&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;is better understood as:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Bring the &lt;code&gt;Read&lt;&#x2F;code&gt; interface into scope so Rust can consider its methods when resolving method calls.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Not:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Import &lt;code&gt;Read&lt;&#x2F;code&gt; because I&#x27;m directly going to use &lt;code&gt;Read&lt;&#x2F;code&gt;.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;h2 id=&quot;the-python-contrast&quot;&gt;The Python contrast&lt;&#x2F;h2&gt;
&lt;p&gt;This is one of those places where your Python mental model gets in the way.&lt;&#x2F;p&gt;
&lt;p&gt;In Python, if an object has a method, you call it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;with&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; open&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;data.txt&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; file&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    contents&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; file&lt;&#x2F;span&gt;&lt;span&gt;.read()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You don&#x27;t need to import some &lt;code&gt;Readable&lt;&#x2F;code&gt; interface before &lt;code&gt;read()&lt;&#x2F;code&gt; becomes available.&lt;&#x2F;p&gt;
&lt;p&gt;Python&#x27;s rough equivalent to a Rust trait is a &lt;code&gt;Protocol&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Protocol&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Readable&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; read&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A type can satisfy that protocol simply by having the right method. You don&#x27;t have to explicitly inherit from &lt;code&gt;Readable&lt;&#x2F;code&gt; to use the method:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; consume&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(source: Readable):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; source.read()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And importantly, you don&#x27;t import &lt;code&gt;Readable&lt;&#x2F;code&gt; before you can call:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;source.read()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The protocol describes the expected interface for type checking. Method lookup at runtime is still performed on the object.&lt;&#x2F;p&gt;
&lt;p&gt;Rust makes a different tradeoff.&lt;&#x2F;p&gt;
&lt;p&gt;A useful simplification is:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Modern Python&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Data → Functions → Composition → Protocols&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Rust&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Data → Ownership → Traits → Composition&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;These aren&#x27;t strict architectural layers, but they capture an important difference in the mental model.&lt;&#x2F;p&gt;
&lt;p&gt;In Python, you tend to think:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;object → what methods does this object have?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Rust, it&#x27;s often more useful to think:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;value → what type is it?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      → what traits does it implement?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      → which of those traits are in scope?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      → what methods does that make available?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s why a seemingly unrelated import can affect whether a method call compiles.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-does-rust-do-this&quot;&gt;Why does Rust do this?&lt;&#x2F;h2&gt;
&lt;p&gt;Because traits are a central part of Rust&#x27;s approach to composition and polymorphism.&lt;&#x2F;p&gt;
&lt;p&gt;Imagine &lt;code&gt;read_to_string&lt;&#x2F;code&gt; were defined separately on every type that can read:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File.read_to_string()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;TcpStream.read_to_string()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Cursor.read_to_string()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Stdin.read_to_string()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You&#x27;d have duplicated interface definitions across types.&lt;&#x2F;p&gt;
&lt;p&gt;Instead, Rust defines the behavior once:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;trait&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Read&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; read_to_string&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and types implement it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File       → Read&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;TcpStream  → Read&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Cursor     → Read&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Stdin      → Read&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now generic Rust code can work with anything that implements &lt;code&gt;Read&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The trait is the abstraction. The concrete type provides the implementation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-compiler-even-hints-at-this&quot;&gt;The compiler even hints at this&lt;&#x2F;h2&gt;
&lt;p&gt;That&#x27;s also why your editor or compiler may tell you:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;trait `Read` is implemented but not in scope&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The type can do this, but the trait providing the method isn&#x27;t visible to the compiler&#x27;s method lookup.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-small-curriculum-tweak&quot;&gt;A small curriculum tweak&lt;&#x2F;h2&gt;
&lt;p&gt;We probably want to go back to our curriculum and change this comment:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&#x2F;&#x2F; This WON&amp;#39;T work - you need to import the trait to use its methods&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;to:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&#x2F;&#x2F; File implements the Read trait, but the trait isn&amp;#39;t in scope,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&#x2F;&#x2F; so Rust can&amp;#39;t find read_to_string through method-call syntax.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That teaches the reason rather than just the rule.&lt;&#x2F;p&gt;
&lt;p&gt;And once you understand traits, the import stops looking so random.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;Read&lt;&#x2F;code&gt; isn&#x27;t an unused import.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;It&#x27;s part of the method lookup.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>How Libraries Run Rust Inside Python (with PyO3)</title>
        <published>2026-09-13T00:00:00+00:00</published>
        <updated>2026-09-13T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/how-libraries-run-rust-inside-python/"/>
        <id>https://belderbos.dev/blog/how-libraries-run-rust-inside-python/</id>
        <summary type="html">&lt;p&gt;Every time you validate data with Pydantic v2, the data-validation library most Python apps reach for, a Rust extension does the work. Its core, pydantic-core, is built with PyO3, the same toolchain we&#x27;ll use here.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Every time you validate data with Pydantic v2, the data-validation library most Python apps reach for, a Rust extension does the work. Its core, pydantic-core, is built with PyO3, the same toolchain we&#x27;ll use here.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;This post builds that same kind of bridge, small enough to read in one sitting: a JSON parser written in Rust, exposed to Python, so you can import it like any other package. The last step, turning the Rust result into Python objects, is the one to understand before you port anything: for a parser like this, it can cost more than the parsing itself.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-four-steps-from-rust-to-import&quot;&gt;The four steps from Rust to import&lt;&#x2F;h2&gt;
&lt;p&gt;Getting Rust code into Python takes four steps:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Write a normal Rust module.&lt;&#x2F;li&gt;
&lt;li&gt;Annotate it with PyO3 macros.&lt;&#x2F;li&gt;
&lt;li&gt;Let &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;PyO3&#x2F;maturin&quot;&gt;maturin&lt;&#x2F;a&gt; compile and install it.&lt;&#x2F;li&gt;
&lt;li&gt;Import the result.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;rust-in-python.png&quot; alt=&quot;Rust in Python: write a Rust module, annotate it with PyO3 macros, build it with maturin, import the shared library&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;#[pyfunction]&lt;&#x2F;code&gt; and &lt;code&gt;#[pymodule]&lt;&#x2F;code&gt; are the two Rust macros that do the wiring. A Rust attribute macro is close to a Python decorator: it rewrites the function it sits on, here adding the glue that lets Python call it and handles the type conversions and reference counting at the boundary.&lt;&#x2F;p&gt;
&lt;p&gt;Maturin then compiles the crate to a shared library (&lt;code&gt;.so&lt;&#x2F;code&gt;, &lt;code&gt;.dylib&lt;&#x2F;code&gt;, &lt;code&gt;.dll&lt;&#x2F;code&gt;) and drops it into your virtual environment, so import just works. I walk through this whole setup, from cargo new to the first import, in &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rsbit.es&#x2F;rust-in-python-pyo3-maturin&#x2F;&quot;&gt;How to run Rust in Python with PyO3 and Maturin&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;That first tutorial returns a single number. This one picks up where it left off, because the interesting part starts once you return a &lt;em&gt;structure&lt;&#x2F;em&gt; instead of a scalar.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-parser-produces-a-rust-value-first&quot;&gt;The parser produces a Rust value first&lt;&#x2F;h2&gt;
&lt;p&gt;The structure this parser returns is a JSON tree, and it&#x27;s the running example for the rest of this post. In our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;scriptertorust.com&quot;&gt;Python to Rust cohort&lt;&#x2F;a&gt;, developers spend six weeks writing a JSON parser from scratch in Rust, a hand-rolled tokenizer and recursive-descent parser with no serde, then expose it to Python through PyO3.&lt;&#x2F;p&gt;
&lt;p&gt;The code I&#x27;ll walk through here is &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;json_parser&quot;&gt;my own implementation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The parser produces a plain Rust enum. A Rust enum holds one of several shapes, and each variant can carry data, so it maps a JSON tree cleanly:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;pub enum&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; JsonValue&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Null&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Boolean&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;bool&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Number&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;f64&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    String&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;String&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Array&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Vec&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;JsonValue&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Object&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;HashMap&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;String&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; JsonValue&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That tree lives entirely in Rust. Python never sees it. The PyO3 layer is a thin adapter on top.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;exposing-one-function&quot;&gt;Exposing one function&lt;&#x2F;h2&gt;
&lt;p&gt;Exposing a function to Python takes two lines:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#[pyfunction]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; parse_json&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;py&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;&amp;gt;(py&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Python&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;py&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;&amp;gt;, input&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;: &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PyResult&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Bound&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;py&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PyAny&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    parse&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(input)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;into_pyobject&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;For a Python reader, the signature is the most interesting part:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;py&lt;&#x2F;code&gt;: &lt;code&gt;Python&amp;lt;&#x27;py&amp;gt;&lt;&#x2F;code&gt; is a token representing access to the Python interpreter and is what you pass to PyO3 APIs that need access to Python objects. On traditional Python builds, this access is associated with holding the GIL. PyO3 hands it to you and you pass it along wherever you touch a Python object.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;Bound&amp;lt;&#x27;py, PyAny&amp;gt;&lt;&#x2F;code&gt; is a handle to a Python object of any type, the Rust side of what you&#x27;d think of as a &lt;code&gt;PyObject&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;PyResult&amp;lt;T&amp;gt;&lt;&#x2F;code&gt; is &lt;code&gt;Result&amp;lt;T, PyErr&amp;gt;&lt;&#x2F;code&gt;: return the value, or an error PyO3 raises as a Python exception.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;?&lt;&#x2F;code&gt; propagates that error. If parse fails, the function returns early and Python sees an exception; otherwise it unwraps the &lt;code&gt;JsonValue&lt;&#x2F;code&gt; and moves on.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;So &lt;code&gt;parse(input)?&lt;&#x2F;code&gt; does the real work, and &lt;code&gt;.into_pyobject(py)&lt;&#x2F;code&gt; builds the Python objects the caller asked for. That last call is where the cost lives: it has to create Python objects for the nodes in the tree, and on a large document that can add up to more work than the parse itself.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-return-trip-is-the-expensive-part&quot;&gt;The return trip is the expensive part&lt;&#x2F;h2&gt;
&lt;p&gt;Here is why that conversion is not free. &lt;code&gt;.into_pyobject&lt;&#x2F;code&gt; walks the entire &lt;code&gt;JsonValue&lt;&#x2F;code&gt; tree and rebuilds it as native Python objects: a dict per object, a list per array, a float or str per leaf. You provide that translation by implementing the &lt;code&gt;IntoPyObject&lt;&#x2F;code&gt; trait, which PyO3 calls to convert a Rust value into a Python one:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;impl&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;py&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; IntoPyObject&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;py&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; JsonValue&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; into_pyobject&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;, py&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Python&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;py&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Result&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;Self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Output&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; Self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Error&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        match&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;            JsonValue&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Null&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Ok&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;into_bound&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py)),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;            JsonValue&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Number&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(n)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Ok&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;into_pyobject&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;to_owned&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;into_any&lt;&#x2F;span&gt;&lt;span&gt;()),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;            JsonValue&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Object&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(obj)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;                let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; py_dict&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PyDict&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                for&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; (k, v)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; obj {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;                    py_dict&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;set_item&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(k, v&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;into_pyobject&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  &#x2F;&#x2F; recurses&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;                Ok&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(py_dict&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;into_any&lt;&#x2F;span&gt;&lt;span&gt;())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;            &#x2F;&#x2F; ...arrays, strings, booleans&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A document with 100,000 values means on the order of 100,000 Python objects being created at the boundary, all after parsing is completely done. On a large document this materialization loop, not the parsing, can dominate the end-to-end time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;errors-cross-the-boundary-the-same-way&quot;&gt;Errors cross the boundary the same way&lt;&#x2F;h2&gt;
&lt;p&gt;The return value is not the only thing that has to translate. A parse failure is a typed Rust error, and Python wants an exception. One From impl, the trait Rust uses to convert one type into another, lets ? do the work:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;impl&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; From&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;JsonError&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PyErr&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; from&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(err&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; JsonError&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PyErr&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        match&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; err {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;            JsonError&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;UnterminatedString&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; { position }&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PyValueError&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new_err&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;                format!&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Unterminated string starting at position {position}&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;            &#x2F;&#x2F; ...one arm per error variant, position preserved&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now malformed input raises a ValueError carrying the offset where parsing broke. The file-reading path gets the same treatment for free: std::io::Error already converts to the matching Python exception, so a missing path raises FileNotFoundError.&lt;&#x2F;p&gt;
&lt;p&gt;The caller gets Python semantics without the Rust layer leaking through.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-means-for-your-own-port&quot;&gt;What this means for your own port&lt;&#x2F;h2&gt;
&lt;p&gt;If the Rust function you&#x27;re porting returns a scalar, port it and move on. The boundary is usually small enough to ignore.&lt;&#x2F;p&gt;
&lt;p&gt;If it returns a large structure, the conversion is your real cost, and it is the next thing to optimize once the parser itself is fast. Preallocating the PyDict can help at the margins, but the bigger win is architectural: don&#x27;t materialize the whole tree if the caller won&#x27;t touch all of it. Hand back a lazy, Rust-backed view and build Python objects on demand.&lt;&#x2F;p&gt;
&lt;p&gt;So when you reach for PyO3, profile the boundary, not just the algorithm. Getting Rust to run fast is the easy half. What you build on the way out, the trip from Rust values to Python objects, is the half that decides whether the port was worth it.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Why Learn to Code If AI Can Code? 6 Reasons</title>
        <published>2026-09-11T00:00:00+00:00</published>
        <updated>2026-09-11T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/why-learn-to-code-if-ai-can-code/"/>
        <id>https://belderbos.dev/blog/why-learn-to-code-if-ai-can-code/</id>
        <summary type="html">&lt;p&gt;Stanford&#x27;s Chris Piech runs Code in Place, an intro programming class with 17,000 students and over 1,000 teachers. They&#x27;ve run it both before and after Cursor and Claude Code arrived, and enrollment basically doubled. It turns out that more people, not less, want to learn to code now that AI can write it.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Stanford&#x27;s Chris Piech runs Code in Place, an intro programming class with 17,000 students and over 1,000 teachers. They&#x27;ve run it both before and after Cursor and Claude Code arrived, and enrollment basically doubled. It turns out that more people, not less, want to learn to code now that AI can write it.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Why learn the thing the machine can now do almost flawlessly? Six reasons out of &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=g-CD1d0q01I&quot;&gt;an interview with him&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;1-syntax-is-the-disposable-half-problem-solving-is-the-durable-one&quot;&gt;1. Syntax is the disposable half; problem-solving is the durable one&lt;&#x2F;h2&gt;
&lt;p&gt;Piech splits learning to program into two parts: the syntax (how you tell a computer to do things) and the problem-solving (how you break a big problem into small pieces and set up data to talk to algorithms). AI is very good at syntax. There is less need to memorize it.&lt;&#x2F;p&gt;
&lt;p&gt;The half worth building is the one that transfers: problem solving. Decomposition, naming the sub-problems, deciding what data structure the algorithm needs. That is the problem solving part you carry into every language and every tool, including AI. The syntax is disposable; the problem-solving is durable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;2-without-foundations-you-can-t-catch-ai-s-bad-decisions&quot;&gt;2. Without foundations, you can&#x27;t catch AI&#x27;s bad decisions&lt;&#x2F;h2&gt;
&lt;p&gt;Piech says he constantly ships with AI, and he&#x27;s explicit about why it works for him: he already knows architecture. Strip that away and the model starts making poor structural calls you often only notice later on in the app&#x27;s lifecycle.&lt;&#x2F;p&gt;
&lt;p&gt;I see this all the time working with LLMs: happy path and call it done. The edge cases show up when real users start hitting it, or when you step back from the first prototype and look hard at what you actually built. At that point you really need to understand the architecture underneath.&lt;&#x2F;p&gt;
&lt;p&gt;This is the whole argument for keeping fundamentals. AI is &lt;a href=&quot;&#x2F;blog&#x2F;ai-accelerator-needs-direction&#x2F;&quot;&gt;an accelerator, not a compass&lt;&#x2F;a&gt;. It can get you to a solution faster, but it can&#x27;t tell you if the solution is good.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;3-outsource-too-much-and-the-muscle-atrophies&quot;&gt;3. Outsource too much and the muscle atrophies&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;If you have AI write too much of your code, at what point can you no longer do that valuable piece of the architecture?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;And the insidious thing is that this will happen quietly. Using AI is fine, Piech says, as long as you stay self-aware about whether you&#x27;re growing alongside it or handing away the growth. I made the same case from a different angle in &lt;a href=&quot;&#x2F;blog&#x2F;ai-skill-atrophy-keep-the-friction&#x2F;&quot;&gt;Guardrails Protect Your Codebase. What Protects Your Judgment?&lt;&#x2F;a&gt;: tooling can protect the coding part (e.g. with harnesses), but only making the hard decisions yourself protects your skill and judgment.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;4-coding-is-the-best-problem-solving-gym-you-have&quot;&gt;4. Coding is the best problem-solving gym you have&lt;&#x2F;h2&gt;
&lt;p&gt;Code gives you immediate, falsifiable feedback. Your logic is wrong, the thing breaks, you see it, you iterate, you learn.&lt;&#x2F;p&gt;
&lt;p&gt;Apply problem-solving to life and the feedback cycle is much slower: you make a decision, and the consequences show up months later, often entangled in a complex web of other decisions.&lt;&#x2F;p&gt;
&lt;p&gt;Coding on the other hand is the rare domain where you can run the loop hundreds of times a week. (&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pybitesplatform.com&quot;&gt;Deliberate practice&lt;&#x2F;a&gt; is how you get the reps.) That&#x27;s what trains judgment, and &lt;a href=&quot;&#x2F;blog&#x2F;thinking-matters-more-than-ever-ai-age&#x2F;&quot;&gt;judgment is the skill AI can&#x27;t hand you&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;5-the-high-order-skill-is-knowing-what-s-worth-building&quot;&gt;5. The high-order skill is knowing what&#x27;s worth building&lt;&#x2F;h2&gt;
&lt;p&gt;Piech frames the durable skill as interfacing between what computers can do and what humans actually need. What&#x27;s the valuable problem? What feature helps a user make progress?&lt;&#x2F;p&gt;
&lt;p&gt;It has always been a critical high-order skill. What&#x27;s changed is that more junior engineers can engage with it now, and if you&#x27;re a junior his advice is to start on it today. You don&#x27;t need the senior title first.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s the same reason I keep arguing &lt;a href=&quot;&#x2F;blog&#x2F;design-over-code&#x2F;&quot;&gt;design beats code&lt;&#x2F;a&gt;: I once reduced a 1,069-line AI-built app down to 156 lines because I put design before code.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;6-you-can-now-learn-faster-than-ever&quot;&gt;6. You can now learn faster than ever&lt;&#x2F;h2&gt;
&lt;p&gt;This is the flip side of reason 3: used well, AI can be an extraordinarily powerful tutor.&lt;&#x2F;p&gt;
&lt;p&gt;Piech&#x27;s own move if he were starting today: build a lot of prototypes with Claude Code, then ask it to teach him the most important concepts behind each one, and iterate.&lt;&#x2F;p&gt;
&lt;p&gt;Foundations first, then learn to code &lt;em&gt;with&lt;&#x2F;em&gt; AI, in that order. That&#x27;s why I&#x27;m building a Python Foundations course: the workflow layer around the language, learned by shipping one real tool. More soon.&lt;&#x2F;p&gt;
&lt;p&gt;The tool that can erode your skills can also compound them, depending entirely on whether you let it think for you or make it teach you.&lt;&#x2F;p&gt;
&lt;p&gt;None of this depends on where AI lands in 2030. Self-driving cars looked close in 2012, and their progress was badly overestimated. It&#x27;s the last 1% that resists the machine: the odd thing on the highway, the judgment call no one scripted. Coding is no different. Decomposition, judgment, taste, knowing what to build; those turn out to be the most human-dependent parts of the job and the ones that will be most valuable in the AI era.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Database-Driven RBAC with FastAPI and Azure Entra ID</title>
        <published>2026-09-09T00:00:00+00:00</published>
        <updated>2026-09-09T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/database-driven-rbac-fastapi-azure-entra/"/>
        <id>https://belderbos.dev/blog/database-driven-rbac-fastapi-azure-entra/</id>
        <summary type="html">&lt;p&gt;This came out of a coaching project: a FastAPI service backing a geotechnical database. We use Azure Entra ID for authentication, but we needed role-based access control (RBAC) granularly per endpoint and across different user roles. It turned into an interesting sprint where we designed it so that role changes did not require code changes.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;This came out of a coaching project: a FastAPI service backing a geotechnical database. We use Azure Entra ID for authentication, but we needed role-based access control (RBAC) granularly per endpoint and across different user roles. It turned into an interesting sprint where we designed it so that role changes did not require code changes.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;I&#x27;ve seen FastAPI auth examples hardcode a policy like: &lt;code&gt;if &quot;Admin&quot; not in user.roles: raise 403&lt;&#x2F;code&gt;, copy-pasted into every route. It works until the day someone asks &quot;can you let Finance approve expenses too?&quot; and now you are editing code and redeploying to answer a question that is really about data.&lt;&#x2F;p&gt;
&lt;p&gt;I built a small demo that does the opposite: Azure Entra ID handles &lt;em&gt;authentication&lt;&#x2F;em&gt; and which roles a user has, but &lt;em&gt;which roles each endpoint demands&lt;&#x2F;em&gt; lives in a database table. An admin re-points access at runtime through an endpoint that is itself protected by the same mechanism.&lt;&#x2F;p&gt;
&lt;p&gt;Full code, setup steps, and tests: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;azure_fastapi_rbac_demo&quot;&gt;bbelderbos&#x2F;azure_fastapi_rbac_demo&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-one-decision-that-matters&quot;&gt;The one decision that matters&lt;&#x2F;h2&gt;
&lt;p&gt;The roles a user carries come from Entra, baked into the JWT &lt;code&gt;roles&lt;&#x2F;code&gt; claim. That part is fixed. The interesting choice is where you store the &lt;em&gt;mapping&lt;&#x2F;em&gt; from endpoint to required roles.&lt;&#x2F;p&gt;
&lt;p&gt;Put it in code and every policy change is a deploy. Put it in a table and it becomes editable state. It is the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;12factor.net&#x2F;config&quot;&gt;12-factor&lt;&#x2F;a&gt; instinct applied to authorization: anything that varies on a schedule you do not control belongs outside the code, as data:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; sqlalchemy&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Column,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; JSON&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; sqlmodel&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Field, SQLModel&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; EndpointPermission&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;SQLModel&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    endpoint_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;primary_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    roles: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default_factory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;list&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; sa_column&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Column(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;JSON&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One row per protected endpoint. The demo seeds three on startup, which is the whole policy at a glance:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;SEED&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;expenses:read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Viewer&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Approver&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Admin&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;expenses:approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Approver&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Admin&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;admin:permissions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Admin&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;So &lt;code&gt;require(&quot;expenses:read&quot;)&lt;&#x2F;code&gt; lets any of the three roles through, while &lt;code&gt;expenses:approve&lt;&#x2F;code&gt; drops &lt;code&gt;Viewer&lt;&#x2F;code&gt;. The only thing hardcoded in a route is its key string. The guard reads the rest from the table:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; require&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(endpoint_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; Callable[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;, Awaitable[User]]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; checker&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        user: User&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Depends(azure_scheme),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        session: Session&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Depends(get_session),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ) -&amp;gt; User:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        perm&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; session.get(EndpointPermission, endpoint_key)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        allowed&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; set&lt;&#x2F;span&gt;&lt;span&gt;(perm.roles)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; perm&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; set&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if not&lt;&#x2F;span&gt;&lt;span&gt; allowed.intersection(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;set&lt;&#x2F;span&gt;&lt;span&gt;(user.roles)):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span&gt; HTTPException(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                status_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;status.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;HTTP_403_FORBIDDEN&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                detail&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;You do not have permission to access this endpoint.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; checker&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;azure_scheme&lt;&#x2F;code&gt; is &lt;code&gt;fastapi-azure-auth&lt;&#x2F;code&gt;&#x27;s &lt;code&gt;SingleTenantAzureAuthorizationCodeBearer&lt;&#x2F;code&gt;. It validates the token and hands back a &lt;code&gt;User&lt;&#x2F;code&gt; with &lt;code&gt;.roles&lt;&#x2F;code&gt; already populated. The guard does one set intersection. That is the entire authorization model.&lt;&#x2F;p&gt;
&lt;p&gt;Routes read clean, the key string is the only literal:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@app.get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;expenses&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; list_expenses&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(user: Annotated[User, Depends(require(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expenses:read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))]):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expenses&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expense1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;expense2&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: user.name}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And it applies to itself as well. Editing the policy table is itself an endpoint guarded by &lt;code&gt;require(&quot;admin:permissions&quot;)&lt;&#x2F;code&gt;, so an Admin can change who approves expenses without touching code. Two guards keep that power from locking everyone out: roles can&#x27;t be empty, and Admin can&#x27;t remove itself from the admin key.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; locks_out_admins&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(endpoint_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, roles: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; endpoint_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;admin:permissions&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; and&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Admin&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; not in&lt;&#x2F;span&gt;&lt;span&gt; roles&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@app.put&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;admin&#x2F;permissions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; set_permission&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(endpoint_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, roles: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;], ...):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; roles:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span&gt; HTTPException(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;422&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;roles must be non-empty&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; locks_out_admins(endpoint_key, roles):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span&gt; HTTPException(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;422&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;cannot remove Admin from admin:permissions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # upsert the row, return it&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;two-azure-traps-that-cost-an-afternoon&quot;&gt;Two Azure traps that cost an afternoon&lt;&#x2F;h2&gt;
&lt;p&gt;The full setup (two app registrations, the &lt;code&gt;user_impersonation&lt;&#x2F;code&gt; scope, defining the app roles, assigning users) is in the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;azure_fastapi_rbac_demo#azure-setup&quot;&gt;repo README&lt;&#x2F;a&gt;. Two gotchas are worth pulling out here, because neither errors in a way that points back at the cause:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;v2 tokens, or everything 401s.&lt;&#x2F;strong&gt; &lt;code&gt;fastapi-azure-auth&lt;&#x2F;code&gt; validates against the &lt;strong&gt;v2&lt;&#x2F;strong&gt; token endpoint. It expects &lt;code&gt;aud&lt;&#x2F;code&gt; to be the bare API client-id GUID and &lt;code&gt;iss&lt;&#x2F;code&gt; to end in &lt;code&gt;&#x2F;v2.0&lt;&#x2F;code&gt;. By default a fresh registration mints &lt;strong&gt;v1&lt;&#x2F;strong&gt; tokens (&lt;code&gt;aud = api:&#x2F;&#x2F;&amp;lt;client-id&amp;gt;&lt;&#x2F;code&gt;, &lt;code&gt;iss = sts.windows.net&#x2F;...&lt;&#x2F;code&gt;), so validation rejects a token that looks perfectly valid at a glance, with &lt;code&gt;invalid_token: &quot;Token contains invalid claims&quot;&lt;&#x2F;code&gt;. The fix is one line in the backend API&#x27;s Manifest:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;api&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;requestedAccessTokenVersion&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 2&lt;&#x2F;span&gt;&lt;span&gt; } }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Mint a token, paste it into &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;jwt.ms&quot;&gt;jwt.ms&lt;&#x2F;a&gt;, and confirm &lt;code&gt;ver&lt;&#x2F;code&gt; reads &lt;code&gt;2.0&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Direct assignment only.&lt;&#x2F;strong&gt; When you assign test users to roles, only &lt;strong&gt;direct&lt;&#x2F;strong&gt; assignment flows into the &lt;code&gt;roles&lt;&#x2F;code&gt; claim; nested group membership does not. The call succeeds, the token validates, and the user simply has no roles, which is its own silent time sink.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;testing-without-azure&quot;&gt;Testing without Azure&lt;&#x2F;h2&gt;
&lt;p&gt;Because authorization is a dependency, you swap it in tests. Override &lt;code&gt;azure_scheme&lt;&#x2F;code&gt; with a fake user and &lt;code&gt;get_session&lt;&#x2F;code&gt; with an in-memory SQLite session, and the entire role matrix is unit-testable with no network and no tenant:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; client_as&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(engine: Engine,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;roles:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; TestClient:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; fake_user&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; SimpleNamespace(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;roles&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;list&lt;&#x2F;span&gt;&lt;span&gt;(roles),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; name&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tester&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; session_override&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # in-memory SQLite&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        with&lt;&#x2F;span&gt;&lt;span&gt; Session(engine)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; s:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            yield&lt;&#x2F;span&gt;&lt;span&gt; s&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.dependency_overrides[azure_scheme]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; fake_user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.dependency_overrides[get_session]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; session_override&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; TestClient(app)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_viewer_cannot_approve&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(engine):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; client_as(engine,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Viewer&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).post(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;expenses&#x2F;1&#x2F;approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).status_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 403&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And because the policy lives in a row, the headline claim is itself a test: an Admin rewrites the row and the &lt;em&gt;same&lt;&#x2F;em&gt; request flips from allowed to forbidden, no redeploy:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_repoint_revokes_access&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(engine):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; client_as(engine,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Approver&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).post(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;expenses&#x2F;1&#x2F;approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).status_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 200&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    client_as(engine,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Admin&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).put(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # drop Approver from expenses:approve&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;&#x2F;admin&#x2F;permissions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        params&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;endpoint_key&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;expenses:approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; json&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Admin&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; client_as(engine,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Approver&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).post(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;expenses&#x2F;1&#x2F;approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).status_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 403&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is the same &lt;code&gt;Depends&lt;&#x2F;code&gt; paying off again. In production it lets an Admin edit the rules in a table. In tests it lets you swap that table for in-memory SQLite and the real login for a fake user, one line each. One design choice, two wins.&lt;&#x2F;p&gt;
&lt;p&gt;When you reach for an &lt;code&gt;if role ==&lt;&#x2F;code&gt; check, ask whether the thing you are encoding is logic or policy. Logic belongs in code. Policy tends to want to change on a schedule you do not control, and the table was cheaper than you think.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>5 design patterns used in my new habit tracker app</title>
        <published>2026-09-07T00:00:00+00:00</published>
        <updated>2026-09-07T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/python-patterns-django-habit-tracker/"/>
        <id>https://belderbos.dev/blog/python-patterns-django-habit-tracker/</id>
        <summary type="html">&lt;p&gt;There are plenty of habit trackers, but I wanted to build mine with constraints, a calendar view and habit streaks. So I built &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;commitgraph.app&quot;&gt;commitgraph&lt;&#x2F;a&gt;: a small Django + HTMX app. Here are five Python design and testing patterns from building it.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;There are plenty of habit trackers, but I wanted to build mine with constraints, a calendar view and habit streaks. So I built &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;commitgraph.app&quot;&gt;commitgraph&lt;&#x2F;a&gt;: a small Django + HTMX app. Here are five Python design and testing patterns from building it.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;1-pure-functions&quot;&gt;1. Pure functions&lt;&#x2F;h2&gt;
&lt;p&gt;Streak counting and calendar shading live in two plain modules. They take dates and return values. And work independently from Django, the database, or the user. That makes them easy to test.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# streaks.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; datetime&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; date, timedelta&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; longest_streak&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(dates: list[date], is_due&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;=lambda&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt; _:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    days&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; set&lt;&#x2F;span&gt;&lt;span&gt;(dates)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; days:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    best&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; run&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    cur, last&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; min&lt;&#x2F;span&gt;&lt;span&gt;(days),&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; max&lt;&#x2F;span&gt;&lt;span&gt;(days)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    while&lt;&#x2F;span&gt;&lt;span&gt; cur&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;lt;=&lt;&#x2F;span&gt;&lt;span&gt; last:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; is_due(cur):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            run&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; run&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; cur&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; days&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            best&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; max&lt;&#x2F;span&gt;&lt;span&gt;(best, run)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        cur&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span&gt; timedelta(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;days&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; best&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Non-due days don&#x27;t break the streak, so a Mon&#x2F;Wed&#x2F;Fri habit can build a streak across its scheduled days.&lt;&#x2F;p&gt;
&lt;p&gt;Thanks to this boundary the streak logic is easy to unit test; no database nor user are needed.&lt;&#x2F;p&gt;
&lt;p&gt;Another example: each calendar cell gets a shade from how much you completed that day. It is tempting to compute that in the template. Instead, I turned it into a helper function:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# calendars.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; shade&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(completed:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, active:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; active&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; completed&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;bg-[var(--g0)] text-muted&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    frac&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; completed&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span&gt; active&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; frac&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;bg-[#a84420] text-white&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; frac&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.6&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;bg-[#d97a4e] text-[#3a1c0e]&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;bg-[#f6ddc9] text-[#3a1c0e]&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;assert shade(5, 5).endswith(&quot;text-white&quot;)&lt;&#x2F;code&gt; is again easy to test and it&#x27;s reusable in different parts of the app.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;2-a-schedule-is-a-7-bit-integer&quot;&gt;2. A schedule is a 7-bit integer&lt;&#x2F;h2&gt;
&lt;p&gt;Claude suggested this nifty approach: a habit runs on a subset of weekdays. That is seven yes&#x2F;no answers, which fit cleanly into a single 7-bit integer.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Habit&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;models&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Model&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ALL_DAYS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; = 0b&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1111111&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # every day&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    WEEKDAYS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; = 0b&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0011111&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # Mon-Fri (weekday() 0-4)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    due_days&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; models.PositiveSmallIntegerField(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;ALL_DAYS&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; is_due_on&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, day: date) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.due_days&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;amp;&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;lt;&amp;lt;&lt;&#x2F;span&gt;&lt;span&gt; day.weekday()))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No join table, no seven boolean columns. Adding &quot;weekends only&quot; is a new constant, not a migration.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;is_due_on&lt;&#x2F;code&gt; one-liner treats &lt;code&gt;due_days&lt;&#x2F;code&gt; as a &lt;strong&gt;7-bit binary calendar&lt;&#x2F;strong&gt;, one switch per weekday, ordered right-to-left from Monday (0) to Sunday (6).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;day.weekday()&lt;&#x2F;code&gt; gives 0-6 (Mon-Sun). &lt;code&gt;1 &amp;lt;&amp;lt; day.weekday()&lt;&#x2F;code&gt; puts a single bit at that day&#x27;s position, and &lt;code&gt;&amp;amp; self.due_days&lt;&#x2F;code&gt; is non-zero only if the habit is scheduled that day. &lt;code&gt;bool()&lt;&#x2F;code&gt; turns that into &lt;code&gt;True&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;False&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;3-let-the-standard-library-build-the-month-grid&quot;&gt;3. Let the standard library build the month grid&lt;&#x2F;h2&gt;
&lt;p&gt;A month grid can be tricky: leading blanks, trailing blanks, weeks that straddle two months. Python&#x27;s &lt;code&gt;calendar&lt;&#x2F;code&gt; module already knows all of it.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; calendar&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;for&lt;&#x2F;span&gt;&lt;span&gt; week&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; calendar.Calendar(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;firstweekday&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;).monthdatescalendar(year, month):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span&gt; day&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; week:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; day.month&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span&gt; month:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;            ...&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # a padding cell from the previous or next month&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;monthdatescalendar&lt;&#x2F;code&gt; returns real &lt;code&gt;date&lt;&#x2F;code&gt; objects, one clean list of weeks; no need to write any logic around how many days April has.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;4-a-query-vocabulary-on-the-model&quot;&gt;4. A query vocabulary on the model&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;Active habits&quot; and &quot;habits active on a given day&quot; show up everywhere. Rather than repeating the filters in every view, I named them on a custom &lt;code&gt;QuerySet&lt;&#x2F;code&gt; so they are chainable:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; HabitQuerySet&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;models&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;QuerySet&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; active&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.filter(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;archived_at__isnull&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; active_on&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, day: date):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.filter(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            Q(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;start_date__lte&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;day)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            &amp;amp;&lt;&#x2F;span&gt;&lt;span&gt; (Q(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;archived_at__isnull&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span&gt; Q(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;archived_at__date__gte&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;day))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Habit&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;models&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Model&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    objects&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; HabitQuerySet.as_manager()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now &lt;code&gt;Habit.objects.filter(user=u).active()&lt;&#x2F;code&gt; is very readable and the relatively complex &lt;code&gt;Q&lt;&#x2F;code&gt; expressions are abstracted into the model.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;5-done-and-due-is-set-algebra&quot;&gt;5. &quot;Done and due&quot; is set algebra&lt;&#x2F;h2&gt;
&lt;p&gt;The Today screen needs to know which due habits are already checked off. Two sets and one operator.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;due_ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {h.id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; h&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; habits}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;done_ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; set&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    HabitCompletion.objects&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    .filter(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;habit__user&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;user,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; date&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;day)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    .values_list(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;habit_id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; flat&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;amp;&lt;&#x2F;span&gt;&lt;span&gt; due_ids&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The intersection drops any completion for a habit that is not due today, so a spurious record can&#x27;t inflate the count. The logic is the math, not a nest of &lt;code&gt;if&lt;&#x2F;code&gt; statements.&lt;&#x2F;p&gt;
&lt;p&gt;Here is a REPL snippet that demonstrates how set intersection (&lt;code&gt;&amp;amp;&lt;&#x2F;code&gt;) filters down to only the elements present in both sets:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; due_ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 11&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 12&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 13&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;      # Habits scheduled for today&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; completed_ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;11&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 13&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 99&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Logged completions (99 is a stray record)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; # The &amp;#39;&amp;amp;&amp;#39; operator keeps ONLY IDs present in BOTH sets&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; done_today&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; completed_ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;amp;&lt;&#x2F;span&gt;&lt;span&gt; due_ids&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; print&lt;&#x2F;span&gt;&lt;span&gt;(done_today)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # 10, 12 and 99 are dropped because they are not in both sets&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;11&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 13&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I have also seen this used in RBAC logic to cross-check user roles with endpoint permissions. The intersection of the two sets is the effective permissions a user has.&lt;&#x2F;p&gt;
&lt;p&gt;The thread running through all five patterns: when a piece of logic doesn&#x27;t strictly need the database or a dependency, stick to this boundary. Fetch your primitive values, do the math in plain Python, so it&#x27;s more testable in isolation.&lt;&#x2F;p&gt;
&lt;p&gt;What is one function in your Django views that would be much easier to trust and test if you decoupled it?&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Why does Alembic need an import you never use?</title>
        <published>2026-09-04T00:00:00+00:00</published>
        <updated>2026-09-04T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/what-table-true-does-sqlmodel/"/>
        <id>https://belderbos.dev/blog/what-table-true-does-sqlmodel/</id>
        <summary type="html">&lt;p&gt;A coaching session this week surfaced a good question: Alembic autogenerate only works if you import your models first, even though you never reference them.&lt;&#x2F;p&gt;
&lt;p&gt;Ruff flags the import as unused.&lt;&#x2F;p&gt;
&lt;p&gt;So why is it there?&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;A coaching session this week surfaced a good question: Alembic autogenerate only works if you import your models first, even though you never reference them.&lt;&#x2F;p&gt;
&lt;p&gt;Ruff flags the import as unused.&lt;&#x2F;p&gt;
&lt;p&gt;So why is it there?&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Instead of just adding &lt;code&gt;# noqa: F401&lt;&#x2F;code&gt;, I stopped and followed the call path into SQLModel and SQLAlchemy. The answer is a nice example of Python &lt;em&gt;metaprogramming&lt;&#x2F;em&gt;: importing the module executes the class definition, and the class definition registers its table with SQLAlchemy&#x27;s metadata.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-unused-import-that-isn-t-unused&quot;&gt;The unused import that isn&#x27;t unused&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s the line every SQLModel + Alembic setup has in &lt;code&gt;env.py&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; tips.models&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Tip, User&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # noqa: F401&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;target_metadata&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; SQLModel.metadata&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;(That&#x27;s a real line from &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;codeimag.es&#x2F;blob&#x2F;main&#x2F;migrations&#x2F;env.py#L22&quot;&gt;this project&#x27;s &lt;code&gt;env.py&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;.)&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;SQLModel.metadata&lt;&#x2F;code&gt; is a single shared &lt;code&gt;MetaData&lt;&#x2F;code&gt; object. A table only gets registered in its tables collection as a side effect of the class being defined.&lt;&#x2F;p&gt;
&lt;p&gt;If &lt;code&gt;tips.models&lt;&#x2F;code&gt; never gets imported, the &lt;code&gt;class Tip(...)&lt;&#x2F;code&gt; statement never executes, so no table is registered. Alembic then sees empty model metadata when it runs autogenerate. Existing database tables aren&#x27;t present in that metadata, so Alembic can propose dropping them.&lt;&#x2F;p&gt;
&lt;p&gt;So the import isn&#x27;t there for the names &lt;code&gt;Tip&lt;&#x2F;code&gt; and &lt;code&gt;User&lt;&#x2F;code&gt;. It&#x27;s there to &lt;em&gt;run the module&lt;&#x2F;em&gt; so the class definitions fire. That&#x27;s why &lt;code&gt;# noqa: F401&lt;&#x2F;code&gt; is justified.&lt;&#x2F;p&gt;
&lt;p&gt;This is a common confusion for people new to Python, importing an object actually reads and executes the module, and the side effects of that execution are often more important than the names it exports. (For a related trap, where &lt;em&gt;when&lt;&#x2F;em&gt; an object gets created changes its behavior, see &lt;a href=&quot;&#x2F;blog&#x2F;two-scoping-bugs-object-lifetimes&#x2F;&quot;&gt;Two Python Scoping Bugs&lt;&#x2F;a&gt;.)&lt;&#x2F;p&gt;
&lt;p&gt;A better way to write it so we can include any future models without having to list them all is:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; tips.models&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # noqa: F401&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The interesting question is what &quot;the class definition fires&quot; actually means. Nothing in your model class calls &lt;code&gt;metadata.add_table()&lt;&#x2F;code&gt;. So who does?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;following-table-true-into-the-source&quot;&gt;Following table=True into the source&lt;&#x2F;h2&gt;
&lt;p&gt;When you write &lt;code&gt;class Tip(SQLModel, table=True)&lt;&#x2F;code&gt;, the &lt;code&gt;table=True&lt;&#x2F;code&gt; is a class keyword argument. It gets routed to &lt;code&gt;SQLModelMetaclass&lt;&#x2F;code&gt;. In sqlmodel 0.0.39, &lt;code&gt;main.py&lt;&#x2F;code&gt; reads it back out and stashes it on the model config:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# sqlmodel&#x2F;main.py — SQLModelMetaclass.__new__&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;config_table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; get_config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;table&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; config_table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    new_cls.model_config[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;table&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; config_table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then &lt;code&gt;__init__&lt;&#x2F;code&gt; on the same metaclass checks that flag and, only when it&#x27;s set, hands the class over to SQLAlchemy&#x27;s declarative machinery:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# sqlmodel&#x2F;main.py — SQLModelMetaclass.__init__&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;base_is_table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; any&lt;&#x2F;span&gt;&lt;span&gt;(is_table_model_class(base)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; base&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; bases)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; is_table_model_class(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;cls&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; and not&lt;&#x2F;span&gt;&lt;span&gt; base_is_table:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # build columns and relationships from the model&amp;#39;s fields&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    DeclarativeMeta.&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;__init__&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;cls&lt;&#x2F;span&gt;&lt;span&gt;, classname, bases, dict_,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; **&lt;&#x2F;span&gt;&lt;span&gt;kw)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;else&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ModelMetaclass.&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;__init__&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;cls&lt;&#x2F;span&gt;&lt;span&gt;, classname, bases, dict_,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; **&lt;&#x2F;span&gt;&lt;span&gt;kw)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;(&lt;code&gt;is_table_model_class&lt;&#x2F;code&gt; is just the check that &lt;code&gt;model_config[&quot;table&quot;]&lt;&#x2F;code&gt; is set.)&lt;&#x2F;p&gt;
&lt;p&gt;That &lt;code&gt;if&#x2F;else&lt;&#x2F;code&gt; is the key fork created by &lt;code&gt;table=True&lt;&#x2F;code&gt;. With it, SQLModel takes the class down SQLAlchemy&#x27;s declarative path. Without it, you get a SQLModel&#x2F;Pydantic model rather than a mapped table model, so no SQLAlchemy &lt;code&gt;Table&lt;&#x2F;code&gt; is registered in &lt;code&gt;SQLModel.metadata&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;From &lt;code&gt;DeclarativeMeta.__init__&lt;&#x2F;code&gt; the trail runs straight down into SQLAlchemy:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# sqlalchemy&#x2F;orm&#x2F;decl_api.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if not&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt;__dict__&lt;&#x2F;span&gt;&lt;span&gt;.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;__abstract__&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _as_declarative(reg,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;, dict_)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;_as_declarative&lt;&#x2F;code&gt; scans the class, builds a &lt;code&gt;Table&lt;&#x2F;code&gt; object from the columns, and that &lt;code&gt;Table&lt;&#x2F;code&gt; registers itself:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# sqlalchemy&#x2F;sql&#x2F;schema.py — Table.__new__&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;metadata._add_table(name, schema, table)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# sqlalchemy&#x2F;sql&#x2F;schema.py — MetaData._add_table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _add_table&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, name, schema, table):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; _get_table_key(name, schema)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;    self&lt;&#x2F;span&gt;&lt;span&gt;.tables._insert_item(key, table)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;There it is. &lt;code&gt;self.tables._insert_item(...)&lt;&#x2F;code&gt; is the exact moment your model becomes an entry in &lt;code&gt;SQLModel.metadata.tables&lt;&#x2F;code&gt;, and it runs during class definition, triggered by importing the module. That&#x27;s the side effect Alembic depends on.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;seeing-it-happen&quot;&gt;Seeing it happen&lt;&#x2F;h2&gt;
&lt;p&gt;You don&#x27;t have to trust the call path. Registration is a side effect of the &lt;code&gt;class&lt;&#x2F;code&gt; statement, so a plain REPL lets you watch the registry grow in real time:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt; from&lt;&#x2F;span&gt;&lt;span&gt; sqlmodel&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; SQLModel, Field&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; print&lt;&#x2F;span&gt;&lt;span&gt;(SQLModel.metadata.tables)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FacadeDict({})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;&amp;gt;&amp;gt;&amp;gt; class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tip&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;SQLModel&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant z-support&quot;&gt;...     id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; primary_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;     text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; print&lt;&#x2F;span&gt;&lt;span&gt;(SQLModel.metadata.tables)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FacadeDict({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;tip&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;: Table(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;tip&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, MetaData(), Column(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;id&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, Integer(),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&amp;lt;&lt;&#x2F;span&gt;&lt;span&gt;tip&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; primary_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; nullable&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;False&lt;&#x2F;span&gt;&lt;span&gt;), Column(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;text&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, AutoString(),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&amp;lt;&lt;&#x2F;span&gt;&lt;span&gt;tip&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; nullable&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;False&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; schema&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;)})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;create_engine&lt;&#x2F;code&gt;, no &lt;code&gt;create_all&lt;&#x2F;code&gt;, no import of your models module. Just defining the class populated the shared &lt;code&gt;metadata&lt;&#x2F;code&gt;. Drop the &lt;code&gt;table=True&lt;&#x2F;code&gt; and run it again: the dict stays empty, because now &lt;code&gt;Tip&lt;&#x2F;code&gt; is a SQLModel&#x2F;Pydantic model rather than a mapped table model.&lt;&#x2F;p&gt;
&lt;p&gt;That proves the &lt;em&gt;class statement&lt;&#x2F;em&gt; does the registering. To tie it back to the opening question, put the same class in a module and let the import fire it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# models.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; sqlmodel&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; SQLModel, Field&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tip&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;SQLModel&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;    id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; primary_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt; from&lt;&#x2F;span&gt;&lt;span&gt; sqlmodel&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; SQLModel&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; print&lt;&#x2F;span&gt;&lt;span&gt;(SQLModel.metadata.tables)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FacadeDict({})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt; import&lt;&#x2F;span&gt;&lt;span&gt; models&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # the &amp;quot;unused&amp;quot; import&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; print&lt;&#x2F;span&gt;&lt;span&gt;(SQLModel.metadata.tables)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FacadeDict({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;tip&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;: Table(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;tip&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;span&gt;)})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You never touch &lt;code&gt;models.Tip&lt;&#x2F;code&gt; after importing it, yet the registry filled up. That&#x27;s the exact &lt;code&gt;env.py&lt;&#x2F;code&gt; situation: the import runs the module, the module runs the class definition, and the class definition registers the table.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;is-this-metaprogramming&quot;&gt;Is this metaprogramming?&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;SQLModelMetaclass&lt;&#x2F;code&gt; inherits from both Pydantic&#x27;s &lt;code&gt;ModelMetaclass&lt;&#x2F;code&gt; and SQLAlchemy&#x27;s &lt;code&gt;DeclarativeMeta&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;This is the same mechanism behind a lot of Python you use daily. If you&#x27;ve ever wondered how a class declaration can acquire behavior you never explicitly wrote, the answer is often a metaclass or &lt;code&gt;__init_subclass__&lt;&#x2F;code&gt; doing work at definition time.&lt;&#x2F;p&gt;
&lt;p&gt;A metaclass is code that runs when a class is defined. Here it inspects a keyword, processes the model&#x27;s fields, and delegates table-model construction to SQLAlchemy.&lt;&#x2F;p&gt;
&lt;p&gt;Your field annotations get read at that moment and turned into &lt;code&gt;Column&lt;&#x2F;code&gt; objects.&lt;&#x2F;p&gt;
&lt;p&gt;The practical payoff: next time autogenerate produces an empty migration or wants to drop all your tables, you&#x27;ll know the cause is registration order, not Alembic being broken. Something imported the models too late, or not at all, and &lt;code&gt;metadata.tables&lt;&#x2F;code&gt; was empty when Alembic read it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;looking-at-libraries&quot;&gt;Looking at libraries&lt;&#x2F;h2&gt;
&lt;p&gt;Reading a library&#x27;s source to answer &quot;why is this import here&quot; is a great way to learn the library and Python itself. You don&#x27;t have to understand every line. Follow the call path of a single feature and you&#x27;ll often see how the pieces fit together.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s also about not taking things at face value. A &lt;code&gt;noqa&lt;&#x2F;code&gt; means you&#x27;re deliberately silencing something a linter flagged. Here it was warranted, but only after understanding &lt;em&gt;why&lt;&#x2F;em&gt; the import exists.&lt;&#x2F;p&gt;
&lt;p&gt;AI can follow this call path for you in seconds. That&#x27;s useful.&lt;&#x2F;p&gt;
&lt;p&gt;But the valuable skill isn&#x27;t memorizing that &lt;code&gt;Table.__new__&lt;&#x2F;code&gt; calls &lt;code&gt;_add_table&lt;&#x2F;code&gt;. It&#x27;s having the instinct to stop when something looks strange and ask: &lt;strong&gt;why is this here?&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;As AI writes more of the code, this habit matters now more than ever I think. If you don&#x27;t understand the code, you can&#x27;t maintain &#x2F; improve it over time.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Unsubscribe links without a login: Django signing</title>
        <published>2026-08-22T00:00:00+00:00</published>
        <updated>2026-08-22T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/unsubscribe-without-login-django-signing/"/>
        <id>https://belderbos.dev/blog/unsubscribe-without-login-django-signing/</id>
        <summary type="html">&lt;p&gt;Django has a signing module that makes it easy to build an unsubscribe link that works with no login and no session: &lt;code&gt;token = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT)&lt;&#x2F;code&gt;. The token itself is the credential, and it ships with Django out of the box.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Django has a signing module that makes it easy to build an unsubscribe link that works with no login and no session: &lt;code&gt;token = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT)&lt;&#x2F;code&gt;. The token itself is the credential, and it ships with Django out of the box.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;unsubscribe-signed-url.webp&quot; alt=&quot;A newsletter email whose unsubscribe link is a signed URL; the token carries the user id and validates without a login, so the URL itself is the credential&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-url-is-the-whole-request&quot;&gt;The URL is the whole request&lt;&#x2F;h2&gt;
&lt;p&gt;Unsubscribing poses a bit of a pickle. A user can click the link in an email without being logged in, and you don&#x27;t want to force them to log in just to stop receiving emails. So the URL must carry both &lt;em&gt;who&lt;&#x2F;em&gt; they are and &lt;em&gt;what&lt;&#x2F;em&gt; they&#x27;re allowed to do, all on its own.&lt;&#x2F;p&gt;
&lt;p&gt;The tempting but insecure way to do this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# don&amp;#39;t do this&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; unsubscribe&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(request):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; request.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;GET&lt;&#x2F;span&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;user_id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Profile.objects.filter(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;user_id).update(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;opt_out&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;&#x2F;unsubscribe&#x2F;?user_id=42&lt;&#x2F;code&gt; works, and it also lets anyone loop from 1 upward and unsubscribe your entire user table. It&#x27;s a plain &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;owasp.org&#x2F;www-community&#x2F;attacks&#x2F;&quot;&gt;insecure direct object reference&lt;&#x2F;a&gt;: the URL names a record and nothing proves the clicker is allowed to touch it.&lt;&#x2F;p&gt;
&lt;p&gt;The usual fix is a random token stored in the database, looked up on click. That&#x27;s fine, but it means a column or a table, a migration, and code to create and expire the tokens. For a boolean flag on a profile, that&#x27;s a lot of moving parts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;django-already-fixes-this&quot;&gt;Django already fixes this&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;django.core.signing&lt;&#x2F;code&gt; gives you a tamper-evident token with no storage at all. It packs your value and a signature made from &lt;code&gt;SECRET_KEY&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; django.core&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; signing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;UNSUBSCRIBE_SALT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;discuss-unsubscribe&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;token&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; signing.dumps(recipient.pk,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; salt&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;UNSUBSCRIBE_SALT&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;url&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; = f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;settings.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DOMAIN}{&lt;&#x2F;span&gt;&lt;span&gt;reverse(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;discuss_unsubscribe&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; args&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[token])&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On the way back in, &lt;code&gt;loads&lt;&#x2F;code&gt; verifies the signature and hands you the original value. In this case unsubscribing a user from forum post notifications:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; unsubscribe&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(request, token):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    try&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        user_pk&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; signing.loads(token,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; salt&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;UNSUBSCRIBE_SALT&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        profile&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Profile.objects.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;user_pk)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;        # writes on GET for now; see &amp;quot;Don&amp;#39;t mutate on a GET&amp;quot; before shipping&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        profile.opt_out_discussion_emails&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        profile.save(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;update_fields&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;opt_out_discussion_emails&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;])&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        success&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    except&lt;&#x2F;span&gt;&lt;span&gt; (signing.BadSignature, Profile.DoesNotExist):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        success&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; render(request,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;unsubscribe.html&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;success&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: success})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Change one character of the token and the signature no longer matches, so &lt;code&gt;loads&lt;&#x2F;code&gt; raises &lt;code&gt;BadSignature&lt;&#x2F;code&gt; and the pk never gets used. Guessing another user&#x27;s pk is pointless: you can&#x27;t produce a valid signature for it without &lt;code&gt;SECRET_KEY&lt;&#x2F;code&gt;. No table, no lookup, no cleanup.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;ve ever clicked a thumbs-up in a GitHub Copilot feedback email and seen a URL that&#x27;s a wall of base64, that&#x27;s the same idea: a signed token identifying you and your action, no login in the loop.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-to-prevent-multiple-flows-from-colliding&quot;&gt;How to prevent multiple flows from colliding&lt;&#x2F;h2&gt;
&lt;p&gt;A &lt;code&gt;salt&lt;&#x2F;code&gt; argument scopes a token to one purpose. This codebase now has two separate unsubscribe flows, and they use different salts:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# bites&#x2F;management&#x2F;commands&#x2F;send_announcement.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;UNSUBSCRIBE_SALT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;announce-unsubscribe&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# discuss&#x2F;notify.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;UNSUBSCRIBE_SALT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;discuss-unsubscribe&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both sign a user pk with the same &lt;code&gt;SECRET_KEY&lt;&#x2F;code&gt;. But a token minted for the announcements list will not validate in the forum-reply handler, because the salts differ.&lt;&#x2F;p&gt;
&lt;p&gt;Without that, a single leaked token would work against every flow that signs a pk. The salt keeps a token minted for one purpose from being accepted by another flow. Name it per action and you get that separation for free.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;signed-is-not-secret&quot;&gt;Signed is not secret&lt;&#x2F;h2&gt;
&lt;p&gt;One honest point the convenience can hide: signing proves a value wasn&#x27;t &lt;em&gt;altered&lt;&#x2F;em&gt;, not that it&#x27;s &lt;em&gt;hidden&lt;&#x2F;em&gt;. The pk is sitting in that token, base64-encoded and readable by anyone who pastes it into a decoder.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s fine for a user id, which isn&#x27;t a secret. It is not fine for anything you&#x27;d mind a recipient reading. Never sign a value you wouldn&#x27;t also be willing to print in the email body.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;don-t-mutate-on-a-get&quot;&gt;Don&#x27;t mutate on a GET&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;s a subtlety the happy path hides: my handler flips a flag on a plain GET request. Mail security scanners and link-prefetchers routinely follow links inside email before a human ever clicks, which would unsubscribe people who never asked to be. The signing is fine, the HTTP method is the risk.&lt;&#x2F;p&gt;
&lt;p&gt;For unsubscribe the fix is cheap: render a confirmation page on GET and do the write on the POST. The signed token still carries the identity, the button carries the intent.&lt;&#x2F;p&gt;
&lt;p&gt;Mailbox providers formalize the same POST-not-GET rule: RFC 8058 has providers like Gmail and Apple Mail POST straight to the URL in your &lt;code&gt;List-Unsubscribe&lt;&#x2F;code&gt; header (paired with &lt;code&gt;List-Unsubscribe-Post: List-Unsubscribe=One-Click&lt;&#x2F;code&gt;), unsubscribing in one click. That endpoint has to be CSRF-exempt, since the provider sends no token.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-you-need-expiry&quot;&gt;When you need expiry&lt;&#x2F;h2&gt;
&lt;p&gt;My unsubscribe tokens never expire, and that&#x27;s a deliberate design choice.&lt;&#x2F;p&gt;
&lt;p&gt;Unsubscribe is low-stakes: a two-year-old link still does the one safe thing, even if the mail gets forwarded and a stranger clicks it. The stakes are what decide this.&lt;&#x2F;p&gt;
&lt;p&gt;Reuse the same pattern for a magic login link or an email verification. Here you do want a lifetime, so set a &lt;code&gt;max_age&lt;&#x2F;code&gt; on the &lt;code&gt;loads&lt;&#x2F;code&gt; call:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# magic login: valid for 15 minutes (single-use needs the state below)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;user_pk&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; signing.loads(token,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; salt&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;magic-login&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; max_age&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;900&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;max_age&lt;&#x2F;code&gt; gives you expiry with still no storage: the deadline is baked into the signed token, so the signature verifies it without a database.&lt;&#x2F;p&gt;
&lt;p&gt;Single-use is different, it needs memory. The signature can&#x27;t tell you a token was already spent, so you&#x27;d have to record each accepted token and check that list. That&#x27;s the state you&#x27;re forced to add.&lt;&#x2F;p&gt;
&lt;p&gt;An unsubscribe link is idempotent, clicking it twice does the same safe thing, so it can stay stateless. A login link can&#x27;t: reusing it opens the account again.&lt;&#x2F;p&gt;
&lt;p&gt;Where else in your app does a URL quietly get trusted? What other pickles have you had to deal with in your Django codebases?&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Guardrails Protect Your Codebase. What Protects Your Judgment?</title>
        <published>2026-08-18T00:00:00+00:00</published>
        <updated>2026-08-18T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/ai-skill-atrophy-keep-the-friction/"/>
        <id>https://belderbos.dev/blog/ai-skill-atrophy-keep-the-friction/</id>
        <summary type="html">&lt;p&gt;Cal Newport&#x27;s &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;calnewport.com&#x2F;on-ai-coding-and-its-discontents&#x2F;&quot;&gt;On AI Coding and Its Discontents&lt;&#x2F;a&gt; lands on an uncomfortable point: the speed AI gives you is paid for with the thinking that made us good engineers in the first place.&lt;&#x2F;p&gt;
&lt;p&gt;Read code instead of writing it and you get passive recognition where you used to have an active model of the code.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Cal Newport&#x27;s &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;calnewport.com&#x2F;on-ai-coding-and-its-discontents&#x2F;&quot;&gt;On AI Coding and Its Discontents&lt;&#x2F;a&gt; lands on an uncomfortable point: the speed AI gives you is paid for with the thinking that made us good engineers in the first place.&lt;&#x2F;p&gt;
&lt;p&gt;Read code instead of writing it and you get passive recognition where you used to have an active model of the code.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;The vendors sell one answer to this: more AI. The nostalgic answer is the opposite: go back to writing everything by hand. I am searching for a midway.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;no-right-answer-but-at-least-a-direction-to-pursue&quot;&gt;No right answer, but at least a direction to pursue&lt;&#x2F;h2&gt;
&lt;p&gt;When I posted about this recently, I offered 3 options: write more code manually, double down on AI, or find some middle with more friction. I got some valuable comments from developers picking the middle. And that&#x27;s where I&#x27;ve landed too.&lt;&#x2F;p&gt;
&lt;p&gt;The more I read and think about it, the more the middle stops looking like a compromise. It seems the way forward, but only if you specify what &lt;em&gt;friction&lt;&#x2F;em&gt; to keep.&lt;&#x2F;p&gt;
&lt;p&gt;Skill atrophy isn&#x27;t caused by using AI per se. It&#x27;s caused by delegating the part that builds your internal model.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ve argued that &lt;a href=&quot;&#x2F;blog&#x2F;dont-delegate-the-friction&#x2F;&quot;&gt;the friction is where the learning lives&lt;&#x2F;a&gt;, and nothing about agents changes that. What changes is that it&#x27;s now easier to bypass friction and it&#x27;s on us to keep it in.&lt;&#x2F;p&gt;
&lt;p&gt;It helps to separate two problems here, because AI creates both and they aren&#x27;t the same. One is skill atrophy: stop doing the thinking and your mental model fades. The other is correctness: AI writes plausible code that can be subtly wrong.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s look at each in turn.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;do-you-own-the-code&quot;&gt;Do you &#x27;own&#x27; the code?&lt;&#x2F;h2&gt;
&lt;p&gt;A sharp reply came from someone on Bluesky:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;agents draft, but re-derive the hard path before merge. If you can&#x27;t explain a weird branch without re-reading the whole file, you rewrote too little.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Not all code is created equal. The boundary conditions, error paths, and testing assumptions are where we as engineers pull our weight.&lt;&#x2F;p&gt;
&lt;p&gt;For every diff, can you explain the change? What are the edge cases and risks?&lt;&#x2F;p&gt;
&lt;p&gt;In practice I find that this gets easier when you scope the agent to narrow, well-defined tasks instead of blanket features. Small tasks keep you in the loop, stop the agent from drifting, and prevent giant code reviews nobody wants to read.&lt;&#x2F;p&gt;
&lt;p&gt;I also like &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;youtu.be&#x2F;tHh0UaL_V4w?si=lLP4cZxvcikLo1yA&amp;amp;t=1155&quot;&gt;how Corey Schafer summarized how he keeps the friction&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Use detailed prompts, not one-liners: spec the tools, architecture, and reuse saved research prompts that force citing official docs.&lt;&#x2F;li&gt;
&lt;li&gt;Review everything: read and understand all generated code like a junior&#x27;s PR you&#x27;d never merge blindly.&lt;&#x2F;li&gt;
&lt;li&gt;Interrogate unfamiliar code &#x2F; concepts: make the AI explain things in plain English, pull docs, and justify the approach and trade-offs.&lt;&#x2F;li&gt;
&lt;li&gt;Push back: offer your own approach, ask if the AI&#x27;s is better&#x2F;worse&#x2F;different, and fold what you learn into your own toolkit.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;push-the-rest-into-the-system&quot;&gt;Push the rest into the system&lt;&#x2F;h2&gt;
&lt;p&gt;Those four habits are all manual. They keep your judgment in the loop, but they run on your attention, and attention is finite. You can&#x27;t re-read every branch on every diff forever. So whatever those habits can&#x27;t cover by hand, you push into the system and let it enforce the rest automatically.&lt;&#x2F;p&gt;
&lt;p&gt;A commenter on Threads put it well:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;the real fix is correctness by design, stronger types, compile-time assertions, more &quot;faff in the way.&quot; We never fully adopted that stuff because it takes patience humans run out of. AI doesn&#x27;t. It learns your constraints once, then works inside them on every diff after.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;And on Fosstodon:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I feel like all these systems have too many surprises, and making invalid states illegal is the best defensive alternative to not using them, which would be the first choice. Once you get guarantees, if you can type faster with gen ai than your fingers, maybe thats worth the squeeze depending on your morals.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That&#x27;s why Rust is so appealing to me. Making invalid states unrepresentable and the agent has to comply with this. I discussed this &lt;a href=&quot;&#x2F;blog&#x2F;rust-compiler-ai-agent-guardrail&#x2F;&quot;&gt;here&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Python gives you less strict guarantees, but we can approximate it. Your &lt;a href=&quot;&#x2F;blog&#x2F;modern-python-tooling-uv-ruff-ty&#x2F;&quot;&gt;type checker (ty, pyrefly, mypy)&lt;&#x2F;a&gt; catches the class of type errors the model would otherwise slip past you. Pydantic models validate at the boundary, so bad data fails loudly instead of drifting three functions deep. And well-thought out test cases can challenge plausible looking code. And for AI generated tests you can use mutation testing (an article on this soon here ...)&lt;&#x2F;p&gt;
&lt;p&gt;So balancing responsible AI use has two sides. Re-derive the decisions that matter, which keeps your judgment sharp. And encode stricter rules to keep your agent accountable to higher standards. One protects your mental model, the other protects the code base. Both protect peace of mind.&lt;&#x2F;p&gt;
&lt;p&gt;As said before: the tooling &#x2F; way of working has changed, but &lt;a href=&quot;&#x2F;blog&#x2F;ai-doesnt-change-what-software-engineering-is&#x2F;&quot;&gt;AI doesn&#x27;t change what software engineering is&lt;&#x2F;a&gt;; we can automate some of the boring parts, but the majority of the work (including the code-producing part) is a lot of back-and-forth and applying experience and judgment.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Newport&#x27;s worry is right: outsourced thinking atrophies. The escape isn&#x27;t more or less AI. It&#x27;s identifying, every time you build software, the small set of decisions you refuse to let the model make for you.&lt;&#x2F;p&gt;
&lt;p&gt;Can you explain each change? Do you truly own it? Did you keep making the hard decisions yourself? And are strict guardrails in place to protect the codebase when AI writes more of the code?&lt;&#x2F;p&gt;
&lt;p&gt;Reach out to me if you want to chat about this. It feels a bit like the wild west right now, and nobody has final answers. I&#x27;m really keen to hear how you&#x27;re navigating the fast pace of change our industry is going through.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Learning New Skills in the AI Era (vBrownBag)</title>
        <published>2026-07-13T00:00:00+00:00</published>
        <updated>2026-07-13T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/vbrownbag-learning-skills-ai-era/"/>
        <id>https://belderbos.dev/blog/vbrownbag-learning-skills-ai-era/</id>
        <summary type="html">&lt;p&gt;I joined the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;vbrownbag.com&#x2F;&quot;&gt;vBrownBag podcast&lt;&#x2F;a&gt; with &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;damiankarlson&quot;&gt;Damian&lt;&#x2F;a&gt; to talk about how to actually learn a new language or skill when an agent can write the code before you finish typing the prompt.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I joined the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;vbrownbag.com&#x2F;&quot;&gt;vBrownBag podcast&lt;&#x2F;a&gt; with &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;damiankarlson&quot;&gt;Damian&lt;&#x2F;a&gt; to talk about how to actually learn a new language or skill when an agent can write the code before you finish typing the prompt.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;keep-the-friction-in&quot;&gt;Keep the friction in&lt;&#x2F;h2&gt;
&lt;p&gt;The thread running through the whole conversation is friction. Agents are close to slot machines: a bit of dopamine, the path of least resistance, and suddenly you are delegating the thinking, not just the typing. The weeks where I hand off the most are the weeks I come out least happy with my own skills.&lt;&#x2F;p&gt;
&lt;img src=&quot;&#x2F;images&#x2F;outsourcing-thinking-drake.png&quot; alt=&quot;Drake meme: rejecting outsourcing your thinking to the model, preferring to read it yourself and ask where you get stuck&quot; style=&quot;max-width: 50%; display: block; margin: 0 auto 2.5rem;&quot;&gt;
&lt;p&gt;So I keep deliberate friction in the loop. I built coding platforms for &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pybitesplatform.com&quot;&gt;Python&lt;&#x2F;a&gt; and &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&quot;&gt;Rust&lt;&#x2F;a&gt; with no AI in them, on purpose, so you still write the code in the browser without assistance.&lt;&#x2F;p&gt;
&lt;p&gt;When you are learning something, you have to go through the cycles at least once before you let an agent do it for you.&lt;&#x2F;p&gt;
&lt;p&gt;That is also why I can lean on agents more in Python (20 years of programming in, I can smell-test the output) than in a language I am still new to.&lt;&#x2F;p&gt;
&lt;p&gt;The litmus test is simple: how well do I understand the thing I am shipping?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;ai-to-explain-not-ai-to-do&quot;&gt;AI to explain, not AI to do&lt;&#x2F;h2&gt;
&lt;p&gt;AI is remarkable at explaining a specific concept. It is dangerous as a crutch for deeper understanding. The distinction I keep drawing: use it to explain, not to do the work you signed up to learn.&lt;&#x2F;p&gt;
&lt;p&gt;We got into where the silent errors hide. Reviewed code can look completely plausible and still be only 70% right, because you never went deep enough to feel the wrong part (I also discussed this recently on &lt;a href=&quot;&#x2F;blog&#x2F;complexity-dot-fm-podcast&#x2F;&quot;&gt;complexity.fm&lt;&#x2F;a&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;On &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;askthecanon.com&quot;&gt;a recent project&lt;&#x2F;a&gt; the app worked and returned good results, but it was silently never searching the second half of every chunk (see &lt;a href=&quot;&#x2F;blog&#x2F;semantic-search-without-a-vector-database&#x2F;&quot;&gt;here&lt;&#x2F;a&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;That is the failure mode I see most with developers shipping AI-built code, which is why I keep coming back to rubber-stamping AI PRs as the real risk.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;learn-by-building-with-tests-as-the-guide&quot;&gt;Learn by building, with tests as the guide&lt;&#x2F;h2&gt;
&lt;p&gt;When people ask how to learn Rust (or anything) without losing ownership, the shape is always the same:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Read enough to get the concepts (the first six to eight chapters of the Rust book, not all 600 pages).&lt;&#x2F;li&gt;
&lt;li&gt;Pick a real project you have a stake in, then break it into digestible pieces.&lt;&#x2F;li&gt;
&lt;li&gt;Write the tests first so you have a definition of done that guides each step.&lt;&#x2F;li&gt;
&lt;li&gt;Contrast sources: read the reference in parallel, and compare answers across models.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;http:&#x2F;&#x2F;scriptertorust.com&quot;&gt;Rust cohort&lt;&#x2F;a&gt; we build a JSON parser this way: tokenizer first, then bindings with PyO3, then benchmarking. Several developers beat the C parser on performance (see &lt;a href=&quot;&#x2F;blog&#x2F;josh-rust-cohort-beat-cpython-json&quot;&gt;here&lt;&#x2F;a&gt; and &lt;a href=&quot;&#x2F;blog&#x2F;jochen-rust-cohort-beat-cpython&#x2F;&quot;&gt;here&lt;&#x2F;a&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;This only happens because they &lt;strong&gt;owned&lt;&#x2F;strong&gt; every line instead of having an agent generate it.&lt;&#x2F;p&gt;
&lt;p&gt;Watch the full conversation:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=6DY7GjnQEbo&quot;&gt;Watch on YouTube&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The line I keep repeating: AI is &lt;a href=&quot;&#x2F;blog&#x2F;ai-accelerator-needs-direction&#x2F;&quot;&gt;an accelerator, not a compass&lt;&#x2F;a&gt;. Start with your own thinking, then let it help, and keep a high enough bar that you never accept the first draft.&lt;&#x2F;p&gt;
&lt;p&gt;Thanks Damian &#x2F; vBrownBag for having me on. If you want to stay technical without outsourcing the thinking, that is exactly what we work on in the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;scriptertorust.com&quot;&gt;Rust&lt;&#x2F;a&gt; and &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;agentic AI&lt;&#x2F;a&gt; cohorts.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>One Core, Two Interfaces, No Rewrites</title>
        <published>2026-07-04T00:00:00+00:00</published>
        <updated>2026-07-04T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/two-interfaces-one-core/"/>
        <id>https://belderbos.dev/blog/two-interfaces-one-core/</id>
        <summary type="html">&lt;p&gt;When building applications, I always build the core first, then the interfaces. It was no different with &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;askthecanon.com&quot;&gt;Ask the Canon&lt;&#x2F;a&gt;: a &lt;code&gt;uv run main.py ask &quot;...&quot;&lt;&#x2F;code&gt; CLI for quick iteration and validation, then the web app for MVP. Search, ranking, citations, all using the same engine.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;When building applications, I always build the core first, then the interfaces. It was no different with &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;askthecanon.com&quot;&gt;Ask the Canon&lt;&#x2F;a&gt;: a &lt;code&gt;uv run main.py ask &quot;...&quot;&lt;&#x2F;code&gt; CLI for quick iteration and validation, then the web app for MVP. Search, ranking, citations, all using the same engine.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Ask the Canon&#x27;s core is a handful of pure functions in one module. Both interfaces are thin wrappers. This is the second post in a series on how it&#x27;s built. &lt;a href=&quot;&#x2F;blog&#x2F;semantic-search-without-a-vector-database&#x2F;&quot;&gt;The first one&lt;&#x2F;a&gt; was about the retrieval engine. This one is about the wider architecture.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;functional-core-two-interfaces&quot;&gt;Functional core, two interfaces&lt;&#x2F;h2&gt;
&lt;p&gt;I just have one module with pure functions, clear contracts, and no hidden state:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; embed&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(texts: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt; np.ndarray:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; load_library&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(book_ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; tuple[list[Passage], np.ndarray]:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; search_passages&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(query, passages, vectors, ...) -&amp;gt; list[tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;span&gt;]]:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; reflow&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;load_library&lt;&#x2F;code&gt; reads the cached &lt;code&gt;.npy&lt;&#x2F;code&gt; files off disk and hands back a list of &lt;code&gt;Passage&lt;&#x2F;code&gt; tuples plus the stacked matrix.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;search_passages&lt;&#x2F;code&gt; takes those two and a query and returns ranked &lt;code&gt;(index, score)&lt;&#x2F;code&gt; pairs.&lt;&#x2F;p&gt;
&lt;p&gt;The web layer consumes the core functions, no re-implementation:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; main&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    embed,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Passage,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    humanize_author,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    load_library,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reflow,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    search_passages,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The CLI&#x27;s &lt;code&gt;ask()&lt;&#x2F;code&gt; and the web app&#x27;s &lt;code&gt;&#x2F;api&#x2F;ask&lt;&#x2F;code&gt; share the same spine: load the library, call &lt;code&gt;search_passages&lt;&#x2F;code&gt;, walk the ranked &lt;code&gt;(index, score)&lt;&#x2F;code&gt; pairs. From there each does its own thing. The CLI prints &lt;code&gt;rich&lt;&#x2F;code&gt; panels and offers an interactive deep-read; the web app serializes to &lt;code&gt;Match&lt;&#x2F;code&gt; JSON and logs a bit of analytics on the way out.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;em&gt;ranking&lt;&#x2F;em&gt; decision, what comes back and in what order, is shared. Everything downstream is presentation, which is exactly where a CLI and a web app &lt;em&gt;should&lt;&#x2F;em&gt; differ.&lt;&#x2F;p&gt;
&lt;p&gt;We do the same in our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;agentic AI program&lt;&#x2F;a&gt;: one core engine, three interfaces (CLI, Telegram, API &#x2F; web dashboard).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;i-needed-caching&quot;&gt;I needed caching&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;load_library&lt;&#x2F;code&gt; is not cheap. It walks &lt;code&gt;books&#x2F;&lt;&#x2F;code&gt;, reads a JSON file and an &lt;code&gt;.npy&lt;&#x2F;code&gt; file per book, and stacks 80k vectors into one matrix with &lt;code&gt;np.vstack&lt;&#x2F;code&gt;. You don&#x27;t want to pay that overhead on every HTTP request!&lt;&#x2F;p&gt;
&lt;p&gt;In the CLI that&#x27;s a non-issue: the process loads once and exits. On the web side, it&#x27;s one decorator away:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; functools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; cache&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@cache&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; library&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; tuple[list[Passage], np.ndarray]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; load_library()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;@cache&lt;&#x2F;code&gt; turns the first call into the real load and every call after into a dictionary lookup, much faster.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@app.get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;api&#x2F;ask&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ask&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(q:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, k:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, per_book:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 2&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, floor:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.6&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Match]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    passages, vectors&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; library()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # cached&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;pre-warm-on-startup-not-on-the-first-visitor&quot;&gt;Pre-warm on startup, not on the first visitor&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;s a subtlety &lt;code&gt;@cache&lt;&#x2F;code&gt; doesn&#x27;t solve on its own. If the &lt;em&gt;first&lt;&#x2F;em&gt; request is what triggers &lt;code&gt;library()&lt;&#x2F;code&gt; (&quot;wakes up PyTorch&quot;), then the first real visitor pays that tax. App restarts are rare, but making the first visitor wait still isn&#x27;t acceptable.&lt;&#x2F;p&gt;
&lt;p&gt;FastAPI&#x27;s &lt;code&gt;lifespan&lt;&#x2F;code&gt; offers a nice fix for this: do it as soon as the app starts, before the first request:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@asynccontextmanager&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; lifespan&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(app: FastAPI):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    init_db()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    logger.info(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Pre-warming vector library and loading models into RAM...&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; library()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;          # fills the @cache with the stacked matrix&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; embed([&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;warmup&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;])&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # forces PyTorch to wake up and allocate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    logger.info(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Ready for traffic.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    yield&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;app&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; FastAPI(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;title&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;classics&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; lifespan&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;lifespan)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ul&gt;
&lt;li&gt;I left a log line to watch the startup time. I also added some comments for possible collaborators and my future self.&lt;&#x2F;li&gt;
&lt;li&gt;I use &lt;code&gt;_&lt;&#x2F;code&gt; as a throwaway variable to make it clear the return value is ignored.&lt;&#x2F;li&gt;
&lt;li&gt;You can put shutdown logic after &lt;code&gt;yield&lt;&#x2F;code&gt;, similar to how pytest fixtures work. Clean.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;By the time the first request lands, both are warm.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;lazy-loading&quot;&gt;Lazy loading&lt;&#x2F;h2&gt;
&lt;p&gt;I am a proponent of imports at the top, but lazy loading is a serious performance consideration. It&#x27;s coming in 3.15:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Lazy imports defer the loading and execution of a module until the first time the imported name is used, in contrast to ‘normal’ imports, which eagerly load and execute a module at the point of the import statement. - &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;peps.python.org&#x2F;pep-0810&#x2F;&quot;&gt;PEP 810 – Explicit lazy imports&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That&#x27;s the automatic version, landing in 3.15. Here I do it by hand: defer the model import into the function that needs it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@cache&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _model&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    import&lt;&#x2F;span&gt;&lt;span&gt; sentence_transformers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; st&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # lazy, so the offline env vars take effect first&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; st.SentenceTransformer(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;EMBED_MODEL&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;So the model loads once, and only if something actually calls &lt;code&gt;_model()&lt;&#x2F;code&gt;. &lt;code&gt;@cache&lt;&#x2F;code&gt; hands back the same instance every time after.&lt;&#x2F;p&gt;
&lt;p&gt;The &quot;offline env vars&quot; part refers to the second reason I need the import here. At the top of the module I have:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;os.environ.setdefault(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;HF_HUB_OFFLINE&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;os.environ.setdefault(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;TRANSFORMERS_OFFLINE&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;os.environ.setdefault(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;TQDM_DISABLE&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Hugging Face reads &lt;code&gt;HF_HUB_OFFLINE&lt;&#x2F;code&gt; &lt;em&gt;at import time&lt;&#x2F;em&gt;. Import &lt;code&gt;sentence-transformers&lt;&#x2F;code&gt; before those are set and it will try to reach out to the internet, which is not what I want because I have the data and model cached locally. Set them first and the model stays fully offline, no surprise network calls.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;functions-vs-classes&quot;&gt;Functions vs classes&lt;&#x2F;h2&gt;
&lt;p&gt;None of this needs a class. The core is functions over plain data (&lt;code&gt;Passage&lt;&#x2F;code&gt; and &lt;code&gt;Chunk&lt;&#x2F;code&gt; are &lt;code&gt;NamedTuple&lt;&#x2F;code&gt;s), the only state is a memoized function, and the two interfaces are thin adapters that share common behavior.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the payoff. When I want a third interface tomorrow (e.g. a scheduled job or a different API), it imports the same functions and gets the same behavior for free.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Next up in part 3: the small post-processing tricks that make the results actually good, no bigger model required.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Ask the Canon: Semantic Search Without a Vector Database</title>
        <published>2026-06-30T00:00:00+00:00</published>
        <updated>2026-06-30T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/semantic-search-without-a-vector-database/"/>
        <id>https://belderbos.dev/blog/semantic-search-without-a-vector-database/</id>
        <summary type="html">&lt;p&gt;I built out &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;askthecanon.com&quot;&gt;askthecanon.com&lt;&#x2F;a&gt; this weekend, a semantic search over 100 public-domain books (from the Gutenberg project). You ask a question in plain language and get the passages that &lt;em&gt;mean&lt;&#x2F;em&gt; that, cited by author, title, and chapter. I wanted a local, no-generative-AI solution: a retrieval engine using Hugging Face embeddings and NumPy that returns original passages, no full vector database (yet), no API calls at query time.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I built out &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;askthecanon.com&quot;&gt;askthecanon.com&lt;&#x2F;a&gt; this weekend, a semantic search over 100 public-domain books (from the Gutenberg project). You ask a question in plain language and get the passages that &lt;em&gt;mean&lt;&#x2F;em&gt; that, cited by author, title, and chapter. I wanted a local, no-generative-AI solution: a retrieval engine using Hugging Face embeddings and NumPy that returns original passages, no full vector database (yet), no API calls at query time.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;why-ask-the-canon&quot;&gt;Why Ask the Canon?&lt;&#x2F;h2&gt;
&lt;p&gt;I am already finding timeless wisdom using it myself (some of the best apps come from &quot;scratching your own itch&quot;), and I hope it offers a breath of fresh air in a world that seems to be dominated by AI-generated content and quick summaries.&lt;&#x2F;p&gt;
&lt;p&gt;My itch was wanting to read the originals, not an AI-generated summary, but also recognizing I don&#x27;t have time and focus to read through a whole work (although it&#x27;s still my aim, I see deep value in it). What if we can meet somewhere in the middle?&lt;&#x2F;p&gt;
&lt;p&gt;Most of what we wrestle with is not new: fear, ambition, grief, how to deal with people who wrong us. A chatbot will distill what the canon says about any of it in seconds, in one smooth, agreeable, slightly forgettable voice. Sometimes that&#x27;s enough. Often I&#x27;d rather read the actual sentence Marcus Aurelius or Francis Bacon wrote and sit with it.&lt;&#x2F;p&gt;
&lt;p&gt;Thoreau, in &lt;em&gt;Walden&lt;&#x2F;em&gt;, on what real reading asks of you:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Most men have learned to read to serve a paltry convenience ... but of reading as a noble intellectual exercise they know little or nothing; yet this only is reading, in a high sense, not that which lulls us as a luxury and suffers the nobler faculties to sleep the while, but what we have to stand on tip-toe to read and devote our most alert and wakeful hours to.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Ask the Canon does only that: it points a plain-language question at a hand-picked shelf of public-domain books and returns the real passages that answer it, cited down to the chapter. It never writes a word of its own, so nothing is invented and nothing is misattributed.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;classics&#x2F;walden-value-of-reading.png&quot; alt=&quot;Ask the Canon card answering &amp;quot;what is the value of reading classic books?&amp;quot; with a passage from Thoreau&amp;#39;s Walden: shall I hear the name of Plato and never read his book?&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;A result, rendered by the app&#x27;s own &quot;share as image&quot; feature. Thoreau made my argument in 1854.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This is the first of three posts on how it&#x27;s built. This one is the engine: how you go from a folder of messy text files to ranked, cited answers without reaching for the heavy infrastructure everyone assumes you need.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-default-is-over-engineered&quot;&gt;The default is over-engineered&lt;&#x2F;h2&gt;
&lt;p&gt;Reach for &quot;semantic search&quot; and the stock answer is a vector database (Pinecone, Weaviate, pgvector) plus an embeddings API you call on every query.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the right shape at a billion vectors. At personal scale, tens of thousands of passages, it buys you operational weight and a network round-trip you don&#x27;t need.&lt;&#x2F;p&gt;
&lt;p&gt;The whole corpus here is 79,292 passages across 100 books. As a &lt;code&gt;float32&lt;&#x2F;code&gt; matrix at 768 dimensions that&#x27;s about 240 MB, small enough to load once and keep resident. Once it&#x27;s in RAM, &quot;find the most similar passage&quot; is a matrix multiply, and &lt;code&gt;np.argsort&lt;&#x2F;code&gt; over the result. That&#x27;s the entire search:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; embed&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(texts: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt; np.ndarray:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; _model().encode(texts,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; normalize_embeddings&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; retrieve&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(query:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, vectors: np.ndarray, k:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;span&gt;]]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    scores&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; vectors&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; @&lt;&#x2F;span&gt;&lt;span&gt; embed([query])[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    top&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; np.argsort(scores)[::&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;-&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;][:k]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; [(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;(i),&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;span&gt;(scores[i]))&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; i&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; top]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Because the vectors are L2-normalized at embed time (&lt;code&gt;normalize_embeddings=True&lt;&#x2F;code&gt;), the dot product &lt;code&gt;vectors @ query&lt;&#x2F;code&gt; &lt;em&gt;is&lt;&#x2F;em&gt; cosine similarity. No similarity function to import, no index to tune. One &lt;code&gt;@&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;I later refactored that bare multiply into a &lt;code&gt;_scores()&lt;&#x2F;code&gt; helper so the index can ship as &lt;code&gt;float16&lt;&#x2F;code&gt;, halving its memory, see &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;classics&#x2F;commit&#x2F;2699a01dde5db4d21d7de5129c86da5d2fc2f3d5&quot;&gt;this commit&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-embed-actually-does&quot;&gt;What &lt;code&gt;embed()&lt;&#x2F;code&gt; actually does&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;ve been waving at &lt;code&gt;embed()&lt;&#x2F;code&gt; as if turning text into numbers is obvious. It isn&#x27;t, so here&#x27;s the honest version at the level you need to follow the rest.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;all-mpnet-base-v2&lt;&#x2F;code&gt; is a pretrained neural network (a sentence-transformer). Hand it a string and it hands back a fixed list of 768 floating-point numbers, always 768, whether the input is a single word or a full paragraph. That list is the vector. Each number is a learned feature, a coordinate on some axis the model discovered in training. None of them map to anything a human named (&quot;axis 412 = melancholy&quot;), and they don&#x27;t need to.&lt;&#x2F;p&gt;
&lt;p&gt;The one property that matters: the model was trained so texts with similar meaning land at nearby points and unrelated texts land far apart. &quot;How do I face death without fear&quot; and a passage from &lt;em&gt;Meditations&lt;&#x2F;em&gt; end up close; that same passage and a Kubernetes tutorial end up far. Everything downstream, the dot product and the ranking, just measures that distance.&lt;&#x2F;p&gt;
&lt;p&gt;And a chunk is &lt;em&gt;one&lt;&#x2F;em&gt; vector, not a matrix. The model does compute a vector per token internally, then pools them into a single 768-number summary of the whole chunk before it ever reaches my NumPy. Query in, one row out; chunk in, one row out; compare the two rows. If you want to pry the box open further, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;jalammar.github.io&#x2F;illustrated-transformer&#x2F;&quot;&gt;Jay Alammar&#x27;s Illustrated Transformer&lt;&#x2F;a&gt; walks through the token-level machinery, but you can build and reason about this whole engine without it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;embed-once-cache-to-disk&quot;&gt;Embed once, cache to disk&lt;&#x2F;h2&gt;
&lt;p&gt;The trick that makes this cheap: you embed the corpus exactly once. The model never runs at query time except on the single short query string. I run a local &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;huggingface.co&#x2F;sentence-transformers&#x2F;all-mpnet-base-v2&quot;&gt;&lt;code&gt;all-mpnet-base-v2&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; model, so there&#x27;s no API key and nothing leaves the machine.&lt;&#x2F;p&gt;
&lt;p&gt;Indexing a book writes the vectors straight to a &lt;code&gt;.npy&lt;&#x2F;code&gt; file next to the source:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; build_index&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(book_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; tuple[list[Chunk], np.ndarray]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    chunks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; chunk_text(text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    vectors&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; embed([c.text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; chunks])&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    np.save(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;BOOKS_DIR&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; &#x2F; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;book_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;.npy&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, vectors)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; chunks, vectors&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;At startup, the per-book matrices stack into one library matrix with &lt;code&gt;np.vstack&lt;&#x2F;code&gt;, and that&#x27;s what every query multiplies against.&lt;&#x2F;p&gt;
&lt;p&gt;Embedding is the only expensive step, and it happens offline, on my laptop, never on the server. This separation also makes the deploy boring: build the &lt;code&gt;.npy&lt;&#x2F;code&gt; files locally, &lt;code&gt;rsync&lt;&#x2F;code&gt; them to the droplet.&lt;&#x2F;p&gt;
&lt;p&gt;The server never loads the model to &lt;em&gt;build&lt;&#x2F;em&gt; anything; it only embeds the incoming query. (The model loading itself is lazy and gated on offline env vars so there&#x27;s no hub round-trip, we&#x27;ll see how in Part 2 of this series.)&lt;&#x2F;p&gt;
&lt;h2 id=&quot;chunks-carry-their-own-citations&quot;&gt;Chunks carry their own citations&lt;&#x2F;h2&gt;
&lt;p&gt;A vector store usually means a second store for metadata: which book, which chapter, where in the text. I don&#x27;t have one. The citation rides along with the chunk.&lt;&#x2F;p&gt;
&lt;p&gt;When I split a book, I track Gutenberg&#x27;s &lt;code&gt;CHAPTER&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;BOOK&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;CANTO&lt;&#x2F;code&gt; headings as I go and stamp each chunk with the section it fell in:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Chunk&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;NamedTuple&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    label:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # e.g. &amp;quot;BOOK XI — Chapter IX&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;So a result isn&#x27;t a naked paragraph. It&#x27;s &lt;code&gt;Marcus Aurelius · Meditations — BOOK IV&lt;&#x2F;code&gt;, reconstructed from data that lived in the chunk all along.&lt;&#x2F;p&gt;
&lt;p&gt;The whole &quot;database&quot; is four kinds of file, no server:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;books&#x2F;&amp;lt;id&amp;gt;.npy&lt;&#x2F;code&gt;: the embeddings for one book&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;books&#x2F;&amp;lt;id&amp;gt;.chunks.json&lt;&#x2F;code&gt;: the passages and their chapter labels&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;books&#x2F;&amp;lt;id&amp;gt;.meta.json&lt;&#x2F;code&gt;: the title, author, and a short book summary&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;library.txt&lt;&#x2F;code&gt;: the list of Gutenberg IDs I grow by hand (now committed to the repo, could split off as &quot;config&quot; later)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;To add or remove books, I update &lt;code&gt;library.txt&lt;&#x2F;code&gt; and run &lt;code&gt;sync&lt;&#x2F;code&gt; to rebuild the index which I then rsync to the server. No database, no migrations, no schema, no API calls.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-bug-hiding-in-the-chunk-size&quot;&gt;The bug hiding in the chunk size&lt;&#x2F;h2&gt;
&lt;p&gt;Chunk size is the one knob that decides whether any of this works, and my first cut got it wrong in a &quot;silent error&quot; way.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;all-mpnet-base-v2&lt;&#x2F;code&gt; reads at most 384 tokens, roughly 290 words, per input. Anything longer is truncated before a single number is computed.&lt;&#x2F;p&gt;
&lt;p&gt;My first &lt;code&gt;chunk_text&lt;&#x2F;code&gt; targeted 600-word chunks, and it checked the size &lt;em&gt;after&lt;&#x2F;em&gt; appending each paragraph, so 600 was a floor it always overshot. A 599-word chunk plus one more paragraph landed well past 1,000 words.&lt;&#x2F;p&gt;
&lt;p&gt;So the model embedded the opening third of each passage and silently dropped the rest. The text I stored and cited was the whole passage, but the vector ranking it represented only the first few hundred words. Search was answering on text it had never read, and a long passage&#x27;s real subject often sat in the part that got cut.&lt;&#x2F;p&gt;
&lt;p&gt;Nothing errored. The results looked on point, but they were quietly wrong: ranking was decided by the opening of each passage and ignored the rest. The cited passages were often long, and search was not matching the later paragraphs that actually could have better answered the query.&lt;&#x2F;p&gt;
&lt;p&gt;The fix was two small changes. Drop the target to 250 words, a safe buffer under the token limit, and flush a chunk &lt;em&gt;before&lt;&#x2F;em&gt; a paragraph pushes it over, so &lt;code&gt;target_words&lt;&#x2F;code&gt; is a ceiling instead of a floor:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;TARGET_WORDS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 250&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # ~330-350 tokens, below mpnet&amp;#39;s 384 max&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;para_words&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; len&lt;&#x2F;span&gt;&lt;span&gt;(para.split())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; current&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; and&lt;&#x2F;span&gt;&lt;span&gt; words&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span&gt; para_words&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; target_words:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    chunks.append(Chunk(chunk_label,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;\n\n&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;.join(current)))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    current&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; current[&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;overlap:]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; overlap&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    words&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;len&lt;&#x2F;span&gt;&lt;span&gt;(p.split())&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; p&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; current)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now every chunk is embedded in full. Matches got sharper and the cited passages are short enough to read at a glance. Smaller chunks also nearly tripled the corpus, from 29,435 passages to 79,292, which is why the matrix grew while the search improved.&lt;&#x2F;p&gt;
&lt;p&gt;One follow-on: tighter chunks shifted the score distribution (spotted doing some end-to-end testing with Claude), so I raised the noise threshold from 0.32 to 0.34 to keep false positives out.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-a-vector-database-stops-being-overkill&quot;&gt;When a vector database stops being overkill&lt;&#x2F;h2&gt;
&lt;p&gt;A thoughtful skeptic would push back: this doesn&#x27;t scale. Correct, and that&#x27;s the point. A linear scan is O(n) per query. At 80k passages it&#x27;s a few milliseconds; at 30 million it&#x27;s not. The moment you outgrow a single machine&#x27;s RAM, or need filtered queries, multi-tenant isolation, or sub-millisecond latency at scale, we&#x27;d go with a real vector database.&lt;&#x2F;p&gt;
&lt;p&gt;But as detailed in &lt;a href=&quot;&#x2F;blog&#x2F;build-the-simplest-thing-that-works&#x2F;&quot;&gt;Build the Simplest Thing That Works&lt;&#x2F;a&gt; I prefer to get something working fast and validate it first putting it in front of real users.&lt;&#x2F;p&gt;
&lt;p&gt;Two lines of NumPy sit at the root of this engine. Everything else, the cards, the PDF export, the off-domain rejection, is built on top of that.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Using Claude was awesome, both in terms of getting the vectorization off the ground and it matching a &lt;em&gt;classic&lt;&#x2F;em&gt; vibe design so well. But 100-plus commits in, I kept having to step in with my experience and judgment to make the right call and tune things.&lt;&#x2F;p&gt;
&lt;p&gt;This judgment call is the same one I keep coming back to: match the tool to the actual size of the problem, not the size you imagine. It&#x27;s &lt;a href=&quot;&#x2F;blog&#x2F;ai-doesnt-change-what-software-engineering-is&#x2F;&quot;&gt;the kind of engineering judgment AI doesn&#x27;t change&lt;&#x2F;a&gt;. AI is an accelerator, not a compass, and &lt;a href=&quot;&#x2F;blog&#x2F;ai-accelerator-needs-direction&#x2F;&quot;&gt;it still needs you to point it&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>There Is No Magic: An AI Agent in 60 Lines of Python</title>
        <published>2026-06-26T00:00:00+00:00</published>
        <updated>2026-06-26T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/build-minimal-ai-agent-python/"/>
        <id>https://belderbos.dev/blog/build-minimal-ai-agent-python/</id>
        <summary type="html">&lt;p&gt;Everybody talks about agents, and a lot of people assume they&#x27;re some new kind of model. They aren&#x27;t. An agent is a small amount of plumbing around an LLM you already understand. Let&#x27;s build one from scratch in Python and see exactly what that plumbing is.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Everybody talks about agents, and a lot of people assume they&#x27;re some new kind of model. They aren&#x27;t. An agent is a small amount of plumbing around an LLM you already understand. Let&#x27;s build one from scratch in Python and see exactly what that plumbing is.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-formula&quot;&gt;The formula&lt;&#x2F;h2&gt;
&lt;p&gt;An agent is: Model + Instructions + Memory + Tools + Execution Loop.&lt;&#x2F;p&gt;
&lt;p&gt;Five parts. None of them is magic. The model is a brain in a jar: useful, fast, but stateless. It generates text; the code around it decides what to do with that text. That second half is the entire job and it&#x27;s code we can reason about.&lt;&#x2F;p&gt;
&lt;p&gt;I made &lt;a href=&quot;&#x2F;blog&#x2F;control-layer-is-the-product&#x2F;&quot;&gt;the same argument about the control layer&lt;&#x2F;a&gt; being the real product. Here it is as a program.&lt;&#x2F;p&gt;
&lt;p&gt;Start with the model. A real one calls an LLM API; we use a fake one that satisfies the same interface:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; dataclasses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Protocol&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;frozen&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Say&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;frozen&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Call&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    tool:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    arg:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Reply&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Say&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span&gt; Call&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Model&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; respond&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, system:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, history: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt; Reply:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;Model&lt;&#x2F;code&gt; protocol has a single method, &lt;code&gt;respond&lt;&#x2F;code&gt;, which takes the system prompt and the conversation history and returns a &lt;code&gt;Reply&lt;&#x2F;code&gt;. It&#x27;s a &lt;code&gt;Protocol&lt;&#x2F;code&gt;, so any object with a matching &lt;code&gt;respond&lt;&#x2F;code&gt; method counts as a &lt;code&gt;Model&lt;&#x2F;code&gt;, no inheritance required.&lt;&#x2F;p&gt;
&lt;p&gt;For this minimal agent, the &lt;code&gt;Reply&lt;&#x2F;code&gt; type captures the two actions we support: say something to the user, or call a tool with an argument. The model is free to return either one, and the agent will execute it. (Real models can also emit plans, ask clarifying questions, or request several tool calls at once; we keep it to two to stay legible.)&lt;&#x2F;p&gt;
&lt;p&gt;The agent&#x27;s entire decision space is those two variants. The &lt;code&gt;match&lt;&#x2F;code&gt; in the loop below reads as a clean two-way branch, one case per reply, instead of a tangle of flags.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; dataclasses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; dataclass, field&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Callable&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Tool&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Callable[[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Agent&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    model: Model&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                                          # 1. Model&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    system:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                                           # 2. Instructions&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    history: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default_factory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;list&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;      # 3. Memory&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    tools: dict[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;, Tool]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default_factory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;dict&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # 4. Tools&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In this example, a tool is a function taking a string and returning a string. The agent holds the other four parts as plain fields:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The model is any object satisfying the &lt;code&gt;Model&lt;&#x2F;code&gt; protocol: a fake model goes in for testing and a real one for production.&lt;&#x2F;li&gt;
&lt;li&gt;The system prompt is a string that tells the model what to do.&lt;&#x2F;li&gt;
&lt;li&gt;The history is the agent&#x27;s working memory: the conversation and tool outputs that get replayed back into the model. Real agents often add retrieval, summarization, or external state on top, because context windows are finite.&lt;&#x2F;li&gt;
&lt;li&gt;The tools field is a mapping of tool names to functions that implement them.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-loop-is-the-agent&quot;&gt;The loop is the agent&lt;&#x2F;h2&gt;
&lt;p&gt;The part that turns a well-instructed chatbot into something agent-like is the fifth piece: an execution loop that lets the model observe outcomes and decide what to do next. Observe, think, act, check, repeat. Greatly simplified, of course, but this is the piece that does the work.&lt;&#x2F;p&gt;
&lt;p&gt;Because the model is stateless, the agent must keep track of what happened and feed the history back into the model until the model decides the job is done.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; run&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, user_input:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.history.append(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;user: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;user_input&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        while&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # real agents cap the iterations; see termination guards below&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            match&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.model.respond(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.system,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.history):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                case&lt;&#x2F;span&gt;&lt;span&gt; Say(text):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;                    self&lt;&#x2F;span&gt;&lt;span&gt;.history.append(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;agent: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;text&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                    return&lt;&#x2F;span&gt;&lt;span&gt; text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                case&lt;&#x2F;span&gt;&lt;span&gt; Call(tool, arg):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.tools.get(tool)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; fn(arg)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; else f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;no such tool: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tool&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;                    self&lt;&#x2F;span&gt;&lt;span&gt;.history.append(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;tool[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tool&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;]: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                    # loop again: the model sees the result and decides what&amp;#39;s next&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Read it as the cycle:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Observe: append the input.&lt;&#x2F;li&gt;
&lt;li&gt;Think: ask the model.&lt;&#x2F;li&gt;
&lt;li&gt;Act: if it asked for a tool, run the tool.&lt;&#x2F;li&gt;
&lt;li&gt;Check and repeat: feed the result back into the history and loop, so the model sees what happened and decides whether it needs another tool or can finally answer.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;There is no separate &quot;check&quot; block in the code. The check happens implicitly when the loop restarts and calls &lt;code&gt;respond&lt;&#x2F;code&gt; again with the new history. That step is the one that matters, because a model has no native sense of when a job is finished, and nothing stops it from asking for one more tool forever. The loop keeps going until the model returns &lt;code&gt;Say&lt;&#x2F;code&gt; instead of &lt;code&gt;Call&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;To run the whole thing without an API key, swap in a fake model and a real tool:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pathlib&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Path&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; read_file&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(path:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    try&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;        return f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-support&quot;&gt;{len&lt;&#x2F;span&gt;&lt;span&gt;(Path(path).read_text())&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; bytes&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    except&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; OSError&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; e:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;        return f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;error: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;e&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; FakeModel&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; respond&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, system:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, history: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt; Reply:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        last&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; history[&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;-&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; history&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; last.startswith(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tool[&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; Say(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Done: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;last&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; last.startswith(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;user: read &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; Call(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;read_file&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, last.removeprefix(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;user: read &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).strip())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; Say(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;I can read files. Try: read &amp;lt;path&amp;gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Wire it into a small &lt;code&gt;main&lt;&#x2F;code&gt; that builds the agent, reads a line, calls &lt;code&gt;agent.run&lt;&#x2F;code&gt;, and prints the reply:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; main&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    agent&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Agent(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;FakeModel(),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        system&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;You can read files.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        tools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;read_file&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: read_file},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    while&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        try&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            line&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; input&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&amp;gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        except&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; EOFError&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            break&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;        print&lt;&#x2F;span&gt;&lt;span&gt;(agent.run(line.strip()))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt; __name__&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;__main__&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    main()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now you can talk to it with no API key. Run it with &lt;code&gt;python agent.py&lt;&#x2F;code&gt; and type at the prompt:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;gt; read pyproject.toml&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Done: tool[read_file]: 76 bytes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That one exchange is a complete agent loop: the model asked for a tool, the loop ran it, fed the byte count back, and the model wrapped up on the second pass. The main thing standing between it and a real one is replacing &lt;code&gt;FakeModel.respond&lt;&#x2F;code&gt; with an HTTP call that returns the same &lt;code&gt;Reply&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The whole thing as one runnable file is &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;c295f5269b8d22dc2b75708537f54f00&quot;&gt;here as a GitHub gist&lt;&#x2F;a&gt;. Save it, run &lt;code&gt;python agent.py&lt;&#x2F;code&gt;, and type at the prompt.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-earns-you&quot;&gt;What this earns you&lt;&#x2F;h2&gt;
&lt;p&gt;Sure, this is a simplified example, and the hard parts are exactly what &lt;code&gt;FakeModel&lt;&#x2F;code&gt; stubs out: prompt design, retries, tool schemas, context compaction, error recovery, and termination guards that stop the loop when a model keeps hallucinating tools. But the &lt;em&gt;core&lt;&#x2F;em&gt; of an agent is 60 lines and easy to reason about. The &lt;em&gt;engineering&lt;&#x2F;em&gt; lives in the control layer around the model.&lt;&#x2F;p&gt;
&lt;p&gt;Build the loop by hand once and frameworks stop feeling magical. LangChain&#x27;s agent executor, AutoGen&#x27;s shared memory, a coding agent&#x27;s plan mode are all variations on these same five parts: engineering tradeoffs, not magic.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Python Is Not Enough: Why Pythonistas Love Rust (Podcast)</title>
        <published>2026-06-25T00:00:00+00:00</published>
        <updated>2026-06-25T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/complexity-dot-fm-podcast/"/>
        <id>https://belderbos.dev/blog/complexity-dot-fm-podcast/</id>
        <summary type="html">&lt;p&gt;I joined &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;bascodes&#x2F;&quot;&gt;Bas Steins&lt;&#x2F;a&gt; and &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;michal-martinka&#x2F;&quot;&gt;Michal Martinka&lt;&#x2F;a&gt; on their complexity.fm show to talk about why Pythonistas are picking up Rust, what AI really does to how we learn, and why vibe coding is a myth. The conversation ran for over an hour because there was a lot to unpack.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I joined &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;bascodes&#x2F;&quot;&gt;Bas Steins&lt;&#x2F;a&gt; and &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;michal-martinka&#x2F;&quot;&gt;Michal Martinka&lt;&#x2F;a&gt; on their complexity.fm show to talk about why Pythonistas are picking up Rust, what AI really does to how we learn, and why vibe coding is a myth. The conversation ran for over an hour because there was a lot to unpack.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Watch the full episode on YouTube:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=XIbHmVQvo7w&quot;&gt;Watch on YouTube&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;highlights-from-the-episode&quot;&gt;Highlights from the episode&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Why Pythonistas reach for Rust&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A lot of tooling is created and rewritten in Rust (Ruff, uv, Polars, Pydantic core).&lt;&#x2F;li&gt;
&lt;li&gt;Rust&#x27;s strictness offers better guardrails for non-deterministic agents. Open Python vs closed Rust (mutability opt-in, single owner, compiler rejects skipped cases).&lt;&#x2F;li&gt;
&lt;li&gt;Fighting the borrow checker changes how you write Python.&lt;&#x2F;li&gt;
&lt;li&gt;Related: &lt;a href=&quot;&#x2F;blog&#x2F;rust-made-me-a-better-python-developer&quot;&gt;Learning Rust made me a better developer&lt;&#x2F;a&gt; and &lt;a href=&quot;&#x2F;blog&#x2F;rust-compiler-ai-agent-guardrail&quot;&gt;The Rust compiler as an AI coding agent guardrail&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;You can get far with Python&#x27;s type checkers&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;We discuss how for business logic and when performance is not essential, Python + type checkers gets you far.&lt;&#x2F;li&gt;
&lt;li&gt;What Rust still gives you though is exhaustive enum matching enforced by the language.&lt;&#x2F;li&gt;
&lt;li&gt;The tooling got a lot better: &lt;a href=&quot;&#x2F;blog&#x2F;modern-python-tooling-uv-ruff-ty&quot;&gt;Modern Python tooling: uv, ruff and ty&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Vibe coding is a slot machine&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Real dopamine effect, working with multiple agents has a lot of context switching, which is bad for productivity (and your brain).&lt;&#x2F;li&gt;
&lt;li&gt;The real speed up number: not 10x, more like ~1.2-2x. You get a prototype in an hour, but then the iteration&#x2F;judgment&#x2F;cleanup often takes days.&lt;&#x2F;li&gt;
&lt;li&gt;The rubber-stamping risk: code that looks plausible, but you miss the 50 lines duplicated three times, because you didn&#x27;t feel the pain of producing the code.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Timeless coaching that outlives the hype&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;All project-based, it&#x27;s the best way to learn holistically, but noticing a shift from greenfield (2020) to more brownfield and AI hardening these days.&lt;&#x2F;li&gt;
&lt;li&gt;Ryan&#x27;s Payroll SaaS case study: tools changed, TDD&#x2F;guardrails mindset didn&#x27;t and stemmed from the coaching years ago (&quot;don&#x27;t give the fish, teach how to fish&quot;). More on this: &lt;a href=&quot;&#x2F;blog&#x2F;ai-coding-tools-fundamentals-case-study&#x2F;&quot;&gt;AI coding tools fundamentals case study&lt;&#x2F;a&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;where-to-go-from-here&quot;&gt;Where to go from here&lt;&#x2F;h2&gt;
&lt;p&gt;Michal ends on a positive note:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I think the world is actually starting to heal. I think there will be a lot of adjustment with all this AI hype.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;And it&#x27;s something I am coming back to as well: yes, we have a powerful set of new tools, but they are only as good as the knowledge and judgment you bring to them. The hype is deflating into something more honest, and the tools aren&#x27;t going anywhere, but neither is the need for engineers with deep skills.&lt;&#x2F;p&gt;
&lt;p&gt;If you want to further develop this skill set, check out &lt;a href=&quot;&#x2F;coaching&#x2F;&quot;&gt;how I help engineers level up&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>From Python to Rust: Master Iterators by Rebuilding 10 Unix Tools</title>
        <published>2026-06-21T00:00:00+00:00</published>
        <updated>2026-06-21T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/learn-rust-building-unix-tools/"/>
        <id>https://belderbos.dev/blog/learn-rust-building-unix-tools/</id>
        <summary type="html">&lt;p&gt;The fastest way I know to learn a language is to rebuild something you already understand. You stop fighting the problem and spend your attention on the syntax and the idioms. That is the whole idea behind the new &lt;em&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;track&#x2F;unix&#x2F;&quot;&gt;Unix tools track&lt;&#x2F;a&gt;&lt;&#x2F;em&gt; I just released on the Rust platform: ten small command-line classics, each one a pure function you implement and &lt;code&gt;cargo test&lt;&#x2F;code&gt; to validate you got it right.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;The fastest way I know to learn a language is to rebuild something you already understand. You stop fighting the problem and spend your attention on the syntax and the idioms. That is the whole idea behind the new &lt;em&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;track&#x2F;unix&#x2F;&quot;&gt;Unix tools track&lt;&#x2F;a&gt;&lt;&#x2F;em&gt; I just released on the Rust platform: ten small command-line classics, each one a pure function you implement and &lt;code&gt;cargo test&lt;&#x2F;code&gt; to validate you got it right.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;why-unix-tools-and-why-from-python&quot;&gt;Why Unix tools, and why from Python&lt;&#x2F;h2&gt;
&lt;p&gt;Every exercise opens with the Python you would write, then teaches the Rust idiom that replaces it. People who love the platform keep pointing at the same thing: the Python-to-Rust bridge is what makes the concepts stick.&lt;&#x2F;p&gt;
&lt;p&gt;You are not memorizing &lt;code&gt;Iterator&lt;&#x2F;code&gt; methods in the abstract, you are watching &lt;code&gt;len(text.split())&lt;&#x2F;code&gt; turn into &lt;code&gt;.split_whitespace().count()&lt;&#x2F;code&gt;, and reaching for &lt;code&gt;Option&lt;&#x2F;code&gt; and &lt;code&gt;Result&lt;&#x2F;code&gt; instead of Python&#x27;s exceptions.&lt;&#x2F;p&gt;
&lt;p&gt;Take &lt;code&gt;cut&lt;&#x2F;code&gt;. In Python an invalid field raises an exception at runtime:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; cut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(text, delim, field):&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # field is 1-based&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; field&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; ValueError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;field values may not include zero&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Rust both outcomes live in the return type:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#[derive(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Debug&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PartialEq&lt;&#x2F;span&gt;&lt;span&gt;)]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;enum&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; CutError&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    ZeroField&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; cut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;: &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;, delim&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; char&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;, field&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; usize&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Result&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Vec&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; CutError&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    &#x2F;&#x2F; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#[test]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; field_zero_is_an_error&lt;&#x2F;span&gt;&lt;span&gt;() {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    assert_eq!&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;cut&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;a:b&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; &amp;#39;:&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Err&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;CutError&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;ZeroField&lt;&#x2F;span&gt;&lt;span&gt;));&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Encoding the failure in the type, not just the docs, is a core Rust idea: the compiler makes every caller account for it, and that is a big part of what makes Rust code safer. The failing case becomes a test you code towards.&lt;&#x2F;p&gt;
&lt;p&gt;Iterators are the spine of the track, seven of the ten exercises turn &lt;code&gt;for&lt;&#x2F;code&gt; loops and comprehensions into iterator chains. Every exercise relates back to one or more Python idioms you already know.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;For me Rust is hard but thanks to comparison with Python I feel that I understand more. - Piotr R&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Having a direct comparison with Python snippets keeps me more in the context of what&#x27;s going on. - Michal S&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The track also follows one rule that matters for real CLIs: the logic lives in a pure, testable function, while the I&#x2F;O (reading a file or stdin) stays in a thin wrapper around it.&lt;&#x2F;p&gt;
&lt;p&gt;That split is exactly how you would structure a professional Rust tool, and it is why every exercise can be validated by a test instead of by running a binary. I wrote more about how this rewiring changes your Python instincts in &lt;a href=&quot;&#x2F;blog&#x2F;rust-made-me-a-better-python-developer&#x2F;&quot;&gt;Rust made me a better Python developer&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-10-exercises-and-what-each-one-teaches&quot;&gt;The 10 exercises and what each one teaches&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;wc&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: count lines, words, characters. Iterators plus &lt;code&gt;.count()&lt;&#x2F;code&gt; replace &lt;code&gt;len(...)&lt;&#x2F;code&gt;, and you return a &lt;code&gt;(usize, usize, usize)&lt;&#x2F;code&gt; tuple. The character count is also a sneak intro to why &lt;code&gt;chars().count()&lt;&#x2F;code&gt; is not &lt;code&gt;len()&lt;&#x2F;code&gt; in a Unicode world.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;head&lt;&#x2F;code&gt; &amp;amp; &lt;code&gt;tail&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: first and last N lines. &lt;code&gt;head&lt;&#x2F;code&gt; uses lazy &lt;code&gt;.take(n)&lt;&#x2F;code&gt; and stops early; &lt;code&gt;tail&lt;&#x2F;code&gt; forces you to collect first and slice from the end, because you cannot run an iterator backwards.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cat -n&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: number the lines. Rust&#x27;s &lt;code&gt;.enumerate()&lt;&#x2F;code&gt; starts at &lt;code&gt;0&lt;&#x2F;code&gt;, not &lt;code&gt;1&lt;&#x2F;code&gt; (there is no &lt;code&gt;start=&lt;&#x2F;code&gt; argument), and you rebuild the numbered text from there.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;tr&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: translate and delete characters. The exercise that makes the &lt;code&gt;char&lt;&#x2F;code&gt; vs &lt;code&gt;&amp;amp;str&lt;&#x2F;code&gt; distinction click: &lt;code&gt;&#x27;l&#x27;&lt;&#x2F;code&gt; is a &lt;code&gt;char&lt;&#x2F;code&gt;, &lt;code&gt;&quot;l&quot;&lt;&#x2F;code&gt; is a &lt;code&gt;&amp;amp;str&lt;&#x2F;code&gt;, and you &lt;code&gt;.map()&lt;&#x2F;code&gt; over &lt;code&gt;.chars()&lt;&#x2F;code&gt; to rewrite each one.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;grep&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: filter matching lines, with &lt;code&gt;-i&lt;&#x2F;code&gt; and &lt;code&gt;-v&lt;&#x2F;code&gt;. Substring tests with &lt;code&gt;.contains()&lt;&#x2F;code&gt;, case folding, and a single boolean condition that handles both &lt;code&gt;-i&lt;&#x2F;code&gt; and &lt;code&gt;-v&lt;&#x2F;code&gt; without branching. First taste of borrowing and lifetimes in the signature.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;unix-tools.jpeg&quot; alt=&quot;The Unix tools track on the Rust platform: all 10 exercises from wc to the top_words capstone, with difficulty levels and completion status&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cut&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: extract a field. Two outcomes that pull in different directions: a missing field is normal (skip the line), but &lt;code&gt;field == 0&lt;&#x2F;code&gt; is a bad request. You model that with &lt;code&gt;Option&lt;&#x2F;code&gt; and &lt;code&gt;Result&lt;&#x2F;code&gt; instead of Python&#x27;s exceptions.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;uniq -c&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: count adjacent duplicates. Rust has no &lt;code&gt;itertools.groupby&lt;&#x2F;code&gt;, so you walk runs by hand with pattern matching and a &lt;code&gt;Vec&lt;&#x2F;code&gt;, keeping the consecutive-only behavior honest.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;sort&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: sort lines, with &lt;code&gt;-n&lt;&#x2F;code&gt;. Iterators do not sort, so you &lt;code&gt;collect&lt;&#x2F;code&gt; into a &lt;code&gt;mut Vec&lt;&#x2F;code&gt; and call &lt;code&gt;.sort()&lt;&#x2F;code&gt;; numeric order means &lt;code&gt;sort_by_key&lt;&#x2F;code&gt; with a closure, Rust&#x27;s answer to Python&#x27;s &lt;code&gt;key=&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;sed s&#x2F;&#x2F;&#x2F;&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: find and replace per line. &lt;code&gt;str::replace&lt;&#x2F;code&gt; swaps every match, &lt;code&gt;replacen&lt;&#x2F;code&gt; stops after a count, so the &lt;code&gt;g&lt;&#x2F;code&gt; flag is just a choice between two methods, no manual counting.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;top_words&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;: the capstone. Compose everything: count word frequencies with the &lt;code&gt;HashMap&lt;&#x2F;code&gt; entry API (&lt;code&gt;entry().or_insert()&lt;&#x2F;code&gt;, since there is no &lt;code&gt;Counter&lt;&#x2F;code&gt;), then sort and take the top &lt;code&gt;n&lt;&#x2F;code&gt;. This is the &lt;code&gt;tr | sort | uniq -c | sort -rn | head&lt;&#x2F;code&gt; pipeline rebuilt as one Rust function.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;If you have been meaning to get past &lt;em&gt;reading&lt;&#x2F;em&gt; about Rust to actually writing it and making the concepts stick, begin with the free &lt;code&gt;wc&lt;&#x2F;code&gt; and &lt;code&gt;head&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;tail&lt;&#x2F;code&gt; exercises. Each one is short, has tests to code towards, and there is no AI on the platform; you have to do the work. I hope you learn a lot: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;track&#x2F;unix&#x2F;&quot;&gt;start the Unix tools track&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Next up on the platform: a track on &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;doc.rust-lang.org&#x2F;nomicon&#x2F;lifetimes.html&quot;&gt;Rust lifetimes&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Profile First: A 10x Faster Django Test Suite</title>
        <published>2026-06-20T00:00:00+00:00</published>
        <updated>2026-06-20T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/profile-django-test-suite-10x-faster/"/>
        <id>https://belderbos.dev/blog/profile-django-test-suite-10x-faster/</id>
        <summary type="html">&lt;p&gt;The Rust Platform Django test suite took 30 seconds to run. I had a hunch it was database-related. Of course I was wrong. I profiled it with cProfile and cut it from 30 to 3 seconds.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;The Rust Platform Django test suite took 30 seconds to run. I had a hunch it was database-related. Of course I was wrong. I profiled it with cProfile and cut it from 30 to 3 seconds.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;stop-guessing-run-the-profiler&quot;&gt;Stop guessing, run the profiler&lt;&#x2F;h2&gt;
&lt;p&gt;The instinct on a slow test suite is to start making assumptions: too many fixtures, the database is slow, I should parallelize, it&#x27;s the GIL. Every one of those is a real fix for &lt;em&gt;some&lt;&#x2F;em&gt; issues. The problem is you don&#x27;t know until you measure.&lt;&#x2F;p&gt;
&lt;p&gt;From &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.amazon.es&#x2F;Micha-Gorelick-ebook&#x2F;dp&#x2F;B0F6VXPL2W?ref_=ast_author_dp&amp;amp;th=1&amp;amp;psc=1&quot;&gt;High Performance Python&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Sometimes it’s good to be lazy. By profiling first, you can quickly identify the bottlenecks that need to be solved, and then you can solve just enough of these to achieve the performance you need. If you avoid profiling and jump to optimization, you’ll quite likely do more work in the long run. Always be driven by the results of profiling.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;&lt;code&gt;cProfile&lt;&#x2F;code&gt; ships with Python. In this case I pointed it at my test suite:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run python&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -m&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; cProfile&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -o&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; tests.pstats&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -m&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; pytest&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -k&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; unit&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That gives you a &lt;code&gt;pstats&lt;&#x2F;code&gt; file. You can read it from the command line, but it&#x27;s not intuitive:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run python&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -c&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;import pstats; pstats.Stats(&amp;#39;tests.pstats&amp;#39;).sort_stats(&amp;#39;tottime&amp;#39;).print_stats(10)&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Adam Johnson recently released &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;adamj.eu&#x2F;tech&#x2F;2026&#x2F;04&#x2F;03&#x2F;python-introducing-profiling-explorer&#x2F;&quot;&gt;profiling-explorer&lt;&#x2F;a&gt;, a browser viewer over the same data. You can invoke it like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uvx&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; profiling-explorer tests.pstats&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It opens on &lt;code&gt;http:&#x2F;&#x2F;127.0.0.1:8099&lt;&#x2F;code&gt;. I sorted by internal time and the bottleneck was obvious:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;profiling-pytest-on-rust-platform.png&quot; alt=&quot;profiling-explorer showing pbkdf2_hmac dominating internal time in the Django test run&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;reading-the-numbers-without-fooling-yourself&quot;&gt;Reading the numbers without fooling yourself&lt;&#x2F;h2&gt;
&lt;p&gt;Two columns matter:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;tottime&lt;&#x2F;strong&gt; (the viewer calls it &quot;internal ms&quot;): time spent &lt;em&gt;inside&lt;&#x2F;em&gt; a function, excluding what it called. This is where the CPU actually went.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;cumtime&lt;&#x2F;strong&gt; (&quot;cumulative ms&quot;): the function plus everything beneath it. Useful for finding the expensive subtree, useless for finding the hot leaf.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Sort by tottime to find what to fix:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Frame&lt;&#x2F;th&gt;&lt;th&gt;Calls&lt;&#x2F;th&gt;&lt;th&gt;tottime&lt;&#x2F;th&gt;&lt;th&gt;Share&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;_hashlib.pbkdf2_hmac&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;177&lt;&#x2F;td&gt;&lt;td&gt;26,470 ms&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;82.8%&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;psycopg2 cursor.execute&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;4,682&lt;&#x2F;td&gt;&lt;td&gt;1,522 ms&lt;&#x2F;td&gt;&lt;td&gt;4.8%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;&lt;code&gt;pbkdf2_hmac&lt;&#x2F;code&gt; is Django&#x27;s default password hasher. It&#x27;s &lt;em&gt;meant&lt;&#x2F;em&gt; to be slow, because it resists brute-forcing real passwords. But it fires on every test that creates a user: fixtures, &lt;code&gt;create_user&lt;&#x2F;code&gt;, &lt;code&gt;client.login&lt;&#x2F;code&gt;. In production it runs once per login. In a test suite it runs hundreds of times for no security benefit at all.&lt;&#x2F;p&gt;
&lt;p&gt;One more thing the table teaches you: on a high-call-count frame, the call &lt;em&gt;count&lt;&#x2F;em&gt; is the signal, not the time. &lt;code&gt;cursor.execute&lt;&#x2F;code&gt; at 4,682 calls is mostly real database wait, and the count is high because every test sets up its own data: inserts, auth lookups, session reads, across 355 tests. A high count is worth a second look (it can hide an N+1), but you have to confirm that before believing it. (cProfile adds a fixed cost per call too, but at a few thousand calls that overhead is marginal; it only distorts the picture when a frame fires millions of times.) PBKDF2 only ran 177 times, so its time really was concentrated there, and it was the real culprit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-five-line-fix&quot;&gt;The five-line fix&lt;&#x2F;h2&gt;
&lt;p&gt;Swap the hasher to fast MD5 in tests only. An &lt;code&gt;autouse&lt;&#x2F;code&gt; fixture in &lt;code&gt;conftest.py&lt;&#x2F;code&gt; (default &quot;function&quot; scope does it for every test):&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; pytest&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;autouse&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; fast_password_hashing&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(settings):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    settings.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;PASSWORD_HASHERS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;django.contrib.auth.hashers.MD5PasswordHasher&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Hash strength is irrelevant under test, so there&#x27;s no loss of coverage. Test suite speed went from ~30s to ~3s.&lt;&#x2F;p&gt;
&lt;p&gt;It turns out this is also a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;code.djangoproject.com&#x2F;ticket&#x2F;18157&quot;&gt;documented Django trick&lt;&#x2F;a&gt;, which is the point: the profiler led me to a known fix I didn&#x27;t know I needed, instead of me making assumptions.&lt;&#x2F;p&gt;
&lt;p&gt;The new top frame is &lt;code&gt;cursor.execute&lt;&#x2F;code&gt; at ~4.7k calls, which smelled like an N+1. So I measured that too: I wrapped the suite to capture every query and grouped them by shape. No N+1. The list views already batch their lookups into one query, and the high count was just 355 tests each doing honest setup. The only real waste was a &lt;code&gt;save()&lt;&#x2F;code&gt; path firing redundant &lt;code&gt;COUNT&lt;&#x2F;code&gt; queries, cheap, but easy to halve.&lt;&#x2F;p&gt;
&lt;p&gt;Which is the whole point: I guessed N+1 and was wrong again. The profiler keeps you honest. What&#x27;s the slowest thing you run every day that you&#x27;ve never actually measured? Let me know on &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;bbelderbos&#x2F;&quot;&gt;LinkedIn&lt;&#x2F;a&gt; or &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;x.com&#x2F;bbelderbos&quot;&gt;X&#x2F;Twitter&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>End-to-End Testing Every Rust Exercise with Playwright</title>
        <published>2026-06-19T00:00:00+00:00</published>
        <updated>2026-06-19T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/playwright-e2e-testing-71-exercises/"/>
        <id>https://belderbos.dev/blog/playwright-e2e-testing-71-exercises/</id>
        <summary type="html">&lt;p&gt;The &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&quot;&gt;Rust platform&lt;&#x2F;a&gt; has 71 exercises and counting (I just added a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;track&#x2F;unix&#x2F;&quot;&gt;new track of Unix exercises&lt;&#x2F;a&gt;). They all share the same interface: load an editor, type code, validate it against a Rust backend. When I make any changes to the platform, how do I confirm nothing breaks? Enter end-to-end testing with Playwright.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;The &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&quot;&gt;Rust platform&lt;&#x2F;a&gt; has 71 exercises and counting (I just added a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;track&#x2F;unix&#x2F;&quot;&gt;new track of Unix exercises&lt;&#x2F;a&gt;). They all share the same interface: load an editor, type code, validate it against a Rust backend. When I make any changes to the platform, how do I confirm nothing breaks? Enter end-to-end testing with Playwright.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h2&gt;
&lt;p&gt;Manual testing doesn&#x27;t scale. Every time I add an exercise, tweak the editor, or update the validation flow, I need confidence that all exercises still work. Not just that the page loads, but the full loop: login, navigate, type code, submit, see results.&lt;&#x2F;p&gt;
&lt;p&gt;Unit tests cover the Django app, and the Rust validator has its own test suite. But neither exercises the full path a student takes: loading an exercise and getting a real pass or fail back.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;one-test-function-71-test-cases&quot;&gt;One Test Function, 71 Test Cases&lt;&#x2F;h2&gt;
&lt;p&gt;Playwright with pytest covers this in under 50 lines. Here&#x27;s the core test file:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; psycopg2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; pytest&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decouple&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; config&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; .constants&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; DOMAIN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;exercises&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;with&lt;&#x2F;span&gt;&lt;span&gt; psycopg2.connect(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;dsn&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;DATABASE_URL&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; conn:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; conn.cursor()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; cursor:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        cursor.execute(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;            &amp;quot;SELECT slug, solution FROM bites_exercise WHERE public = true&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        exercises&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; cursor.fetchall()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.mark.parametrize&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;exercise&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, exercises,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; ids&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[ex[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; ex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; exercises])&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(logged_in_page, exercise):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    slug, solution&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; exercise&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; logged_in_page&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    exercise_url&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; = f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{DOMAIN}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;slug&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.goto(exercise_url)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.wait_for_url(exercise_url)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.wait_for_selector(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;.CodeMirror&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;visible&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.wait_for_function(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;document.querySelector(&amp;#39;.CodeMirror&amp;#39;)?.CodeMirror !== undefined&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.evaluate(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&amp;quot;&amp;quot;document.querySelector(&amp;#39;.CodeMirror&amp;#39;).CodeMirror.setValue(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-support&quot;&gt;{repr&lt;&#x2F;span&gt;&lt;span&gt;(solution)&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;)&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.click(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;#validate-button&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.wait_for_function(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;document.querySelector(&amp;#39;#feedback&amp;#39;).innerText.includes(&amp;#39;Congrats&amp;#39;) || &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;document.querySelector(&amp;#39;#feedback&amp;#39;).innerText.includes(&amp;#39;Oops&amp;#39;)&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        timeout&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;30000&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    validate_result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; page.text_content(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;#feedback&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Congrats, you passed this exercise&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; validate_result&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The database query at module load fetches every public exercise with its solution. &lt;code&gt;@pytest.mark.parametrize&lt;&#x2F;code&gt; turns that into 71 test cases. Each test navigates, injects the solution, validates, and asserts success.&lt;&#x2F;p&gt;
&lt;p&gt;Here is Playwright running the tests locally against the real Rust validator, one exercise after another:&lt;&#x2F;p&gt;
&lt;video src=&quot;&#x2F;videos&#x2F;playwright-e2e.mp4&quot; autoplay loop muted playsinline controls width=&quot;100%&quot;&gt;
  Your browser does not support embedded video.
&lt;&#x2F;video&gt;
&lt;h2 id=&quot;patterns-that-made-it-work&quot;&gt;Patterns That Made It Work&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;session-scoped-fixtures-for-speed&quot;&gt;Session-scoped fixtures for speed&lt;&#x2F;h3&gt;
&lt;p&gt;Launching a browser is expensive. Logging in is expensive. Do it once:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;scope&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;session&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; browser&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(e2e_user):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; sync_playwright()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; p:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        with&lt;&#x2F;span&gt;&lt;span&gt; p.chromium.launch(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;headless&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;HEADLESS&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; browser:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            yield&lt;&#x2F;span&gt;&lt;span&gt; browser&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;scope&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;session&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; logged_in_page&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(browser):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; browser.new_page()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.set_default_timeout(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;30_000&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.goto(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{DOMAIN}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;pbadmin&#x2F;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.fill(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;input[name=&amp;quot;username&amp;quot;]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; LOGIN&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.fill(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;input[name=&amp;quot;password&amp;quot;]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; PASSWORD&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    page.click(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;input[type=&amp;quot;submit&amp;quot;]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    yield&lt;&#x2F;span&gt;&lt;span&gt; page&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;All 71 exercises run against the same authenticated browser session, so the login cost is paid once.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;waiting-for-codemirror&quot;&gt;Waiting for CodeMirror&lt;&#x2F;h3&gt;
&lt;p&gt;One tricky thing with Playwright is timing. Sometimes elements are not yet ready when you hit the page. In this case you have to wait for the CodeMirror editor to be fully initialized before injecting code:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;page.wait_for_selector(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;.CodeMirror&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;visible&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;page.wait_for_function(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;document.querySelector(&amp;#39;.CodeMirror&amp;#39;)?.CodeMirror !== undefined&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;page.evaluate(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&amp;quot;&amp;quot;document.querySelector(&amp;#39;.CodeMirror&amp;#39;).CodeMirror.setValue(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-support&quot;&gt;{repr&lt;&#x2F;span&gt;&lt;span&gt;(solution)&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;)&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;First we wait for the selector to be visible, then we wait for the JavaScript instance to be ready.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;avoiding-django-s-async-context-trap&quot;&gt;Avoiding Django&#x27;s async context trap&lt;&#x2F;h3&gt;
&lt;p&gt;Another issue I faced was creating a Django user inside a Playwright fixture triggered:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SynchronousOnlyOperation: You cannot call this from an async context&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I worked around it by creating the test user in a separate fixture that runs &lt;em&gt;before&lt;&#x2F;em&gt; Playwright starts:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;scope&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;session&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; e2e_user&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(django_db_blocker):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; django_db_blocker.unblock():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; ensure_e2e_user()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;scope&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;session&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; browser&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(e2e_user):&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # e2e_user runs first&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; sync_playwright()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; p:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;        ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;django_db_blocker.unblock()&lt;&#x2F;code&gt; context manager allows database access in session-scoped fixtures. Order matters: the user must exist before the browser fixture runs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;running-locally-vs-ci&quot;&gt;Running Locally vs CI&lt;&#x2F;h2&gt;
&lt;p&gt;The E2E suite runs against a live database with the Rust validator running. That&#x27;s deliberate: for this layer I want real integration, not mocked responses. (The mocked cases have their own home, more on that below.)&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Run all 71 exercises&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run pytest tests&#x2F;test_e2e.py&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -v&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Debug a specific exercise&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;HEADLESS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;False&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run pytest tests&#x2F;test_e2e.py&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -v -k&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;exercise-slug&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;By default, the tests run headless, which means no browser window opens. This is faster and works well in CI. If you want to see what Playwright is doing, set &lt;code&gt;HEADLESS=False&lt;&#x2F;code&gt; to open a visible browser window.&lt;&#x2F;p&gt;
&lt;p&gt;This is essential for debugging why a particular exercise fails. Use the &lt;code&gt;-k&lt;&#x2F;code&gt; option to filter for a specific exercise by slug. And you can use &lt;code&gt;--pdb&lt;&#x2F;code&gt; to leave the browser window open when a test fails, so you can inspect the state.&lt;&#x2F;p&gt;
&lt;p&gt;For CI, I run unit tests only. E2E tests require the Rust backend and take longer. I run them locally before pushing major changes. This is a good example of separating unit and integration tests.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-about-the-unhappy-paths&quot;&gt;What about the unhappy paths?&lt;&#x2F;h2&gt;
&lt;p&gt;A reader pointed out that these E2E tests only cover the happy path: type the correct solution, see &quot;Congrats&quot;. Fair observation. What happens when a student submits wrong code, or the validator itself blows up?&lt;&#x2F;p&gt;
&lt;p&gt;Those cases live in the unit tests, where I mock out the validator API call:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;&#x2F;th&gt;&lt;th&gt;Test&lt;&#x2F;th&gt;&lt;th&gt;Asserted message&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Wrong code (tests fail)&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;test_validate_failure&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;mock returns &lt;code&gt;success: False&lt;&#x2F;code&gt; → &lt;code&gt;b&quot;Oops, try again&quot;&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Correct code&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;test_validate_success&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;success: True&lt;&#x2F;code&gt; → &lt;code&gt;b&quot;Congrats&quot;&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Runner&#x2F;API throws&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;test_validate_api_error&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;mock_post.side_effect = Exception&lt;&#x2F;code&gt; → &lt;code&gt;b&quot;Error while executing code&quot;&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Exercise doesn&#x27;t exist&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;test_validate_exercise_not_found&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;b&quot;Exercise not found&quot;&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Tests deleted&#x2F;altered&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;test_validate_missing_tests&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;b&quot;tests are missing&quot;&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Prohibited code (&lt;code&gt;std::fs&lt;&#x2F;code&gt;, &lt;code&gt;unsafe&lt;&#x2F;code&gt;, &lt;code&gt;include!&lt;&#x2F;code&gt;, &lt;code&gt;std::io&lt;&#x2F;code&gt;...)&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;test_validate_prohibited_pattern_*&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;b&quot;prohibited pattern&quot;&lt;&#x2F;code&gt; (parametrized over 6 snippets)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;This is the split that makes the whole thing manageable. The E2E suite proves the full loop works end to end against the real validator. The unit tests, with the validator mocked, assert the exact message a student sees in each error case. Mocking is the natural home for these: some failure modes, like the runner throwing an exception, are hard to trigger on demand against a live backend, and even the ones you could reproduce in a browser are faster and clearer to pin down with a mock. See &lt;a href=&quot;&#x2F;blog&#x2F;python-mock-patch-verify-interception&#x2F;&quot;&gt;How to Tell if Your Python Mock Is Actually Working&lt;&#x2F;a&gt; for the gotchas there.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-buys-me&quot;&gt;What this buys me&lt;&#x2F;h2&gt;
&lt;p&gt;Add an exercise, it&#x27;s tested automatically, no new test code. That&#x27;s the whole point of parameterizing over the database instead of hand-writing cases. Every frontend change now runs through a regression suite before it reaches users.&lt;&#x2F;p&gt;
&lt;p&gt;I find Playwright more modern and ergonomic than Selenium; the one rough edge is element timing, which the &lt;code&gt;wait_for_function&lt;&#x2F;code&gt; calls above handle. If you&#x27;re testing anything with dynamic content, parameterizing over your real data beats writing one test per case.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>When to use classmethod, staticmethod, or instance method in Python</title>
        <published>2026-06-18T00:00:00+00:00</published>
        <updated>2026-06-18T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/classmethod-vs-staticmethod-vs-instance-method-python/"/>
        <id>https://belderbos.dev/blog/classmethod-vs-staticmethod-vs-instance-method-python/</id>
        <summary type="html">&lt;p&gt;In a coaching call this week we discussed a &lt;code&gt;create&lt;&#x2F;code&gt; classmethod, and someone asked the obvious question: why is that here? It just forwarded its arguments to &lt;code&gt;__init__&lt;&#x2F;code&gt;. We ended up discussing the difference between instance methods, classmethods, and staticmethods, and how to tell which is which. Here&#x27;s a simple decision rule.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;In a coaching call this week we discussed a &lt;code&gt;create&lt;&#x2F;code&gt; classmethod, and someone asked the obvious question: why is that here? It just forwarded its arguments to &lt;code&gt;__init__&lt;&#x2F;code&gt;. We ended up discussing the difference between instance methods, classmethods, and staticmethods, and how to tell which is which. Here&#x27;s a simple decision rule.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-decision-rule&quot;&gt;The decision rule&lt;&#x2F;h2&gt;
&lt;p&gt;Look at what the method actually touches:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Needs the instance&lt;&#x2F;strong&gt; (&lt;code&gt;self&lt;&#x2F;code&gt;) → instance method&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Needs the class&lt;&#x2F;strong&gt; (&lt;code&gt;cls&lt;&#x2F;code&gt;) but not a specific instance → &lt;code&gt;@classmethod&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Needs neither&lt;&#x2F;strong&gt; → &lt;code&gt;@staticmethod&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Nice, but what are some actual use cases? Let&#x27;s look at the &lt;code&gt;create&lt;&#x2F;code&gt; method that prompted the question.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;create&lt;&#x2F;code&gt; method from the call fails the rule above. It took the same arguments as &lt;code&gt;__init__&lt;&#x2F;code&gt; and passed them straight through. It still adds a nice interface (&lt;code&gt;Class.create(...)&lt;&#x2F;code&gt;), but it doesn&#x27;t do any work that the constructor doesn&#x27;t already do:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# shortened for clarity&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;classmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; create&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(cls, amount: Decimal, currency: Currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;EUR&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Expense&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;amount,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;currency)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;when-a-classmethod-earns-its-place&quot;&gt;When a classmethod earns its place&lt;&#x2F;h2&gt;
&lt;p&gt;A classmethod pulls its weight when it does work the constructor shouldn&#x27;t, or builds the object from a different starting point. Add a normalization step and the same method suddenly has a job:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;classmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; create&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(cls, amount: Decimal, currency: Currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;EUR&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Expense&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;amount.quantize(Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;0.01&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;currency)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The canonical use of a &lt;code&gt;@classmethod&lt;&#x2F;code&gt; is the &lt;strong&gt;alternative constructor&lt;&#x2F;strong&gt;. Python won&#x27;t let you overload &lt;code&gt;__init__&lt;&#x2F;code&gt;, so when you want to build an object several ways, each way becomes a classmethod.&lt;&#x2F;p&gt;
&lt;p&gt;The standard library has rich examples, for example take a look at &lt;code&gt;datetime.date&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;date.today()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                      # from the system clock&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;date.fromtimestamp(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1718539200&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # from a POSIX timestamp&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;date.fromisoformat(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;2026-06-16&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # from an ISO 8601 string&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;date.fromordinal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;739418&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;          # from a proleptic Gregorian ordinal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;date.fromisocalendar(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2026&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 25&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; # from ISO year&#x2F;week&#x2F;day&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Source:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Additional constructors&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;classmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; fromtimestamp&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(cls, t):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;Construct a date from a POSIX timestamp (like time.time()).&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; TypeError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&amp;#39;NoneType&amp;#39; object cannot be interpreted as an integer&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        y, m, d, hh, mm, ss, weekday, jday, dst&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; _time.localtime(t)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;(y, m, d)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;classmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; today&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(cls):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;Construct a date from time.time().&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; _time.time()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;.fromtimestamp(t)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every one of those returns a &lt;code&gt;date&lt;&#x2F;code&gt;, but starts from different raw material. They have to be classmethods because they need &lt;code&gt;cls&lt;&#x2F;code&gt; to construct the instance, and they return &lt;code&gt;cls(...)&lt;&#x2F;code&gt; which makes it also work with subclasses. For instance, if &lt;code&gt;MyDate&lt;&#x2F;code&gt; subclasses &lt;code&gt;date&lt;&#x2F;code&gt;, then &lt;code&gt;MyDate.today()&lt;&#x2F;code&gt; will return a &lt;code&gt;MyDate&lt;&#x2F;code&gt; instance, not a &lt;code&gt;date&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;strong&gt;Bonus&lt;&#x2F;strong&gt;: I was annoyed that &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;PyBites-Open-Source&#x2F;pysource&quot;&gt;my &lt;code&gt;pysource&lt;&#x2F;code&gt; package&lt;&#x2F;a&gt; didn&#x27;t work, so I&#x27;ve since patched it, and now you can get to this source code easily with:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uvx&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --from&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; pybites-pysource pysource&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -m&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; datetime.date&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;(I tend to pip this into Vim with &lt;code&gt;| vi -&lt;&#x2F;code&gt; to read the source code in a scratch buffer.)&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;You&#x27;ll see the same pattern across the ecosystem: &lt;code&gt;dict.fromkeys(...)&lt;&#x2F;code&gt;, &lt;code&gt;int.from_bytes(...)&lt;&#x2F;code&gt;, and in Pydantic &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;grep.app&#x2F;pydantic&#x2F;pydantic&#x2F;main&#x2F;pydantic&#x2F;main.py?q=model_validate#L722&quot;&gt;&lt;code&gt;Model.model_validate(...)&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; &#x2F; &lt;code&gt;model_validate_json(...)&lt;&#x2F;code&gt; are all classmethods that build an instance from different raw material.&lt;&#x2F;p&gt;
&lt;p&gt;Another classmethod use case is &lt;strong&gt;class-level state&lt;&#x2F;strong&gt;: registries, caches, counters. A plugin registry is the clean example, because the method reads and mutates state that belongs to the class, not to any instance:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Handler&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _registry: dict[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;, type[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Handler&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;classmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; register&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(cls, name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, handler: type[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Handler&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        cls&lt;&#x2F;span&gt;&lt;span&gt;._registry[name]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; handler&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;classmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(cls, name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; type[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Handler&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; cls&lt;&#x2F;span&gt;&lt;span&gt;._registry[name]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# called on the class, no instance needed; it mutates state that lives on the class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Handler.register(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;json&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, JSONHandler)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;when-it-s-really-a-staticmethod&quot;&gt;When it&#x27;s really a staticmethod&lt;&#x2F;h2&gt;
&lt;p&gt;If the method touches neither &lt;code&gt;self&lt;&#x2F;code&gt; nor &lt;code&gt;cls&lt;&#x2F;code&gt;, it&#x27;s a staticmethod, which is a plain function that happens to live inside the class for namespacing. That&#x27;s a legitimate choice when the helper is tightly bound to the class and you want &lt;code&gt;Expense.normalize(...)&lt;&#x2F;code&gt; to read well. It&#x27;s now part of the class API (it shows up in &lt;code&gt;dir(Expense)&lt;&#x2F;code&gt;) and can be called without an instance.&lt;&#x2F;p&gt;
&lt;p&gt;Genuine staticmethods are rarer than the other two, which itself tells you something. A clean example is a &lt;code&gt;Color&lt;&#x2F;code&gt; class with conversion helpers (from a Pybites exercise):&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Color&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.name&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; name&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.rgb&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; COLOR_NAMES&lt;&#x2F;span&gt;&lt;span&gt;.get(name.upper())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;staticmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; hex2rgb&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(hex_value:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; tuple&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;(hex_value[i:i&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 2&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 16&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; i&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 3&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;staticmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; rgb2hex&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(rgb: tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;        return f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;#&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;rgb[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;:02x&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}{&lt;&#x2F;span&gt;&lt;span&gt;rgb[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;:02x&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}{&lt;&#x2F;span&gt;&lt;span&gt;rgb[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;:02x&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;hex2rgb&lt;&#x2F;code&gt; and &lt;code&gt;rgb2hex&lt;&#x2F;code&gt; touch neither the instance nor the class. They&#x27;re pure conversions that live on &lt;code&gt;Color&lt;&#x2F;code&gt; so &lt;code&gt;Color.hex2rgb(&quot;#ff0000&quot;)&lt;&#x2F;code&gt; reads well next to the rest of the API.&lt;&#x2F;p&gt;
&lt;p&gt;But that&#x27;s exactly the signal worth noticing: a staticmethod might be just a function in disguise, and sometimes the honest move is to pull it out to a module-level function where it&#x27;s easier to test and use on its own.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th style=&quot;text-align: left&quot;&gt;Method type&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: left&quot;&gt;First argument&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: left&quot;&gt;Access to&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: left&quot;&gt;Common use case&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: left&quot;&gt;Instance method&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;&lt;code&gt;self&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;Instance &amp;amp; class state&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;Modifying object state&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: left&quot;&gt;Class method (&lt;code&gt;@classmethod&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;&lt;code&gt;cls&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;Class state only&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;Alternative constructors, registries&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: left&quot;&gt;Static method (&lt;code&gt;@staticmethod&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;none&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;Neither&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: left&quot;&gt;Isolated utility&#x2F;helper functions&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;why-this-matters-more-now&quot;&gt;Why this matters more now&lt;&#x2F;h2&gt;
&lt;p&gt;When you write the code yourself, you rarely add a method without a reason. When an agent writes it, you get plausible-looking structure that nobody chose. A &lt;code&gt;create&lt;&#x2F;code&gt; classmethod that does nothing, a staticmethod that should be a free function, a helper hanging off the wrong class. That&#x27;s your judgment call: is this method doing work that belongs to the class, or is it just a pattern the agent learned from other code?&lt;&#x2F;p&gt;
&lt;p&gt;It pays to slow down and look critically at any code and ask those questions. With AI producing more code faster, it&#x27;s easy to assume that if it looks like Python, it&#x27;s good Python. But the agent has no taste, and it will happily produce code that is technically correct but structurally wrong.&lt;&#x2F;p&gt;
&lt;p&gt;This is also why I keep writing articles like this one: to give you a simple decision rule you can run in your head during review. It reminds me of Rust, which makes data flow explicit right in the signature with &lt;code&gt;self&lt;&#x2F;code&gt;, &lt;code&gt;&amp;amp;self&lt;&#x2F;code&gt;, and &lt;code&gt;&amp;amp;mut self&lt;&#x2F;code&gt;. The signature tells you what the method touches, same idea as the rule here. (That data-and-behavior split is the whole theme of &lt;a href=&quot;&#x2F;blog&#x2F;why-rust-does-not-need-oop&#x2F;&quot;&gt;Why Rust does not need OOP&lt;&#x2F;a&gt;.)&lt;&#x2F;p&gt;
&lt;p&gt;So use AI, but keep developing your knowledge and taste. The more you know, the better you judge the code that comes your way, whether a human or an agent wrote it.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Building an AI Agent in 6 Weeks (and Finally Understanding How They Work)</title>
        <published>2026-06-17T00:00:00+00:00</published>
        <updated>2026-06-17T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/jeff-haemer-agentic-ai-cohort/"/>
        <id>https://belderbos.dev/blog/jeff-haemer-agentic-ai-cohort/</id>
        <summary type="html">&lt;p&gt;Jeff Haemer has written software since he was teaching it at the University of Colorado in the early 1980s. But he felt he needed to brush up his Python, and above all get a grounding in AI.&lt;&#x2F;p&gt;
&lt;p&gt;In his words AI was &quot;a big undifferentiated cloud of things I didn&#x27;t know.&quot; Time to change that.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Jeff Haemer has written software since he was teaching it at the University of Colorado in the early 1980s. But he felt he needed to brush up his Python, and above all get a grounding in AI.&lt;&#x2F;p&gt;
&lt;p&gt;In his words AI was &quot;a big undifferentiated cloud of things I didn&#x27;t know.&quot; Time to change that.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Six weeks into our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;Python Agentic AI&lt;&#x2F;a&gt; cohort, he ended up with an &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Sudavit&#x2F;expenses-ai-agent&quot;&gt;agentic application&lt;&#x2F;a&gt; with a few thousand lines of code, almost 250 unit tests, 100% coverage, and three working interfaces: a web UI, a command line, and a Telegram bot that talks to his phone.&lt;&#x2F;p&gt;
&lt;p&gt;He even went as far as running mutation testing. More than that, through the program he developed a &lt;em&gt;mental model&lt;&#x2F;em&gt; of how agents actually work under the covers.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-gap&quot;&gt;The gap&lt;&#x2F;h2&gt;
&lt;p&gt;Jeff came in with a long career behind him and a clear-eyed view of what he was missing:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;It&#x27;s a giant world and changing every day, and I didn&#x27;t even know in many ways where to start. When you told me you were thinking about doing an AI cohort, I said, please do that and sign me up, and I will happily jump in feet first.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;He set expectations low on purpose. &quot;I expected myself to crash and burn because I have a very spotty background.&quot; He&#x27;d taught plenty of courses; he knew how the first run of anything goes. He signed on as the cohort&#x27;s beta tester and decided to stay enthusiastic through whatever broke.&lt;&#x2F;p&gt;
&lt;p&gt;Watch the full interview with Jeff:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=ZMe0d9M7ARs&quot;&gt;Watch on YouTube&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;core-logic-first-interfaces-on-top&quot;&gt;Core logic first, interfaces on top&lt;&#x2F;h2&gt;
&lt;p&gt;An agent is a program that takes a request, reasons about it with an AI model, and acts. The cohort builds the reasoning core first, then wraps interfaces around it. That structure got tested the hard way in the Telegram week, when connecting the agent to a chat app hit a wall.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I got to the end of that week and I wasn&#x27;t even close, and I said, okay, I failed you. And you said, go on to the next week, because we designed the software so those units are independent of one another. We&#x27;ll rewrite it.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;He moved on. The Telegram unit was independent, so an unfinished interface didn&#x27;t block the rest of the build. We later rebuilt that week&#x27;s material from almost 100 pages down to 41, starting from a simpler working version. When Jeff came back to it, it worked.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;It was beautiful. When we hit a speed bump, that worked. It proved itself.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;There&#x27;s a meta lesson here, and a reminder of what well-designed software looks like: build the thing that does the work first, keep the edges replaceable. This saved the beta cohort from going under.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;mocking-the-thing-he-kept-putting-off&quot;&gt;Mocking, the thing he kept putting off&lt;&#x2F;h2&gt;
&lt;p&gt;The concept that gave Jeff the most trouble was mocking, the practice of replacing a real outside service (an API, a database) with a stand-in so a test can run fast and offline.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;The biggest pain for me was finally understanding what mocking involves. I even asked a friend of mine, a test automation Python guy, and he said, I never really understood that either.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;He avoided it until week four or five, when Juanjo made it non-negotiable:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;You got me to the point where I could actually separate my unit tests, which were independent of outside services, from my integration tests. I learned stuff about pytest, I learned stuff about mocking, and I learned stuff about my own code. I just ended up thinking about things in a different way.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Jeff&#x27;s rigor with the test suite paid dividends. By the end they were catching real issues, including a function that slipped coverage when he deployed to a Debian box, which he set out to track down on his own.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;ai-as-teaching-assistant-not-autopilot&quot;&gt;AI as teaching assistant, not autopilot&lt;&#x2F;h2&gt;
&lt;p&gt;Jeff wrote the code by hand. He used AI deliberately, as a tutor for the spots where his Python ran out, not as a generator to fill the file for him.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I understand conceptually what to do here, but I don&#x27;t know enough Python to struggle through the docs and work through all the bugs in that short amount of time. So, show me how to do this. AI turns into a great teaching assistant.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Unit tests gave each week a &lt;em&gt;definition of done&lt;&#x2F;em&gt;. The result is an artifact he can reason about, not a diff he has to trust on faith.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;understanding-by-building&quot;&gt;Understanding by building&lt;&#x2F;h2&gt;
&lt;p&gt;The win Jeff cared about most wasn&#x27;t the repo. It was the &lt;em&gt;mental model&lt;&#x2F;em&gt;:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;It&#x27;s different having done it than even reading about it. If somebody had written an explanation, a week later I&#x27;d have a vague idea, a month later I&#x27;d say I read an article about it but don&#x27;t remember anything, and two months later I&#x27;d say I&#x27;ve never heard of that. This, I think I understand.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;He tested it against a toy agent he found in the wild, a hallucinating Wikipedia clone, and could trace exactly what it did: validate the input, structure it, hand it to the model, get structured output back, cache it in a database.&lt;&#x2F;p&gt;
&lt;p&gt;From &quot;big undifferentiated cloud of things I didn&#x27;t know&quot; to a firm grounding in how AI agents work. Jeff ended up with a great artifact on GitHub, but the real win has been this deeper understanding that he will take to his next AI project.&lt;&#x2F;p&gt;
&lt;p&gt;We&#x27;re excited to see what Jeff will build next with this new skill set.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Why Rust does not need OOP</title>
        <published>2026-06-14T00:00:00+00:00</published>
        <updated>2026-06-14T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/why-rust-does-not-need-oop/"/>
        <id>https://belderbos.dev/blog/why-rust-does-not-need-oop/</id>
        <summary type="html">&lt;p&gt;When I heard structs replace classes in Rust, I was a bit surprised. I thought, how can you do without classes? But as I started to learn Rust, I realized that structs, traits, ownership and composition help resist the temptation of OOP. In fact, Rust&#x27;s approach to programming is more focused on data and behavior rather than objects.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s look at 5 reasons why Rust does not need OOP.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;When I heard structs replace classes in Rust, I was a bit surprised. I thought, how can you do without classes? But as I started to learn Rust, I realized that structs, traits, ownership and composition help resist the temptation of OOP. In fact, Rust&#x27;s approach to programming is more focused on data and behavior rather than objects.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s look at 5 reasons why Rust does not need OOP.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;1-composition&quot;&gt;1. Composition&lt;&#x2F;h2&gt;
&lt;p&gt;One major drawback of OOP is deep inheritance trees.&lt;&#x2F;p&gt;
&lt;p&gt;In classic OOP you would define class A, then class B inherits from A, then class C inherits from B, and so on. This can lead to a complex and fragile codebase where changes in one class can affect many others.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Animal&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Dog&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage&quot;&gt; extends&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; Animal&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ServiceDog&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage&quot;&gt; extends&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; Dog&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In &lt;a href=&quot;&#x2F;blog&#x2F;what-rust-structs-taught-me-about-state-ownership&#x2F;&quot;&gt;What Rust Structs Taught Me About State Ownership&lt;&#x2F;a&gt; I showed this example:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tokenizer&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Vec&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;char&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    position&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; usize&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;impl&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tokenizer&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; advance&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Option&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;char&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; ch&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;position)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;copied&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;position &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;+=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        ch&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; peek&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Option&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;char&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;position)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;copied&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; &#x2F;&#x2F; char is Copy, returns a value not a reference&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;We see a clear separation of data (the fields) and behavior (the methods). The Tokenizer struct holds the state, while the methods define how to interact with that state. This is a more flexible and modular approach than OOP&#x27;s class-based design.&lt;&#x2F;p&gt;
&lt;p&gt;Rust also uses composition instead of inheritance. You can create complex types by combining simpler ones without the need for a class hierarchy.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Animal&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Dog&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    animal&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Animal&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ServiceDog&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    dog&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Dog&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;A great resource on this principle is &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;python-patterns.guide&#x2F;gang-of-four&#x2F;composition-over-inheritance&#x2F;&quot;&gt;Composition Over Inheritance&lt;&#x2F;a&gt;, part of Brandon Rhodes&#x27; &lt;em&gt;Python Patterns Guide&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;For an example where I think OOP &amp;amp; inheritance went off the rails is Django&#x27;s &lt;em&gt;class-based views&lt;&#x2F;em&gt;. The inheritance tree of those views is too deep making it an unpleasant API to work with, and the code so much harder to reason about. A better way is the more functional approach, see &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;spookylukey.github.io&#x2F;django-views-the-right-way&#x2F;&quot;&gt;Luke Plant&#x27;s Django Views — The Right Way&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;2-traits&quot;&gt;2. Traits&lt;&#x2F;h2&gt;
&lt;p&gt;In Python, think Protocols. In Java, think interfaces. In Rust, traits are a powerful way to define shared behavior without the need for a class hierarchy; polymorphism without inheritance.&lt;&#x2F;p&gt;
&lt;p&gt;Classic OOP:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-java z-variable z-other&quot;&gt;Animal a&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = new&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Dog&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;a.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;speak&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Rust, you can achieve similar behavior using traits:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;trait&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Speak&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; speak&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; make_noise&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(x&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;: &amp;amp;impl&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Speak&lt;&#x2F;span&gt;&lt;span&gt;) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    x&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;speak&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The advantage here is that you can implement the &lt;code&gt;Speak&lt;&#x2F;code&gt; trait for any type, and you don&#x27;t need to have a common base class. This allows for more flexibility and code reuse.&lt;&#x2F;p&gt;
&lt;p&gt;Python has something similar with Protocols, which are part of the &lt;code&gt;typing&lt;&#x2F;code&gt; module. They allow you to define a set of methods that a class must implement, without requiring inheritance.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Protocol&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Speak&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; speak&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; make_noise&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(x: Speak) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    x.speak()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is a more flexible alternative to ABCs (Abstract Base Classes) and allows for &lt;em&gt;duck typing&lt;&#x2F;em&gt; while still providing type safety. I wrote &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pybit.es&#x2F;articles&#x2F;typing-protocol-abc-alternative&#x2F;&quot;&gt;an article about this on Pybites&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Where OOP couples data and behavior, Rust&#x27;s traits allow you to define behavior separately from data. This promotes code reuse and flexibility without the need for a rigid class structure.&lt;&#x2F;p&gt;
&lt;p&gt;Rust encourages:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; User&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;trait&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Serialize&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;trait&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Validate&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;trait&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Persist&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is closer to the Single Responsibility Principle, the Unix philosophy of &quot;do one thing and do it well&quot;, functional programming of data-oriented design with pure functions that operate on data, and composition over inheritance.&lt;&#x2F;p&gt;
&lt;p&gt;Hence Rust allows you to mix and match traits to create complex behavior without the need for a class hierarchy.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;3-ownership-and-borrowing&quot;&gt;3. Ownership and borrowing&lt;&#x2F;h2&gt;
&lt;p&gt;Many OOP patterns exist to control mutation and provide proper encapsulation. In Rust, ownership and borrowing rules ensure that data is accessed safely and efficiently.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Data&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;        &#x2F;&#x2F; takes ownership (moved in)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;: &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Data&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;       &#x2F;&#x2F; borrows, read-only&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;: &amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Data&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   &#x2F;&#x2F; borrows, can mutate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Just by looking at the function signature in Rust, you can understand how data is being used and modified: who owns it, who mutates it, and when it goes out of scope. It eliminates the need for patterns like getters&#x2F;setters, which are often used in OOP to control access to data. And these rules are enforced at compile time, not runtime.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;4-modularity&quot;&gt;4. Modularity&lt;&#x2F;h2&gt;
&lt;p&gt;I came to the conclusion some time ago that Python&#x27;s module scope is a great feature. It allows you to organize code in a way that is more flexible than OOP&#x27;s class-based organization. In Rust, modules and crates provide a way to organize code without the need for classes.&lt;&#x2F;p&gt;
&lt;p&gt;Classic OOP:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;public class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Counter&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-storage z-type&quot;&gt;    private int&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; value;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-storage z-type&quot;&gt;    public void&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; increment&lt;&#x2F;span&gt;&lt;span&gt;() {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        value &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;+=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Rust, you can use module-level functions and structs to achieve the same result:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;mod&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; internal&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;    pub struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Counter&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; i32&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    impl&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Counter&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        pub fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; increment&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;            self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;value &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;+=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Outside:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;counter&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;value &lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&#x2F;&#x2F; inaccessible&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;counter&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;increment&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; &#x2F;&#x2F; ok&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Yes, &lt;code&gt;increment&lt;&#x2F;code&gt; is a mutating method, the same as a setter. The win isn&#x27;t avoiding methods, it&#x27;s two things.&lt;&#x2F;p&gt;
&lt;p&gt;First, the privacy boundary is the module, not the object: &lt;code&gt;value&lt;&#x2F;code&gt; is hidden from the whole module, and you can hide free functions and structs too, not just wrap fields in a class.&lt;&#x2F;p&gt;
&lt;p&gt;Second, you only write the method when you need to guard an invariant. If a field is just plain data, mark it &lt;code&gt;pub&lt;&#x2F;code&gt; and read it directly, no getter ceremony. Java&#x27;s idiom nudges you to wrap every field in &lt;code&gt;get&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;set&lt;&#x2F;code&gt; whether it needs it or not.&lt;&#x2F;p&gt;
&lt;p&gt;So encapsulation lives at the module level, not the class level. It also leans toward a more functional style where pure functions operate on data without mutable state.&lt;&#x2F;p&gt;
&lt;p&gt;In Python you can also hide functions and variables at the module level:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _private_function&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But this does not prevent somebody importing it so you need fencing mechanisms like &lt;code&gt;__all__&lt;&#x2F;code&gt; to control what gets imported. Python&#x27;s &lt;code&gt;_private&lt;&#x2F;code&gt; is a suggestion, Rust&#x27;s module privacy is enforced by the compiler.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;5-enums-and-pattern-matching&quot;&gt;5. Enums and pattern matching&lt;&#x2F;h2&gt;
&lt;p&gt;In OOP, you often use class hierarchies to represent different types of objects. In Rust, you can use enums and pattern matching to achieve similar results without the need for a class hierarchy.&lt;&#x2F;p&gt;
&lt;p&gt;Classic OOP:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;abstract class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Shape&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Circle&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage&quot;&gt; extends&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; Shape&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Square&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage&quot;&gt; extends&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; Shape&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Rust, you can use enums to represent different shapes:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;enum&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Shape&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Circle&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;f64&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    Square&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;f64&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; area&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(shape&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Shape&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; f64&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    match&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; shape {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;        Shape&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Circle&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(r)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 3.14&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; r&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; r,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;        Shape&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Square&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(s)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; s&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; s,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Not only is an enum more lightweight than a class hierarchy, it also pairs up really well with pattern matching handling different cases in a concise and (!) exhaustive manner. The compiler will not let you forget a case.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;In short, OOP bundles data and behavior together which made a lot of sense to me for a long time. Learning Rust though, I am seeing new paradigms how the language designers have decoupled the two.&lt;&#x2F;p&gt;
&lt;p&gt;With Rust you get the good parts of OOP: encapsulation, abstraction and polymorphism, while dropping the less maintainable parts: inheritance trees, mutable state and coupling.&lt;&#x2F;p&gt;
&lt;p&gt;So to wrap up, here is a comparison of classic OOP -&amp;gt; Python -&amp;gt; Rust:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Concern&lt;&#x2F;th&gt;&lt;th&gt;Traditional OOP&lt;&#x2F;th&gt;&lt;th&gt;Python&lt;&#x2F;th&gt;&lt;th&gt;Rust&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Encapsulation&lt;&#x2F;td&gt;&lt;td&gt;Classes with private&#x2F;public members&lt;&#x2F;td&gt;&lt;td&gt;Modules + &lt;code&gt;_private&lt;&#x2F;code&gt; convention&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;mod&lt;&#x2F;code&gt; + &lt;code&gt;pub&lt;&#x2F;code&gt; visibility&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Polymorphism&lt;&#x2F;td&gt;&lt;td&gt;Inheritance + virtual methods&lt;&#x2F;td&gt;&lt;td&gt;Duck typing &#x2F; Protocols&lt;&#x2F;td&gt;&lt;td&gt;Traits&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Reuse&lt;&#x2F;td&gt;&lt;td&gt;Inheritance&lt;&#x2F;td&gt;&lt;td&gt;Composition&lt;&#x2F;td&gt;&lt;td&gt;Composition&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;State modeling&lt;&#x2F;td&gt;&lt;td&gt;Class hierarchies&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;dict&lt;&#x2F;code&gt;, &lt;code&gt;dataclass&lt;&#x2F;code&gt;, classes&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;struct&lt;&#x2F;code&gt; + &lt;code&gt;enum&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Object lifecycle&lt;&#x2F;td&gt;&lt;td&gt;GC &#x2F; constructors&lt;&#x2F;td&gt;&lt;td&gt;GC&lt;&#x2F;td&gt;&lt;td&gt;Ownership + borrowing&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Error handling&lt;&#x2F;td&gt;&lt;td&gt;Exceptions&lt;&#x2F;td&gt;&lt;td&gt;Exceptions&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;&#x2F;code&gt; + exhaustive matching&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h3 id=&quot;typical-style-evolution&quot;&gt;Typical style evolution&lt;&#x2F;h3&gt;
&lt;p&gt;Old-school OOP:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Object → Class → Inheritance → Framework&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Modern Python:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Data → Functions → Composition → Protocols&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Rust:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Data → Ownership → Traits → Composition&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;As you see Python gets you far and is versatile, but Rust&#x27;s ownership and traits give you an even more reliable and maintainable way to structure your code without the need for OOP.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;different-philosophy&quot;&gt;Different philosophy&lt;&#x2F;h3&gt;
&lt;p&gt;I am not picking on Java or OOP, I&#x27;ve been a fan for a long time. Studying philosophy (e.g. Plato) gives you &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;posts&#x2F;bbelderbos_python-philosophy-share-7313152877324066819-tzzs&#x2F;&quot;&gt;a deeper appreciation for this way of thinking&lt;&#x2F;a&gt;. But it can also lead to overcomplication and unnecessary coupling.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s good to learn the different paradigms and understand their strengths and weaknesses. In summary:&lt;&#x2F;p&gt;
&lt;p&gt;Traditional OOP:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Model the world as interacting objects.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Python:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;We trust developers.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Which is awesome, but not without risk. It allows for great flexibility and rapid development, but it can also lead to bugs and maintenance issues if not used carefully.&lt;&#x2F;p&gt;
&lt;p&gt;Rust:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Prove correctness to the compiler.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;A newer paradigm for me, but the way Rust decouples data and behavior, enforces ownership and borrowing rules, and promotes composition over inheritance, all sit well with me.&lt;&#x2F;p&gt;
&lt;p&gt;Those things do lead to more reliable and maintainable code. It encourages you to think about your code in a way that is more focused on data and behavior rather than objects.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>How to Update Multiple Page Elements from One htmx Request</title>
        <published>2026-06-05T00:00:00+00:00</published>
        <updated>2026-06-05T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/htmx-hx-swap-oob-django/"/>
        <id>https://belderbos.dev/blog/htmx-hx-swap-oob-django/</id>
        <summary type="html">&lt;p&gt;A button submits code, tests run, feedback appears. Standard htmx. But the submissions dropdown stays stale; the new submission is in the database, just not in the dropdown. One request, two elements to update.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;A button submits code, tests run, feedback appears. Standard htmx. But the submissions dropdown stays stale; the new submission is in the database, just not in the dropdown. One request, two elements to update.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-problem-one-request-two-things-to-update&quot;&gt;The problem: one request, two things to update&lt;&#x2F;h2&gt;
&lt;p&gt;On our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;&quot;&gt;Rust platform&lt;&#x2F;a&gt;, each exercise page has a &quot;Run Tests&quot; button. It posts the editor code to a Django view, which compiles and runs the tests, then swaps a pass&#x2F;fail panel into a &lt;code&gt;#feedback&lt;&#x2F;code&gt; div.&lt;&#x2F;p&gt;
&lt;p&gt;Next to the editor there is a dropdown of your past submissions. Run the tests, and a new submission gets saved server-side. But the dropdown stayed stale until you reloaded the page. The new submission was there in the database, just not in the &lt;code&gt;&amp;lt;select&amp;gt;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;So now I have one request that needs to update two unrelated parts of the page: the feedback panel (the htmx target) and the submissions dropdown (somewhere else entirely in the DOM).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;htmx-oob.png&quot; alt=&quot;Rust platform exercise page showing feedback panel and submissions dropdown&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;First instinct: write JavaScript to read the response, build a new &lt;code&gt;&amp;lt;option&amp;gt;&lt;&#x2F;code&gt;, prepend it to the select. That works until you remember the dropdown also enforces a max number of submissions, drops duplicates, and orders newest-first. Replicate that logic in the browser and you now have two sources of truth that drift apart the first time you change the server rule.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;htmx-out-of-band-swaps&quot;&gt;Htmx out-of-band swaps&lt;&#x2F;h2&gt;
&lt;p&gt;htmx has a feature for exactly this: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;htmx.org&#x2F;attributes&#x2F;hx-swap-oob&#x2F;&quot;&gt;out-of-band swaps&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;The hx-swap-oob attribute allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target, that is &quot;Out of Band&quot;. This allows you to piggyback updates to other element updates on a response.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;htmx swaps any element carrying &lt;code&gt;hx-swap-oob&lt;&#x2F;code&gt; into its matching target on the page, separately from the main swap. One response, many updates.&lt;&#x2F;p&gt;
&lt;p&gt;First I pulled the submissions dropdown options into a partial so the page and the view render them identically:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&amp;lt;!-- _submission_options.html --&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;option&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; disabled selected&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;Submissions &#x2F; Reset&amp;lt;&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;option&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{% for submission in submissions %}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  &amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;option&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; value&lt;&#x2F;span&gt;&lt;span&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;{{ submission.unique_hash }}&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;{{ submission.created_at|date:&amp;quot;Y-m-d H:i&amp;quot; }} {% if submission.ok %}(OK){% else %}(Failed){% endif %}&amp;lt;&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;option&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{% endfor %}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;option&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt; value&lt;&#x2F;span&gt;&lt;span&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;reset&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;Reset&amp;lt;&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;option&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The page includes it inside the &lt;code&gt;&amp;lt;select id=&quot;submissions&quot;&amp;gt;&lt;&#x2F;code&gt;. The view renders the same partial and tags it for an out-of-band swap:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; django.template.loader&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; render_to_string&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; django.utils.html&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; escape&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; validate&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(request):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # ... run the tests, save the submission ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    submissions&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Submission.objects.filter(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;exercise,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; user&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ).order_by(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;-created_at&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    options&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; render_to_string(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;_submission_options.html&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;submissions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: submissions}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; HttpResponse(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        &amp;lt;div class=&amp;quot;...&amp;quot;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;message&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;lt;&#x2F;div&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        &amp;lt;pre&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;escape(output)&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;lt;&#x2F;pre&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        &amp;lt;div hx-swap-oob=&amp;quot;innerHTML:#submissions&amp;quot;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;options&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;lt;&#x2F;div&amp;gt;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        &amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first part of the response swaps into &lt;code&gt;#feedback&lt;&#x2F;code&gt; as usual. htmx spots the &lt;code&gt;hx-swap-oob&lt;&#x2F;code&gt; element, pulls it out, and applies it to &lt;code&gt;#submissions&lt;&#x2F;code&gt; instead. The button HTML only knows about &lt;code&gt;#feedback&lt;&#x2F;code&gt;. The view decides what else to update. Whoever owns the data controls how it renders.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-second-example-progress-bars-that-update-themselves&quot;&gt;A second example: progress bars that update themselves&lt;&#x2F;h2&gt;
&lt;p&gt;On the Python platform the same trick drives the learning-path progress widget. Passing an exercise recomputes your progress along every path it belongs to and swaps the bars into a sidebar, from the same request that renders the pass&#x2F;fail panel:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; ok:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    paths_html&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span&gt; path&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; bite.bite_paths.prefetch_related(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;bites&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;        # ... compute completed &#x2F; total &#x2F; pct for this path ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        paths_html&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span&gt; render_progress_bar(path, completed, total, pct)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    extra_html&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;#39;&amp;lt;div id=&amp;quot;learning-paths-progress&amp;quot; hx-swap-oob=&amp;quot;innerHTML&amp;quot;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;paths_html&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;lt;&#x2F;div&amp;gt;&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The two examples aim at their targets differently. The progress widget uses a bare &lt;code&gt;hx-swap-oob=&quot;innerHTML&quot;&lt;&#x2F;code&gt;: htmx swaps the fragment into whatever element already shares its &lt;code&gt;id&lt;&#x2F;code&gt;. The dropdown uses the selector form, &lt;code&gt;hx-swap-oob=&quot;innerHTML:#submissions&quot;&lt;&#x2F;code&gt;, so the carrier &lt;code&gt;&amp;lt;div&amp;gt;&lt;&#x2F;code&gt; can target the &lt;code&gt;&amp;lt;select id=&quot;submissions&quot;&amp;gt;&lt;&#x2F;code&gt; without needing to share its id.&lt;&#x2F;p&gt;
&lt;p&gt;Use &lt;code&gt;innerHTML&lt;&#x2F;code&gt; instead of the default &lt;code&gt;hx-swap-oob=&quot;true&quot;&lt;&#x2F;code&gt;. &lt;code&gt;true&lt;&#x2F;code&gt; replaces the whole element (its &lt;code&gt;outerHTML&lt;&#x2F;code&gt;), which for the &lt;code&gt;&amp;lt;select&amp;gt;&lt;&#x2F;code&gt; throws away the htmx listener attached to it. &lt;code&gt;innerHTML&lt;&#x2F;code&gt; keeps the element and swaps only its children, so the listener survives and the options refresh underneath it.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s the progress bars before and after passing an exercise. The bars update via out-of-band swap from the same response that renders the pass&#x2F;fail feedback:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;pybites-platform-before-pass.png&quot; alt=&quot;PyBites platform showing progress bars before passing an exercise&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;pybites-platform-after-pass.png&quot; alt=&quot;PyBites platform showing updated progress bars after passing an exercise&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You might spot the submissions dropdown in these shots and wonder why it is not OOB-swapped here too, like on the Rust platform above. It is the boundary of the technique: OOB swap is solid when you replace an element&#x27;s whole inner content (&lt;code&gt;innerHTML:#submissions&lt;&#x2F;code&gt;), but inserting a single new &lt;code&gt;&amp;lt;option&amp;gt;&lt;&#x2F;code&gt; means wrapping it in a &lt;code&gt;&amp;lt;template&amp;gt;&lt;&#x2F;code&gt; so the browser does not strip it, and htmx 2.x handled that case unreliably. So here the one-option insert runs through a small &lt;code&gt;htmx:afterSwap&lt;&#x2F;code&gt; listener instead. The view writes the new submission into a hidden div, and the listener reads it and prepends the &lt;code&gt;&amp;lt;option&amp;gt;&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;document.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;addEventListener&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;htmx:afterSwap&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, ()&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;  const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; newSub&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; document.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;getElementById&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;new-submission&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;  if&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;!&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;newSub)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; return&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;  const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; select&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; document.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;getElementById&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;submissions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;  const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; opt&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; document.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;createElement&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;option&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;  opt.value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; newSub.dataset.hash;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;  opt.textContent&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; newSub.dataset.label;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;  select.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;insertBefore&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(opt, select.children[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;]);&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; &#x2F;&#x2F; after the placeholder&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;  select.value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; opt.value;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;});&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One honest cost: this adds a query. After saving, the view re-fetches the submissions to render the partial. The alternative is re-implementing state changes in pure JavaScript, creating behavior in two places. With out-of-band swaps you drive the logic from the view, all in one place. One more query, but less code and a more maintainable solution.&lt;&#x2F;p&gt;
&lt;p&gt;Whoever fetches the data should render it. Keep the query and the template together.&lt;&#x2F;p&gt;
&lt;p&gt;For more on hypermedia-driven applications, see this great book: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;hypermedia.systems&quot;&gt;Hypermedia Systems&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>&quot;Rust Is for People Who Want to Be Punished.&quot; Now Jochen Trusts It More Than Python.</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/jochen-rust-cohort-beat-cpython/"/>
        <id>https://belderbos.dev/blog/jochen-rust-cohort-beat-cpython/</id>
        <summary type="html">&lt;p&gt;Jochen Deister is a lawyer who codes for fun. He has years of Python behind him and no intention of ever being hired to program.&lt;&#x2F;p&gt;
&lt;p&gt;Three months ago, Rust was just a name to him, the language for &quot;the big shots&quot; with a notoriously steep learning curve. Then he built a JSON parser from scratch in Rust, and it ran faster than the equivalent in Python on every dataset he tested, up to 3.5x faster on some. &quot;Holy F&quot; he reacted when he saw the results.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Jochen Deister is a lawyer who codes for fun. He has years of Python behind him and no intention of ever being hired to program.&lt;&#x2F;p&gt;
&lt;p&gt;Three months ago, Rust was just a name to him, the language for &quot;the big shots&quot; with a notoriously steep learning curve. Then he built a JSON parser from scratch in Rust, and it ran faster than the equivalent in Python on every dataset he tested, up to 3.5x faster on some. &quot;Holy F&quot; he reacted when he saw the results.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Six weeks of work produced:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Pattkopp&#x2F;rust-cohort&quot;&gt;from-scratch JSON parser&lt;&#x2F;a&gt;, no parsing libraries&lt;&#x2F;li&gt;
&lt;li&gt;Benchmarks beating Python&#x27;s standard &lt;code&gt;json&lt;&#x2F;code&gt; module (C-accelerated in CPython), up to 3.5x faster&lt;&#x2F;li&gt;
&lt;li&gt;Close to 30 commits in the final week alone, each one a single performance step&lt;&#x2F;li&gt;
&lt;li&gt;A deliberate 78-error refactor, with the compiler as the guide to a faster implementation&lt;&#x2F;li&gt;
&lt;li&gt;A new default language: Rust is now the one he reaches for first&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Here&#x27;s how it happened.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-gap&quot;&gt;The gap&lt;&#x2F;h2&gt;
&lt;p&gt;Jochen learned to code on a Commodore VIC-20 with six kilobytes of RAM, then a C64, then a stint in assembly and Turbo Pascal when the bottleneck moved from memory to speed.&lt;&#x2F;p&gt;
&lt;p&gt;Then life took him into law and academia, and he forgot all of it until he picked Python back up years ago.&lt;&#x2F;p&gt;
&lt;p&gt;Python suited him, but it hid the machine. &quot;Python abstracts a lot of these concepts away&quot; he said. &quot;It hides the mechanics&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;He&#x27;d heard Rust had a notoriously steep learning curve, and he was doing this for fun. &quot;Rust is for people who want to be punished in their life&quot; he figured, and left it there.&lt;&#x2F;p&gt;
&lt;p&gt;The trigger that changed it was small: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=-5uLLBvWK5Q&quot;&gt;the last Pybites podcast episode&lt;&#x2F;a&gt;, a $49 lifetime offer &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;&quot;&gt;on our Rust practice platform&lt;&#x2F;a&gt;, and a remote cabin on the Danish coast where his only job was to keep his kids fed during exam season.&lt;&#x2F;p&gt;
&lt;p&gt;He finished all 61 platform exercises, third on the leaderboard, then shortly after signed up for the cohort for a deeper challenge.&lt;&#x2F;p&gt;
&lt;p&gt;The platform taught him the vocabulary. What it couldn&#x27;t give him was a real project with a coach reading his code in detail. That&#x27;s what our cohort is about: six weeks building a JSON parser, one PR review a week with &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.refactorcoach.com&quot;&gt;Jim Hodapp&lt;&#x2F;a&gt;, expert Rust coach.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-constraints-stopped-feeling-like-constraints&quot;&gt;The constraints stopped feeling like constraints&lt;&#x2F;h2&gt;
&lt;p&gt;Most people describe their first weeks of Rust as a &lt;em&gt;fight with the borrow checker&lt;&#x2F;em&gt;; the compiler rule that tracks who owns each value and won&#x27;t let two parts of your code modify the same data at once. Jochen didn&#x27;t feel it this way at all.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I never had the feeling that I was fighting the borrow checker. The error messages were my friends right out of the gate. They had a good explanation of the error, but also a hint about what you could do differently.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;What hooked him was aesthetics. Run the formatter on a chain of iterator steps and each transformation lands on its own line, readable top to bottom.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Rust is a beautiful language. It&#x27;s an aesthetic language. It looks good, and working toward more beautiful code was really something I liked.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That pulled him toward idiomatic Rust on its own. He stopped wanting code that merely worked, the bar he&#x27;d accepted in Python, and started wanting code that was safe, performant and idiomatic.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;he-broke-his-own-code-on-purpose&quot;&gt;He broke his own code on purpose&lt;&#x2F;h2&gt;
&lt;p&gt;Week five, PyO3, was the real step up. &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pyo3.rs&#x2F;v0.28.3&#x2F;&quot;&gt;PyO3&lt;&#x2F;a&gt; is the bridge that lets you call a Rust module straight from Python, the same layer Pydantic and Polars are built on. It was the first concept the practice platform hadn&#x27;t prepared him for, so he leaned on the implementation steps and went slowly.&lt;&#x2F;p&gt;
&lt;p&gt;The clearest sign of how his thinking changed came in the final week. Three of his four benchmark datasets were already beating Python; one wasn&#x27;t. He suspected the parser was copying the entire input onto the heap instead of borrowing it. So he changed the entry point to take a borrowed string with an explicit lifetime (a lifetime is Rust&#x27;s way of letting you reference data without copying it, while proving the reference can&#x27;t outlive the data) and ran &lt;code&gt;cargo check&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;It reported 78 errors.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Those 78 errors were my path of what I needed to fix to get to the results. You change something up the chain and 78 reduces to 50, and so on down the line. It is your implementation guide.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;He&#x27;d deliberately broken the code, then followed the compiler error by error back to a working, faster version. It&#x27;s like having a 200% test suite for free; you feel confident making changes.&lt;&#x2F;p&gt;
&lt;p&gt;The rewrite turned a parser that collected every token into a list up front into one that reads tokens on demand in a single pass. Jim&#x27;s note on the PR: &quot;This is such a clean functional style API for your tokenizer, it&#x27;s evolved and matured nicely&quot;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-profiler-told-him-where-he-was-wrong&quot;&gt;The profiler told him where he was wrong&lt;&#x2F;h2&gt;
&lt;p&gt;Speed in Rust isn&#x27;t automatic, and Jochen learned that the hard way. He&#x27;d swapped a list for a double-ended queue, proud of it.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Two days later, when I looked at the profiler, that very line that I was so proud of was now by far the biggest offender.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;A profiler measures where a program actually spends its time, so you optimize the real bottleneck instead of a guessed one. His showed the standard-library hash map dominating. He read the docs, realized that map carries protection against denial-of-service attacks he&#x27;d never need in a local command-line tool, and replaced it with a stripped-down one. Data-driven, one commit at a time, until the last dataset crossed the line.&lt;&#x2F;p&gt;
&lt;p&gt;Through all of it he kept AI out of the code on purpose. He used it to make himself learn faster, NotebookLM turning Rust docs into podcasts and flashcards, never to write a solution. &quot;Only I write the code&quot; is the rule he gives his AI mentor.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What changed&lt;&#x2F;h2&gt;
&lt;p&gt;Ask him how confident he feels starting a new Rust project and he says a 3 out of 10, and means it as a compliment to the language.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I&#x27;m not a total noob anymore. I have a rough understanding of the key concepts, but I also know there&#x27;s a heck of a lot to learn.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The transfer is in the habits. Rust is now his default for new projects, he caught himself skipping the Python newsletters to read about Rust instead, and the deliberate, idiomatic thinking followed him back into his Python. After years in the Python community, his loyalty quietly shifted:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I&#x27;ve always liked Python. But it&#x27;s changed in a way that I think I like Rust more, because of its honesty and because it forces you to think stricter.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;His favorite piece of the language is pattern matching, the construct that lets you branch on the shape of a value and pull data out of it in one move. He went deep enough that he used a binding trick his coach hadn&#x27;t seen before, matching and naming a value in the same arm. Jim&#x27;s reply on the PR:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;You taught me something I didn&#x27;t realize Rust has. It&#x27;s a nice match-and-bind pattern that saves boilerplate code.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The reason he loves it is the same one running through everything he said:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Computer languages need to be beautiful.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Next up for Jochen: porting a coding agent from Python to Rust, and a privacy tool that strips personal data out of text before it reaches an LLM.&lt;&#x2F;p&gt;
&lt;p&gt;For someone who started three months ago thinking Rust was punishment, that&#x27;s a real shift. (For more on how Rust rewires the way you write Python, see &lt;a href=&quot;&#x2F;blog&#x2F;rust-made-me-a-better-python-developer&#x2F;&quot;&gt;Learning Rust Made Me a Better Python Developer&lt;&#x2F;a&gt;.)&lt;&#x2F;p&gt;
&lt;p&gt;Here is our full conversation with Jochen about his cohort experience, the parser he built, and the performance work he did:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=i5WJCthYhSE&quot;&gt;Watch on YouTube&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;If you&#x27;re a Python developer wanting to reach a new level in your career, Rust is a strong contender. &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;calendly.com&#x2F;bob-belderbos&#x2F;chat&quot;&gt;Book me in for a call&lt;&#x2F;a&gt; and we&#x27;ll discuss this further.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>How to Tell if Your Python Mock Is Actually Working</title>
        <published>2026-06-03T00:00:00+00:00</published>
        <updated>2026-06-03T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/python-mock-patch-verify-interception/"/>
        <id>https://belderbos.dev/blog/python-mock-patch-verify-interception/</id>
        <summary type="html">&lt;p&gt;A test can pass for the wrong reason. When you&#x27;re mocking a third-party API call, the test might look green because the real API happened to return an error, not because your mock did anything at all.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;A test can pass for the wrong reason. When you&#x27;re mocking a third-party API call, the test might look green because the real API happened to return an error, not because your mock did anything at all.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;This came up in a recent session in our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;agentic AI cohort&lt;&#x2F;a&gt; where we were looking at a test to verify that converting to an invalid currency raised an exception. The test passed. But something felt off.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-test-that-passed-for-the-wrong-reason&quot;&gt;The test that passed for the wrong reason&lt;&#x2F;h2&gt;
&lt;p&gt;The code under test calls the ExchangeRate API and raises &lt;code&gt;CurrencyConversionError&lt;&#x2F;code&gt; when the response signals failure:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; convert_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(amount: Decimal, from_currency:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, to_currency:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; Decimal:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; from_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; to_currency:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; amount&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; requests.get(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;https:&#x2F;&#x2F;v6.exchangerate-api.com&#x2F;v6&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{EXCHANGE_RATE_API_KEY}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;pair&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;from_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;to_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; response.json()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;result&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;success&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span&gt; CurrencyConversionError(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;error-type&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; Decimal(data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;conversion_rate&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;])&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span&gt; amount&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The test set up a &lt;code&gt;mock_response&lt;&#x2F;code&gt;, patched &lt;code&gt;requests.get&lt;&#x2F;code&gt; to return it (&lt;code&gt;mock_get.return_value = mock_response&lt;&#x2F;code&gt;), but configured it as a &lt;em&gt;successful&lt;&#x2F;em&gt; response:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;mock_response.json.return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;result&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;success&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # &amp;lt;-- this will never raise CurrencyConversionError&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;conversion_rate&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1.5&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the mock was intercepting, the function would return normally and &lt;code&gt;pytest.raises&lt;&#x2F;code&gt; would fail. But the test was passing. That meant the mock wasn&#x27;t intercepting at all: the real API was being hit, and it was returning an error for the bogus &quot;CTM&quot; code.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;proving-the-mock-actually-intercepted&quot;&gt;Proving the mock actually intercepted&lt;&#x2F;h2&gt;
&lt;p&gt;My instinct was to add &lt;code&gt;print(&quot;calling external api&quot;)&lt;&#x2F;code&gt; before &lt;code&gt;requests.get&lt;&#x2F;code&gt;. That proves the code reached that line. It does not prove whether the mock intercepted the call or the real network was hit.&lt;&#x2F;p&gt;
&lt;p&gt;At this point you can put a &lt;code&gt;breakpoint()&lt;&#x2F;code&gt; in the actual &lt;code&gt;requests.get&lt;&#x2F;code&gt; code in your venv, but there is a better way: &lt;code&gt;mock_get.assert_called_once()&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;with&lt;&#x2F;span&gt;&lt;span&gt; pytest.raises(CurrencyConversionError):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    convert_currency(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;1.00&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        from_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;CAD&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        to_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;CTM&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # Canadian Tire Money, not a real currency&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;mock_get.assert_called_once()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the mock was never called, this assertion fails and tells you directly: your patch didn&#x27;t intercept the request. If the mock was called, the assertion passes and you know for sure that the test is relying on the mock, not the real API.&lt;&#x2F;p&gt;
&lt;p&gt;Running the test with this assertion in place settled it. Once the patch targeted the right name (the fix in the next section), the mock intercepted the call and &lt;code&gt;pytest.raises&lt;&#x2F;code&gt; failed with &lt;code&gt;DID NOT RAISE&lt;&#x2F;code&gt;. That flip is the proof: a real call for &quot;CTM&quot; would have raised, so a non-raising run means the mock was in control. The earlier green had been the real API answering, never the mock. With the success response still in place, nothing raised. Fixing the response to signal an error made the test pass for the right reason, and &lt;code&gt;assert_called_once()&lt;&#x2F;code&gt; then confirmed the call went through the mock and not the network:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;mock_get.return_value.json.return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;result&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;error&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;error-type&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;unknown-code&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;patch-where-the-name-is-used-not-where-it-s-defined&quot;&gt;Patch where the name is used, not where it&#x27;s defined&lt;&#x2F;h2&gt;
&lt;p&gt;The currency module does &lt;code&gt;import requests&lt;&#x2F;code&gt; then calls &lt;code&gt;requests.get(...)&lt;&#x2F;code&gt;, so patching &lt;code&gt;expenses_ai_agent.utils.currency.requests.get&lt;&#x2F;code&gt; targets the call site. With this &lt;code&gt;import requests&lt;&#x2F;code&gt; style, patching &lt;code&gt;requests.get&lt;&#x2F;code&gt; happens to work too, since both names point at the same module object. The rule bites when a module does &lt;code&gt;from requests import get&lt;&#x2F;code&gt;: now &lt;code&gt;get&lt;&#x2F;code&gt; is a local name in the currency module, and you must patch &lt;code&gt;expenses_ai_agent.utils.currency.get&lt;&#x2F;code&gt;, not &lt;code&gt;requests.get&lt;&#x2F;code&gt;. Patching the wrong location is a common mistake that leads to the mock not intercepting and the real API being called.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-cleaned-up-test-with-pytest-mock&quot;&gt;The cleaned-up test with pytest-mock&lt;&#x2F;h2&gt;
&lt;p&gt;Once the mock response was correct and interception was verified, the test got two more improvements. First, the intermediate &lt;code&gt;mock_response&lt;&#x2F;code&gt; variable is unnecessary: chain directly off &lt;code&gt;mock_get.return_value&lt;&#x2F;code&gt;, as in the snippet above. Second, &lt;code&gt;pytest-mock&lt;&#x2F;code&gt; (added with &lt;code&gt;uv add --dev pytest-mock&lt;&#x2F;code&gt;) replaces the nested &lt;code&gt;with patch(...)&lt;&#x2F;code&gt; context managers with a &lt;code&gt;mocker&lt;&#x2F;code&gt; fixture. The result is flatter and easier to scan. Annotated:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_bad_currency_conversion_raises&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, mocker):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;Converting to a non-existing currency should raise an exception.&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Patch requests.get *as imported inside the currency module* so no&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # real HTTP call is made; patch target must match where the name is used&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_get&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; mocker.patch(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expenses_ai_agent.utils.currency.requests.get&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Simulate the API response for an unrecognised currency code&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_get.return_value.json.return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;result&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;error&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;error-type&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;unknown-code&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; pytest.raises(CurrencyConversionError):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        convert_currency(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;1.00&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            from_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;CAD&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            to_currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;CTM&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Confirm the mock intercepted the call; if this fails, the real API was hit&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_get.assert_called_once()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;mocker&lt;&#x2F;code&gt; also handles teardown automatically via the fixture lifecycle, so you don&#x27;t need &lt;code&gt;with&lt;&#x2F;code&gt; to ensure cleanup.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;another-reason-to-mock-forcing-a-collision&quot;&gt;Another reason to mock: forcing a collision&lt;&#x2F;h2&gt;
&lt;p&gt;So far the mock has stood in for a network call. That&#x27;s not the only reason to reach for one. Here&#x27;s a test from &lt;a href=&quot;&#x2F;blog&#x2F;build-the-simplest-thing-that-works&#x2F;&quot;&gt;my simple CRM&lt;&#x2F;a&gt; that stores contacts as files on disk:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; create_contact&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;    name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, email:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, company:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, product:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    contacts_dir().mkdir(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;parents&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; exist_ok&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; next_code(name)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    path&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; contact_path(code)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; path.exists():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; FileExistsError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Contact &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;code&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; already exists&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    path.write_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; code&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;next_code&lt;&#x2F;code&gt; generates a unique code from the name. To test that creating two contacts with the same code raises &lt;code&gt;FileExistsError&lt;&#x2F;code&gt;, you need both calls to produce the &lt;em&gt;same&lt;&#x2F;em&gt; code. That&#x27;s nondeterministic by design, so you patch &lt;code&gt;next_code&lt;&#x2F;code&gt; to pin it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@patch&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;crm.data.next_code&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_cannot_create_contact_with_same_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(mock_next_code):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_next_code.return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;jd1&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    data.create_contact(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Jane Doe&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; pytest.raises(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;FileExistsError&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        data.create_contact(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Jane Doe&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Note the patch target again: &lt;code&gt;crm.data.next_code&lt;&#x2F;code&gt;, where the function is &lt;em&gt;used&lt;&#x2F;em&gt;. Same rule as before. And note that&#x27;s the &lt;em&gt;only&lt;&#x2F;em&gt; mock here.&lt;&#x2F;p&gt;
&lt;p&gt;Isolation matters as much as the mock, but it doesn&#x27;t belong in this test. An autouse fixture already points the data dir at a fresh &lt;code&gt;tmp_path&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;autouse&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; crm_data&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(tmp_path, monkeypatch):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    monkeypatch.setenv(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;CRM_DATA&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;(tmp_path))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (tmp_path&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;contacts&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).mkdir()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; tmp_path&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;create_contact&lt;&#x2F;code&gt; calls &lt;code&gt;path.write_text(...)&lt;&#x2F;code&gt;, so the first call writes a real &lt;code&gt;jd1&lt;&#x2F;code&gt; file. Because every test runs against a fresh &lt;code&gt;tmp_path&lt;&#x2F;code&gt;, that file lives only for the test: the collision can only come from the second call, nothing leaks between runs, and the test fails solely when the duplicate guard fires. Without that isolation, a leftover &lt;code&gt;jd1&lt;&#x2F;code&gt; from a previous run makes the &lt;em&gt;first&lt;&#x2F;em&gt; call raise, &lt;code&gt;pytest.raises&lt;&#x2F;code&gt; still passes, and you&#x27;ve tested nothing.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Update: I later dropped this mock for an explicit override parameter.&lt;&#x2F;strong&gt; Instead of patching &lt;code&gt;next_code&lt;&#x2F;code&gt;, I gave &lt;code&gt;create_contact&lt;&#x2F;code&gt; an optional &lt;code&gt;code&lt;&#x2F;code&gt; parameter (keyword-only, so it can&#x27;t be passed by accident):&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; create_contact&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, email:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, company:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;                    product:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, code:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; next_code(name)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The test pins the code through the public surface, no patching:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_cannot_create_contact_with_same_code&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    data.create_contact(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Jane Doe&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; pytest.raises(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;FileExistsError&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        data.create_contact(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Jane Doe&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;jd1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One naming caveat, since this post points to Harry Percival&#x27;s &quot;Stop Using Mocks&quot; below: this isn&#x27;t dependency injection, tempting as it is to call it that. DI would pass &lt;code&gt;next_code&lt;&#x2F;code&gt; itself in and let the test swap a fake. Here I pass the &lt;em&gt;value&lt;&#x2F;em&gt; the dependency would have produced, so it&#x27;s really an explicit override parameter, the simpler tool. Real DI, with an injected collaborator, comes up at the end of this post.&lt;&#x2F;p&gt;
&lt;p&gt;The trade-off is worth being honest about: I added a production parameter partly to make the test simpler. That&#x27;s the &quot;test-induced design damage&quot; critics of mocking warn about: a seam that exists &lt;em&gt;only&lt;&#x2F;em&gt; to serve tests. I think it&#x27;s justified here because &lt;code&gt;code&lt;&#x2F;code&gt; doubles as a real feature: an explicit-code escape hatch for imports or restoring from backup. The test just happens to use it. If the parameter was only added for the test, I&#x27;d consider leaving the mock.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;unit-vs-integration-where-does-this-test-belong&quot;&gt;Unit vs integration: where does this test belong?&lt;&#x2F;h2&gt;
&lt;p&gt;All this then led to a related question:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;How should you organize tests that hit real external services?&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The convention that holds up in practice:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;tests&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;├── unit&#x2F;        # fast, fully mocked, no network, no secrets&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;└── integration&#x2F; # slower, hits real DB &#x2F; LLM &#x2F; API endpoints&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The currency test above belongs in &lt;code&gt;unit&#x2F;&lt;&#x2F;code&gt;: it mocks &lt;code&gt;requests.get&lt;&#x2F;code&gt; and never touches the network. A test that actually calls the ExchangeRate API to verify end-to-end behavior belongs in &lt;code&gt;integration&#x2F;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;A &lt;code&gt;@pytest.mark.integration&lt;&#x2F;code&gt; marker is a lighter-weight way to get the same split without moving files. Register it in &lt;code&gt;pyproject.toml&lt;&#x2F;code&gt;, then skip those tests in CI with &lt;code&gt;pytest -m &#x27;not integration&#x27;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Both work, but the directory structure makes the distinction obvious at a glance. Explicit is better than implicit.&lt;&#x2F;p&gt;
&lt;p&gt;The practical rule: if your test needs an environment variable or some external service to do its real work, it&#x27;s an integration test. Mock that dependency out and it becomes a unit test. Or &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;put it at the boundary&lt;&#x2F;a&gt; so you can inject a fake in unit tests and the real thing in integration tests (if still needed).&lt;&#x2F;p&gt;
&lt;p&gt;For a practical example of test organization, see this video: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=krb9b6eRinY&quot;&gt;Python Unit vs. Functional Testing: Understanding the Difference + Practical Example&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-mocks-are-the-wrong-tool&quot;&gt;When mocks are the wrong tool&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;s a broader point underneath all this. Every time you patch &lt;code&gt;requests.get&lt;&#x2F;code&gt; you&#x27;re writing a test that&#x27;s tightly coupled to one import path. Change &lt;code&gt;import requests&lt;&#x2F;code&gt; to &lt;code&gt;from requests import get&lt;&#x2F;code&gt; and every patch breaks. The tests test implementation, not behavior.&lt;&#x2F;p&gt;
&lt;p&gt;I highly recommend watching &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=rk-f3B-eMkI&quot;&gt;Harry Percival&#x27;s PyCon talk &quot;Stop Using Mocks&quot;&lt;&#x2F;a&gt;. He makes the case for alternatives: build an adapter class that owns the external call, write a fake in-memory implementation of it, and use dependency injection to pass it in. The &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;repository pattern&lt;&#x2F;a&gt; is the same idea: your test passes in a fake, your production code passes in the real thing, and neither needs patching.&lt;&#x2F;p&gt;
&lt;p&gt;Mocks are still the right choice here: we want to test one small unit whose only external dependency is well contained.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>AI Human-in-the-loop: News Digest Triage Telegram Bot</title>
        <published>2026-06-01T00:00:00+00:00</published>
        <updated>2026-06-01T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/human-in-the-loop-telegram-bot-python/"/>
        <id>https://belderbos.dev/blog/human-in-the-loop-telegram-bot-python/</id>
        <summary type="html">&lt;p&gt;In my &lt;a href=&quot;&#x2F;blog&#x2F;python-ai-trend-digest-asyncio-protocol&#x2F;&quot;&gt;trend digest article&lt;&#x2F;a&gt; I shared a quick tool to keep on top of tech trends, but it&#x27;s a one-way street: the model gives information, but I still have to decide what to do with it. Let&#x27;s build the second half: a Telegram bot that shows me each story, guesses a tag, and lets me confirm or overrule it with one tap.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;In my &lt;a href=&quot;&#x2F;blog&#x2F;python-ai-trend-digest-asyncio-protocol&#x2F;&quot;&gt;trend digest article&lt;&#x2F;a&gt; I shared a quick tool to keep on top of tech trends, but it&#x27;s a one-way street: the model gives information, but I still have to decide what to do with it. Let&#x27;s build the second half: a Telegram bot that shows me each story, guesses a tag, and lets me confirm or overrule it with one tap.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;human-in-the-loop-hitl-the-model-proposes-you-decide&quot;&gt;Human-in-the-loop (HITL): the model proposes, you decide&lt;&#x2F;h2&gt;
&lt;p&gt;AI makes suggestions but it can hallucinate, so it&#x27;s important to have a human in the loop to catch mistakes. The model does the work of categorizing, the human makes the final decision. This is a good example of &lt;a href=&quot;&#x2F;blog&#x2F;control-layer-is-the-product&#x2F;&quot;&gt;the control layer above the model&lt;&#x2F;a&gt; and it&#x27;s where you can make AI more reliable.&lt;&#x2F;p&gt;
&lt;p&gt;This is what we teach in week 4 of our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;Agentic AI cohort&lt;&#x2F;a&gt; where things come together: expense parsing, AI category suggestion, and the human in the loop to confirm it. This requires the bot to keep state, route responses, and a way to be wrong gracefully. Below is a smaller version so you can get a taste for how this works.&lt;&#x2F;p&gt;
&lt;p&gt;We&#x27;ll build it in seven steps. Clone &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;telegram-hitl&quot;&gt;the repo&lt;&#x2F;a&gt; to follow along step by step.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;step-1-create-the-bot-and-get-a-token&quot;&gt;Step 1: create the bot and get a token&lt;&#x2F;h2&gt;
&lt;p&gt;Telegram bots are created by another bot. Open Telegram and search for &lt;strong&gt;@BotFather&lt;&#x2F;strong&gt; (it has a blue checkmark):&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Send &lt;code&gt;&#x2F;newbot&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Give it a display name (anything).&lt;&#x2F;li&gt;
&lt;li&gt;Give it a username ending in &lt;code&gt;bot&lt;&#x2F;code&gt; that is globally unique, e.g. &lt;code&gt;alice_trend_bot&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;BotFather replies with a token like &lt;code&gt;123456789:ABCdef...&lt;&#x2F;code&gt;. Treat it like a password. Put it in a &lt;code&gt;.env&lt;&#x2F;code&gt; file next to your script (or &lt;code&gt;export&lt;&#x2F;code&gt; in your shell), together with your OpenAI key:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;TELEGRAM_BOT_TOKEN&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;123456789:ABCdef...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;OPENAI_API_KEY&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;sk-...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the token ever leaks, send &lt;code&gt;&#x2F;revoke&lt;&#x2F;code&gt; to BotFather for a fresh one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;step-2-the-dependencies&quot;&gt;Step 2: the dependencies&lt;&#x2F;h2&gt;
&lt;p&gt;The whole thing is one file. I use a &lt;a href=&quot;&#x2F;blog&#x2F;python-ai-trend-digest-asyncio-protocol&#x2F;&quot;&gt;PEP 723 header&lt;&#x2F;a&gt; so &lt;code&gt;uv run&lt;&#x2F;code&gt; resolves everything into its own environment, no virtualenv to manage. Put this at the top of &lt;code&gt;trend_triage_bot.py&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# &#x2F;&#x2F;&#x2F; script&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# requires-python = &amp;quot;&amp;gt;=3.12&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# dependencies = [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;#   &amp;quot;python-telegram-bot&amp;gt;=21&amp;quot;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;#   &amp;quot;openai&amp;gt;=1.40&amp;quot;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;#   &amp;quot;httpx&amp;quot;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;#   &amp;quot;python-decouple&amp;quot;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;#   &amp;quot;pydantic&amp;quot;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# &#x2F;&#x2F;&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If you would rather build this inside an existing project, the equivalent is:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; init&lt;&#x2F;span&gt;&lt;span&gt; &amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; add python-telegram-bot openai httpx python-decouple pydantic&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then the imports and a few constants:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; logging&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pathlib&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Path&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Literal, Protocol&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; httpx&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decouple&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; config&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; openai&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; AsyncOpenAI&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pydantic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; BaseModel&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; telegram&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; InlineKeyboardButton, InlineKeyboardMarkup, Message, Update&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; telegram.ext&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Application,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    CallbackQueryHandler,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    CommandHandler,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ContextTypes,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;logger&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; logging.getLogger(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt;__name__&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;TAGS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;lib&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;skip&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_TOPIC&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;rust&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;READING_LIST&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Path(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;reading_list.jsonl&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;LOBSTERS_FEED&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;lobste.rs&#x2F;t&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{tag}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string z-punctuation z-definition z-string&quot;&gt;.json&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;step-3-fetch-the-stories&quot;&gt;Step 3: fetch the stories&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;lobste.rs&quot;&gt;Lobsters&lt;&#x2F;a&gt; has a per-tag JSON feed, no auth required: &lt;code&gt;https:&#x2F;&#x2F;lobste.rs&#x2F;t&#x2F;rust.json&lt;&#x2F;code&gt; returns the latest Rust-tagged stories, &lt;code&gt;...&#x2F;t&#x2F;python.json&lt;&#x2F;code&gt; the Python ones, and so on. It&#x27;s a tighter, more engineering-focused signal than a broad keyword search, and parameterizing the tag is what lets &lt;code&gt;&#x2F;digest rust&lt;&#x2F;code&gt; and &lt;code&gt;&#x2F;digest python&lt;&#x2F;code&gt; hit the same code. A &lt;code&gt;Story&lt;&#x2F;code&gt; is just a title and a URL.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s set up the model and fetch the latest five stories for a given tag:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pydantic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; BaseModel, HttpUrl&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Story&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;BaseModel&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    title:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    url: HttpUrl&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; fetch_stories&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(tag:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, limit:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Story]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    async with&lt;&#x2F;span&gt;&lt;span&gt; httpx.AsyncClient(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        timeout&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; headers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;User-Agent&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;trend-triage-bot&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; http:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; http.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;LOBSTERS_FEED&lt;&#x2F;span&gt;&lt;span&gt;.format(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;tag))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        response.raise_for_status()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            Story.model_validate(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;                    &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: story[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;                    &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: story[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; or f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;https:&#x2F;&#x2F;lobste.rs&#x2F;s&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;story[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;short_id&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            for&lt;&#x2F;span&gt;&lt;span&gt; story&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; response.json()[:limit]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            if&lt;&#x2F;span&gt;&lt;span&gt; story.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two small details: Lobsters expects a &lt;code&gt;User-Agent&lt;&#x2F;code&gt; header, and a text&#x2F;discussion post has an empty &lt;code&gt;url&lt;&#x2F;code&gt;, so we fall back to its comments page (&lt;code&gt;&#x2F;s&#x2F;{short_id}&lt;&#x2F;code&gt;), the same pattern you&#x27;d use for an HN self-post.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;*&lt;&#x2F;code&gt; in the function signature makes the &lt;code&gt;limit&lt;&#x2F;code&gt; keyword-only, so you have to call &lt;code&gt;fetch_stories(&quot;rust&quot;, limit=10)&lt;&#x2F;code&gt;, which is a nice safeguard against accidentally changing the default.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;step-4-let-the-llm-propose-a-tag&quot;&gt;Step 4: let the LLM propose a tag&lt;&#x2F;h2&gt;
&lt;p&gt;The model picks one of &lt;code&gt;TAGS&lt;&#x2F;code&gt;. As the digest topic is variable (&lt;code&gt;&#x2F;digest rust&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;digest python&lt;&#x2F;code&gt;), the tags have to be topic-agnostic, so they describe what a story &lt;em&gt;is&lt;&#x2F;em&gt; (&lt;code&gt;read&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;lib&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;tool&lt;&#x2F;code&gt;), not anything Rust- or Python-specific.&lt;&#x2F;p&gt;
&lt;p&gt;Content-type beats intent here: &quot;is this a tool or a library&quot; is answerable from a headline, whereas &quot;will I read this or build with it&quot; depends on me, not the title. And a tag the model can&#x27;t infer is a tag you end up correcting every time.&lt;&#x2F;p&gt;
&lt;p&gt;And using &lt;a href=&quot;&#x2F;blog&#x2F;structured-outputs-pydantic-openai&#x2F;&quot;&gt;structured outputs&lt;&#x2F;a&gt; I get typed values back, not strings I have to parse and second-guess; consistent data types are the foundation of reliable AI.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;SYSTEM&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;Tag this software&#x2F;tech headline with one of: &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;read (an article, post, or tutorial), &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;lib (a library, framework, or package you import), &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;tool (a CLI, app, or utility you run). &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;Use &amp;#39;skip&amp;#39; only if it is off-topic or clickbait.&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; TagChoice&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;BaseModel&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    tag: Literal[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;lib&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;skip&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Classifier&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, story: Story) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; OpenAIClassifier&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, api_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, model:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;gpt-4o-mini&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._client&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; AsyncOpenAI(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;api_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;api_key)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; model&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, story: Story) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        completion&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._client.beta.chat.completions.parse(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;._model,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;system&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; SYSTEM&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: story.title},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            response_format&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;TagChoice,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        choice&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; completion.choices[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].message.parsed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; choice.tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; choice&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;skip&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _build_classifier&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; Classifier:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; OpenAIClassifier(config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;OPENAI_API_KEY&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;_build_classifier()&lt;&#x2F;code&gt; constructs the client at runtime, not at import; we call it once in &lt;code&gt;main()&lt;&#x2F;code&gt; and stash the result (more on that in step 7).&lt;&#x2F;p&gt;
&lt;p&gt;This decoupling allows a test to inject a fake tagger without touching OpenAI. It&#x27;s the same lazy-wiring trick I used &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;demonstrating the repository pattern&lt;&#x2F;a&gt;. The &lt;code&gt;Protocol&lt;&#x2F;code&gt; means any class with an &lt;code&gt;async tag()&lt;&#x2F;code&gt; method drops in. Protocols are more flexible here, because they don&#x27;t require inheritance like ABCs do, so the test double doesn&#x27;t have to know about the real classifier at all.&lt;&#x2F;p&gt;
&lt;p&gt;Filing a tagged story is a one-liner to a JSONL file. JSONL (or JSON Lines) is a way to store structured data; each line contains a single, valid JSON object.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; save_to_reading_list&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(story: Story, tag:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; READING_LIST&lt;&#x2F;span&gt;&lt;span&gt;.open(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;a&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; f:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        f.write(json.dumps({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: tag,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; **&lt;&#x2F;span&gt;&lt;span&gt;story.model_dump(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;mode&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;json&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)})&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;\n&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Note that &lt;code&gt;model_dump()&lt;&#x2F;code&gt; hands back a pydantic &lt;code&gt;Url&lt;&#x2F;code&gt; object (&lt;code&gt;HttpUrl&lt;&#x2F;code&gt;) that &lt;code&gt;json.dumps&lt;&#x2F;code&gt; can&#x27;t serialize; &lt;code&gt;mode=&quot;json&quot;&lt;&#x2F;code&gt; coerces it to a string first.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;step-5-the-keyboard-that-highlights-the-guess&quot;&gt;Step 5: the keyboard that highlights the guess&lt;&#x2F;h2&gt;
&lt;p&gt;The AI&#x27;s pick is prefixed with &lt;code&gt;&amp;gt;&amp;gt;&lt;&#x2F;code&gt;, but every other tag is one tap away. The &lt;code&gt;callback_data&lt;&#x2F;code&gt; stays plain (&lt;code&gt;tag:read&lt;&#x2F;code&gt;) so the handler never has to strip the decoration:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; triage_keyboard&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(suggested:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; InlineKeyboardMarkup:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    buttons&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        InlineKeyboardButton(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;            f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&amp;gt;&amp;gt; &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; suggested&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; tag,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            callback_data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;=f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;tag:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        for&lt;&#x2F;span&gt;&lt;span&gt; tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; TAGS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    rows&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [buttons[i : i&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 3&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; i&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; range&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; len&lt;&#x2F;span&gt;&lt;span&gt;(buttons),&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 3&lt;&#x2F;span&gt;&lt;span&gt;)]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; InlineKeyboardMarkup(rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The marker goes in &lt;em&gt;front&lt;&#x2F;em&gt;, and that detail matters: Telegram clips long button labels from the end, so my first attempt, wrapping the tag in &lt;code&gt;&amp;gt;&amp;gt; tool &amp;lt;&amp;lt;&lt;&#x2F;code&gt;, showed up as &lt;code&gt;&amp;gt;&amp;gt; tool…&lt;&#x2F;code&gt; with the closing marker eaten. The kind of bug you only catch by testing it on a real phone.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;trend-triage-bot-keyboard.jpeg&quot; alt=&quot;Telegram inline keyboard showing read, lib, tool and skip buttons, with the model&amp;#39;s guess &amp;quot;lib&amp;quot; prefixed by a &amp;gt;&amp;gt; marker&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;step-6-two-steps-one-stashed-queue&quot;&gt;Step 6: two steps, one stashed queue&lt;&#x2F;h2&gt;
&lt;p&gt;A simple bot is stateless: message in, reply out. This one is not. Step one (the &lt;code&gt;&#x2F;digest&lt;&#x2F;code&gt; command) fetches and shows the first story; step two fires later, when I tap a button, and needs the queue from step one. &lt;code&gt;context.user_data&lt;&#x2F;code&gt; is a per-user dict the library keeps between handler calls, so I park the queue there:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; start_digest&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(update: Update, context: ContextTypes.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_TYPE&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; update.message&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; context.user_data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    topic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; context.args[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].lower()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; context.args&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; DEFAULT_TOPIC&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; update.message.reply_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Fetching today&amp;#39;s &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;topic&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; stories...&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    try&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        context.user_data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;queue&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; fetch_stories(topic)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    except&lt;&#x2F;span&gt;&lt;span&gt; httpx.HTTPStatusError:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; update.message.reply_text(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;            f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;No feed for &amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;topic&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;#39;. Try a Lobsters tag like rust, python, or go.&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    except&lt;&#x2F;span&gt;&lt;span&gt; httpx.RequestError:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; update.message.reply_text(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;            &amp;quot;Couldn&amp;#39;t reach Lobsters right now — try again in a bit.&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; show_next(update.message, context)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; show_next&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(message: Message, context: ContextTypes.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_TYPE&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    queue: list[Story]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (context.user_data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; {}).get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;queue&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, [])&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; queue:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; message.reply_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Inbox zero. That&amp;#39;s all the trends today.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    story&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; queue[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    suggested&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; context.bot_data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;classifier&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;].tag(story)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; message.reply_text(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;story.title&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}\n{&lt;&#x2F;span&gt;&lt;span&gt;story.url&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        reply_markup&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;triage_keyboard(suggested),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;context.args&lt;&#x2F;code&gt; is whatever followed the command: &lt;code&gt;&#x2F;digest python&lt;&#x2F;code&gt; gives &lt;code&gt;[&quot;python&quot;]&lt;&#x2F;code&gt;, a bare &lt;code&gt;&#x2F;digest&lt;&#x2F;code&gt; gives &lt;code&gt;[]&lt;&#x2F;code&gt; and falls back to &lt;code&gt;DEFAULT_TOPIC&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;A typo&#x27;d topic is a 404 from Lobsters, so I catch &lt;code&gt;HTTPStatusError&lt;&#x2F;code&gt; and reply with a helpful message, otherwise the user would just stare at a digest that never arrives. Validate at the boundary where untrusted input enters.&lt;&#x2F;p&gt;
&lt;p&gt;The second &lt;code&gt;except&lt;&#x2F;code&gt; covers the other failure mode: the request never gets an HTTP response at all. &lt;code&gt;HTTPStatusError&lt;&#x2F;code&gt; only fires once Lobsters answers with a 4xx&#x2F;5xx. A connect timeout, read timeout, or DNS failure is an &lt;code&gt;httpx.RequestError&lt;&#x2F;code&gt;, which is a &lt;em&gt;sibling&lt;&#x2F;em&gt; of &lt;code&gt;HTTPStatusError&lt;&#x2F;code&gt;, not a subclass. Miss it and a flaky network crashes the handler with a traceback instead of a friendly reply. Catching &lt;code&gt;RequestError&lt;&#x2F;code&gt; covers every transport-level failure (&lt;code&gt;ConnectTimeout&lt;&#x2F;code&gt;, &lt;code&gt;ReadTimeout&lt;&#x2F;code&gt;, &lt;code&gt;ConnectError&lt;&#x2F;code&gt;) in one branch.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;trend-triage-bot-keyword-guard.jpeg&quot; alt=&quot;Telegram chat: &#x2F;digest agentic ai returns &amp;quot;No feed for &amp;#39;agentic&amp;#39;. Try a Lobsters tag like rust, python, or go&amp;quot;, then &#x2F;digest rust starts fetching&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Read &lt;code&gt;user_data&lt;&#x2F;code&gt; with &lt;code&gt;.get(...)&lt;&#x2F;code&gt;, never &lt;code&gt;[...]&lt;&#x2F;code&gt;. It lives in memory, so if the bot restarts mid-flow the dict is empty and you want a graceful reply, not a &lt;code&gt;KeyError&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;context.bot_data&lt;&#x2F;code&gt; is its per-bot sibling: one dict shared across all users. That makes it the right home for the classifier, which holds no per-user state. We build it once in step 7 and read it back here, so every story reuses the same OpenAI client instead of constructing a fresh one each time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;step-7-the-callback-then-wire-it-up&quot;&gt;Step 7: the callback, then wire it up&lt;&#x2F;h2&gt;
&lt;p&gt;When I tap a button Telegram sends a callback query, not a message. Three rules keep it sane, numbered in the code:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; on_tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(update: Update, context: ContextTypes.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_TYPE&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    query&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; update.callback_query&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; query&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; query.data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; query.answer()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # 1. stop the spinner, first thing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _, tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; query.data.split(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;:&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # 2. &amp;quot;tag:read&amp;quot; -&amp;gt; &amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    queue&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (context.user_data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; {}).get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;queue&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, [])&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; queue:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # stale button after a restart&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; query.edit_message_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Session expired, send &#x2F;digest again.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    story&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; queue.pop(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;skip&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        save_to_reading_list(story, tag)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # the human&amp;#39;s final say&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; query.edit_message_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # 3. edit, don&amp;#39;t reply&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Filed under &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;story.title&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;skip&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;        else f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Skipped: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;story.title&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; isinstance&lt;&#x2F;span&gt;&lt;span&gt;(query.message, Message):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; show_next(query.message, context)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Call &lt;code&gt;await query.answer()&lt;&#x2F;code&gt; first or the loading spinner on the button never stops, even when everything else works. Edit the original message instead of replying, or the dead keyboard sits there inviting a second tap on a story you already filed. The same &lt;code&gt;.get(...)&lt;&#x2F;code&gt;-not-&lt;code&gt;[...]&lt;&#x2F;code&gt; rule applies here: an old keyboard from before a restart can still send a tap, and you want a &quot;send &#x2F;digest again&quot; nudge, not a &lt;code&gt;KeyError&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Routing is by prefix. The &lt;code&gt;pattern=&quot;^tag:&quot;&lt;&#x2F;code&gt; is why a future second keyboard (say &lt;code&gt;setcurrency:EUR&lt;&#x2F;code&gt;) would not trip this handler:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; on_error&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(update:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; object&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, context: ContextTypes.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_TYPE&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    logger.exception(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Handler failed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; exc_info&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;context.error)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; main&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    logging.basicConfig(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        format&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;%(asctime)s %(name)s %(levelname)s %(message)s&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        level&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;logging.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;INFO&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    logger.info(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Starting trend triage bot, polling for updates&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Application.builder().token(config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;TELEGRAM_BOT_TOKEN&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)).build()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.bot_data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;classifier&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; _build_classifier()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.add_handler(CommandHandler(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;digest&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, start_digest))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.add_handler(CallbackQueryHandler(on_tag,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; pattern&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;^tag:&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.add_error_handler(on_error)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.run_polling()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt; __name__&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;__main__&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    main()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three things to notice here in &lt;code&gt;main()&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;logging.basicConfig(...)&lt;&#x2F;code&gt; turns on output: &lt;code&gt;run_polling()&lt;&#x2F;code&gt; blocks silently otherwise, so without it a freshly started bot looks dead in the terminal even though it&#x27;s happily polling.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;app.bot_data[&quot;classifier&quot;]&lt;&#x2F;code&gt; builds the OpenAI client once instead of per story.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;add_error_handler&lt;&#x2F;code&gt; means a network blip or a rate limit gets logged through &lt;code&gt;on_error&lt;&#x2F;code&gt; rather than vanishing into the framework.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;run-it&quot;&gt;Run it&lt;&#x2F;h2&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; export OPENAI_API_KEY=sk-proj-...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; export TELEGRAM_BOT_TOKEN=...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; uv run trend_triage_bot.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;2026-06-01&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; 13:19:42,866 __main__ INFO Starting trend triage bot, polling for updates&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;2026-06-01&lt;&#x2F;span&gt;&lt;span class=&quot;z-string z-punctuation z-definition z-string&quot;&gt; 13:19:43,190 httpx INFO HTTP Request: POST https:&#x2F;&#x2F;api.telegram.org&#x2F;bot...&#x2F;getMe &amp;quot;HTTP&#x2F;1.1 200 OK&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;2026-06-01&lt;&#x2F;span&gt;&lt;span class=&quot;z-string z-punctuation z-definition z-string&quot;&gt; 13:19:43,242 httpx INFO HTTP Request: POST https:&#x2F;&#x2F;api.telegram.org&#x2F;bot...&#x2F;deleteWebhook &amp;quot;HTTP&#x2F;1.1 200 OK&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;2026-06-01&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; 13:19:43,244 telegram.ext.Application INFO Application started&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Open your bot in Telegram and send &lt;code&gt;&#x2F;digest&lt;&#x2F;code&gt; for the default topic, or use a tag like &lt;code&gt;&#x2F;digest python&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;digest rust&lt;&#x2F;code&gt; or any &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;lobste.rs&#x2F;tags&quot;&gt;Lobsters tag&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The bot walks you through today&#x27;s stories one at a time. Tap the highlighted tag to accept the model&#x27;s guess, or any other tag to overrule it, until you hit inbox zero:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;trend-triage-bot-inbox-zero.jpeg&quot; alt=&quot;Telegram chat showing stories filed under read and tool and one skipped, ending with &amp;quot;Inbox zero. That&amp;#39;s all the trends today.&amp;quot;&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Same bot, any topic: finish the Rust queue, then &lt;code&gt;&#x2F;digest python&lt;&#x2F;code&gt; and triage that, no code change:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;trend-triage-bot-topic-switch.jpeg&quot; alt=&quot;Telegram chat: after the Rust digest reaches inbox zero, &#x2F;digest python fetches Python stories and shows the first one with the read tag highlighted&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Filed stories land in &lt;code&gt;reading_list.jsonl&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;One year of Roto, the compiled scripting language for Rust&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;blog.nlnetlabs.nl&#x2F;one-year-of-roto-the-compiled-scripting-language-for-rust&#x2F;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;lib&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Announcing Rust 1.96.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;blog.rust-lang.org&#x2F;2026&#x2F;05&#x2F;28&#x2F;Rust-1.96.0&#x2F;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;What kache actually caches&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;kunobi.ninja&#x2F;blog&#x2F;what-kache-actually-caches&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Creusot helps you prove your Rust code is correct&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;github.com&#x2F;creusot-rs&#x2F;creusot&#x2F;tree&#x2F;master&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;uv must be installed to build a standalone Python distribution&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;github.com&#x2F;astral-sh&#x2F;python-build-standalone&#x2F;commit&#x2F;c9c40c56eb53136587f0a32382cad9e5cd8d184a&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;SPy: an interpreter and a compiler for a statically typed variant of Python&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;github.com&#x2F;spylang&#x2F;spy&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Opaque Types in Python&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;blog.glyph.im&#x2F;2026&#x2F;05&#x2F;opaque-types-in-python.html&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;&amp;quot;tag&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;read&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;uv is fantastic, but its package management UX is a mess&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; &amp;quot;url&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;https:&#x2F;&#x2F;www.loopwerk.io&#x2F;articles&#x2F;2026&#x2F;uv-ux-mess&#x2F;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That file is the actual output; one JSON object per line, ready to feed into whatever reads it next. You can find the repo &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;telegram-hitl&quot;&gt;here&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The interesting question is not whether the model can tag a headline. It&#x27;s pretty accurate, but it can get it wrong, and that&#x27;s where you want to have a human in the loop. This has been a simple example to show the flow, but real workflows might involve more interesting things like approving trades, triaging support tickets, or moderating content. The model can do the heavy lifting of making a guess, but the human gets the final say, and that&#x27;s where the value is.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>The control layer is the product, not the model</title>
        <published>2026-05-30T00:00:00+00:00</published>
        <updated>2026-05-30T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/control-layer-is-the-product/"/>
        <id>https://belderbos.dev/blog/control-layer-is-the-product/</id>
        <summary type="html">&lt;p&gt;Gary Bernhardt posted something this week that names a phenomenon we&#x27;re teaching in our agentic AI cohort:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Everyone seems fixated on the models, but I think there&#x27;s so much low-hanging fruit in the control layer above the model. &quot;Agent&quot; and &quot;harness&quot; sell that layer short. There&#x27;s so much more that we can do beyond &quot;read input, send to model, run commands it returns.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;</summary>
        <content type="html">&lt;p&gt;Gary Bernhardt posted something this week that names a phenomenon we&#x27;re teaching in our agentic AI cohort:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Everyone seems fixated on the models, but I think there&#x27;s so much low-hanging fruit in the control layer above the model. &quot;Agent&quot; and &quot;harness&quot; sell that layer short. There&#x27;s so much more that we can do beyond &quot;read input, send to model, run commands it returns.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;He&#x27;s right. The model is a brain in a jar. Useful, fast, occasionally wrong, stateless. Everything that turns it into a product lives in the code that wraps it: the routing, the validation, the state, the audit trail. Gary calls that the &lt;em&gt;control layer&lt;&#x2F;em&gt;. I&#x27;m stealing the term.&lt;&#x2F;p&gt;
&lt;p&gt;One of the replies under &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;x.com&#x2F;garybernhardt&#x2F;status&#x2F;2059869845265752156&quot;&gt;the tweet&lt;&#x2F;a&gt; nailed the design goal in a single question: &lt;em&gt;do you actually know what the agent is going to do?&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s what a control layer buys you. Not magic, not autonomy, predictability. A workflow where, by the time the model is called, the next move is already constrained to something safe.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-agent-and-harness-sell-it-short&quot;&gt;Why &quot;agent&quot; and &quot;harness&quot; sell it short&lt;&#x2F;h2&gt;
&lt;p&gt;When a developer says &quot;I&#x27;m building an agent&quot;, they usually mean a &lt;code&gt;while True&lt;&#x2F;code&gt; loop that pings an LLM, parses a tool call, runs it, feeds the result back, and repeats. That pattern works for demos. It rarely survives contact with a real workflow.&lt;&#x2F;p&gt;
&lt;p&gt;The word &quot;harness&quot; makes the wrapping code sound passive, a strap that holds the model in place. It&#x27;s actually the control layer where the engineering happens. The model is a function call inside it. Once you flip that mental model, you stop asking &quot;which LLM should I use&quot; and start asking &quot;what guarantees does my control layer make?&quot; and &quot;how can I make the inherently unpredictable model fit into a predictable workflow?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;These are the questions production teams have to answer.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-1-deterministic-state-machines-not-unconstrained-agents&quot;&gt;Pattern 1: deterministic state machines, not unconstrained agents&lt;&#x2F;h2&gt;
&lt;p&gt;An agent without constraints decides what to do next from inside the model. A &lt;a href=&quot;&#x2F;blog&#x2F;build-finite-state-machine-python&#x2F;&quot;&gt;state machine&lt;&#x2F;a&gt; decides outside the model and gives the model one bounded job at each step. The pipeline runs &lt;code&gt;categorize → validate → confirm → persist&lt;&#x2F;code&gt;, and the LLM only ever gets called inside one of those buckets.&lt;&#x2F;p&gt;
&lt;p&gt;This shifts control flow back to your code, where you can test it, log it, and reason about it. The expense agent we build in our cohort, which I broke down in &lt;a href=&quot;&#x2F;blog&#x2F;ai-agent-architecture-python&#x2F;&quot;&gt;How an AI expense agent is actually structured&lt;&#x2F;a&gt;, follows exactly this pattern: Protocol-defined LLM boundary, Pydantic-validated outputs, service layer holds the state, human-in-the-loop (HITL) confirms before anything writes. Four layers, no free-roaming agent, constraints at every step.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-2-the-model-behind-a-typed-boundary&quot;&gt;Pattern 2: the model behind a typed boundary&lt;&#x2F;h2&gt;
&lt;p&gt;The model should be one swappable function call inside your control layer, not a dependency threaded through every layer. In our cohort the LLM lives behind a Python Protocol: a small interface the service layer depends on, so nothing downstream knows or cares whether the call goes to OpenAI or Anthropic.&lt;&#x2F;p&gt;
&lt;p&gt;Once the boundary is a Protocol, the decisions people reach for &quot;routing&quot; to solve become wiring instead of rewrites. Picking a cheap fast model for a 12-way classification and saving the expensive one for hard reasoning is a one-line change. Falling back to a second provider when the first is rate-limited is a small factory, not a refactor. Swapping OpenAI for Anthropic, two SDKs that disagree on almost every detail, touches one file because the boundary absorbs the difference.&lt;&#x2F;p&gt;
&lt;p&gt;And it makes the whole pipeline testable. Tests pass a mock that satisfies the Protocol, so you exercise every path without an API call incurring latency or cost.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-3-evaluators-and-guardrails&quot;&gt;Pattern 3: evaluators and guardrails&lt;&#x2F;h2&gt;
&lt;p&gt;The model&#x27;s output is not the user&#x27;s output. Between the two sits validation: schema checks, business rules, PII filters, sometimes a second model grading the first one&#x27;s work.&lt;&#x2F;p&gt;
&lt;p&gt;This is the &lt;em&gt;generator-evaluator split&lt;&#x2F;em&gt; and it&#x27;s an important pattern (apart from HITL) I&#x27;ve found for AI code that has to be right. The generator proposes. The evaluator approves or rejects. When the evaluator rejects, control loops back with feedback, not a stack trace.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s also the layer that catches the worst failure mode of multi-step agents. &lt;a href=&quot;&#x2F;blog&#x2F;production-ai-agents-real-workflows&#x2F;&quot;&gt;What production AI agents actually require&lt;&#x2F;a&gt; goes deeper on the four questions the control layer answers before any action runs: state, idempotency, audit, rollback.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-4-structured-generation&quot;&gt;Pattern 4: structured generation&lt;&#x2F;h2&gt;
&lt;p&gt;A raw string from the model is the start of your problems. You can&#x27;t store it, validate it, or test it well. The fix is to constrain output at the boundary: the model is allowed to speak, but only in shapes your code understands.&lt;&#x2F;p&gt;
&lt;p&gt;Where the typed boundary in Pattern 2 decides &lt;em&gt;where&lt;&#x2F;em&gt; the model sits in your code, structured generation decides &lt;em&gt;what shape&lt;&#x2F;em&gt; it&#x27;s allowed to emit.&lt;&#x2F;p&gt;
&lt;p&gt;Pydantic plus your model&#x27;s structured outputs gives you typed data instead of strings, which means the next layer of your control flow becomes ordinary Python.&lt;&#x2F;p&gt;
&lt;p&gt;I covered this in &lt;a href=&quot;&#x2F;blog&#x2F;build-data-layer-before-llm&#x2F;&quot;&gt;Build the data layer before you touch the LLM&lt;&#x2F;a&gt;, explaining why we teach developers to build the schema before they make a single API call.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;The frontier models make the headlines. The control layer ships the product. Gary&#x27;s tweet names a gap that has been there the whole time, between the people optimizing benchmarks and the people building products. The control layer is the product, not the model. If you want to build AI products, that&#x27;s where you need to spend your time.&lt;&#x2F;p&gt;
&lt;p&gt;If you want a working walkthrough of the patterns above, the &lt;a href=&quot;&#x2F;blog&#x2F;learn-agentic-ai-python-10-step-journey&#x2F;&quot;&gt;10 small agentic AI exercises&lt;&#x2F;a&gt; &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;juanjoseexposito.com&quot;&gt;Juanjo&lt;&#x2F;a&gt; and I shipped, run in the browser and cover the arc from a 3-line model call to a complete loop with HITL. They&#x27;re the conceptual map.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;cohort&lt;&#x2F;a&gt; is the same map, end to end. Six weeks, no frameworks, the control layer built explicitly, with code review at every step. By the end you can answer that one question: you know what your agent is going to do.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Two Python Scoping Bugs: A Lesson in Object Lifetimes</title>
        <published>2026-05-28T00:00:00+00:00</published>
        <updated>2026-05-28T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/two-scoping-bugs-object-lifetimes/"/>
        <id>https://belderbos.dev/blog/two-scoping-bugs-object-lifetimes/</id>
        <summary type="html">&lt;p&gt;Your app works fine with one user. You open a second browser tab and the data is wrong. Your tests pass individually but fail when run together. The culprit: a global object created at module scope.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Your app works fine with one user. You open a second browser tab and the data is wrong. Your tests pass individually but fail when run together. The culprit: a global object created at module scope.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;how-it-starts&quot;&gt;How it starts&lt;&#x2F;h2&gt;
&lt;p&gt;I see this a lot in Python web projects:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# database.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; sqlmodel&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; create_engine, Session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;engine&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; create_engine(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;sqlite:&#x2F;&#x2F;&#x2F;database.db&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_session&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; Session(engine)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; session:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        yield&lt;&#x2F;span&gt;&lt;span&gt; session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This &#x27;innocent&#x27; &lt;code&gt;engine&lt;&#x2F;code&gt; is created the moment &lt;code&gt;database.py&lt;&#x2F;code&gt; is first imported. Every module that imports from &lt;code&gt;database&lt;&#x2F;code&gt; shares the same engine, the same connection pool, the same database file. For a simple script, this is fine. For a multi-module app, it creates hidden coupling and shared state.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-test-isolation-problem&quot;&gt;The test isolation problem&lt;&#x2F;h2&gt;
&lt;p&gt;I hit this recently in a FastAPI app:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# test_app.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; myapp.database&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; engine, create_db_and_tables, clear_db_and_tables&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;autouse&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; setup_database&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    clear_db_and_tables()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    create_db_and_tables()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_create_race&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(race_events):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    championship&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; create_races(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2026&lt;&#x2F;span&gt;&lt;span&gt;, race_events)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; championship.id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # Passes alone, fails in suite&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That &lt;code&gt;assert championship.id == 1&lt;&#x2F;code&gt; works when the test runs first. Run it after another test that inserts data, and the auto-increment ID comes back as 2. The fixture does its job, but state still leaks between tests in subtle ways: connection pool state, cached metadata, and SQLite&#x27;s own bookkeeping on the shared file can carry over even with drop&#x2F;recreate cycles.&lt;&#x2F;p&gt;
&lt;p&gt;The root cause is upstream: every test reaches for the same module-level engine pointed at the same on-disk database. If you want true isolation, the engine itself has to be per-test, not the cleanup ritual around it.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is creating an engine per test session:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; engine&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    engine&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; create_engine(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;sqlite:&#x2F;&#x2F;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; echo&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;False&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    SQLModel.metadata.create_all(engine)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    yield&lt;&#x2F;span&gt;&lt;span&gt; engine&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    engine.dispose()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@pytest.fixture&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; session&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(engine):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; Session(engine)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; session:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        yield&lt;&#x2F;span&gt;&lt;span&gt; session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now each test gets a fresh database (no scope defined on the fixture decorator means function scope = per test). No cleanup needed. No shared state.&lt;&#x2F;p&gt;
&lt;p&gt;Alternatively, keep the module-level engine and wrap each test in a transaction you roll back at teardown (sqlmodel&#x27;s &lt;code&gt;Session&lt;&#x2F;code&gt; supports this).&lt;&#x2F;p&gt;
&lt;p&gt;If you omit &lt;code&gt;engine.dispose()&lt;&#x2F;code&gt; in the code above, you may see a &lt;code&gt;ResourceWarning: unclosed database&lt;&#x2F;code&gt;, but only when running &lt;code&gt;pytest --cov&lt;&#x2F;code&gt;. Coverage&#x27;s &lt;code&gt;sys.settrace()&lt;&#x2F;code&gt; hook keeps frame locals alive longer, delaying GC of the engine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-shared-simulator-problem&quot;&gt;The shared simulator problem&lt;&#x2F;h2&gt;
&lt;p&gt;The database engine bug is about too much sharing. Here is the inverse: not enough sharing, which breaks in a different way.&lt;&#x2F;p&gt;
&lt;p&gt;Consider a race simulation dashboard. &lt;code&gt;FakeDataSource&lt;&#x2F;code&gt; wraps a &lt;code&gt;RaceSimulator&lt;&#x2F;code&gt; that holds the full mutable race state, driver positions, lap counter, cumulative changes, and advances it on each call:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; FakeDataSource&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;RaceDataSource&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, data_file: Path, delay_ms:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 100&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._load_drivers(data_file)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.simulator&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; RaceSimulator(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;drivers)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # mutable state lives here&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_positions&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, fixture_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Position]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.simulator.tick()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # randomly swaps adjacent positions, advances lap&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.simulator.get_current_positions()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The FastAPI dependency looks like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_data_source&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; RaceDataSource:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;DATA_SOURCE&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;fake&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;fake&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; FakeDataSource(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;data_file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; delay_ms&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # new instance every call&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_race_data_source&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; RaceDataSource:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; get_data_source()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;FastAPI calls &lt;code&gt;get_race_data_source()&lt;&#x2F;code&gt; once per request. Each browser tab that opens the SSE stream gets a brand new &lt;code&gt;FakeDataSource&lt;&#x2F;code&gt; with a brand new &lt;code&gt;RaceSimulator&lt;&#x2F;code&gt; starting at lap 1 with drivers in their initial order.&lt;&#x2F;p&gt;
&lt;p&gt;The random swaps then diverge independently: Tab A shows Verstappen in P1 at lap 12, Tab B shows Hamilton in P1 at lap 3. Neither reflects a shared reality, because there is no shared state at all.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-fixes-from-quick-to-idiomatic&quot;&gt;Two fixes, from quick to idiomatic&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;cache&lt;&#x2F;code&gt;: one line, works immediately&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; functools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; lru_cache&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@lru_cache&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;maxsize&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_data_source&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; RaceDataSource:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;DATA_SOURCE&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;fake&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;fake&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; FakeDataSource(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; SportmonksDataSource()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One instance for the lifetime of the process. Simple, but hard to override in tests.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;2. FastAPI &lt;code&gt;app.state&lt;&#x2F;code&gt;: idiomatic and testable&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@asynccontextmanager&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; lifespan&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(app: FastAPI):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    app.state.data_source&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; get_data_source()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    yield&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;app&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; FastAPI(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;lifespan&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;lifespan)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_race_data_source&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(request: Request) -&amp;gt; RaceDataSource:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; request.app.state.data_source&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The data source is created once at startup, shared across all requests, and easy to replace in tests via &lt;code&gt;app.dependency_overrides[get_race_data_source] = lambda: test_source&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;key-takeaways&quot;&gt;Key takeaways&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Module-level objects are created at import time and shared everywhere: global mutable state, and a common source of subtle bugs.&lt;&#x2F;li&gt;
&lt;li&gt;Tests that share a database engine aren&#x27;t isolated, even with setup&#x2F;teardown fixtures.&lt;&#x2F;li&gt;
&lt;li&gt;Web apps that create per-request instances lose shared state; apps that share module-level instances lose testability.&lt;&#x2F;li&gt;
&lt;li&gt;Use &lt;code&gt;app.state&lt;&#x2F;code&gt; or &lt;code&gt;cache&lt;&#x2F;code&gt; for shared runtime state; override the FastAPI dependency in tests for isolation.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The rule of thumb: if an object holds mutable state, pick its scope deliberately. Too broad (module scope) and tests leak into each other. Too narrow (per-request) and there&#x27;s no shared reality. Match the scope to the object&#x27;s intended lifetime.&lt;&#x2F;p&gt;
&lt;p&gt;Or put more sharply: any module-level object that holds mutable state or owns a resource (DB engines, HTTP clients, caches, queues, connection pools) should be encapsulated. Move it into a fixture, a &lt;code&gt;Depends()&lt;&#x2F;code&gt;, or &lt;code&gt;app.state&lt;&#x2F;code&gt;. Constants and pure values at module scope are fine; resources are not.&lt;&#x2F;p&gt;
&lt;p&gt;The cost of &quot;just import it&quot; is paid later, in test isolation, debugging, and concurrency. Under real concurrency the GIL hides this class of bug until it doesn&#x27;t, see &lt;a href=&quot;&#x2F;blog&#x2F;race-condition-rust-wouldnt-let-me-write&#x2F;&quot;&gt;a race condition Rust wouldn&#x27;t have let me write&lt;&#x2F;a&gt;, where the same module-global pattern leaked one user&#x27;s data into another user&#x27;s response.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>From Python Script to Production: A Django Coaching Case Study</title>
        <published>2026-05-26T00:00:00+00:00</published>
        <updated>2026-05-26T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/daniele-django-movie-anime-discovery/"/>
        <id>https://belderbos.dev/blog/daniele-django-movie-anime-discovery/</id>
        <summary type="html">&lt;p&gt;Six weeks of 1:1 coaching. The output: a Django app in production on Fly.io, covering movies, anime, and manga, with user accounts, a save library, Docker, and CI&#x2F;CD on every push. Daniele started with Python skills and a project idea. Here&#x27;s what the work actually looked like.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Six weeks of 1:1 coaching. The output: a Django app in production on Fly.io, covering movies, anime, and manga, with user accounts, a save library, Docker, and CI&#x2F;CD on every push. Daniele started with Python skills and a project idea. Here&#x27;s what the work actually looked like.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;img src=&quot;&#x2F;images&#x2F;daniele.jpeg&quot; alt=&quot;Daniele&quot; class=&quot;case-study-photo&quot;&gt;
&lt;h2 id=&quot;the-starting-point&quot;&gt;The starting point&lt;&#x2F;h2&gt;
&lt;p&gt;The idea was a platform to discover and track movies, anime, and manga. He had enough Python to start, already building &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;a CLI tool with a swappable data layer&lt;&#x2F;a&gt; in our first app together. What he didn&#x27;t have was experience building a web app: the real mechanics of Django, how the pieces connect, what &quot;ready to ship&quot; means in practice.&lt;&#x2F;p&gt;
&lt;p&gt;Self-study can get you to a prototype. It won&#x27;t tell you when code that works is teaching you the wrong habits. That&#x27;s what weekly PR reviews are for.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;starting-with-discipline-not-speed&quot;&gt;Starting with discipline, not speed&lt;&#x2F;h2&gt;
&lt;p&gt;The first PR was a Python script. It called the TMDB API, parsed the results, and displayed them. Functional and already a place to build habits.&lt;&#x2F;p&gt;
&lt;p&gt;For example: the type hint on &lt;code&gt;_fetch_tmdb_data&lt;&#x2F;code&gt; said &lt;code&gt;-&amp;gt; dict&lt;&#x2F;code&gt;, but the function could return &lt;code&gt;None&lt;&#x2F;code&gt;. Fix the type hint. The constants &lt;code&gt;TMDB_URL&lt;&#x2F;code&gt; and headers weren&#x27;t uppercased consistently. Follow PEP8 conventions. The API key loaded from an &lt;code&gt;.env&lt;&#x2F;code&gt; file, but there was no &lt;code&gt;.env-template&lt;&#x2F;code&gt; telling other developers which variables to set. Add the template.&lt;&#x2F;p&gt;
&lt;p&gt;None of these changes affect whether the script runs. All of them affect whether another developer, or Daniele himself in six months, can reason about it. That&#x27;s where professional developer habits form.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;django-s-machinery-is-yours-to-understand&quot;&gt;Django&#x27;s machinery is yours to understand&lt;&#x2F;h2&gt;
&lt;p&gt;Moving from a script to Django means the framework does a lot for you. The risk is accepting what it does for you without understanding its deeper workings.&lt;&#x2F;p&gt;
&lt;p&gt;In week 2, Daniele ran &lt;code&gt;uv run ty check .&lt;&#x2F;code&gt; and got two errors: &lt;code&gt;Class &#x27;Movie&#x27; has no attribute &#x27;objects&#x27;&lt;&#x2F;code&gt;. Django adds the &lt;code&gt;objects&lt;&#x2F;code&gt; manager dynamically at runtime; &lt;code&gt;ty&lt;&#x2F;code&gt; is a static type checker and can&#x27;t see it. He asked the right question:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;So this was because of ty&#x2F;type checker flagging an error? If we have a way to instrument ty globally to recognize Django&#x27;s dynamic managers, that would be better. Do we need to pull in &#x27;django-stubs&#x27; or a similar configuration?&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The answer was: Django&#x27;s dynamic ORM creates real friction with static type checkers. The pragmatic fix was to explicitly declare &lt;code&gt;objects: models.Manager&lt;&#x2F;code&gt; on the model, making the implicit explicit for both the type checker and any developer reading the code. The question itself was the point. Understanding why the error existed led to a better solution.&lt;&#x2F;p&gt;
&lt;p&gt;We also made a note to compare with other type checkers like &lt;code&gt;pyrefly&lt;&#x2F;code&gt; (v1.0 just came out) or &lt;code&gt;mypy&lt;&#x2F;code&gt; and see if they have better Django support.&lt;&#x2F;p&gt;
&lt;p&gt;The same week, the movie detail page returned 404s even though data existed. The cause: &lt;code&gt;movie_list&lt;&#x2F;code&gt; fetched from the API but didn&#x27;t persist to the database. &lt;code&gt;movie_detail&lt;&#x2F;code&gt; queried the database. Nothing matched. Daniele fixed the sync logic and wrote:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I ran across a problem earlier that was caused by not having the DB in sync. So lesson learned and now I try not to forget to run it.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Running &lt;code&gt;makemigrations&lt;&#x2F;code&gt; after every model change now became a habit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;refactoring-is-how-architecture-emerges&quot;&gt;Refactoring is how architecture emerges&lt;&#x2F;h2&gt;
&lt;p&gt;By week 6, the codebase had grown. Two API functions, &lt;code&gt;get_movie_list_from_api&lt;&#x2F;code&gt; and &lt;code&gt;get_services_list_from_api&lt;&#x2F;code&gt;, had identical try&#x2F;except blocks. The only difference was the endpoint and the default return value.&lt;&#x2F;p&gt;
&lt;p&gt;Extracting a private &lt;code&gt;_get_from_api(endpoint, default)&lt;&#x2F;code&gt; helper isn&#x27;t a trick. It&#x27;s a principle: if two pieces of code do the same thing, one is a future bug waiting to diverge.&lt;&#x2F;p&gt;
&lt;p&gt;The refactor also cleaned up the return types from &lt;code&gt;list[Movie] | None&lt;&#x2F;code&gt; to &lt;code&gt;list[Movie]&lt;&#x2F;code&gt;, replacing a &lt;code&gt;None&lt;&#x2F;code&gt; sentinel with a proper empty list default.&lt;&#x2F;p&gt;
&lt;p&gt;Each review surfaced one decision that sharpened his model of how good code behaves.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;week-6-docker-ci-cd-and-a-live-url&quot;&gt;Week 6: Docker, CI&#x2F;CD, and a live URL&lt;&#x2F;h2&gt;
&lt;p&gt;Week 6 was Docker and Fly.io. The app that ran locally needed to run in the cloud: no SQLite disappearing on container restart (or moving to Postgres), environment variables properly set, static files served correctly, no secrets hardcoded anywhere, GitHub Actions deploying on every push to main (with passing tests).&lt;&#x2F;p&gt;
&lt;p&gt;Daniele learned a lot here &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;movie-anime-discovery-platform.fly.dev&#x2F;&quot;&gt;and shipped his app&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;daniele-movie-manga-app.webp&quot; alt=&quot;Daniele&amp;#39;s movie and anime discovery platform&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;When Daniele shared the deploy win in my coaching Slack, he put into words something that sits at the center of anybody wanting to improve their coding&#x2F;developer skills now:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Addressing quality and best practices and security and maintainability in your code does not pair well with velocity, especially when you&#x27;re learning. So I preferred to improve my code quality and to become better at it by learning properly, postponing some feature release for later. In the process I learned Django and its main mechanics, Docker and deployment in the cloud.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The velocity-vs-quality tradeoff he named isn&#x27;t unique to learning Django. It&#x27;s the choice every developer makes every week. My coaching gave him the framework, the discipline and persistence were his.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>What production AI agents actually require</title>
        <published>2026-05-22T00:00:00+00:00</published>
        <updated>2026-05-22T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/production-ai-agents-real-workflows/"/>
        <id>https://belderbos.dev/blog/production-ai-agents-real-workflows/</id>
        <summary type="html">&lt;p&gt;Most &quot;AI agents&quot; shipping right now are demos wearing production paint. They answer questions fluently and break the moment they touch a workflow with money, state, or consequences.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Most &quot;AI agents&quot; shipping right now are demos wearing production paint. They answer questions fluently and break the moment they touch a workflow with money, state, or consequences.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-agent-illusion&quot;&gt;The agent illusion&lt;&#x2F;h2&gt;
&lt;p&gt;Multi-agent frameworks benchmark beautifully. Five specialist LLMs cooperate, the demo plays cleanly, the README has a diagram with arrows. Then someone wires the thing to a real billing system and it issues three refunds for the same chargeback because a tool call retried on a flaky network.&lt;&#x2F;p&gt;
&lt;p&gt;That gap is the actual job most agent tutorials skip.&lt;&#x2F;p&gt;
&lt;p&gt;When I review AI code, the same pattern keeps appearing. The LLM call is fine. The agent loop is fine. What is missing is the layer underneath: state, idempotency, audit, and a tool surface the agent cannot use to hurt you.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-unsexy-layer&quot;&gt;The unsexy layer&lt;&#x2F;h2&gt;
&lt;p&gt;Systems answer four questions before the agent does anything:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;What did the agent already do?&lt;&#x2F;strong&gt; Persisted state, not &quot;look at the conversation history.&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;What happens if this action runs twice?&lt;&#x2F;strong&gt; Idempotency keys on every external effect.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Who approved this?&lt;&#x2F;strong&gt; An audit log a human can read during a postmortem.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Can I roll this back?&lt;&#x2F;strong&gt; A clear inverse for every irreversible operation, or a freeze before execution.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;None of this is glamorous. It is also what separates a system that is a toy demo from one that can run mostly unsupervised in production.&lt;&#x2F;p&gt;
&lt;p&gt;The shape of that contract in code:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ExpenseAction&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;BaseModel&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    idempotency_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    requested_by:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    requested_at: datetime&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    approval_required:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    dry_run:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    payload: ExpensePayload&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; submit&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(action: ExpenseAction, repo: ExpenseRepo) -&amp;gt; Result:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; repo.find_by_key(action.idempotency_key):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; Result.duplicate()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; action.dry_run:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; Result.preview(action.plan())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; action.approval_required&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; and not&lt;&#x2F;span&gt;&lt;span&gt; action.is_approved():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; Result.pending_approval()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    repo.persist(action)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; Result.ok(action.execute())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The agent does not call the side effect. It builds a typed plan. A function decides whether to run it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;state-that-survives-retries&quot;&gt;State that survives retries&lt;&#x2F;h2&gt;
&lt;p&gt;Agents need state management that works across restarts and network failures. The Telegram expense bot we build in our Agentic AI cohort program, uses &lt;code&gt;context.user_data&lt;&#x2F;code&gt; to track multi-step flows:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; handle_expense_text&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, update, context):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; update.message.text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._preprocessor.preprocess(text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; result.is_valid:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; update.message.reply_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Invalid: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result.error&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; ConversationHandler.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;END&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._build_service().classify(result.text).response&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Store state for the callback handler&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    context.user_data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expense_description&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; result.text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    context.user_data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;classification_response&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; response&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    keyboard&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; build_category_confirmation_keyboard(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        suggested_category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response.category,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        all_categories&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[c.value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; ExpenseCategory],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; update.message.reply_text(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;I categorized this as &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;response.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;response.total_amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;} {&lt;&#x2F;span&gt;&lt;span&gt;response.currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;). Confirm or pick another category:&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        reply_markup&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;keyboard,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; ConversationState.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;WAITING_FOR_CATEGORY&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; handle_category_selection&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, update, context):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    query&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; update.callback_query&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; query.answer()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # Retrieve state from previous handler&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; context.user_data.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expense_description&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; context.user_data.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;classification_response&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        await&lt;&#x2F;span&gt;&lt;span&gt; query.edit_message_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Session expired. Send expense again.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; ConversationHandler.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;END&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _, category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; query.data.split(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;:&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;    self&lt;&#x2F;span&gt;&lt;span&gt;._build_service().persist_with_category(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        expense_description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;description,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        category_name&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;category,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        telegram_user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;update.effective_user.id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span&gt; query.edit_message_text(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Saved as &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;!&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; ConversationHandler.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;END&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;.get()&lt;&#x2F;code&gt; with defensive error handling is what saves you when the bot restarts mid-conversation. No silent corruption, no half-written database rows. The user just has to resend their expense description and pick the category again. This is the work of production agents.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools-the-agent-cannot-trust&quot;&gt;Tools the agent cannot trust&lt;&#x2F;h2&gt;
&lt;p&gt;LLMs are undeterministic and hallucinate. Design your tool surface for mistrust:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Narrow scopes.&lt;&#x2F;strong&gt; &lt;code&gt;read_expense&lt;&#x2F;code&gt; and &lt;code&gt;flag_expense&lt;&#x2F;code&gt; are two tools, not one tool with a mode flag the LLM can flip.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Dry-run by default.&lt;&#x2F;strong&gt; Every write tool returns a plan first. The agent opts in to execute. You get human-in-the-loop (HITL) for free.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Schema-validated inputs.&lt;&#x2F;strong&gt; &lt;a href=&quot;&#x2F;blog&#x2F;ai-agent-architecture-python&#x2F;&quot;&gt;Pydantic at the tool boundary&lt;&#x2F;a&gt; so a malformed argument cannot reach your database.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Explicit confirmation for anything destructive.&lt;&#x2F;strong&gt; The agent proposes, a human taps approve.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The agent is not the brain of your application. It is a planner that we acknowledge is fallible. The real logic lives in the tools, and the agent&#x27;s job is to call them with valid inputs and ask for help when it is unsure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;input-validation-before-the-llm-sees-anything&quot;&gt;Input validation before the LLM sees anything&lt;&#x2F;h2&gt;
&lt;p&gt;Validate at system boundaries before user input reaches your tools. This prevents XSS, length attacks, and malformed data from consuming tokens:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; dataclasses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; dataclass, field&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; re&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;XSS_PATTERNS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&amp;lt;script&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;javascript:&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;onerror=&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;onload=&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;CURRENCY_SYMBOLS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;$&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;USD&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;€&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;EUR&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;£&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;GBP&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;¥&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;JPY&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;AMOUNT_PATTERN&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; re.compile(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;r&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;\d&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;+&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-constant&quot;&gt;([.,]\d&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;+&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;?&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; PreprocessingResult&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    is_valid:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    warnings: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default_factory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;list&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    error:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; InputPreprocessor&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; preprocess&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, text:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; PreprocessingResult:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; text.strip()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; len&lt;&#x2F;span&gt;&lt;span&gt;(text)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 3&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; PreprocessingResult(text,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; error&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Input too short&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; len&lt;&#x2F;span&gt;&lt;span&gt;(text)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; PreprocessingResult(text,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; error&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Input too long&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; any&lt;&#x2F;span&gt;&lt;span&gt;(pattern&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; text.lower()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; pattern&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; XSS_PATTERNS&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; PreprocessingResult(text,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; error&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Invalid characters&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        for&lt;&#x2F;span&gt;&lt;span&gt; symbol, code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; CURRENCY_SYMBOLS&lt;&#x2F;span&gt;&lt;span&gt;.items():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; text.replace(symbol, code)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        warnings&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; AMOUNT_PATTERN&lt;&#x2F;span&gt;&lt;span&gt;.search(text):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            warnings.append(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;No amount detected&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; PreprocessingResult(text,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;, warnings)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This runs before the LLM call, returning error messages without burning tokens or risking injection.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;human-in-the-loop-as-a-design-pattern&quot;&gt;Human-in-the-loop as a design pattern&lt;&#x2F;h2&gt;
&lt;p&gt;Production agents are not fully autonomous. They classify, extract, or suggest, then wait for a human to confirm. Confidence scores guide when to ask:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; dataclasses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;frozen&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ClassificationResult&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response: ExpenseCategorizationResponse&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    persisted:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process_with_hitl&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(result: ClassificationResult, threshold:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.8&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; result.response.confidence&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span&gt; threshold:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; result.response.category&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;    print&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Low confidence (&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result.response.confidence&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;:.0%&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;): &amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result.response.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;#39; — &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result.response.reason&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_input&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; input&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Accept &amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result.response.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;#39;? (Enter to confirm, or type a category): &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ).strip()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; user_input:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; result.response.category&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; user_input&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In the Telegram bot, this becomes an inline keyboard. The bot states its category guess and asks the human to confirm or pick a different one, with the AI suggestion highlighted.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;telegram-expense-bot-keyboards.png&quot; alt=&quot;Telegram expense bot confidence-based keyboards&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The pattern: AI proposes, human disposes. This surfaces in the service layer we built in prior weeks:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ClassificationService&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    assistant: Assistant&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    expense_repo: ExpenseRepository&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; classify&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, description:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; ClassificationResult:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._build_messages(description)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.assistant.completion(messages)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; ClassificationResult(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; persisted&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;False&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; persist_with_category&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        self,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        expense_description:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        category_name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        response: ExpenseCategorizationResponse,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        telegram_user_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;&amp;quot;&amp;quot;Store the user&amp;#39;s chosen category, not the AI guess.&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        expense&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Expense(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response.total_amount,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response.currency,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ExpenseCategory(category_name),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;expense_description,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            telegram_user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;telegram_user_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.expense_repo.add(expense)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;persist_with_category&lt;&#x2F;code&gt; method accepts the human&#x27;s decision. The database stores what the user confirmed, not what the model guessed. As the &lt;code&gt;ExpenseCategorizationResponse&lt;&#x2F;code&gt; captures the AI&#x27;s original category and confidence, we can analyze overrides later to identify model weaknesses.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dependency-injection-for-testable-agents&quot;&gt;Dependency injection for testable agents&lt;&#x2F;h2&gt;
&lt;p&gt;The service layer pattern separates business logic from LLM provider details. Inject dependencies rather than hardcoding them:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; unittest.mock&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; create_autospec&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decimal&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_classify_calls_assistant&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # No real OpenAI call, no .env file, no network&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_assistant&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; create_autospec(Assistant)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_assistant.completion.return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ExpenseCategorizationResponse(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Food&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        total_amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;5.50&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;USD&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        confidence&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0.95&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        cost&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;0.001&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_repo&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; create_autospec(ExpenseRepository)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    service&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ClassificationService(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;assistant&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;mock_assistant,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; expense_repo&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;mock_repo)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; service.classify(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Coffee at Starbucks $5.50&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_assistant.completion.assert_called_once()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; result.response.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Food&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; result.persisted&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Because the service receives its dependencies rather than creating them, you can test classification logic without burning API credits or waiting on network calls. This is a key strategy to test the interface at the service layer, not the LLM provider.&lt;&#x2F;p&gt;
&lt;p&gt;The same service powers the CLI, Telegram bot, and REST API. Change providers (OpenAI to Anthropic) or add caching by swapping the &lt;code&gt;Assistant&lt;&#x2F;code&gt; implementation. Business logic stays untouched.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;speed-vs-safety&quot;&gt;Speed vs safety&lt;&#x2F;h2&gt;
&lt;p&gt;The tradeoff is iteration speed vs execution safety.&lt;&#x2F;p&gt;
&lt;p&gt;Put the LLM behind a typed &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;service boundary&lt;&#x2F;a&gt; and you can swap models without touching business logic. Store actions as events instead of overwriting state, and your audit log writes itself. I wrote about &lt;a href=&quot;&#x2F;blog&#x2F;event-sourcing-python-store-events-not-state&#x2F;&quot;&gt;why event sourcing pays off&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;agentic-loops-with-typed-tool-results&quot;&gt;Agentic loops with typed tool results&lt;&#x2F;h2&gt;
&lt;p&gt;The tool-use loop needs to handle partial results, retries, and tool failures. Here is the pattern from the warm up exercises you can do on &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;our Agentic Cohort page&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; cast&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; anthropic&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; anthropic.types&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    MessageParam,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    TextBlock,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ToolUseBlock,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ToolResultBlockParam,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# TOOLS defined with JSON schema for get_exchange_rate(from_currency, to_currency)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; answer_with_tools&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(question:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, client: anthropic.Anthropic) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    messages: list[MessageParam]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: question}]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    while&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.messages.create(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;claude-sonnet-4-6&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            max_tokens&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;512&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            tools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;TOOLS&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;messages,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; response.stop_reason&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;end_turn&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; cast(TextBlock, response.content[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;]).text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; response.stop_reason&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool_use&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;            # anything other than tool_use here means no tool calls to process — looping would spin forever&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; RuntimeError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Unexpected stop reason: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;response.stop_reason&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tool_uses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            cast(ToolUseBlock, b)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; b&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; response.content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; b.type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool_use&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tool_results: list[ToolResultBlockParam]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;                &amp;quot;type&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tool_result&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;                &amp;quot;tool_use_id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: b.id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;                &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;(get_exchange_rate(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;**&lt;&#x2F;span&gt;&lt;span&gt;cast(dict[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;], b.input))),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            for&lt;&#x2F;span&gt;&lt;span&gt; b&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; tool_uses&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        messages.append({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;assistant&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: response.content})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        messages.append({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: tool_results})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The loop continues until &lt;code&gt;stop_reason == &quot;end_turn&quot;&lt;&#x2F;code&gt;. Tool results are typed, preventing schema drift between the tool definition and implementation.&lt;&#x2F;p&gt;
&lt;p&gt;In production, wrap &lt;code&gt;get_exchange_rate()&lt;&#x2F;code&gt; in a try&#x2F;except and return error results to the LLM when tools fail. The agent can retry, pick a different tool, or surface the error to the user.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;The fix is separation of concerns, typed interfaces, and a well-defined contract between the agent and its tools.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Learn agentic AI in Python with 10 small exercises</title>
        <published>2026-05-14T00:00:00+00:00</published>
        <updated>2026-05-14T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/learn-agentic-ai-python-10-step-journey/"/>
        <id>https://belderbos.dev/blog/learn-agentic-ai-python-10-step-journey/</id>
        <summary type="html">&lt;p&gt;Most &quot;build an AI agent&quot; tutorials hand you a framework and skip the part where you actually understand what it&#x27;s doing under the hood. When the abstraction breaks, you can&#x27;t debug it because you never built the layer underneath. &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;juanjoseexposito.com&quot;&gt;Juanjo&lt;&#x2F;a&gt; and I think that gap is worth closing.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Most &quot;build an AI agent&quot; tutorials hand you a framework and skip the part where you actually understand what it&#x27;s doing under the hood. When the abstraction breaks, you can&#x27;t debug it because you never built the layer underneath. &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;juanjoseexposito.com&quot;&gt;Juanjo&lt;&#x2F;a&gt; and I think that gap is worth closing.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Yesterday we shipped &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&#x2F;exercises&#x2F;&quot;&gt;10 small browser-based exercises&lt;&#x2F;a&gt; that walk through that layer one pattern at a time (more on how we run them in the browser with Pyodide &lt;a href=&quot;&#x2F;blog&#x2F;python-exercises-browser-pyodide&#x2F;&quot;&gt;here&lt;&#x2F;a&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;This article is the conceptual journey behind them: how you get from &quot;I can call Claude&quot; to a complete agent loop with a testable architecture and a human-in-the-loop workflow. Each stage builds on the previous one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-1-make-a-model-reply-exercise-1&quot;&gt;Stage 1: make a model reply (exercise 1)&lt;&#x2F;h2&gt;
&lt;p&gt;Every agent app starts with the same 3-line skeleton. Build a client, call &lt;code&gt;messages.create&lt;&#x2F;code&gt;, read &lt;code&gt;content[0].text&lt;&#x2F;code&gt;. The shape doesn&#x27;t change much. Only what wraps around it does.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; anthropic&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;client&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; anthropic.Anthropic()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;msg&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.messages.create(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;    model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;claude-sonnet-4-6&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;    max_tokens&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;256&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;    messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Say hi&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;print&lt;&#x2F;span&gt;&lt;span&gt;(msg.content[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Why &lt;code&gt;content[0].text&lt;&#x2F;code&gt; and not &lt;code&gt;.text&lt;&#x2F;code&gt;? Because &lt;code&gt;content&lt;&#x2F;code&gt; is a list of blocks (text, tool_use, and others). That list is how tool use plugs in later without breaking the response shape. Get this mental model before anything else.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-2-make-the-reply-machine-readable-exercises-2-3&quot;&gt;Stage 2: make the reply machine-readable (exercises 2, 3)&lt;&#x2F;h2&gt;
&lt;p&gt;Raw LLM strings are unreliable. The fix is two paired habits: a specific system prompt that locks the output shape, and a Pydantic model that validates it on the way back in.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pydantic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; BaseModel&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ExpenseResult&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;BaseModel&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    confidence:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ExpenseResult.model_validate_json(msg.content[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Treat the system prompt like an API contract. Say &quot;JSON only&quot;, show the literal shape, forbid improvisation (&quot;no punctuation, no explanation, nothing else&quot;). The phrase &quot;nothing else&quot; is doing real work; without it, models love to append a friendly sentence that breaks your parser.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-3-make-it-remember-exercise-4&quot;&gt;Stage 3: make it remember (exercise 4)&lt;&#x2F;h2&gt;
&lt;p&gt;LLMs don&#x27;t remember anything. They have no state, no memory, no context beyond the current call. The &quot;conversation&quot; is a fiction we create by sending the whole message history every time.&lt;&#x2F;p&gt;
&lt;p&gt;To get a continuous conversation, &lt;em&gt;you&lt;&#x2F;em&gt; keep the list of &lt;code&gt;{&quot;role&quot;: ..., &quot;content&quot;: ...}&lt;&#x2F;code&gt; dicts and send the whole thing every turn. Append the user message before the call, the assistant reply after. Roles must alternate.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;history&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ask&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(user_msg):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    history.append({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: user_msg})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reply&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.messages.create(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;claude-sonnet-4-6&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        max_tokens&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;512&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;history,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ).content[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    history.append({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;assistant&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: reply})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; reply&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;State lives in your code, not the model. That single realization clears up most of the confusion developers have about context windows and &quot;memory.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-4-give-the-model-hands-exercise-5&quot;&gt;Stage 4: give the model hands (exercise 5)&lt;&#x2F;h2&gt;
&lt;p&gt;Tool use turns a chatbot into something that can act. The loop is dumber than people think:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;while&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.messages.create(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; tools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;TOOLS&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;messages)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; response.stop_reason&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;end_turn&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; response.content[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # else: run the tool the model asked for, append the result, loop again&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two gotchas: append the &lt;em&gt;full&lt;&#x2F;em&gt; &lt;code&gt;response.content&lt;&#x2F;code&gt; as the assistant turn (it contains the &lt;code&gt;tool_use&lt;&#x2F;code&gt; blocks the model needs to see), and tool results come back wrapped in a &lt;code&gt;user&lt;&#x2F;code&gt; message, not assistant.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-5-make-it-swappable-and-testable-exercises-6-7-8&quot;&gt;Stage 5: make it swappable and testable (exercises 6, 7, 8)&lt;&#x2F;h2&gt;
&lt;p&gt;By exercise 6 the chatbot works, but it&#x27;s also often a highly coupled mess importing external dependencies like &lt;code&gt;anthropic&lt;&#x2F;code&gt; and &lt;code&gt;sqlite3&lt;&#x2F;code&gt; into the business logic. Time for three common patterns, applied to LLM apps:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;Protocol&lt;&#x2F;code&gt; for the LLM provider, so tests can pass a &lt;code&gt;MockProvider&lt;&#x2F;code&gt; with a &lt;code&gt;.calls&lt;&#x2F;code&gt; list instead of an API key.&lt;&#x2F;li&gt;
&lt;li&gt;A &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;Repository pattern&lt;&#x2F;a&gt; for the persistence layer, so an in-memory dict satisfies the same interface as a database backend.&lt;&#x2F;li&gt;
&lt;li&gt;A service layer that accepts both via &lt;code&gt;__init__&lt;&#x2F;code&gt; and orchestrates: call provider, parse, save, return.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That&#x27;s the &lt;a href=&quot;&#x2F;blog&#x2F;ai-agent-architecture-python&#x2F;&quot;&gt;four-layer agent architecture&lt;&#x2F;a&gt;, built piece by piece instead of dumped on you all at once.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-6-keep-a-human-in-the-loop-exercise-9&quot;&gt;Stage 6: keep a human in the loop (exercise 9)&lt;&#x2F;h2&gt;
&lt;p&gt;When the model returns a confidence score, use it. Above the threshold: auto-accept. Below: show the suggestion and let the user confirm or override.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(result, threshold&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0.8&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; result.confidence&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span&gt; threshold:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; result.category&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    answer&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; input&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Accept &amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;result.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;#39;? (Enter to confirm): &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).strip()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; answer&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; result.category&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Make the accept path the cheapest action (empty input or &lt;code&gt;y&lt;&#x2F;code&gt;). Users pay the manual handling cost only when overriding. This is what separates a trusted assistant from one that quietly mislabels things, and it&#x27;s the gap between &quot;AI demo&quot; and &lt;a href=&quot;&#x2F;blog&#x2F;production-ai-agents-real-workflows&#x2F;&quot;&gt;production-ready workflow&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;stage-7-generalize-the-loop-exercise-10&quot;&gt;Stage 7: generalize the loop (exercise 10)&lt;&#x2F;h2&gt;
&lt;p&gt;The agent is exercise 5 with one change: replace the hardcoded function call with a &lt;code&gt;TOOL_FUNCTIONS[name]&lt;&#x2F;code&gt; lookup.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;TOOL_FUNCTIONS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;add&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; lambda&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt; a, b: a&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span&gt; b,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;multiply&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; lambda&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt; a, b: a&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span&gt; b,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# inside the loop:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;TOOL_FUNCTIONS&lt;&#x2F;span&gt;&lt;span&gt;[block.name](&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;**&lt;&#x2F;span&gt;&lt;span&gt;block.input))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now adding a tool is one schema entry plus one dict entry. Swap &lt;code&gt;add&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;multiply&lt;&#x2F;code&gt; for &lt;code&gt;search_web&lt;&#x2F;code&gt;, &lt;code&gt;query_db&lt;&#x2F;code&gt;, &lt;code&gt;send_email&lt;&#x2F;code&gt; and the loop is identical. Look at agent frameworks under the hood (LangChain, OpenAI Assistants) and you&#x27;ll see this same pattern.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-the-journey-teaches&quot;&gt;What the journey teaches&lt;&#x2F;h2&gt;
&lt;p&gt;Frameworks make sense once you can write the layer underneath. Skip that, and you are stuck the first time the abstraction leaks. After coaching many developers through this, the dividing line is clear: have they ever written the loop themselves?&lt;&#x2F;p&gt;
&lt;p&gt;The 10 exercises are deliberately small. The arc matters more than any single one. Once you&#x27;ve done them, &quot;agentic AI&quot; stops being &quot;magic&quot; and starts being a loop, schema, and some patterns you might already know.&lt;&#x2F;p&gt;
&lt;p&gt;Try them out:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;In the browser: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&#x2F;exercises&#x2F;&quot;&gt;pythonagenticai.com&#x2F;exercises&lt;&#x2F;a&gt;. No install, no API key, no dependencies. Loads fast.&lt;&#x2F;li&gt;
&lt;li&gt;Locally: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;ai_bites&quot;&gt;clone the repo&lt;&#x2F;a&gt; and work through them in your IDE.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Coding exercises that run in the browser with Pyodide</title>
        <published>2026-05-13T00:00:00+00:00</published>
        <updated>2026-05-13T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/python-exercises-browser-pyodide/"/>
        <id>https://belderbos.dev/blog/python-exercises-browser-pyodide/</id>
        <summary type="html">&lt;p&gt;I&#x27;ve built coding-exercise platforms before (&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pybitesplatform.com&quot;&gt;Python&lt;&#x2F;a&gt;, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&quot;&gt;Rust&lt;&#x2F;a&gt;). AWS API Gateway + Lambda, Docker, etc. It works great, but that&#x27;s a lot of infrastructure to teach someone a four-line function.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;I&#x27;ve built coding-exercise platforms before (&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pybitesplatform.com&quot;&gt;Python&lt;&#x2F;a&gt;, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&quot;&gt;Rust&lt;&#x2F;a&gt;). AWS API Gateway + Lambda, Docker, etc. It works great, but that&#x27;s a lot of infrastructure to teach someone a four-line function.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;For our new &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;Agentic AI cohort&lt;&#x2F;a&gt; I wanted a free warm-up: ten short Python exercises that introduce the AI vendor SDK patterns (in this case Anthropic). The hard constraint was that visitors should be able to click &quot;Run&quot; without signing up, without bringing an API key, and without complex third party infrastructure. As this site is built on Cloudflare Pages, that meant an in-browser Python runtime. Enter Pyodide ...&lt;&#x2F;p&gt;
&lt;p&gt;Unlike toy Python interpreters, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pyodide.org&quot;&gt;Pyodide&lt;&#x2F;a&gt; runs real CPython compiled to WebAssembly (listen to my interview &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=MAK6nApfIxY&quot;&gt;Elmer Bulthuis&lt;&#x2F;a&gt; why Wasm is cool), which enables broad compatibility with the Python ecosystem, including native extension packages.&lt;&#x2F;p&gt;
&lt;p&gt;Getting it working was easy with some Claude Code prototyping; the interesting part was the last 20%. Some of the challenges I faced and how I worked around them.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;mocked-tests-a-stubbed-sdk&quot;&gt;Mocked tests + a stubbed SDK&lt;&#x2F;h2&gt;
&lt;p&gt;Every exercise has a &lt;code&gt;solution.py&lt;&#x2F;code&gt; and a &lt;code&gt;test_exercise.py&lt;&#x2F;code&gt;. The tests look like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; unittest.mock&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; MagicMock, patch&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; solution&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; get_completion&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_returns_text&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_client&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; MagicMock()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_client.messages.create.return_value.content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [MagicMock(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Hello, Pythonista!&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; patch(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;solution.anthropic.Anthropic&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;mock_client):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        assert&lt;&#x2F;span&gt;&lt;span&gt; get_completion(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Say hello&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Hello, Pythonista!&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;patch(&quot;solution.anthropic.Anthropic&quot;)&lt;&#x2F;code&gt; replaces the class with a mock for the duration of the &lt;code&gt;with&lt;&#x2F;code&gt; block. The original &lt;code&gt;Anthropic&lt;&#x2F;code&gt; class is never instantiated. Which means the only thing the &lt;em&gt;real&lt;&#x2F;em&gt; SDK contributes is the &lt;em&gt;name&lt;&#x2F;em&gt; &lt;code&gt;anthropic.Anthropic&lt;&#x2F;code&gt; existing somewhere on the Python path.&lt;&#x2F;p&gt;
&lt;p&gt;So I don&#x27;t install it. I write a tiny stub package straight to Pyodide&#x27;s in-browser filesystem:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; ANTHROPIC_INIT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; `&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;class Anthropic:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;    def __init__(self, *args, **kwargs):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; ANTHROPIC_TYPES&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; `&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;class TextBlock: ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;class MessageParam: ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;class ToolParam: ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;class ToolUseBlock: ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;await&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; pyodide.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;loadPackage&lt;&#x2F;span&gt;&lt;span&gt;([&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;pytest&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;pydantic&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;pyodide.&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt;FS&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;mkdirTree&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;home&#x2F;pyodide&#x2F;anthropic&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;pyodide.&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt;FS&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;writeFile&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;home&#x2F;pyodide&#x2F;anthropic&#x2F;__init__.py&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; ANTHROPIC_INIT&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;pyodide.&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt;FS&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;writeFile&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;home&#x2F;pyodide&#x2F;anthropic&#x2F;types.py&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; ANTHROPIC_TYPES&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It&#x27;s a package, not a single file, because some exercises also do &lt;code&gt;from anthropic.types import TextBlock&lt;&#x2F;code&gt;, which I needed to fix &lt;code&gt;ty&lt;&#x2F;code&gt; type errors. Both modules exist only so the imports resolve. The bodies never execute under test thanks to the mocking.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Inside Pyodide, before running pytest:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sys.path.insert(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;&#x2F;home&#x2F;pyodide&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# `import anthropic` finds the stub. `patch` replaces it. Tests run.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That one decision cuts ~3 seconds and several megabytes off the boot. The real &lt;code&gt;anthropic&lt;&#x2F;code&gt; package pulls in &lt;code&gt;pydantic-core&lt;&#x2F;code&gt;, &lt;code&gt;httpx&lt;&#x2F;code&gt;, &lt;code&gt;httpcore&lt;&#x2F;code&gt;, &lt;code&gt;anyio&lt;&#x2F;code&gt;, &lt;code&gt;sniffio&lt;&#x2F;code&gt;, &lt;code&gt;idna&lt;&#x2F;code&gt;, &lt;code&gt;distro&lt;&#x2F;code&gt;, &lt;code&gt;certifi&lt;&#x2F;code&gt;, &lt;code&gt;typing-extensions&lt;&#x2F;code&gt;. Every byte irrelevant to learning the pattern, because the test never lets the SDK run anyway.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;ve read &lt;a href=&quot;&#x2F;blog&#x2F;build-data-layer-before-llm&#x2F;&quot;&gt;build the data layer before you touch the LLM&lt;&#x2F;a&gt;, this is the same strategy: cut the AI piece down to its smallest shape so the rest of the engineering is more flexible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;lazy-loading-the-runtime&quot;&gt;Lazy-loading the runtime&lt;&#x2F;h2&gt;
&lt;p&gt;Pyodide is 5MB+ over the network. I don&#x27;t want this to load on the homepage, not even on the exercise index page. Even on an exercise page, visitors might skim and leave. So the &lt;code&gt;pyodide.js&lt;&#x2F;code&gt; script tag isn&#x27;t in the HTML. The page ships a ~250-line &lt;code&gt;runner.js&lt;&#x2F;code&gt; and that script injects Pyodide on demand:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&#x2F;&#x2F; Module-level constants, defined once at the top of runner.js:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; PYODIDE_VERSION&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;0.27.7&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; PYODIDE_URL&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; `https:&#x2F;&#x2F;cdn.jsdelivr.net&#x2F;pyodide&#x2F;v${&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt;PYODIDE_VERSION&lt;&#x2F;span&gt;&lt;span class=&quot;z-string z-punctuation z-definition z-string&quot;&gt;}&#x2F;full&#x2F;`&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; PYODIDE_JS_SRI&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;sha384-90so5tCKvl0xs9agU29IMKlAVzhfzFX7QO&#x2F;&#x2F;YxQkRhJG58bBZrFN+2ZTRB026X5X&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-storage z-type&quot;&gt;async function&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ensurePyodide&lt;&#x2F;span&gt;&lt;span&gt;() {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;  if&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; (pyodide)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; pyodide;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;  if&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; (bootPromise)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; bootPromise;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;  bootPromise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage&quot;&gt;async&lt;&#x2F;span&gt;&lt;span&gt; ()&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;typeof&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; loadPyodide&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;function&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;      await new&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; Promise&lt;&#x2F;span&gt;&lt;span&gt;((&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;resolve&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; reject&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; s&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; document.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;createElement&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;script&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        s.src&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; PYODIDE_URL&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;pyodide.js&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        s.integrity&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; PYODIDE_JS_SRI&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        s.crossOrigin&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;anonymous&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        s.onload&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; resolve;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        s.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;onerror&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ()&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; reject&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;new&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Error&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Failed to load pyodide.js&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;));&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        document.head.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;appendChild&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(s);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      });&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    pyodide&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; loadPyodide&lt;&#x2F;span&gt;&lt;span&gt;({ indexURL:&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; PYODIDE_URL&lt;&#x2F;span&gt;&lt;span&gt; });&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    await&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; pyodide.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;loadPackage&lt;&#x2F;span&gt;&lt;span&gt;([&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;pytest&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;pydantic&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    &#x2F;&#x2F; write the anthropic stub here…&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; pyodide;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  })();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;  return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; bootPromise;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two triggers prewarm the runtime &lt;em&gt;before&lt;&#x2F;em&gt; the user clicks Run:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;cm.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;on&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;focus&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;, prewarm);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;runBtn.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;addEventListener&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;mouseenter&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;, prewarm, { once:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; true&lt;&#x2F;span&gt;&lt;span&gt; });&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The moment they tab into the editor or hover the button, the 3-second cold start starts ticking. By the time they&#x27;re done typing, the runtime is usually ready. The cached &lt;code&gt;bootPromise&lt;&#x2F;code&gt; deduplicates: focus and hover both await the same in-flight promise, never two parallel boots.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tracking-progress-without-a-backend&quot;&gt;Tracking progress without a backend&lt;&#x2F;h2&gt;
&lt;p&gt;No users, no database, no sessions, but I still want:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;✓ badges on completed exercises in the list view&lt;&#x2F;li&gt;
&lt;li&gt;A progress bar across all ten&lt;&#x2F;li&gt;
&lt;li&gt;Draft code that survives a tab close&lt;&#x2F;li&gt;
&lt;li&gt;A next step that only appears once all ten are green&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;One &lt;code&gt;localStorage&lt;&#x2F;code&gt; key holds the whole state:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; STORAGE_KEY&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;pyai_progress_v1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;&#x2F;&#x2F; { &amp;quot;first-api-call&amp;quot;: { passed: true, code: &amp;quot;...&amp;quot;, lastRun: 1736... } }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three operations carry the state: &lt;code&gt;saveCode(slug, code)&lt;&#x2F;code&gt; runs on every CodeMirror change, &lt;code&gt;markPassed(slug)&lt;&#x2F;code&gt; runs when pytest returns 0, and &lt;code&gt;get(slug)&lt;&#x2F;code&gt; reads on page load to restore drafts and badges.&lt;&#x2F;p&gt;
&lt;p&gt;In a similar vein, the &lt;strong&gt;Solution&lt;&#x2F;strong&gt; tab stays locked until the tests pass. The point of an exercise is the struggle, not the answer.&lt;&#x2F;p&gt;
&lt;p&gt;Once &lt;code&gt;markPassed(slug)&lt;&#x2F;code&gt; writes to localStorage, it also fires a &lt;code&gt;pyai:passed&lt;&#x2F;code&gt; event, and a separate &lt;code&gt;tabs.js&lt;&#x2F;code&gt; listener flips the solution from &lt;code&gt;&amp;lt;div data-solution-locked&amp;gt;&lt;&#x2F;code&gt; to &lt;code&gt;&amp;lt;div data-solution-revealed&amp;gt;&lt;&#x2F;code&gt; and lazy-fetches &lt;code&gt;solution.py&lt;&#x2F;code&gt; for a side-by-side compare. No reload. One key, three consumers (runner, list page, solution tab).&lt;&#x2F;p&gt;
&lt;p&gt;And the key is versioned: &lt;code&gt;pyai_progress_v1&lt;&#x2F;code&gt;. The day I want to change the shape, I can bump it to &lt;code&gt;_v2&lt;&#x2F;code&gt; and old state cleanly stops loading. No migration code, no schema check.&lt;&#x2F;p&gt;
&lt;p&gt;The list page reads the same store on render and walks the DOM:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;document.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;querySelectorAll&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;.exercises-list-item&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;forEach&lt;&#x2F;span&gt;&lt;span&gt;((&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;item&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; =&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;  const&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; slug&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; item.dataset.exerciseSlug;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;  const&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other z-constant&quot;&gt; passed&lt;&#x2F;span&gt;&lt;span&gt; }&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; window.PyAIProgress.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;get&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(slug);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;  if&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; (passed) item.classList.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;add&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;is-passed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;});&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When &lt;code&gt;passedCount() &amp;gt;= total&lt;&#x2F;code&gt;, a hidden next-step block flips visible. That&#x27;s the whole mechanism: ten green checks reveal one element, all computed in the browser from that one &lt;code&gt;localStorage&lt;&#x2F;code&gt; key.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;all-static-all-local&quot;&gt;All static, all local&lt;&#x2F;h2&gt;
&lt;p&gt;The whole thing is a static site. Cloudflare serves the HTML, JS, and the synced exercise files. The browser does the rest. Zero extra cost. It scales for free because the load is on the client, not a server.&lt;&#x2F;p&gt;
&lt;p&gt;For development, &lt;a href=&quot;&#x2F;blog&#x2F;modern-python-tooling-uv-ruff-ty&#x2F;&quot;&gt;uv runs the end-to-end check&lt;&#x2F;a&gt; with a single command:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run scripts&#x2F;e2e_test.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It walks every exercise in headless Chromium, pastes the reference solution, clicks Run, asserts the test suite passes. Ten exercises in ~22 seconds. Anytime the upstream content changes I know in under half a minute whether all ten warm-ups still pass end-to-end. I will save the details of this Playwright end-to-end testing for another article.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;starter-code&quot;&gt;Starter code&lt;&#x2F;h2&gt;
&lt;p&gt;The site this runs on is standalone so I put together a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;4921cc7aba4d78f99b9bacbd49c8f934&quot;&gt;single-file Pyodide starter gist&lt;&#x2F;a&gt; of a mini coding platform experience: code in the browser, click &quot;Run tests&quot;, pytest runs against your code, all in the browser. Lazy boot and the Solution&#x2F;Tests tabs are wired up. The SDK stub and localStorage progress I left out for simplicity, but the core Pyodide integration is there. You can download and build on it if you want to try your hand at a browser-based Python coding experience.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it-out&quot;&gt;Try it out&lt;&#x2F;h2&gt;
&lt;p&gt;Back to the 10 exercises, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&#x2F;exercises&#x2F;&quot;&gt;you can try them out here&lt;&#x2F;a&gt;. They cover the basics that show up in the typical production Agentic AI app: a first API call, structured outputs with Pydantic, system prompts, multi-turn state, tool use, then the architectural patterns (Protocol, Repository, Service layer, HITL, the agent loop).&lt;&#x2F;p&gt;
&lt;p&gt;One bigger lesson I&#x27;m taking away from this: every time I&#x27;ve built a thing server-side over the years, I was usually paying a complexity tax for flexibility I didn&#x27;t need. Sometimes the right architecture is to push the work to the client, especially where modern browsers and Wasm can handle this performantly and securely.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>A Race Condition Rust Wouldn&#x27;t Have Let Me Write</title>
        <published>2026-05-12T00:00:00+00:00</published>
        <updated>2026-05-12T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/race-condition-rust-wouldnt-let-me-write/"/>
        <id>https://belderbos.dev/blog/race-condition-rust-wouldnt-let-me-write/</id>
        <summary type="html">&lt;p&gt;A two-agent Python service ran fine in tests. Two concurrent users hit it and one user&#x27;s search results showed up in the other user&#x27;s response. The pattern looked safe. The Rust port doesn&#x27;t compile.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;A two-agent Python service ran fine in tests. Two concurrent users hit it and one user&#x27;s search results showed up in the other user&#x27;s response. The pattern looked safe. The Rust port doesn&#x27;t compile.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-pattern-that-looked-fine&quot;&gt;The pattern that looked fine&lt;&#x2F;h2&gt;
&lt;p&gt;A former student walked me through this one. It&#x27;s another case of module-level globals biting in concurrent code.&lt;&#x2F;p&gt;
&lt;p&gt;The agent in this service had a tool-call budget per query. Five tool calls, then stop. The implementation was the kind of thing I see in a lot of Python codebases:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;_call_count&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;_sources: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;_lock&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; threading.Lock()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; reset&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;    global&lt;&#x2F;span&gt;&lt;span&gt; _call_count, _sources&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; _lock:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        _call_count&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        _sources&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _check_and_increment&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;    global&lt;&#x2F;span&gt;&lt;span&gt; _call_count&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; _lock:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; _call_count&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; MAX_CALLS&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span&gt; ToolCallLimitExceeded()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        _call_count&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; _call_count&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _add_source&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(source:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    with&lt;&#x2F;span&gt;&lt;span&gt; _lock:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        _sources.append(source)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every operation locks. Looks safe. Locally it is.&lt;&#x2F;p&gt;
&lt;p&gt;The orchestrator runs the Cypher and Mongo agents in parallel via &lt;code&gt;asyncio.gather&lt;&#x2F;code&gt;. A single user&#x27;s request is fine because the two agents touch different modules. Streamlit puts each session on its own thread, so when two users query at the same time, both threads share the same &lt;code&gt;_call_count&lt;&#x2F;code&gt;, &lt;code&gt;_sources&lt;&#x2F;code&gt;, and &lt;code&gt;_lock&lt;&#x2F;code&gt;. Because Python modules are cached in &lt;code&gt;sys.modules&lt;&#x2F;code&gt;, &lt;code&gt;_call_count&lt;&#x2F;code&gt; isn&#x27;t just a variable; it&#x27;s a piece of memory shared by every thread in that process.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-race&quot;&gt;The race&lt;&#x2F;h2&gt;
&lt;p&gt;Two users, each plans four tool calls (within their own five-call budget). Output from the repro:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[userA] DONE: made=2 expected=4 | sources: 2 own + 2 foreign&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[userA] LEAKED foreign sources: [&amp;#39;userB:q0&amp;#39;, &amp;#39;userB:q1&amp;#39;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[userB] DONE: made=3 expected=4 | sources: 3 own + 2 foreign&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[userB] LEAKED foreign sources: [&amp;#39;userA:q0&amp;#39;, &amp;#39;userA:q1&amp;#39;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two failures at once.&lt;&#x2F;p&gt;
&lt;p&gt;The shared counter hits 5 before either user finishes, so each one gets their budget eaten. And &lt;code&gt;get_sources()&lt;&#x2F;code&gt; returns whatever happens to be in the shared list, mixed across users.&lt;&#x2F;p&gt;
&lt;p&gt;A timeline makes the leak obvious:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;T+0  userA: lock, count 0-&amp;gt;1, unlock     # userA&amp;#39;s call 1 of 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;T+1  userB: lock, count 1-&amp;gt;2, unlock     # userB&amp;#39;s call 1 of 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;T+2  userA: lock, count 2-&amp;gt;3, unlock     # userA&amp;#39;s call 2 of 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;T+3  userB: lock, count 3-&amp;gt;4, unlock     # userB&amp;#39;s call 2 of 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;T+4  userA: lock, count 4-&amp;gt;5, unlock     # userA&amp;#39;s call 3 of 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;T+5  userB: lock, sees 5 &amp;gt;= MAX, raises  # userB barely started, budget gone&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;userA looks at the counter after two of its own increments and sees 4. &quot;Wait, why is my count already 4?&quot; Because userB has been incrementing the same number.&lt;&#x2F;p&gt;
&lt;p&gt;The locks were doing their job. Each individual op is atomic. They don&#x27;t give per-request isolation, because there is no per-request anything. The data is one global.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix-contextvars&quot;&gt;The fix: contextvars&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;contextvars.ContextVar&lt;&#x2F;code&gt; was built for this. Each thread, and each asyncio Task, gets its own copy. Default values give every fresh context a clean slate.&lt;&#x2F;p&gt;
&lt;p&gt;This matters more in asyncio than in threads. &lt;code&gt;threading.local&lt;&#x2F;code&gt; would catch the threaded case, but every asyncio task runs on the same thread. They all share one &lt;code&gt;threading.local&lt;&#x2F;code&gt;. Picture two tasks on one event loop: task A sets &lt;code&gt;foo = 2&lt;&#x2F;code&gt;, hits &lt;code&gt;await&lt;&#x2F;code&gt;, the loop runs task B, B reads &lt;code&gt;foo&lt;&#x2F;code&gt; and sees &lt;code&gt;2&lt;&#x2F;code&gt;. There&#x27;s no isolation, because there&#x27;s no separate thread to key off. &lt;code&gt;ContextVar&lt;&#x2F;code&gt; keys on &lt;em&gt;context&lt;&#x2F;em&gt; instead, and &lt;code&gt;asyncio.Task&lt;&#x2F;code&gt; copies the context when it&#x27;s created, so each Task gets its own slot. A&#x27;s &lt;code&gt;set()&lt;&#x2F;code&gt; is invisible to B even though they&#x27;re on the same thread.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; contextvars&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;_call_count: contextvars.ContextVar[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; contextvars.ContextVar(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;call_count&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;_sources: contextvars.ContextVar[tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;span&gt;]]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; contextvars.ContextVar(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;sources&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; reset&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _call_count.set(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _sources.set(())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _check_and_increment&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; _call_count.get()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; MAX_CALLS&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span&gt; ToolCallLimitExceeded()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _call_count.set(n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _add_source&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(source:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    _sources.set(_sources.get()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span&gt; (source,))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same demo, fixed:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[userA] DONE: made=4 expected=4 | sources: 4 own + 0 foreign&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[userB] DONE: made=4 expected=4 | sources: 4 own + 0 foreign&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One subtle point: &lt;code&gt;_sources&lt;&#x2F;code&gt; is a tuple, not a list. &lt;strong&gt;With &lt;code&gt;ContextVar(default=[])&lt;&#x2F;code&gt;, every context that hasn&#x27;t called &lt;code&gt;set()&lt;&#x2F;code&gt; shares the same default list object.&lt;&#x2F;strong&gt; A stray &lt;code&gt;cv.get().append(x)&lt;&#x2F;code&gt; would silently leak across contexts, mutating the default that every other context still points at. Tuples make that mistake non-expressible, which is close to Rust where immutable data is the default and mutable state has to be explicitly marked (&lt;code&gt;mut&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-would-rust-make-of-this&quot;&gt;What would Rust make of this?&lt;&#x2F;h2&gt;
&lt;p&gt;If you mostly write Python, the gist of Rust&#x27;s model is: by default everything is immutable, and the type system tracks who is allowed to read or write each piece of memory. That tracking is what blocks the bug shape from existing. Porting the Python pattern naively, the compiler refuses it four different ways.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;1. Module globals can&#x27;t just exist.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The Python &lt;code&gt;_call_count = 0&lt;&#x2F;code&gt; at module scope has no clean Rust equivalent. The closest thing is &lt;code&gt;static mut CALL_COUNT: u32 = 0&lt;&#x2F;code&gt; (&lt;code&gt;static&lt;&#x2F;code&gt; is the Rust word for a true module-level value, &lt;code&gt;mut&lt;&#x2F;code&gt; opts into mutability), and every read or write of it requires an &lt;code&gt;unsafe { ... }&lt;&#x2F;code&gt; block. The compiler is flagging the same risk we hit in Python (module-level mutable state is shared by every thread), but it forces you to acknowledge it in the syntax. You cannot accidentally write the buggy pattern.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;2. Two threads cannot share a &lt;code&gt;&amp;amp;mut&lt;&#x2F;code&gt; reference.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;In Python you pass an object reference into a thread and trust that locks will sort it out at runtime. Rust tracks references at compile time. The rule is &lt;em&gt;aliasing XOR mutability&lt;&#x2F;em&gt;: a value is either readable by many or writable by one, never both at once. &lt;code&gt;&amp;amp;mut T&lt;&#x2F;code&gt; is the &quot;writable by one&quot; case. While it exists, no other reference of any kind is allowed. That single rule is what blocks the Python bug; two threads writing the same counter is exactly the case it forbids. Moving the same &lt;code&gt;Tracker&lt;&#x2F;code&gt; into two &lt;code&gt;thread::spawn&lt;&#x2F;code&gt; closures doesn&#x27;t compile:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;error[E0382]: use of moved value&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The exact aliasing the Python bug relied on, two threads writing to one shared counter, is not a thing the type system will let you express.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;3. Shared global state must be wrapped in a lock.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Try a global without a lock and the compiler refuses with a different error:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;error: `Tracker` cannot be shared between threads safely&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Rust calls this the &lt;code&gt;Sync&lt;&#x2F;code&gt; trait, &quot;safe to access from multiple threads at once&quot;. &lt;code&gt;Tracker&lt;&#x2F;code&gt; doesn&#x27;t qualify because mutating its fields would race. To opt in, you wrap it: &lt;code&gt;Mutex&amp;lt;Tracker&amp;gt;&lt;&#x2F;code&gt;, similar to a &lt;code&gt;threading.Lock&lt;&#x2F;code&gt; in Python but with a critical difference. &lt;strong&gt;The lock wraps the data, not the operations.&lt;&#x2F;strong&gt; In our Python version we had a &lt;code&gt;_lock&lt;&#x2F;code&gt; and three free functions that called it; nothing prevented a fourth function from forgetting. In Rust, the only way to read the counter is to call &lt;code&gt;.lock()&lt;&#x2F;code&gt; on the mutex first, because the counter lives inside it. The bug class of &quot;I forgot to take the lock here&quot; is structurally absent.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;4. The idiomatic version doesn&#x27;t share state at all.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The cleanest Rust port doesn&#x27;t go anywhere near a global. Each thread owns its own &lt;code&gt;Tracker&lt;&#x2F;code&gt; on its own stack:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; run_agent&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; String&lt;&#x2F;span&gt;&lt;span&gt;) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-storage&quot;&gt;    let mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; tracker&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tracker&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; i&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;..&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;CALLS_PER_USER&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;        call_tool&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; tracker,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;user_id,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;format!&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;q{i}&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;));&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;There is no global to reset. The Python &lt;code&gt;reset()&lt;&#x2F;code&gt; race, where userA&#x27;s reset zeroes userB&#x27;s mid-flight counter, has no syntax in this design. This is the kind of explicitness that I described in &lt;a href=&quot;&#x2F;blog&#x2F;what-rust-structs-taught-me-about-state-ownership&#x2F;&quot;&gt;what Rust structs taught me about state ownership&lt;&#x2F;a&gt;: the compiler refuses to let you store state in places it shouldn&#x27;t live.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-rust-doesn-t-save-you-from&quot;&gt;What Rust doesn&#x27;t save you from&lt;&#x2F;h2&gt;
&lt;p&gt;Worth pinning down two terms that get conflated:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Bug class&lt;&#x2F;th&gt;&lt;th&gt;What it is&lt;&#x2F;th&gt;&lt;th&gt;Does Rust prevent it?&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Data race&lt;&#x2F;td&gt;&lt;td&gt;Two threads touching the same memory without synchronization&lt;&#x2F;td&gt;&lt;td&gt;Yes, won&#x27;t compile&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Race condition&lt;&#x2F;td&gt;&lt;td&gt;Logic that breaks because operations interleave in an unexpected order&lt;&#x2F;td&gt;&lt;td&gt;No, even with &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;T&amp;gt;&amp;gt;&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Wrap the tracker in &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;Tracker&amp;gt;&amp;gt;&lt;&#x2F;code&gt; and share it across users, and the compiler is satisfied. Two pieces of jargon there, but they map directly onto Python ideas:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Arc&amp;lt;T&amp;gt;&lt;&#x2F;code&gt; is Python&#x27;s reference counting, made explicit.&lt;&#x2F;strong&gt; When you write &lt;code&gt;data = []&lt;&#x2F;code&gt; in Python and pass it to two threads, both hold the same list. CPython tracks how many references exist and frees it when the count hits zero. That bookkeeping is automatic and invisible. Rust doesn&#x27;t do it for you by default. When you genuinely want &quot;many owners, last one out cleans up&quot; across threads, you opt in with &lt;code&gt;Arc&lt;&#x2F;code&gt; (Atomic Reference Count). Same model as Python; you just ask for it by name.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Mutex&amp;lt;T&amp;gt;&lt;&#x2F;code&gt; is &lt;code&gt;threading.Lock&lt;&#x2F;code&gt;, except the lock owns the data.&lt;&#x2F;strong&gt; In Python you write:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;lock&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; threading.Lock()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# somewhere else, hopefully:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;with&lt;&#x2F;span&gt;&lt;span&gt; lock:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    data.append(x)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two separate objects, held together by convention. Nothing stops a caller from touching &lt;code&gt;data&lt;&#x2F;code&gt; without the &lt;code&gt;with&lt;&#x2F;code&gt;. In Rust the data lives &lt;em&gt;inside&lt;&#x2F;em&gt; the mutex:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; mutex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Mutex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Vec&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;new&lt;&#x2F;span&gt;&lt;span&gt;());&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type z-storage&quot;&gt;let mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; guard&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; mutex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;lock&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;unwrap&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;guard&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;push&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(x);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The only way to reach the vec is to call &lt;code&gt;.lock()&lt;&#x2F;code&gt;, which hands back a guard that auto-releases when it goes out of scope. &quot;I forgot the &lt;code&gt;with lock:&lt;&#x2F;code&gt;&quot; doesn&#x27;t compile.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Arc&amp;lt;Mutex&amp;lt;T&amp;gt;&amp;gt;&lt;&#x2F;code&gt; is the two combined.&lt;&#x2F;strong&gt; Think of it as the Python idiom &lt;code&gt;(threading.Lock(), shared_data)&lt;&#x2F;code&gt; welded into one type, with the compiler enforcing that you never use the data without the lock.&lt;&#x2F;p&gt;
&lt;p&gt;No data race. But you have reintroduced the Python bug at a higher level, because userA&#x27;s &lt;code&gt;reset()&lt;&#x2F;code&gt; still clobbers userB&#x27;s counter under that same lock. Rust rules out memory unsafety. Per-request isolation is still your design decision.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is the same in both languages: one tracker per request. Rust rules out the data-corruption variant at compile time.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Shared mutable state is the root of all evil in concurrent systems.&quot; — Edward Kmett&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The bug here is hard to see in tests. It needs concurrent traffic to fire, and that&#x27;s the painful kind. Lesson: in Python you can guard against it, but it takes knowledge and discipline. In Rust, the compiler does more of the work: it makes illegal states unrepresentable. The more bugs you can design out of the syntax, the fewer you debug at runtime.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Build a daily AI digest in 200 lines of Python (no framework)</title>
        <published>2026-05-08T00:00:00+00:00</published>
        <updated>2026-05-08T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/python-ai-trend-digest-asyncio-protocol/"/>
        <id>https://belderbos.dev/blog/python-ai-trend-digest-asyncio-protocol/</id>
        <summary type="html">&lt;p&gt;One command and get the AI stories worth reading today, ranked by an LLM loaded from config. Two hundred lines of Python, no framework, no venv juggling thanks to PEP 723 (inline script metadata). Below are four patterns I explored.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;One command and get the AI stories worth reading today, ranked by an LLM loaded from config. Two hundred lines of Python, no framework, no venv juggling thanks to PEP 723 (inline script metadata). Below are four patterns I explored.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;we-need-curation&quot;&gt;We need curation&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s hard to keep up with AI: a lot is happening fast, and most of it is noise. I wanted a script that fans out to my sources concurrently, dedupes, and asks a model to surface what&#x27;s worth my limited time.&lt;&#x2F;p&gt;
&lt;p&gt;Plenty of tools do this already, but it was a good excuse to play with asyncio and Protocols, keeping the LLM provider swappable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-1-fan-out-with-asyncio-gather-fail-soft&quot;&gt;Pattern 1: fan out with asyncio.gather, fail soft&lt;&#x2F;h2&gt;
&lt;p&gt;IO-bound work is where &lt;code&gt;asyncio&lt;&#x2F;code&gt; works well. Here I parallelize the data gathering work with &lt;code&gt;asyncio.gather&lt;&#x2F;code&gt;. But a single raised exception would cancel the whole batch. That&#x27;s why I ended up wrapping each coroutine in a try&#x2F;except that prints the error and returns an empty list:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; safe_fetch&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(coro: Awaitable[list[Post]], label:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Post]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    try&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return await&lt;&#x2F;span&gt;&lt;span&gt; coro&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    except&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; Exception&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; e:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;        print&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;# fetch failed (&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;label&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;): &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;e&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; main&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    async with&lt;&#x2F;span&gt;&lt;span&gt; httpx.AsyncClient()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; http:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tasks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [safe_fetch(fetch_hn(http, q),&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;hn:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;q&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; q&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; HN_QUERIES&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tasks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span&gt; [safe_fetch(fetch_reddit(http, s),&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;r&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;s&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; s&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; SUBREDDITS&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        results&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; asyncio.gather(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;tasks)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The batch survives a single failure, and the error stays close to its source.&lt;&#x2F;p&gt;
&lt;p&gt;If you are on Python 3.11+ and want stricter structured concurrency, &lt;code&gt;asyncio.TaskGroup&lt;&#x2F;code&gt; is the modern default. Here I stuck with &lt;code&gt;gather&lt;&#x2F;code&gt; because the &lt;code&gt;safe_fetch&lt;&#x2F;code&gt; wrapper makes the fail-soft behavior explicit at the call site.&lt;&#x2F;p&gt;
&lt;p&gt;A small &lt;code&gt;dedupe_and_filter&lt;&#x2F;code&gt; step (sort by points, drop seen URLs) runs after &lt;code&gt;gather&lt;&#x2F;code&gt; returns. Standard Python, no library needed, but it stops the LLM from scoring the same HN-vs-Reddit story twice.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-2-swap-the-llm-with-a-protocol&quot;&gt;Pattern 2: swap the LLM with a Protocol&lt;&#x2F;h2&gt;
&lt;p&gt;I initially hardcoded the AI vendor, but it&#x27;s nicer to have people use their vendor of choice. Using a &lt;code&gt;Protocol&lt;&#x2F;code&gt; is a nice way to define an interface without needing full inheritance. Both &lt;code&gt;AnthropicRanker&lt;&#x2F;code&gt; and &lt;code&gt;OpenAIRanker&lt;&#x2F;code&gt; have the same shape, so they both satisfy the &lt;code&gt;Ranker&lt;&#x2F;code&gt; contract without needing to inherit from it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Ranker&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    model:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; rank&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, digest:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, system:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; AnthropicRanker&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, api_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, model:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.client&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; AsyncAnthropic(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;api_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;api_key)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; model&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; rank&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, digest:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, system:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        msg&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.client.messages.create(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.model,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; max_tokens&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2048&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; system&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;system,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: digest}],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        block&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; msg.content[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if not&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; isinstance&lt;&#x2F;span&gt;&lt;span&gt;(block, TextBlock):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; RuntimeError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;expected text block, got &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;block.type&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; block.text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;OpenAIRanker&lt;&#x2F;code&gt; has the same shape but has a different rank implementation. Neither inherits from &lt;code&gt;Ranker&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Structural typing means the type checker confirms the contract without inheritance ceremony. I covered the broader pattern in &lt;a href=&quot;&#x2F;blog&#x2F;ai-agent-architecture-python&#x2F;&quot;&gt;How an AI expense agent is actually structured&lt;&#x2F;a&gt;, where the same idea drives a four-layer architecture (used in our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;Agentic AI cohort&lt;&#x2F;a&gt;) that keeps the core logic of the agent separate from the LLM provider and the data source.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-3-dispatch-with-strenum-match-assert-never&quot;&gt;Pattern 3: dispatch with StrEnum + match + &lt;code&gt;assert_never&lt;&#x2F;code&gt;&lt;&#x2F;h2&gt;
&lt;p&gt;The provider comes from an environment variable. A plain string would let typos through to runtime. &lt;code&gt;StrEnum&lt;&#x2F;code&gt; makes it a typed value, and &lt;code&gt;match&lt;&#x2F;code&gt; with &lt;code&gt;assert_never&lt;&#x2F;code&gt; makes adding a third provider a type error until I handle it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Provider&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;StrEnum&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ANTHROPIC&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;anthropic&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    OPENAI&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;openai&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; make_ranker&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(provider: Provider) -&amp;gt; Ranker:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    match&lt;&#x2F;span&gt;&lt;span&gt; provider:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; Provider.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;ANTHROPIC&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; AnthropicRanker(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;ANTHROPIC_API_KEY&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;CLAUDE_MODEL&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_ANTHROPIC_MODEL&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; Provider.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;OPENAI&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; OpenAIRanker(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;OPENAI_API_KEY&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;OPENAI_MODEL&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DEFAULT_OPENAI_MODEL&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; _:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            assert_never(provider)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Add &lt;code&gt;Provider.GROQ = &quot;groq&quot;&lt;&#x2F;code&gt; tomorrow and &lt;a href=&quot;&#x2F;blog&#x2F;modern-python-tooling-uv-ruff-ty&#x2F;&quot;&gt;&lt;code&gt;ty&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; flags it before the script runs:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;error[type-assertion-failure]:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; Argument does not have asserted type `&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Never&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;  --&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; trend_digest.py:NN:13&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;NN&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;             assert_never(provider&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   |&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;             ^^^^^^^^^^^^^--------^&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   |                          |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   |&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;                          Inferred&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; type of argument is `&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Literal[Provider.GROQ]&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;info:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; `&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Never&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; and&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; `&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Literal[Provider.GROQ]&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; are&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; not equivalent types&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The checker is saying: I thought this case was unreachable (&lt;code&gt;Never&lt;&#x2F;code&gt;), but I found a way to reach it when &lt;code&gt;provider&lt;&#x2F;code&gt; is &lt;code&gt;Provider.GROQ&lt;&#x2F;code&gt;. The closest thing Python has to Rust&#x27;s exhaustive &lt;code&gt;match&lt;&#x2F;code&gt;, which is &lt;a href=&quot;&#x2F;blog&#x2F;rust-made-me-a-better-python-developer&#x2F;&quot;&gt;what I really like about Rust&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Two layers of safety in one line: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;docs.python.org&#x2F;3&#x2F;library&#x2F;typing.html#typing.assert_never&quot;&gt;&lt;code&gt;assert_never&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; gives a static error at check time, and at runtime it raises &lt;code&gt;AssertionError&lt;&#x2F;code&gt; if anything ever bypasses the enum.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-4-a-new-way-to-share-scripts-pep-723-inline-script-metadata&quot;&gt;Pattern 4: a new way to share scripts: PEP 723 inline script metadata&lt;&#x2F;h2&gt;
&lt;p&gt;The whole script (&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;af7097f98d5d0e4baee003367b472b56&quot;&gt;gist here&lt;&#x2F;a&gt;) ships with a &lt;a href=&quot;&#x2F;blog&#x2F;modern-python-tooling-uv-ruff-ty&#x2F;&quot;&gt;PEP 723 inline script header&lt;&#x2F;a&gt;, and the rank prompt lives as a string constant in the same file. So &lt;code&gt;uv run trend_digest.py&lt;&#x2F;code&gt; resolves dependencies in its own environment, and I can keep the whole thing in one file. I can even run the gist URL directly:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; uv run https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;af7097f98d5d0e4baee003367b472b56&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;However now I get &lt;code&gt;decouple.UndefinedValueError: REDDIT_USER_AGENT not found&lt;&#x2F;code&gt;, because the script does require some environment variables to be set.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;config(...)&lt;&#x2F;code&gt; calls come from &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pypi.org&#x2F;project&#x2F;python-decouple&#x2F;&quot;&gt;python-decouple&lt;&#x2F;a&gt;. It reads environment variables and falls back to a &lt;code&gt;.env&lt;&#x2F;code&gt; file (searched upward from the script&#x27;s directory), raising &lt;code&gt;UndefinedValueError&lt;&#x2F;code&gt; for anything missing without a &lt;code&gt;default=&lt;&#x2F;code&gt;. There is a small catch when running the gist URL directly: &lt;code&gt;uv&lt;&#x2F;code&gt; downloads the script to a temp location, so a &lt;code&gt;.env&lt;&#x2F;code&gt; in your current directory is not picked up. Two ways around it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Option A: export them in your shell first&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;export&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; ANTHROPIC_API_KEY&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;sk-ant-...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;export&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; REDDIT_USER_AGENT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;trend-digest&#x2F;1.0 by yourname&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage&quot;&gt;export&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; PROVIDER&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;anthropic&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;af7097f98d5d0e4baee003367b472b56&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Option B: let uv load the file into the subprocess env&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --env-file&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; .env https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;af7097f98d5d0e4baee003367b472b56&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The rank prompt is a string constant at the top of the file (not an external file, to keep it all in one script), so tweak it to your taste: bias toward certain topics, change the output format, or ask for a one-line &quot;why this matters&quot; per story.&lt;&#x2F;p&gt;
&lt;p&gt;Set those, and one command from anywhere fetches and ranks today&#x27;s AI news. Four patterns, one file, no framework.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Event Sourcing in Python: Get More Insights Into Your Data</title>
        <published>2026-05-05T00:00:00+00:00</published>
        <updated>2026-05-05T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/event-sourcing-python-store-events-not-state/"/>
        <id>https://belderbos.dev/blog/event-sourcing-python-store-events-not-state/</id>
        <summary type="html">&lt;p&gt;Chris May knew about event sourcing for over a decade before he used it for real. Once he tried it in production, he was sold. After hearing him &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=8aQ7j8mz5Ug&quot;&gt;describe the pattern&lt;&#x2F;a&gt;, I can see why, so in this article I&#x27;ll walk you through what event sourcing is and why it changes what questions your data can answer.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Chris May knew about event sourcing for over a decade before he used it for real. Once he tried it in production, he was sold. After hearing him &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=8aQ7j8mz5Ug&quot;&gt;describe the pattern&lt;&#x2F;a&gt;, I can see why, so in this article I&#x27;ll walk you through what event sourcing is and why it changes what questions your data can answer.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=8aQ7j8mz5Ug&quot;&gt;Watch on YouTube&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem-crud-loses-data&quot;&gt;The Problem: CRUD loses data&lt;&#x2F;h2&gt;
&lt;p&gt;To make this tangible imagine two users completing an exercise on the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&quot;&gt;Rust platform&lt;&#x2F;a&gt;. With a CRUD (Create, Read, Update, Delete) approach, you would have a table that records the final state of each user&#x27;s progress:&lt;&#x2F;p&gt;
&lt;p&gt;User starts → submits once → passes.&lt;&#x2F;p&gt;
&lt;p&gt;However, this only records the end state. What actually happens in the exercise lifecycle for a user is much richer:&lt;&#x2F;p&gt;
&lt;p&gt;User starts → submits (fails) → submits (fails) → submits (fails) → requests hint → submits (passes).&lt;&#x2F;p&gt;
&lt;p&gt;In a CRUD app, both rows look identical:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;user&lt;&#x2F;th&gt;&lt;th&gt;exercise&lt;&#x2F;th&gt;&lt;th&gt;completed&lt;&#x2F;th&gt;&lt;th&gt;score&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;user-a&lt;&#x2F;td&gt;&lt;td&gt;ownership&lt;&#x2F;td&gt;&lt;td&gt;true&lt;&#x2F;td&gt;&lt;td&gt;10&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;user-b&lt;&#x2F;td&gt;&lt;td&gt;ownership&lt;&#x2F;td&gt;&lt;td&gt;true&lt;&#x2F;td&gt;&lt;td&gt;10&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;What you lost:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;How many attempts did each user need?&lt;&#x2F;li&gt;
&lt;li&gt;Which exercises cause the most failures?&lt;&#x2F;li&gt;
&lt;li&gt;Do users who request hints complete faster or slower?&lt;&#x2F;li&gt;
&lt;li&gt;What is the conversion path from free to paying?&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;There might even be questions you do not know you have yet. With just CRUD you overwrite data, which you need to make up for with ETL pipelines, bolted on analytics, and guesswork. With event sourcing, you have all the data you need to answer these questions immediately. It just requires a bit more planning upfront, designing the right events and commands.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# What CRUD sees: identical end states&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;state_a.total_score&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; state_b.total_score&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 10&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # True, indistinguishable&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# What event sourcing sees:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;state_a.attempts[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;ownership&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # sailed through&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;state_b.attempts[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;ownership&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 4&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;   # struggled&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;state_b.hints_used[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;ownership&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; # needed help&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;em&gt;(This is from the test suite of a demo app we&#x27;ll look at later; the full comparison test is &lt;code&gt;test_crud_loses_data_event_sourcing_keeps_it&lt;&#x2F;code&gt;.)&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-event-sourcing-actually-is&quot;&gt;What Event Sourcing Actually Is&lt;&#x2F;h2&gt;
&lt;p&gt;Essentially with event sourcing you have two types of tables: the event table and the projection tables. The event table is your source of truth: an append-only log of everything that has happened. The projection tables are optimized read models, built by listeners that fire when events arrive.&lt;&#x2F;p&gt;
&lt;p&gt;Appends are cheap (like Python &lt;code&gt;list.append&lt;&#x2F;code&gt;), while updates and deletes are not; shifting to append-only means you pay less and keep more. You derive the current state by &lt;em&gt;replaying&lt;&#x2F;em&gt; the events. This also takes advantage of storage being much cheaper than compute.&lt;&#x2F;p&gt;
&lt;p&gt;I also like the immutability aspect: events are facts, not mutable state, similar to having to explicitly say &lt;code&gt;mut&lt;&#x2F;code&gt; for mutable variables in Rust.&lt;&#x2F;p&gt;
&lt;p&gt;There are no updates and no deletes. A &quot;delete&quot; is just an event: ExerciseCancelled, CartClosed. State is derived by replaying events, never stored directly.&lt;&#x2F;p&gt;
&lt;p&gt;This has its origins in DDD (Domain-Driven Design); Greg Young coined the term &quot;event sourcing&quot;, though the concept is much older: accounting ledgers, shipping logs, double-entry bookkeeping. Software just needed a name for it.&lt;&#x2F;p&gt;
&lt;p&gt;Chris described it well in our conversation:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;You just keep writing events to a table. The complexity shifts from mutations to queries, which is a better problem to have.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Here is what the events look like in our demo app, a coding platform where users complete exercises. Each event captures a specific action that happened, along with relevant data and a timestamp.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;frozen&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ExerciseStarted&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    stream_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    exercise:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    timestamp: datetime&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default_factory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;_now)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;frozen&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; TestsFailed&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    stream_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    exercise:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    output:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;          # the exact compiler error: preserved forever&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    timestamp: datetime&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default_factory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;_now)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;frozen&lt;&#x2F;code&gt; means immutable; once created, you cannot change the event.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;rebuilding-state-from-events&quot;&gt;Rebuilding state from events&lt;&#x2F;h2&gt;
&lt;p&gt;As mentioned, state is not stored, it&#x27;s computed by replaying the event stream. This is a fundamental shift from CRUD, where you store the current state directly. You get an audit trail for free and can replay to any point in time, powerful for debugging and for questions you haven&#x27;t thought to ask yet.&lt;&#x2F;p&gt;
&lt;p&gt;Here is &lt;code&gt;rebuild_state&lt;&#x2F;code&gt;, which takes the event stream and returns the current state:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; rebuild_state&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(events: list[Event]) -&amp;gt; ExerciseState:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ExerciseState()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span&gt; event&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; events:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        match&lt;&#x2F;span&gt;&lt;span&gt; event:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            case&lt;&#x2F;span&gt;&lt;span&gt; ExerciseStarted(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ex):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                state.exercises_in_progress.add(ex)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            case&lt;&#x2F;span&gt;&lt;span&gt; CodeSubmitted(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ex):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                state.attempts[ex]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; state.attempts.get(ex,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            case&lt;&#x2F;span&gt;&lt;span&gt; HintRequested(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ex):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                state.hints_used[ex]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; state.hints_used.get(ex,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            case&lt;&#x2F;span&gt;&lt;span&gt; ExerciseCompleted(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ex,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; points&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;pts):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                state.exercises_in_progress.discard(ex)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                state.exercises_completed[ex]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; pts&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                state.total_score&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span&gt; pts&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; state&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And here we see another paradigm I really like: &lt;em&gt;functional programming&lt;&#x2F;em&gt;. The &lt;code&gt;rebuild_state&lt;&#x2F;code&gt; function is a pure function; same input, same output, no side effects. This makes it easy to test and debug. Replay the same events, get the same state every time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;commands-validate-before-you-produce-events&quot;&gt;Commands: validate before you produce events&lt;&#x2F;h2&gt;
&lt;p&gt;Events are one half. Commands are the other: requests to change state, validated against the current state before producing events. This is where your business rules live, similar to how I covered &lt;a href=&quot;&#x2F;blog&#x2F;build-finite-state-machine-python&#x2F;&quot;&gt;FSM transition validation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Chris&#x27;s example: &quot;no more than one item added per minute&quot;; with event sourcing, you load the stream, check the timestamps, and reject or accept based on that history. With CRUD, you would need a separate audit table. When your boss asks for 30-day performance metrics, the structured history is already there for AI tools to query.&lt;&#x2F;p&gt;
&lt;p&gt;Here is how &lt;code&gt;execute_command&lt;&#x2F;code&gt; validates a start request:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; execute_command&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(store: EventStore,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, stream_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, command:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; **&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;kwargs):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    events&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; store.load_stream(stream_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; rebuild_state(events)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;      # current reality&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    match&lt;&#x2F;span&gt;&lt;span&gt; command:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;start_exercise&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; kwargs[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;exercise&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            if&lt;&#x2F;span&gt;&lt;span&gt; exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; state.exercises_in_progress:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                raise&lt;&#x2F;span&gt;&lt;span&gt; CommandError(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; already in progress&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            if&lt;&#x2F;span&gt;&lt;span&gt; exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; state.exercises_completed:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                raise&lt;&#x2F;span&gt;&lt;span&gt; CommandError(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; already completed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            event&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ExerciseStarted(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;stream_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;stream_id,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; exercise&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;exercise)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;        ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    store.append(event)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; event&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Business rule enforcement is just: load stream → rebuild state → check conditions → append or reject.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;cqrs-separating-reads-from-writes&quot;&gt;CQRS: Separating Reads from Writes&lt;&#x2F;h2&gt;
&lt;p&gt;Next up is CQRS, which stands for Command Query Responsibility Segregation, a different pattern from event sourcing, but they pair naturally.&lt;&#x2F;p&gt;
&lt;p&gt;Chris told a story that made this click for me:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Every now and then we would have to create a report for important people in the organization. Whenever they clicked the button — &#x27;okay, I want the report now&#x27; — it would take so many resources to build that report in memory that it slowed down the rest of the application. Users could tell when they did it because all of a sudden the application just slowed down. We didn&#x27;t use event sourcing at the time, but what we figured out we could do is just update the report on the server in place. And so when the executive said &#x27;okay, I want that report,&#x27; it was now a static file download instead of building the whole thing up.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That is the projection pattern in a nutshell: the expensive work happens on write, so the read is instant.&lt;&#x2F;p&gt;
&lt;p&gt;In our coding platform example we can build two projections from the same event stream: a leaderboard that shows the top scores, and a progress view that shows which exercises a user has completed and which are in progress. Both projections listen to the same events, but they build different read models optimized for their specific queries.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; build_leaderboard&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(store: EventStore) -&amp;gt; list[tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;]]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    scores&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Counter()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span&gt; event&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; store.all_events():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; isinstance&lt;&#x2F;span&gt;&lt;span&gt;(event, ExerciseCompleted):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            scores[event.stream_id]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span&gt; event.points&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; scores.most_common()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; build_progress&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(store: EventStore, stream_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; UserProgress:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    events&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; store.load_stream(stream_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; rebuild_state(events)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; UserProgress(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        completed&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;set&lt;&#x2F;span&gt;&lt;span&gt;(state.exercises_completed),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        in_progress&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;state.exercises_in_progress,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Vertical slice architecture pairs naturally with event sourcing. Every bit of code related to one feature lives in one folder. Independent. No cross-cutting dependencies, no &quot;which file handles this again?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Chris had a dashboard showing 200 items. A user asked for filtering: query by this, sort by that. His first instinct: &quot;This is really going to complicate the code.&quot; Then he realized he could just create a new slice. Duplicate the folder, add the filtering logic, run it alongside the original. Iterate with the user until it was right, then swap it in. The old version kept running the whole time.&lt;&#x2F;p&gt;
&lt;p&gt;Rust&#x27;s strong safety guarantees come to mind here. With Rust, you have to be explicit about mutability and ownership. This forces you to think about data flow and side effects in a way that is similar to event sourcing. You are always aware of when you are changing state and when you are not. (&lt;a href=&quot;&#x2F;blog&#x2F;rust-made-me-a-better-python-developer&#x2F;&quot;&gt;Rust has that effect on how you think about code.&lt;&#x2F;a&gt;)&lt;&#x2F;p&gt;
&lt;p&gt;There is an AI angle here too. When Chris asks Claude Code to change something in the event sourced code, it reads one folder and produces good results. When it touches the CRUD code, it has to scan the whole codebase to figure out what is involved. This is slower, more token greedy, and more error prone. The vertical slice architecture with event sourcing is much more AI-friendly because it has clear boundaries and less coupling.&lt;&#x2F;p&gt;
&lt;p&gt;Gradual adoption works the same way. You do not convert your whole app. Pick one component, define events for it, start a new append table alongside your existing CRUD tables, and try it there. Chris was hesitant for a long time, partly because he could not see how event sourcing could coexist with regular CRUD code. Now he has seen that the two different approaches can actually co-exist.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-practical-demo&quot;&gt;A practical demo&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;The only way to learn a new programming language is by writing programs in it&quot; (Dennis Ritchie). Same goes for understanding a new design pattern.&lt;&#x2F;p&gt;
&lt;p&gt;The demo below walks you through building an event sourcing system from scratch, test by test, using the coding platform domain example from this article. You will start with a blank slate and end up with a fully functional event sourcing implementation, complete with commands, projections, and a comparison to CRUD.&lt;&#x2F;p&gt;
&lt;p&gt;The demo has six levels, each unlocked by making a group of tests pass:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Level&lt;&#x2F;th&gt;&lt;th&gt;What you build&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1. Events&lt;&#x2F;td&gt;&lt;td&gt;Frozen dataclasses (immutable facts)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;2. Event Store&lt;&#x2F;td&gt;&lt;td&gt;Append-only, load by stream&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3. State Reconstruction&lt;&#x2F;td&gt;&lt;td&gt;Replay events → current state&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;4. Commands&lt;&#x2F;td&gt;&lt;td&gt;Validate business rules before appending&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;5. Projections (CQRS)&lt;&#x2F;td&gt;&lt;td&gt;Leaderboard, progress views&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;6. Integration&lt;&#x2F;td&gt;&lt;td&gt;Full journey + the CRUD comparison&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;git&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; clone git@github.com:bbelderbos&#x2F;event_sourcing_demo.git&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;cd&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; event_sourcing_demo&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; sync&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;cp&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; event_sourcing_template.py event_sourcing.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run pytest test_event_sourcing.py&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -v&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# work through the levels, making the tests pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Work through &lt;code&gt;event_sourcing_template.py&lt;&#x2F;code&gt; top to bottom. Each step has a hint. The tests tell you exactly when you are done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-to-go-from-here&quot;&gt;Where to go from here&lt;&#x2F;h2&gt;
&lt;p&gt;Start with my conversation with Chris that inspired this article: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=8aQ7j8mz5Ug&quot;&gt;Chris May on Event Sourcing, Simpler Frontends &amp;amp; AI Workflows&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Two more resources Chris recommends:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Package:&lt;&#x2F;strong&gt; &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;pyeventsourcing&#x2F;eventsourcing&quot;&gt;&lt;code&gt;eventsourcing&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; by John Bywater: over a decade of active maintenance. Uses base classes and decorators to hide the ceremony. The &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;eventsourcing.readthedocs.io&#x2F;en&#x2F;stable&#x2F;topics&#x2F;introduction.html&quot;&gt;Event sourcing in 15 minutes&lt;&#x2F;a&gt; video in the docs, starts from scratch with TDD, installing the package, etc.; very approachable.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Book:&lt;&#x2F;strong&gt; &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;leanpub.com&#x2F;understanding-eventsourcing&quot;&gt;&lt;em&gt;Understanding Event Sourcing&lt;&#x2F;em&gt;&lt;&#x2F;a&gt; by Martin Dilger. Written in Java but with minimal code, easy to follow, but profound.&lt;&#x2F;p&gt;
&lt;p&gt;Some patterns are old and do not expire. They just wait for new use cases. I hope you find a use case for this pattern soon, and that Chris and I have helped you get started.&lt;&#x2F;p&gt;
&lt;p&gt;Actually, as I was publishing this article, Chris pushed up the first version of his intro event sourcing ebook, check it out &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;everydaysuperpowers.dev&#x2F;esintro&quot;&gt;here&lt;&#x2F;a&gt;, as well as &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=s37d6yN2P70&quot;&gt;his latest Talk Python interview&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Build the data layer before you touch the LLM</title>
        <published>2026-04-29T00:00:00+00:00</published>
        <updated>2026-04-29T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/build-data-layer-before-llm/"/>
        <id>https://belderbos.dev/blog/build-data-layer-before-llm/</id>
        <summary type="html">&lt;p&gt;Every AI tutorial I&#x27;ve seen opens the same way: &lt;code&gt;client.chat.completions.create(...)&lt;&#x2F;code&gt;. Within ten lines you have a response. Within twenty you feel like you&#x27;re building something.&lt;&#x2F;p&gt;
&lt;p&gt;What you&#x27;re actually building is a demo.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Every AI tutorial I&#x27;ve seen opens the same way: &lt;code&gt;client.chat.completions.create(...)&lt;&#x2F;code&gt;. Within ten lines you have a response. Within twenty you feel like you&#x27;re building something.&lt;&#x2F;p&gt;
&lt;p&gt;What you&#x27;re actually building is a demo.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-instinct-to-reach-for-the-llm-first&quot;&gt;The instinct to reach for the LLM first&lt;&#x2F;h2&gt;
&lt;p&gt;It makes sense. The LLM is the interesting part. You want to see it work. So you call the API, print the result, and layer on functionality from there.&lt;&#x2F;p&gt;
&lt;p&gt;The problem surfaces when you try to do something real with that result, for example save it to a database, validate it against business rules, test it without paying per call. You have a string from an API and no structure underneath it.&lt;&#x2F;p&gt;
&lt;p&gt;In the first week of our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;Agentic AI cohort&lt;&#x2F;a&gt;, participants don&#x27;t touch the LLM at all. Instead, they build the data layer: models, enums, repositories, and 29 tests that all pass before a single API call is made. This article shows why that order matters.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;strenum-for-currencies-compile-time-safety-over-runtime-surprises&quot;&gt;StrEnum for currencies: compile-time safety over runtime surprises&lt;&#x2F;h2&gt;
&lt;p&gt;The app tracks expenses. Expenses have currencies. The obvious approach is a string field. The problem with strings is that &lt;code&gt;&quot;USD&quot;&lt;&#x2F;code&gt;, &lt;code&gt;&quot;usd&quot;&lt;&#x2F;code&gt;, &lt;code&gt;&quot;Us Dollars&quot;&lt;&#x2F;code&gt;, and a typo like &lt;code&gt;&quot;USd&quot;&lt;&#x2F;code&gt; are all valid Python; and all wrong in different ways.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;StrEnum&lt;&#x2F;code&gt; fixes this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; enum&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; UNIQUE&lt;&#x2F;span&gt;&lt;span&gt;, StrEnum, verify&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@verify&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;UNIQUE&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Currency&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;StrEnum&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    USD&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;USD&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    EUR&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;EUR&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    GBP&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;GBP&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    JPY&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;JPY&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # ... 6 more&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;docs.python.org&#x2F;3&#x2F;library&#x2F;enum.html#enum.EnumCheck&quot;&gt;&lt;code&gt;@verify(UNIQUE)&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; decorator raises at class definition time if any two members share a value. For currency codes that&#x27;s not a real risk, but it documents intent: these values should be distinct. The decorator turns a convention into a constraint.&lt;&#x2F;p&gt;
&lt;p&gt;Because &lt;code&gt;StrEnum&lt;&#x2F;code&gt; members are also strings, they work everywhere strings work (database columns, JSON serialization, comparisons) without any conversion step.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sqlmodel-one-class-two-roles&quot;&gt;SQLModel: one class, two roles&lt;&#x2F;h2&gt;
&lt;p&gt;SQLAlchemy handles persistence. Pydantic handles validation. Traditionally you write two separate classes and keep them in sync. &lt;code&gt;SQLModel&lt;&#x2F;code&gt; collapses them into one:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decimal&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; datetime&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; datetime, timezone&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; sqlmodel&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Field, SQLModel&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Expense&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;SQLModel&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; table&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;    id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; primary_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount: Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    currency: Currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;EUR&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    description:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    date: datetime&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;datetime.now(timezone.utc))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Field(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; foreign_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expensecategory.id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category: ExpenseCategory&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Relationship(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;back_populates&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;expenses&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;table=True&lt;&#x2F;code&gt; tells SQLModel to also create a database table from this class. The type annotations become both Pydantic validators and SQLAlchemy column definitions. Add an expense with wrong types and Pydantic raises before the database ever sees it.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;Decimal&lt;&#x2F;code&gt; type for &lt;code&gt;amount&lt;&#x2F;code&gt; matters too. Floating point arithmetic is not appropriate for money. &lt;code&gt;Decimal(&quot;10.50&quot;) + Decimal(&quot;0.10&quot;)&lt;&#x2F;code&gt; gives &lt;code&gt;Decimal(&quot;10.60&quot;)&lt;&#x2F;code&gt;. &lt;code&gt;10.50 + 0.10&lt;&#x2F;code&gt; gives &lt;code&gt;10.600000000000001&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-repository-pattern-swap-implementations-without-changing-the-app&quot;&gt;The repository pattern: swap implementations without changing the app&lt;&#x2F;h2&gt;
&lt;p&gt;The real payoff of a proper data layer is the &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&quot;&gt;repository pattern&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; abc&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ABC&lt;&#x2F;span&gt;&lt;span&gt;, abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Sequence&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ExpenseRepository&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;ABC&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; add&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, expense: Expense) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, expense_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; Expense&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; list&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self) -&amp;gt; Sequence[Expense]:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; delete&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, expense_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # ... more methods&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The abstract base class defines what a repository can do. Concrete implementations decide how. The app gets two: &lt;code&gt;InMemoryExpenseRepository&lt;&#x2F;code&gt; (a dict) and &lt;code&gt;DBExpenseRepo&lt;&#x2F;code&gt; (SQLite via SQLModel).&lt;&#x2F;p&gt;
&lt;p&gt;Tests use the in-memory version: fast, no I&#x2F;O, no cleanup. The production app uses the database version. The classification service, the CLI, the Telegram bot, they all accept an &lt;code&gt;ExpenseRepository&lt;&#x2F;code&gt; and don&#x27;t care which one they get.&lt;&#x2F;p&gt;
&lt;p&gt;When you add a new interface (an API endpoint, a dashboard), you wire it to the same repository interface you already have. No duplication. No hidden coupling.&lt;&#x2F;p&gt;
&lt;p&gt;The abstract class also enforces completeness:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; IncompleteRepo&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;ExpenseRepository&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; add&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, expense: Expense) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # forgot the other methods&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;repo&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; IncompleteRepo()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# TypeError: Can&amp;#39;t instantiate abstract class IncompleteRepo&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# without an implementation for abstract methods &amp;#39;delete&amp;#39;, &amp;#39;get&amp;#39;, &amp;#39;list&amp;#39;, ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You find out at instantiation, not at runtime when a user tries to list their expenses.&lt;&#x2F;p&gt;
&lt;p&gt;Aggregator patterns exist for combining multiple data sources behind a single repository interface. But rolling your own is the point here: once you understand the pattern, the library that wraps it stops being magic.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tdd-write-the-test-first&quot;&gt;TDD: write the test first&lt;&#x2F;h2&gt;
&lt;p&gt;The discipline that makes all of this work is test-driven development. Not &quot;write tests after.&quot; Write the test, watch it fail, then write the code that makes it pass.&lt;&#x2F;p&gt;
&lt;p&gt;A simple model test:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_create_expense&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(db_session):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ExpenseCategory(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;name&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Transportation&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    expense&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Expense(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;100&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;USD&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Train tickets&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        date&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;datetime(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2025&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 8&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 31&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 20&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;category,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    db_session.add(category)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    db_session.add(expense)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    db_session.commit()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; expense.id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; expense.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; category&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; expense.currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;USD&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;db_session&lt;&#x2F;code&gt; fixture creates a fresh in-memory SQLite database for each test and tears it down after. Tests are isolated and fast; the full model + repository suite runs in under a second.&lt;&#x2F;p&gt;
&lt;p&gt;In practice, the first friction is tooling, not the pattern: one developer ran into the src-layout gotcha directly: &lt;code&gt;pythonpath = [&quot;src&quot;]&lt;&#x2F;code&gt; in pyproject.toml before pytest can even find your code. Pre-commit hooks need wiring, and six dev tools have to coexist before you submit your first PR. Another common discovery is &lt;code&gt;conftest.py&lt;&#x2F;code&gt;: that fixtures can be centralized rather than redefined per test class. One developer skipped it entirely (no shared fixtures, each test class self-contained) and still hit 97% coverage. The discipline of testing at every step, for teaching and code quality both, is non-negotiable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-foundation-buys-you&quot;&gt;What this foundation buys you&lt;&#x2F;h2&gt;
&lt;p&gt;When the LLM returns a classification next week, there&#x27;s a typed &lt;code&gt;ExpenseCategory&lt;&#x2F;code&gt; to validate it against, a &lt;code&gt;Decimal&lt;&#x2F;code&gt; field to store the amount, and a repository to persist the result. The LLM output is just one more input to a data layer that already knows how to handle it.&lt;&#x2F;p&gt;
&lt;p&gt;Without the foundation, you&#x27;re forever parsing strings from the API and hoping they match whatever your database expects. With it, the LLM integration is a small piece of a well-typed pipeline.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the switch in mindset this series is built on: the LLM is one component, not the app. The app is the data model, the service layer, the interfaces. Build those first. &lt;a href=&quot;&#x2F;blog&#x2F;ai-agent-architecture-python&quot;&gt;See how the full agent is structured once the data layer is in place.&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Vibe Coding is Easy, Owning the Architecture Isn&#x27;t</title>
        <published>2026-04-28T00:00:00+00:00</published>
        <updated>2026-04-28T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/vibe-coding-owning-architecture/"/>
        <id>https://belderbos.dev/blog/vibe-coding-owning-architecture/</id>
        <summary type="html">&lt;p&gt;These days you can prompt Claude to generate a working feature in twenty minutes. File uploads, database queries, API calls; the code appears, it runs, it looks right. But if you&#x27;ve never owned a mature architecture, AI-generated code becomes a liability you don&#x27;t know how to manage.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;These days you can prompt Claude to generate a working feature in twenty minutes. File uploads, database queries, API calls; the code appears, it runs, it looks right. But if you&#x27;ve never owned a mature architecture, AI-generated code becomes a liability you don&#x27;t know how to manage.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;You don&#x27;t know how the pieces fit together, what breaks first under load, or how to go from &lt;em&gt;works locally&lt;&#x2F;em&gt; to &lt;em&gt;deployed securely and ready to scale&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;A developer recently put it well:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I barely write code from scratch anymore. AI can handle a big part of that, incredibly fast. But interestingly, the job hasn’t become easier.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;A few years ago the trap was tutorial paralysis. With LLMs, it just looks different: the code runs, but you didn&#x27;t decide how it&#x27;s structured.&lt;&#x2F;p&gt;
&lt;p&gt;The tools have changed, but the problem remains: when we hand over decision-making, whether to tutorials or to LLMs, we keep getting stuck.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;before-tutorial-hell&quot;&gt;Before: tutorial hell&lt;&#x2F;h2&gt;
&lt;p&gt;Tutorials are good at one thing: showing you that code works. They walk you through syntax in a logical order and give you working code to follow. Automate the Boring Stuff, CS50, a Udemy course, all solid resources. They do what they promise, but it&#x27;s often biased towards the happy path.&lt;&#x2F;p&gt;
&lt;p&gt;What they don&#x27;t teach:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;How to break an app idea into features small enough to actually build.&lt;&#x2F;li&gt;
&lt;li&gt;Which feature to build first and why.&lt;&#x2F;li&gt;
&lt;li&gt;How to structure a project so it doesn&#x27;t collapse past a thousand lines.&lt;&#x2F;li&gt;
&lt;li&gt;How to use Git and GitHub to save your work and work effectively with other developers.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These aren&#x27;t advanced topics per se. In practice though, they have so much more nuance and ambiguity.&lt;&#x2F;p&gt;
&lt;p&gt;The result: someone finishes a Django tutorial, sits down to build something real, and spends two hours stuck on a decision the tutorial never prepared them for.&lt;&#x2F;p&gt;
&lt;p&gt;As Mike Tyson once said:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Everybody has a plan until they get punched in the face.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That&#x27;s the blank page syndrome. Every decision has already been made for you in a tutorial. When you open your own project, you feel lost.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;now-vibe-coding-hell&quot;&gt;Now: vibe coding hell&lt;&#x2F;h2&gt;
&lt;p&gt;LLMs have made this more pronounced. You can prompt your way to working code faster. But to go from prototype to something real and high quality, people still hit a wall, just differently. Instead of &quot;I cannot replicate the syntax after consuming tutorials,&quot; now it is:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;I can vibe code but I&#x27;ve never owned an architecture end to end; if it breaks in production, I&#x27;m guessing&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&quot;I know there are couplings between parts of my system but I don&#x27;t fully understand the infrastructure beneath them&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&quot;I&#x27;ve set up logs, tests, and audits, but I don&#x27;t feel confident interpreting what they&#x27;re telling me&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&quot;I can&#x27;t keep one part of the system consistent when another part changes&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&quot;Deploying my app to the cloud is still a black box to me, even with AI&quot;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The code runs. But you didn&#x27;t decide how it&#x27;s structured, and you can&#x27;t foresee what breaks when it grows. You&#x27;re executing without deeper understanding.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-gap-is-the-same&quot;&gt;The gap is the same&lt;&#x2F;h2&gt;
&lt;p&gt;Both traps share one root cause: someone else made the decisions.&lt;&#x2F;p&gt;
&lt;p&gt;Tutorials gave you syntax and a happy path to follow. Vibe coding with AI gives you code and a design to accept. Neither forced you to decide what to build first, how the data should flow, or what your architecture will cost you in six months.&lt;&#x2F;p&gt;
&lt;p&gt;Being deliberate is hard. AI is designed to feel fast, intuitive, and effortless. That pull is System 1 thinking by design. But real architectural ownership requires System 2 thinking: the slow, deliberate, and often frustrating &quot;productive struggle&quot; of weighing trade-offs.&lt;&#x2F;p&gt;
&lt;p&gt;When the AI removes that struggle, the knowledge doesn&#x27;t stick. I see this all the time in coaching:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;The hands-on approach made all the difference. Building something real made Rust click in a way that tutorials never could — and honestly, it&#x27;s made me a better Python developer too.&quot; - Vikas Z&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;It&#x27;s the decision-making muscle we need to keep training to really own the code we write. To better understand System 1 vs 2 thinking, read &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Thinking,_Fast_and_Slow&quot;&gt;Thinking Fast and Slow&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-solution-isn-t-more-prompts&quot;&gt;The solution isn&#x27;t more prompts&lt;&#x2F;h2&gt;
&lt;p&gt;More prompts will make you better at prompting. If the goal is to own what you build, that&#x27;s not the skill you need. You need to learn how to make decisions about your project, how to structure it, and how to maintain it.&lt;&#x2F;p&gt;
&lt;p&gt;Working on your real project with someone who&#x27;s done it before gives you that. Not abstract lessons, but your actual code, your actual decisions. Feedback on why something will break, how to structure it better, or what to build first and why.&lt;&#x2F;p&gt;
&lt;p&gt;Vibe coding is fast at output. But it can&#x27;t tell you which problem to solve first, why your architecture will hurt in six months, or that you&#x27;re modeling the whole thing wrong. It also can&#x27;t &lt;a href=&quot;&#x2F;blog&#x2F;design-over-code&#x2F;&quot;&gt;ask you &lt;em&gt;why&lt;&#x2F;em&gt; you made that decision&lt;&#x2F;a&gt;, and that question is often where the real learning happens.&lt;&#x2F;p&gt;
&lt;p&gt;Vibe coding doesn&#x27;t have to be a trap; it can be a power tool. But that only happens when you use the AI to augment your decisions rather than replace them. A power user prompts for a feature, but then pauses to ask: &#x27;Why did you choose this pattern?&#x27; or &#x27;How does this change our database load?&#x27; They use the generation speed to test architectural hypotheses and apply critical thinking.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;from-prompts-to-software-architecture&quot;&gt;From prompts to software architecture&lt;&#x2F;h2&gt;
&lt;p&gt;Start with a problem worth solving. I usually scratch my own itch or look at what people are complaining about in forums. If the problem belongs to someone else and it&#x27;s real enough, they might pay for it.&lt;&#x2F;p&gt;
&lt;p&gt;Don&#x27;t get bogged down in documentation or jump straight into prompts. Start with the design: How does data flow? What are the main features? Plan before you prompt.&lt;&#x2F;p&gt;
&lt;p&gt;If you need help, reach out to me. In one or two sessions you stop prompting and start thinking in architecture, and how to own your project end to end. I give you deep feedback on your code and design, not lessons in the abstract. You learn to make those decisions yourself, and feel confident in them, instead of relying on the model to tell you what to do next.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s 1:1 coaching. &lt;a href=&quot;&#x2F;blog&#x2F;ai-coding-tools-fundamentals-case-study&#x2F;&quot;&gt;Here&#x27;s what it did for Ryan&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;My Python coaching, however, gave me the ability to &#x27;see under the hood.&#x27; Because I understand the core principles of Python and Django, I can effectively direct the AI. Without coaching, you&#x27;re just a passenger; with it, you&#x27;re the navigator who actually understands the map.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Ryan went from building alone to directing AI agents with confidence for a business that now supports his life in the Bahamas. If you&#x27;re ready to stop being a passenger in your own codebase and start navigating with a map you actually understand, let&#x27;s talk.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>From Hobby Code to SaaS to Orchestrating AI Agents</title>
        <published>2026-04-20T00:00:00+00:00</published>
        <updated>2026-04-20T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/ai-coding-tools-fundamentals-case-study/"/>
        <id>https://belderbos.dev/blog/ai-coding-tools-fundamentals-case-study/</id>
        <summary type="html">&lt;p&gt;Ryan Austin runs a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.thepayroll.app&quot;&gt;payroll SaaS solution&lt;&#x2F;a&gt; in the Bahamas. He uses AI agents for customer support triage, feature scaffolding, and automated issue resolution. He has even implemented a rating system for issues, allowing agents to autonomously tackle specific tasks based on my available token budget.&lt;&#x2F;p&gt;
&lt;p&gt;Four years ago, none of this would have been possible.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Ryan Austin runs a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.thepayroll.app&quot;&gt;payroll SaaS solution&lt;&#x2F;a&gt; in the Bahamas. He uses AI agents for customer support triage, feature scaffolding, and automated issue resolution. He has even implemented a rating system for issues, allowing agents to autonomously tackle specific tasks based on my available token budget.&lt;&#x2F;p&gt;
&lt;p&gt;Four years ago, none of this would have been possible.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;img src=&quot;&#x2F;images&#x2F;ryan-austin.png&quot; alt=&quot;Ryan Austin&quot; class=&quot;case-study-photo&quot;&gt;
&lt;h2 id=&quot;the-gap&quot;&gt;The gap&lt;&#x2F;h2&gt;
&lt;p&gt;In 2021, Ryan had a real business idea and enough Python to be dangerous. He was building alone, with no one to tell him whether his architecture was sound or whether he was creating a hobby project or something that could actually run a business.&lt;&#x2F;p&gt;
&lt;p&gt;He didn&#x27;t know what he didn&#x27;t know.&lt;&#x2F;p&gt;
&lt;p&gt;We did 1:1 coaching together. He shipped his first SaaS MVP. And the foundation he built then is paying off now in ways neither of us anticipated.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-foundation&quot;&gt;The foundation&lt;&#x2F;h2&gt;
&lt;p&gt;When I asked Ryan what coaching gave him that made the payroll app possible, he was direct:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Delivering this product would have been impossible without that coaching. It provided the necessary space to brainstorm and, more importantly, provided validation from someone who had shipped products at a high level. Coaching didn&#x27;t just help me execute a plan; it gave me the confidence that my architecture was sound and that the product met a professional standard rather than just being a &#x27;hobby project.&#x27;&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Later it all started to click:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;There were several moments where I initially followed certain patterns simply because I was told they were &#x27;best practices.&#x27; The click happened when I stopped just following instructions and began to understand the &lt;em&gt;why&lt;&#x2F;em&gt; behind the architecture. I realized that with a solid structural foundation, the complexity of a system like payroll wasn&#x27;t an obstacle, but a series of solvable logic problems.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Understanding &lt;em&gt;why&lt;&#x2F;em&gt; patterns exist is what makes them useful. Without that, you&#x27;re following rules you can&#x27;t adapt when circumstances change.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-ios-test&quot;&gt;The iOS test&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s what Ryan told me when I asked about the link between coaching and AI effectiveness:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I&#x27;ve never had coaching for iOS development, and despite having AI tools, I don&#x27;t feel comfortable building a native app because I lack the fundamental mental model of the folder structures and utilities. My Python coaching, however, gave me the ability to &#x27;see under the hood.&#x27; Because I understand the core principles of Python and Django, I can effectively direct the AI. Without coaching, you&#x27;re just a passenger; with it, you&#x27;re the navigator who actually understands the map.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;AI tools take the path of least resistance. Ryan knows his app must stay Content Security Policy compliant because of his previous security audits. He constantly has to remind the AI to avoid inline scripts or styles. If you don&#x27;t know what good software looks like (e.g. security, modularity, compliance), you&#x27;ll get something that works today but breaks tomorrow.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-ryan-uses-ai-tools-today&quot;&gt;How Ryan uses AI tools today&lt;&#x2F;h2&gt;
&lt;p&gt;His workflows:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Customer support:&lt;&#x2F;strong&gt; &quot;Payroll is complex, and our support tickets often involve nuanced inquiries about tax calculations or labor law compliance. I use agents to ingest these emails and run tests against the live logic to see if a bug exists or if the system performed as intended. This results in faster, more accurate data-driven responses without breaking my deep-work flow.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Feature scaffolding:&lt;&#x2F;strong&gt; &quot;I find that structural planning is 80% of the work; the agents handle that heavy lifting, allowing me to drive the &#x27;last mile.&#x27; I always stay hands-on for the final implementation because I need to intimately understand how the features I support actually function.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Issue triage:&lt;&#x2F;strong&gt; He&#x27;s automated the low-hanging fruit by having agents review GitHub issues, test if bugs still exist, and propose fixes. As said, he rates issues so agents can tackle tasks autonomously based on his available token budget.&lt;&#x2F;p&gt;
&lt;p&gt;Ryan can now build onboarding and integration tools that were stuck in the backlog.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-tdd-inversion&quot;&gt;The TDD inversion&lt;&#x2F;h2&gt;
&lt;p&gt;One habit from coaching stuck harder than any other:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;In the early days of AI, the models weren&#x27;t quite reliable enough to write production code, but they were decent at code review. Today, the roles have flipped: I&#x27;m writing and reading less &#x27;raw&#x27; code, but I am reading tests more than ever. You always emphasized that code will eventually break and bugs are inevitable. Consequently, the tests have become my &#x27;source of truth.&#x27; By focusing on the test suite, I can verify the AI&#x27;s output without getting bogged down in every line of implementation.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Nearly all his prompts now begin with &quot;Using a TDD approach...&quot; and end with a request to review the tests before any code is written. He calls it a &quot;short-circuit manager&#x27;s review&quot;, ensuring the logic is sound before committing to the build.&lt;&#x2F;p&gt;
&lt;p&gt;But there&#x27;s a catch:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;I recently experienced a session where an agent modified some essential tax calculations. Because the agent was also responsible for the tests, it updated the test suite to match its own logical error, creating a &#x27;self-validating&#x27; mistake. The tests passed, but the math was wrong. I only caught the error during a manual walkthrough because I am intimately familiar with the expected Bahamian tax outputs.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;If you let AI write the logic and the proof of that logic simultaneously, you risk a closed loop of misinformation. You must remain the ultimate authority on expected outcomes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;practical-guardrails&quot;&gt;Practical guardrails&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Aggressive prompt editing:&lt;&#x2F;strong&gt; &quot;If an agent veers off course, I don&#x27;t keep chatting; I stop and edit the initial prompt immediately. It&#x27;s a massive time and token saver.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Variable reasoning:&lt;&#x2F;strong&gt; Toggle reasoning levels based on stakes. High-stakes payroll logic gets maximum reasoning; UI tweaks get minimal settings.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Decision fatigue is real:&lt;&#x2F;strong&gt; &quot;Using agents is cognitively taxing. It reminds me of the &#x27;innovation tokens&#x27; concept: we only have so much mental bandwidth per day. Agentic workflows require dozens of high-level decisions every hour. I&#x27;ve found that &#x27;resting&#x27; the context, and myself, results in much better output.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The trap of easy:&lt;&#x2F;strong&gt; &quot;The friction of development has decreased so significantly that it&#x27;s tempting to over-engineer or add &#x27;nice-to-have&#x27; features simply because they are now easy to implement. However, every line of code, even AI-generated code, is a future liability. Just because it&#x27;s easier to build doesn&#x27;t mean it belongs in your product. Maintaining a lean, intentional roadmap is harder when the &#x27;cost&#x27; of building feels like it has dropped to zero.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Context files:&lt;&#x2F;strong&gt; Ryan maintains an agents.md file that defines his system&#x27;s context and standards, allowing new agents to get to work instantly without a long onboarding chat.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-compounding&quot;&gt;The compounding&lt;&#x2F;h2&gt;
&lt;p&gt;Ryan&#x27;s point generalizes: our tolerance for &quot;good enough&quot; is vanishing. Because agents make refactoring and profiling so much faster, we no longer have an excuse for slow queries or poor performance. We can tackle those painful refactors in a fraction of the time.&lt;&#x2F;p&gt;
&lt;p&gt;But the developers who benefit most are the ones who know what good looks like before the agent starts writing. The foundational skills compound.&lt;&#x2F;p&gt;
&lt;p&gt;Ryan went from building alone with no validation to directing AI agents with confidence. The coaching didn&#x27;t teach him AI tools, they didn&#x27;t even exist yet. It taught him the fundamentals that make any tool useful.&lt;&#x2F;p&gt;
&lt;p&gt;He went from passenger to a navigator who actually understands the map.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>How an AI expense agent is actually structured</title>
        <published>2026-04-15T00:00:00+00:00</published>
        <updated>2026-04-15T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/ai-agent-architecture-python/"/>
        <id>https://belderbos.dev/blog/ai-agent-architecture-python/</id>
        <summary type="html">&lt;p&gt;Most AI agent tutorials show you the LLM call. They skip the part where you have to do something useful with the response.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Most AI agent tutorials show you the LLM call. They skip the part where you have to do something useful with the response.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;An LLM call is just a function call that returns structured data. Once you see it that way, the rest of the architecture follows using patterns you already know.&lt;&#x2F;p&gt;
&lt;p&gt;This is the architecture we use for the expense AI agent program. Four layers, all standard Python.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layer-1-abstract-the-provider-with-a-protocol&quot;&gt;Layer 1: Abstract the provider with a Protocol&lt;&#x2F;h2&gt;
&lt;p&gt;The first decision is how to talk to the LLM. You could hardcode &lt;code&gt;openai.chat.completions.create(...)&lt;&#x2F;code&gt; everywhere, but then you are coupled to one vendor. Swap to Groq, Anthropic, or a local model later, you have to update it in many places.&lt;&#x2F;p&gt;
&lt;p&gt;Instead, define a &lt;code&gt;Protocol&lt;&#x2F;code&gt; that describes what any LLM provider must do:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decimal&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; typing&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Protocol, Sequence&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Assistant&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;Protocol&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; completion&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, messages: list[dict[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;]]) -&amp;gt; ExpenseCategorizationResponse:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; calculate_cost&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, prompt_tokens:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, completion_tokens:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; Decimal:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_available_models&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self) -&amp;gt; Sequence[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now &lt;code&gt;OpenAIAssistant&lt;&#x2F;code&gt; and &lt;code&gt;GroqAssistant&lt;&#x2F;code&gt; satisfy this protocol without inheriting from it. Your service layer accepts &lt;code&gt;Assistant&lt;&#x2F;code&gt;, the concrete provider is injected at startup. Swap it in tests without touching anything else.&lt;&#x2F;p&gt;
&lt;p&gt;This is called &lt;em&gt;structural typing&lt;&#x2F;em&gt; and the type checker enforces the contract for you; duck typing with safety.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layer-2-return-a-pydantic-model-not-a-string&quot;&gt;Layer 2: Return a Pydantic model, not a string&lt;&#x2F;h2&gt;
&lt;p&gt;The LLM returns text. You want typed data. Pydantic bridges this gap.&lt;&#x2F;p&gt;
&lt;p&gt;OpenAI&#x27;s structured outputs API accepts a Pydantic model directly and returns an instance of it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decimal&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pydantic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; BaseModel, Field&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ExpenseCategorizationResponse&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;BaseModel&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    total_amount: Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    currency: Currency&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    confidence:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; float&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    cost: Decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    comments:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.beta.chat.completions.parse(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;    model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;gpt-4o-mini&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;    messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;messages,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;    response_format&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ExpenseCategorizationResponse,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;result: ExpenseCategorizationResponse&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; response.choices[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].message.parsed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# result.total_amount is Decimal(&amp;quot;5.50&amp;quot;), not a string&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# result.currency is Currency.USD, not &amp;quot;USD&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No regex. No JSON parsing. No &lt;code&gt;float(response.split(&quot;$&quot;)[1])&lt;&#x2F;code&gt; hacks. Pydantic validates types on the way in, coerces where it can, and raises a clear error when the model returns something unexpected.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;ve worked with FastAPI, you&#x27;ve already seen what Pydantic does for request&#x2F;response models. The same applies here.&lt;&#x2F;p&gt;
&lt;p&gt;The LLM also returns a &lt;code&gt;confidence&lt;&#x2F;code&gt; score for each classification. You&#x27;ll use it in layer 4.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layer-3-the-service-layer-coordinates-everything&quot;&gt;Layer 3: The service layer coordinates everything&lt;&#x2F;h2&gt;
&lt;p&gt;Without a service layer, classification logic ends up copy-pasted into the CLI, the Telegram bot, and the REST API. The service layer centralizes it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; dataclasses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ClassificationService&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    assistant: Assistant&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    expense_repo: ExpenseRepository&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; classify&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        self,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        expense_description:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;        persist:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ) -&amp;gt; ClassificationResult:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._build_messages(expense_description)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.assistant.completion(messages)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; persist&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; and&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.expense_repo:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;            self&lt;&#x2F;span&gt;&lt;span&gt;._persist_expense(expense_description, response)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; ClassificationResult(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; persisted&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;persist)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;assistant&lt;&#x2F;code&gt; is injected, not created inside. The &lt;code&gt;expense_repo&lt;&#x2F;code&gt; is optional so you can classify without writing to storage (useful for preview UI, dry runs, and testing).&lt;&#x2F;p&gt;
&lt;p&gt;Testing this service is straightforward: pass in a mock &lt;code&gt;Assistant&lt;&#x2F;code&gt; that returns a known &lt;code&gt;ExpenseCategorizationResponse&lt;&#x2F;code&gt;. No real API call. No &lt;code&gt;.env&lt;&#x2F;code&gt; file.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; unittest.mock&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; create_autospec&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; test_classify_calls_assistant&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_assistant&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; create_autospec(Assistant)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mock_assistant.completion.return_value&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ExpenseCategorizationResponse(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Food&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        total_amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;5.50&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Currency.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;USD&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        confidence&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0.95&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        cost&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;0.001&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    service&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ClassificationService(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;assistant&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;mock_assistant)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; service.classify(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Coffee at Starbucks $5.50&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; result.response.category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;Food&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    assert&lt;&#x2F;span&gt;&lt;span&gt; result.persisted&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; False&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Note that, using &lt;code&gt;create_autospec&lt;&#x2F;code&gt; instead of &lt;code&gt;MagicMock&lt;&#x2F;code&gt; directly, means the test will fail if you refactor the &lt;code&gt;Assistant&lt;&#x2F;code&gt; protocol but forget to update the mock. See Jan Giacomelli&#x27;s tip &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;python-testing.com&#x2F;python-testing-tips&#x2F;create_autospec-ensure-that-mocked-objects-are-called-correctly&#x2F;&quot;&gt;here&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layer-4-human-in-the-loop-catches-what-the-model-gets-wrong&quot;&gt;Layer 4: Human-in-the-loop catches what the model gets wrong&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;Uber to airport&quot; → Transport. Correct.
&quot;Uber Eats dinner&quot; → Transport (85% confidence). Wrong.&lt;&#x2F;p&gt;
&lt;p&gt;The LLM is often right. Not always. Human-in-the-loop (HITL) is how you catch what the model gets wrong. It proposes, you decide.&lt;&#x2F;p&gt;
&lt;p&gt;When confidence drops below a threshold, the Telegram bot presents an inline keyboard with the AI&#x27;s suggestion highlighted:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; build_category_confirmation_keyboard&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;    suggested_category:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;    all_categories: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) -&amp;gt; InlineKeyboardMarkup:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    buttons&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span&gt; category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; all_categories:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; = f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&amp;gt;&amp;gt; &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; &amp;lt;&amp;lt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; suggested_category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; category&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        buttons.append(InlineKeyboardButton(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;text,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; callback_data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;=f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;cat:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;category&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The user sees &lt;code&gt;[&amp;gt;&amp;gt; Transport &amp;lt;&amp;lt;] [Food] [Shopping]&lt;&#x2F;code&gt;. One tap to confirm or correct. When they select, the service persists with &lt;code&gt;confidence=1.0&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; persist_with_category&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, expense_description:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, category_name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, response: ExpenseCategorizationResponse) -&amp;gt; Expense:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    expense&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Expense(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        amount&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response.total_amount,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        currency&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response.currency,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;expense_description,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        category&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;ExpenseCategory(category_name),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        confidence&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Decimal(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;1.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # human confirmed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;    self&lt;&#x2F;span&gt;&lt;span&gt;._expense_repo.add(expense)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; expense&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A human-confirmed label is by definition correct. The stored confidence should reflect that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;python-ai-agent-architecture-four-layers&quot;&gt;Python AI agent architecture: four layers&lt;&#x2F;h2&gt;
&lt;p&gt;The four layers map to Python patterns you already know:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Layer&lt;&#x2F;th&gt;&lt;th&gt;Pattern&lt;&#x2F;th&gt;&lt;th&gt;What it gives you&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Provider&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;Protocol&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Swap LLM backends without touching business logic&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Response&lt;&#x2F;td&gt;&lt;td&gt;Pydantic &lt;code&gt;BaseModel&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Typed, validated data from unstructured text&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Coordinator&lt;&#x2F;td&gt;&lt;td&gt;Service + dependency injection&lt;&#x2F;td&gt;&lt;td&gt;Testable logic, reused across CLI&#x2F;bot&#x2F;API&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Verification&lt;&#x2F;td&gt;&lt;td&gt;HITL&lt;&#x2F;td&gt;&lt;td&gt;Human oversight where model confidence is low&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;None of this is new. Service layers and dependency injection are &lt;a href=&quot;&#x2F;blog&#x2F;repository-pattern-swappable-data-sources&#x2F;&quot;&gt;standard patterns&lt;&#x2F;a&gt;. Protocols give you duck typing with type safety: structural contracts that don&#x27;t require inheritance. Pydantic is the de facto standard for structured data in modern Python APIs. And HITL is a form flow where AI proposes and humans confirm. Simple in concept, but the feedback loop is what makes it powerful.&lt;&#x2F;p&gt;
&lt;p&gt;What makes it feel like &quot;AI development&quot; is that the LLM sits in the middle. The code around it is just Python. The patterns are the same; the capabilities are new.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;This is the project structure from our &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pythonagenticai.com&quot;&gt;Expense AI Agent cohort&lt;&#x2F;a&gt; that &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;juanjoseexposito.com&quot;&gt;Juan José Expósito&lt;&#x2F;a&gt; and I are running.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Build Your First MCP Server: Code Tips in Claude and Slack</title>
        <published>2026-04-13T00:00:00+00:00</published>
        <updated>2026-04-13T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/build-mcp-server-python-tips-slack/"/>
        <id>https://belderbos.dev/blog/build-mcp-server-python-tips-slack/</id>
        <summary type="html">&lt;p&gt;MCP (&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;modelcontextprotocol.io&#x2F;docs&#x2F;getting-started&#x2F;intro&quot;&gt;Model Context Protocol&lt;&#x2F;a&gt;) is the standard connector between AI clients and external tools. I built a server from scratch in 78 lines. Result: my coding tips repo, searchable inside Claude Code and a Slack slash command, using the same server.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;MCP (&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;modelcontextprotocol.io&#x2F;docs&#x2F;getting-started&#x2F;intro&quot;&gt;Model Context Protocol&lt;&#x2F;a&gt;) is the standard connector between AI clients and external tools. I built a server from scratch in 78 lines. Result: my coding tips repo, searchable inside Claude Code and a Slack slash command, using the same server.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Before MCP, connecting an AI assistant to your own data meant one-off integrations (a custom plugin, a pasted prompt). MCP defines a &lt;em&gt;shared protocol&lt;&#x2F;em&gt;, so you write the server once and any compliant client can use it.&lt;&#x2F;p&gt;
&lt;p&gt;A server exposes three kinds of things:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tools&lt;&#x2F;strong&gt;: functions Claude can call (search, get a specific tip, list categories)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Resources&lt;&#x2F;strong&gt;: data Claude can browse (a full index of all tips)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Prompts&lt;&#x2F;strong&gt;: suggested workflows (pre-written instructions for common tasks)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;You don&#x27;t need all three. For a tips collection, tools are enough.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s how Claude Code connects to an MCP server:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;┌─────────────────────────────────────────────────────┐&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│                   Claude Code                       │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│  You type: &amp;quot;search my tips for generators&amp;quot;          │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;└──────────────────────┬──────────────────────────────┘&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                       │ MCP protocol (JSON-RPC &#x2F; stdio)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                       ▼&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;┌─────────────────────────────────────────────────────┐&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│              MCP Server (server.py)                 │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│                                                     │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│  Tools:    search_tips(query)                       │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│            get_tip(tip_id)                          │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│  Resource: tips:&#x2F;&#x2F;all                               │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│                                                     │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;│  Data: ~&#x2F;code&#x2F;bobcodesit&#x2F;notes&#x2F;*.md  (240 files)    │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;└─────────────────────────────────────────────────────┘&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Claude Code launches the server as a subprocess and communicates over stdin&#x2F;stdout. Claude discovers your tools at startup and calls them whenever the conversation warrants it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-data-my-python-tip-collection&quot;&gt;The data: my Python tip collection&lt;&#x2F;h2&gt;
&lt;p&gt;My &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;bobcodesit&quot;&gt;&lt;code&gt;bobcodesit&lt;&#x2F;code&gt; repo&lt;&#x2F;a&gt; is a good candidate. It holds 240 markdown files in the &lt;code&gt;notes&#x2F;&lt;&#x2F;code&gt; subfolder. Each one looks like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-markup z-heading z-entity z-name&quot;&gt;# divmod built-in&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Ever needed both integer division and remainder?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Use Python&amp;#39;s &lt;&#x2F;span&gt;&lt;span class=&quot;z-markup z-inline z-raw&quot;&gt;`divmod()`&lt;&#x2F;span&gt;&lt;span&gt; built-in — it returns both in one go.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;```python&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; minutes, seconds&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; divmod&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;133&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 60&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&amp;gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; minutes, seconds&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 13&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;```&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#built-ins&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The structure is consistent: title on line one, body in the middle, one or more &lt;code&gt;#hashtags&lt;&#x2F;code&gt; at the end. That predictability makes parsing trivial.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;building-the-mcp-server&quot;&gt;Building the MCP server&lt;&#x2F;h2&gt;
&lt;p&gt;First, clone the tips repo so the notes are on your machine:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# assuming a code directory in your home folder&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;cd&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; ~&#x2F;code&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;git&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; clone git@github.com:bbelderbos&#x2F;bobcodesit.git&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then set up the MCP server project:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;mkdir&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; tips-mcp&lt;&#x2F;span&gt;&lt;span&gt; &amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; cd&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; tips-mcp&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; init&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; add mcp&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Create &lt;code&gt;server.py&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; random&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; re&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; functools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; cache&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pathlib&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Path&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; mcp.server.fastmcp&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; FastMCP&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;NOTES_DIR&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Path.home()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;code&#x2F;bobcodesit&#x2F;notes&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;mcp&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; FastMCP(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;code-tips&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@cache&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; load_notes&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;dict&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    notes&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    for&lt;&#x2F;span&gt;&lt;span&gt; path&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; sorted&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;NOTES_DIR&lt;&#x2F;span&gt;&lt;span&gt;.glob(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;*.md&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        lines&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; path.read_text().strip().splitlines()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        title&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].lstrip(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;# &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).strip()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tag_line&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;-&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;-&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;].startswith(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;#&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tags&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [t.lstrip(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;#&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; tag_line.split()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; t.startswith(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;#&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        content_lines&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;-&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; tag_line&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;:]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        body&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;\n&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;.join(content_lines).strip()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        notes.append({&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: path.stem,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: title,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: tags,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;body&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: body})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; notes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@mcp.tool&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; search_tips&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(query:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, limit:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 10&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;dict&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;Search code tips by keyword. Pass a single short keyword (e.g. &amp;#39;generator&amp;#39;, &amp;#39;decorator&amp;#39;, &amp;#39;contextmanager&amp;#39;), not a full sentence. Returns up to `limit` matching tips with id, title, and tags. Use get_tip to fetch the full body of a specific tip.&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pattern&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; re.compile(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;rf&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;\b&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;re.escape(query.lower())&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;\b&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    results&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        for&lt;&#x2F;span&gt;&lt;span&gt; n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; load_notes()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; pattern.search(n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;].lower())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        or&lt;&#x2F;span&gt;&lt;span&gt; pattern.search(n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;body&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;].lower())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        or&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; any&lt;&#x2F;span&gt;&lt;span&gt;(pattern.search(t)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;])&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; results[:limit]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@mcp.tool&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; random_tip&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(tag:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; dict&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;Return a random tip, optionally filtered by tag (e.g. &amp;#39;built-ins&amp;#39;, &amp;#39;generators&amp;#39;, &amp;#39;decorators&amp;#39;).&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    notes&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; load_notes()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; tag:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; tag.lower().lstrip(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;#&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        notes&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; notes&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; any&lt;&#x2F;span&gt;&lt;span&gt;(t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; note_tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; note_tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;])]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; notes:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;error&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;No tips found for tag: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; random.choice(notes)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@mcp.tool&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_tip&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(tip_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; dict&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;Get the full content of a single tip by its filename (without .md).&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    path&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; NOTES_DIR&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt; &#x2F; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tip_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;.md&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; path.exists():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;error&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Tip not found: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;tip_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lines&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; path.read_text().strip().splitlines()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: tip_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;].lstrip(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;# &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).strip(),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;body&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;\n&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;.join(lines[&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;:]).strip(),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@mcp.resource&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tips:&#x2F;&#x2F;all&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; all_tips&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;Browse all tips: IDs, titles, and tags.&amp;quot;&amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; json.dumps(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        [{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;title&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;],&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: n[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tags&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]}&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; n&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; load_notes()],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;        indent&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt; __name__&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;__main__&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    mcp.run()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;@cache&lt;&#x2F;code&gt; from &lt;code&gt;functools&lt;&#x2F;code&gt; memoises the result of the first call and returns it on every subsequent call, no global variable needed. The notes are only read from disk once per server lifetime.&lt;&#x2F;p&gt;
&lt;p&gt;A few design decisions worth explaining:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;search_tips&lt;&#x2F;code&gt; returns only &lt;code&gt;id&lt;&#x2F;code&gt;, &lt;code&gt;title&lt;&#x2F;code&gt;, and &lt;code&gt;tags&lt;&#x2F;code&gt;, no body. Claude uses this summary list to decide which tip is most relevant, then calls &lt;code&gt;get_tip&lt;&#x2F;code&gt; to fetch the full content. Returning bodies for all matches at once can easily overflow the context window (a broad query like &quot;ast&quot; matches dozens of tips that all contain the substring). The two-phase pattern (search for summaries, then fetch the one you need) keeps responses small and Claude&#x27;s reasoning sharp.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;random_tip&lt;&#x2F;code&gt; accepts an optional tag filter, so &quot;give me a random built-in tip&quot; becomes a direct call to &lt;code&gt;random_tip(tag=&quot;built-ins&quot;)&lt;&#x2F;code&gt; rather than a broad search. Word-boundary matching in &lt;code&gt;search_tips&lt;&#x2F;code&gt; (using &lt;code&gt;re.compile(r&quot;\bquery\b&quot;)&lt;&#x2F;code&gt;) prevents false positives where short queries match as substrings inside unrelated words.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Tool results go to Claude, not directly to the user. Claude reads the structured dicts and decides how to present them. That&#x27;s why the tools return raw data rather than formatted strings. Claude handles the formatting for the conversation context.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Test it standalone before wiring it up:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run python server.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No output is good. This means the server is waiting for a client to connect over stdio.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;register-with-claude-code&quot;&gt;Register with Claude Code&lt;&#x2F;h2&gt;
&lt;p&gt;You can add the MCP server with:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;claude&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; mcp add&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --scope&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; user code-tips&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --project&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; &#x2F;path&#x2F;to&#x2F;tips-mcp run python &#x2F;path&#x2F;to&#x2F;tips-mcp&#x2F;server.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;--scope user&lt;&#x2F;code&gt; registers it in &lt;code&gt;~&#x2F;.claude.json&lt;&#x2F;code&gt; so it&#x27;s available across all your projects. For a per-project setup instead, add a &lt;code&gt;.mcp.json&lt;&#x2F;code&gt; file in the project root:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;  &amp;quot;mcpServers&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;    &amp;quot;code-tips&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;      &amp;quot;type&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;stdio&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;      &amp;quot;command&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;uv&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;      &amp;quot;args&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;--project&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;&#x2F;path&#x2F;to&#x2F;tips-mcp&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;run&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;python&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;&#x2F;path&#x2F;to&#x2F;tips-mcp&#x2F;server.py&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Restart Claude Code. You&#x27;ll now see the server listed:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;❯ &#x2F;mcp&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Manage MCP servers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 servers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    User MCPs (&#x2F;Users&#x2F;pybob&#x2F;.claude.json)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ❯ code-tips · ✔ connected&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    claude.ai&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Let&#x27;s search for a tip:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;❯ Search my tips for anything about generators&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ code-tips - search_tips (MCP)(query: &amp;quot;generator&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⎿  {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       &amp;quot;result&amp;quot;: [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     … +63 lines (ctrl+o to expand)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ Found 5 tips directly tagged #generators:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1. pathlib list files in directory — using yield from with .glob()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2. itertools.count — infinite iterator with optional step&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3. split a sequence into pairs — generator function + itertools.batched() (3.12+)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4. slicing generators — can&amp;#39;t subscript a generator; use itertools.islice()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5. Logging memory usage — shows list vs generator memory difference with psutil&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Plus a couple of tangential mentions (classes tip mentions generators as an alternative; sets tip uses a generator&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  expression with set.intersection).&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;❯ all_tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ readMcpResource(Read resource &amp;quot;tips:&#x2F;&#x2F;all&amp;quot; from server &amp;quot;code-tips&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⎿  {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       &amp;quot;contents&amp;quot;: [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     … +321 lines (ctrl+o to expand)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ You have 218 tips in total. Here&amp;#39;s a breakdown by tag frequency from what&amp;#39;s there:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Most common topics:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - zen — 15+ tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - built-ins — 15+ tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - strings — 12+ tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - collections — 8 tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - itertools — 7 tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - dataclasses — 6 tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - django — 6 tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - pytest — 5 tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - generators — 5 tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - sets, regex, pathlib, exceptions, oop, datetime — 4–5 each&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - debugging, packaging, typehints, functools, classes, dicts, decorators — 3–4 each&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Newest tips (2026):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Sending email with Resend SDK&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - StrEnum and auto()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Rust ? operator&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Context manager for transaction rollback&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Dataclass config flags&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Binary search with bisect&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Mermaid Diagrams in GitHub READMEs&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Reliable CI Benchmarks with pytest-codspeed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;❯ get_tip&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ Which tip would you like to retrieve? Give me the title or ID (e.g. 20240711112100 for &amp;quot;slicing generators&amp;quot;).&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;❯ StrEnum and auto()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ code-tips - get_tip (MCP)(tip_id: &amp;quot;20260410104036&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ StrEnum and auto() (Python 3.11+)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  If you&amp;#39;re still using standard Enum for states, categories, or API keys, you&amp;#39;re missing out.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Wins:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Direct comparison: a StrEnum member is a string — no more if status.value == &amp;quot;active&amp;quot;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Auto-naming: auto() assigns the lowercase variable name as the value automatically&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  - Serialization: works with json.dumps() without custom encoders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  # Old way — forced to use .value&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  from enum import Enum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  class Status(Enum):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      PENDING = &amp;quot;pending&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  if Status.PENDING == &amp;quot;pending&amp;quot;:  # False!&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  print(Status.PENDING.value)  # &amp;quot;pending&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  # Modern way&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  from enum import StrEnum, auto&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  class Status(StrEnum):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      PENDING = auto()  # &amp;quot;pending&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      ACTIVE = auto()   # &amp;quot;active&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  if Status.PENDING == &amp;quot;pending&amp;quot;:  # True!&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      print(&amp;quot;It&amp;#39;s a match!&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  print(f&amp;quot;Current status: {Status.ACTIVE}&amp;quot;)  # &amp;quot;active&amp;quot; — no .value needed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Useful for state machines, API clients, and configs. #enums&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Let&#x27;s try one more:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;❯ search for zen of python tips&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ code-tips - search_tips (MCP)(query: &amp;quot;zen&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⎿  {&amp;quot;result&amp;quot;:[{&amp;quot;id&amp;quot;:&amp;quot;20220904170128&amp;quot;,&amp;quot;title&amp;quot;:&amp;quot;Regex replacements and counting&amp;quot;,&amp;quot;tags&amp;quot;:[&amp;quot;regex&amp;quot;],&amp;quot;body&amp;quot;:&amp;quot;Here is ho&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     w to replace all vowels from a text while keeping a count of the number of replacements made:\n\n```\n&amp;gt;&amp;gt;&amp;gt; impor&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     t re\n&amp;gt;&amp;gt;&amp;gt; vowels = &amp;#39;aeiou&amp;#39;\n&amp;gt;&amp;gt;&amp;gt; text = \&amp;quot;\&amp;quot;\&amp;quot;\n... The Zen of Python, by Tim Peters\n...\n... Beautiful is bett&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     … +179 lines (ctrl+o to expand)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;⏺ You have quite a few Zen of Python tips. Here&amp;#39;s a summary:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ---&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Directly tagged #zen:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1. zip() strict keyword arg — zip(..., strict=True) surfaces mismatched-length bugs. &amp;quot;Errors should never pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  silently.&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2. How to capture the Zen of Python — Uses contextlib.redirect_stdout + codecs to grab import this output silently.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3. Sparse is better than dense — Code formatting with whitespace for readability.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4. Beautiful is better than ugly — Idiomatic sum(data.values()) vs verbose loops.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5. Readability counts — Meaningful variable names.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6. Special cases aren&amp;#39;t special enough — Consistent interfaces via capitalize(), len(), iteration protocols.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7. Although practicality beats purity — When except Exception or *args&#x2F;**kwargs is justified.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8. Errors should never pass silently — Proper exception handling with logging + typed return float | None.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9. Unless explicitly silenced — contextlib.suppress for intentional error suppression.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  10. In the face of ambiguity, refuse the temptation to guess — Python&amp;#39;s explicit type errors vs JS coercion.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  11. There should be one obvious way — enumerate, f-strings, Counter, dict.get, etc.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  12. Now is better than never — Ship early, drop perfectionism.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ---&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  That&amp;#39;s a solid series — looks like you worked through the Zen aphorisms one by one. Want to do anything with these?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;integrate-with-slack&quot;&gt;Integrate with Slack&lt;&#x2F;h2&gt;
&lt;p&gt;I want to create a Slack slash command that searches my tips. When I type &lt;code&gt;&#x2F;tip generators&lt;&#x2F;code&gt;, the bot calls &lt;code&gt;search_tips(&quot;generator&quot;)&lt;&#x2F;code&gt; on the MCP server, gets the results, and responds with a summary of the most relevant tips.&lt;&#x2F;p&gt;
&lt;p&gt;Because we&#x27;re not using Claude Code here, we interface through the API directly. The MCP server is still in the middle of the reasoning loop though:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Slack                 slack_bot.py             server.py (tools)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │                        │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │  &amp;quot;&#x2F;tip generators&amp;quot;    │                        │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │──────────────────────→│                        │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │  Claude API call       │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │  (tools: search_tips,  │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │   get_tip)             │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │──────────────────────→ │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │  search_tips(&amp;quot;generator&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │←───────────────────────│&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │  [5 matching tips]     │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │                       │                        │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │  &amp;quot;Here are 5 tips...&amp;quot; │                        │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  │←──────────────────────│                        │&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Claude receives your Slack message, decides which tools to call, gets the results, and writes the response. Same reasoning engine, different interface.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;configure-the-slack-app&quot;&gt;Configure the Slack App&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Create the App:&lt;&#x2F;strong&gt; Go to &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;api.slack.com&#x2F;apps&quot;&gt;api.slack.com&#x2F;apps&lt;&#x2F;a&gt; and create an app &quot;From Scratch&quot;. Call it &quot;Code Tips Bot&quot; and select a workspace.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Enable Socket Mode:&lt;&#x2F;strong&gt; In the left sidebar, click &lt;strong&gt;Socket Mode&lt;&#x2F;strong&gt; and toggle it on. You&#x27;ll be prompted to generate an &lt;strong&gt;App-Level Token&lt;&#x2F;strong&gt; (starts with &lt;code&gt;xapp-&lt;&#x2F;code&gt;). This opens an outbound WebSocket to Slack, no public URL or ngrok needed.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Add Permissions:&lt;&#x2F;strong&gt; Under &lt;strong&gt;OAuth &amp;amp; Permissions&lt;&#x2F;strong&gt; → &lt;strong&gt;Scopes&lt;&#x2F;strong&gt;, add the &lt;code&gt;commands&lt;&#x2F;code&gt; bot scope. No &lt;code&gt;chat:write&lt;&#x2F;code&gt; needed; responses use the &lt;code&gt;response_url&lt;&#x2F;code&gt; from the slash command payload.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Create the Slash Command:&lt;&#x2F;strong&gt; Go to &lt;strong&gt;Slash Commands&lt;&#x2F;strong&gt; and create &lt;code&gt;&#x2F;tip&lt;&#x2F;code&gt;. Socket Mode means no Request URL is needed.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Install &amp;amp; Connect:&lt;&#x2F;strong&gt; Click &lt;strong&gt;Install App&lt;&#x2F;strong&gt; to generate your &lt;strong&gt;Bot User OAuth Token&lt;&#x2F;strong&gt; (starts with &lt;code&gt;xoxb-&lt;&#x2F;code&gt;).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;slack-bot-code&quot;&gt;Slack bot code&lt;&#x2F;h3&gt;
&lt;p&gt;First add the dependencies:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; add slack-bolt python-decouple anthropic&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You&#x27;ll also need Claude API credits. Add a payment method at &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;console.anthropic.com&quot;&gt;console.anthropic.com&lt;&#x2F;a&gt; and grab an API key. A few dollars is enough to get started.&lt;&#x2F;p&gt;
&lt;p&gt;The bot uses the &lt;code&gt;mcp&lt;&#x2F;code&gt; package (already installed) as a &lt;em&gt;client&lt;&#x2F;em&gt; to connect to your running MCP server over stdio, the same protocol Claude Code uses. All the connection state (the event loop, the session, the tool list) lives in an &lt;code&gt;MCPClient&lt;&#x2F;code&gt; class that starts the server once at bot startup and keeps it alive.&lt;&#x2F;p&gt;
&lt;p&gt;Add &lt;code&gt;slack_bot.py&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; asyncio&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;import&lt;&#x2F;span&gt;&lt;span&gt; threading&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; pathlib&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Path&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; anthropic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; AsyncAnthropic&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; anthropic.types&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; MessageParam, TextBlock, ToolParam, ToolResultBlockParam, ToolUseBlock&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decouple&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; config&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; mcp&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; ClientSession, StdioServerParameters&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; mcp.client.stdio&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; stdio_client&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; mcp.types&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; TextContent&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; slack_bolt&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; App&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; slack_bolt.adapter.socket_mode&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; SocketModeHandler&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;SERVER_PATH&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Path(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt;__file__&lt;&#x2F;span&gt;&lt;span&gt;).parent&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;server.py&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;anthropic&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; AsyncAnthropic(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;api_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;ANTHROPIC_API_KEY&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;app&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; App(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;token&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;SLACK_BOT_TOKEN&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;SYSTEM_PROMPT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;Format all responses for Slack mrkdwn: &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;use *bold* not **bold**, _italic_ (underscores) for italics, &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;`code` for inline code, no ## headers, no markdown tables, &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;use bullet points instead.&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; MCPClient&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.loop&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; asyncio.new_event_loop()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.session: ClientSession&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.tools: list[ToolParam]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._ready&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; threading.Event()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._lock&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; asyncio.Lock()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        threading.Thread(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;target&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;=lambda&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.loop.run_until_complete(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;._start()),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; daemon&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;True&lt;&#x2F;span&gt;&lt;span&gt;).start()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._ready.wait()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _start&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        server_params&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; StdioServerParameters(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            command&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;uv&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            args&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;--project&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;SERVER_PATH&lt;&#x2F;span&gt;&lt;span&gt;.parent),&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;run&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;python&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;SERVER_PATH&lt;&#x2F;span&gt;&lt;span&gt;)],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        async with&lt;&#x2F;span&gt;&lt;span&gt; stdio_client(server_params)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; (read, write):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            async with&lt;&#x2F;span&gt;&lt;span&gt; ClientSession(read, write)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; session:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                await&lt;&#x2F;span&gt;&lt;span&gt; session.initialize()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                tools_result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; session.list_tools()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;                self&lt;&#x2F;span&gt;&lt;span&gt;.tools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    ToolParam(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                        name&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;t.name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                        description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;t.description&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                        input_schema&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;t.inputSchema,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                    for&lt;&#x2F;span&gt;&lt;span&gt; t&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; tools_result.tools&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;                self&lt;&#x2F;span&gt;&lt;span&gt;.session&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;                self&lt;&#x2F;span&gt;&lt;span&gt;._ready.set()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                await&lt;&#x2F;span&gt;&lt;span&gt; asyncio.Event().wait()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # keep server alive for the bot&amp;#39;s lifetime&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; ask&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, query:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        async with&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._lock:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return await&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._ask(query)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; _ask&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, query:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        messages: list[MessageParam]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;role&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;content&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: query}]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        for&lt;&#x2F;span&gt;&lt;span&gt; _&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; range&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; anthropic.messages.create(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;claude-haiku-4-5-20251001&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                max_tokens&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1024&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                tools&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.tools,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                messages&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;messages,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                system&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;SYSTEM_PROMPT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            if&lt;&#x2F;span&gt;&lt;span&gt; response.stop_reason&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;end_turn&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; next&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    (block.text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; block&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; response.content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; isinstance&lt;&#x2F;span&gt;&lt;span&gt;(block, TextBlock)),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;                    &amp;quot;No response.&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            assert&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.session&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            tool_results: list[ToolResultBlockParam]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            for&lt;&#x2F;span&gt;&lt;span&gt; block&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; response.content:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; isinstance&lt;&#x2F;span&gt;&lt;span&gt;(block, ToolUseBlock):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    result&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.session.call_tool(block.name, block.input)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    tool_results.append(ToolResultBlockParam(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                        type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;tool_result&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                        tool_use_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;block.id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                        content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;json.dumps([c.text&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; result.content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; isinstance&lt;&#x2F;span&gt;&lt;span&gt;(c, TextContent)]),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    ))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            if not&lt;&#x2F;span&gt;&lt;span&gt; tool_results:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;                break&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            messages.append(MessageParam(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;role&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;assistant&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;response.content))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            messages.append(MessageParam(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;role&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;user&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; content&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;tool_results))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;No response after maximum tool-use iterations.&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;mcp&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; MCPClient()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@app.command&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;tip&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; handle_tip&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(ack, command, respond):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ack()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    query&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; command[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;text&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;].strip()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if not&lt;&#x2F;span&gt;&lt;span&gt; query:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        respond(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Usage: `&#x2F;tip &amp;lt;question&amp;gt;`  e.g. `&#x2F;tip how do generators work?`&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    respond(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&amp;gt; `&#x2F;tip &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;query&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;`&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;\n\n&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;_Searching bobcodesit tips..._&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        try&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            future&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; asyncio.run_coroutine_threadsafe(mcp.ask(query), mcp.loop)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            respond(future.result(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;timeout&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;60&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        except&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; Exception&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; e:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            respond(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Error: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;e&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    threading.Thread(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;target&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;process).start()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;if&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt; __name__&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;__main__&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    SocketModeHandler(app, config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;SLACK_APP_TOKEN&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)).start()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;MCPClient.__init__&lt;&#x2F;code&gt; spins up a dedicated background thread running a private event loop, calls &lt;code&gt;_start&lt;&#x2F;code&gt; on it, then blocks on &lt;code&gt;_ready&lt;&#x2F;code&gt; until the server is initialised. From that point the &lt;code&gt;stdio_client&lt;&#x2F;code&gt; and &lt;code&gt;ClientSession&lt;&#x2F;code&gt; context managers stay open. The server process and its in-memory note cache persist for the bot&#x27;s lifetime. Each &lt;code&gt;&#x2F;tip&lt;&#x2F;code&gt; command submits &lt;code&gt;mcp.ask&lt;&#x2F;code&gt; to that loop with &lt;code&gt;run_coroutine_threadsafe&lt;&#x2F;code&gt; and waits for the result.&lt;&#x2F;p&gt;
&lt;p&gt;A few beginner traps I fell into:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Use Haiku, not Opus, for tool-use loops.&lt;&#x2F;strong&gt; Opus is powerful but expensive and slow. For a search task over local notes, Haiku is more than capable and roughly 20× cheaper. Each &lt;code&gt;&#x2F;tip&lt;&#x2F;code&gt; invocation makes multiple API calls (one per tool-use round), so model choice compounds quickly.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Guard against empty tool results.&lt;&#x2F;strong&gt; If Claude finishes a round without calling any tools but also doesn&#x27;t return &lt;code&gt;stop_reason: &quot;end_turn&quot;&lt;&#x2F;code&gt;, appending an empty &lt;code&gt;content&lt;&#x2F;code&gt; list to the messages causes a &lt;code&gt;400 Bad Request: user messages must have non-empty content&lt;&#x2F;code&gt;. The &lt;code&gt;if not tool_results: break&lt;&#x2F;code&gt; guard handles this.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cache your data loading.&lt;&#x2F;strong&gt; The naive version of &lt;code&gt;load_notes()&lt;&#x2F;code&gt; reads all 240 files from disk on every tool call. Since the MCP server process stays alive for the duration of a session, decorating &lt;code&gt;load_notes&lt;&#x2F;code&gt; with &lt;code&gt;@cache&lt;&#x2F;code&gt; means the files are only read once, making subsequent calls much faster.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Spawning a new server per request defeats the cache.&lt;&#x2F;strong&gt; The first version called &lt;code&gt;stdio_client&lt;&#x2F;code&gt; inside &lt;code&gt;ask_claude&lt;&#x2F;code&gt;, spawning a fresh process on every &lt;code&gt;&#x2F;tip&lt;&#x2F;code&gt; command, so the &lt;code&gt;_cache&lt;&#x2F;code&gt; in &lt;code&gt;server.py&lt;&#x2F;code&gt; never warmed up. Claude Code keeps the server alive across your whole session; the bot has to do that explicitly. &lt;code&gt;MCPClient&lt;&#x2F;code&gt; is the fix. Beyond the cache: each &lt;code&gt;uv run python server.py&lt;&#x2F;code&gt; launch costs ~1-2 seconds of subprocess startup before any actual work happens. The persistent &lt;code&gt;MCPClient&lt;&#x2F;code&gt; eliminates that overhead on every request, even for a single user.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Protect the session from concurrent commands.&lt;&#x2F;strong&gt; The MCP session over stdio is a single pipe. If two &lt;code&gt;&#x2F;tip&lt;&#x2F;code&gt; commands arrive at the same time, both try to read and write on it simultaneously and the responses get scrambled. An &lt;code&gt;asyncio.Lock&lt;&#x2F;code&gt; in &lt;code&gt;MCPClient&lt;&#x2F;code&gt; serialises calls so only one runs at a time. Unlikely in a private bot, but the right default.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Keep tool responses small.&lt;&#x2F;strong&gt; The first version of &lt;code&gt;search_tips&lt;&#x2F;code&gt; returned full tip bodies for every match. A query like &quot;ast&quot; matched 30+ tips and returned 150k characters, enough to hit MCP&#x27;s token limit outright and fill Claude&#x27;s context window even when it didn&#x27;t. The fix is a two-phase design: search returns id&#x2F;title&#x2F;tags only, &lt;code&gt;get_tip&lt;&#x2F;code&gt; fetches the body for the specific tip Claude wants to show. Also use word-boundary matching (&lt;code&gt;\bquery\b&lt;&#x2F;code&gt;) so short tokens don&#x27;t match as substrings inside unrelated words.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;To run it, add the Slack tokens and Anthropic API key to an &lt;code&gt;.env&lt;&#x2F;code&gt; file in the project root. Also add .env to .gitignore so you don&#x27;t accidentally commit secrets.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SLACK_BOT_TOKEN=xoxb-...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SLACK_APP_TOKEN=xapp-...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANTHROPIC_API_KEY=sk-ant-...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Run it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run python slack_bot.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;&#x2F;tip how do generators save memory?&lt;&#x2F;code&gt; slash command in Slack gets a real Claude response, not a keyword match. Claude reads the tips and explains the relevant ones in context. Using &lt;code&gt;respond()&lt;&#x2F;code&gt; instead of &lt;code&gt;say()&lt;&#x2F;code&gt; keeps the reply only visible to you, so it works in any channel without spamming everyone.&lt;&#x2F;p&gt;
&lt;p&gt;How to write a good decorator? &lt;code&gt;&#x2F;tip how to write a good decorator&lt;&#x2F;code&gt; in Slack:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;mcp-slack-decorator.png&quot; alt=&quot;Slack bot responding to &#x2F;tip how to write a good decorator&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Check for tips about context managers with &lt;code&gt;&#x2F;tip context manager&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;mcp-slack-context-managers.png&quot; alt=&quot;Slack bot responding to &#x2F;tip context managers&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Check if there is any library recommendation for article scraping with &lt;code&gt;&#x2F;tip web scraping&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;mcp-slack-newspaper3k.png&quot; alt=&quot;Slack bot finding a tip about newspaper3k for a scraping question&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;In case there is no matching tip, the bot still responds with a general answer based on the knowledge in the tips collection, rather than just saying &quot;no results&quot;, for example:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;mcp-slack-design-pattern.png&quot; alt=&quot;Slack bot falling back to general knowledge when no specific tip is found&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-bother-with-mcp-at-all&quot;&gt;Why bother with MCP at all?&lt;&#x2F;h2&gt;
&lt;p&gt;You could point Claude at your notes directory directly, or grep in a shell script, or have the Slack bot do its own keyword matching.&lt;&#x2F;p&gt;
&lt;p&gt;The difference is the reasoning layer. When &lt;code&gt;&#x2F;tip how do generators save memory?&lt;&#x2F;code&gt; arrives in Slack, Claude decides whether to call &lt;code&gt;search_tips&lt;&#x2F;code&gt;, which query to use, whether to follow up with &lt;code&gt;get_tip&lt;&#x2F;code&gt; for the best result, and how to synthesize the answer. That&#x27;s not a grep, it&#x27;s the same reasoning you get in Claude Code, reachable from a different interface.&lt;&#x2F;p&gt;
&lt;p&gt;The MCP server is also a reusable contract. Claude Code uses it natively over stdio. Claude Desktop uses it. Any other MCP-compliant client uses it without any new integration code. Slack isn&#x27;t an MCP client, so the bot bridges the gap with the Claude API. It still connects to the same MCP server via stdio, fetching tools dynamically rather than duplicating them.&lt;&#x2F;p&gt;
&lt;p&gt;As more tools adopt MCP (editors, terminals, agents), your server becomes more valuable without changes on your end.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;The MCP server is 78 lines. The Slack bot is another 120. My 240 tips are now searchable from two places I work in every day. In both cases Claude is doing the reasoning through the same MCP server, not a keyword grep or a duplicated tool definition.&lt;&#x2F;p&gt;
&lt;p&gt;Full source on GitHub: &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;mcp_tips_example&quot;&gt;bbelderbos&#x2F;mcp_tips_example&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;What would you use MCP for? A personal knowledge base, a code snippet library, a recipe collection? Let me know when you build one.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>What Rust Structs Taught Me About State Ownership</title>
        <published>2026-04-08T00:00:00+00:00</published>
        <updated>2026-04-08T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/what-rust-structs-taught-me-about-state-ownership/"/>
        <id>https://belderbos.dev/blog/what-rust-structs-taught-me-about-state-ownership/</id>
        <summary type="html">&lt;p&gt;Python classes bundle data and behavior together. Rust separates them, and that separation taught me to ask a question I&#x27;d been skipping for years.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Python classes bundle data and behavior together. Rust separates them, and that separation taught me to ask a question I&#x27;d been skipping for years.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;python-classes&quot;&gt;Python classes&lt;&#x2F;h2&gt;
&lt;p&gt;In Python, when you need to track state across multiple operations, you reach for a class:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tokenizer&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, input:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; list&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;input&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.position&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; advance&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ch&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.chars[&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.position]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.position&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; ch&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; peek&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.chars[&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.position]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every method receives &lt;code&gt;self&lt;&#x2F;code&gt; and can read or mutate any field. The language doesn&#x27;t distinguish between methods that observe state and methods that change it. That&#x27;s convenient, but it hides something important.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;rust-separates-data-from-behavior&quot;&gt;Rust separates data from behavior&lt;&#x2F;h2&gt;
&lt;p&gt;In Rust, you define data with &lt;code&gt;struct&lt;&#x2F;code&gt; and behavior with &lt;code&gt;impl&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;struct&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tokenizer&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Vec&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;char&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;    position&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; usize&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;impl&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Tokenizer&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; advance&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword z-storage&quot;&gt;&amp;amp;mut&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Option&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;char&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;        let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; ch&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;position)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;copied&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;position &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;+=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        ch&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; peek&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Option&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;char&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;chars&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span&gt;position)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;copied&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt; &#x2F;&#x2F; char is Copy, returns a value not a reference&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Notice the difference in method signatures. &lt;code&gt;advance&lt;&#x2F;code&gt; takes &lt;code&gt;&amp;amp;mut self&lt;&#x2F;code&gt; because it modifies &lt;code&gt;position&lt;&#x2F;code&gt;. &lt;code&gt;peek&lt;&#x2F;code&gt; takes &lt;code&gt;&amp;amp;self&lt;&#x2F;code&gt; because it only reads. This isn&#x27;t a style choice. The compiler enforces it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-rust-is-explicit-about&quot;&gt;What Rust is explicit about&lt;&#x2F;h2&gt;
&lt;p&gt;In Python, I never asked &quot;does this method mutate state?&quot; I&#x27;d look at the implementation if I cared. In Rust, the signature tells you before you read a single line of the method body.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&amp;amp;self&lt;&#x2F;code&gt; means: this method borrows the struct immutably. You can call it while other code holds a reference. It&#x27;s safe to call in parallel (unlike Python, where you&#x27;d need manual locking to guarantee the same).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&amp;amp;mut self&lt;&#x2F;code&gt; means: this method borrows the struct mutably. No other code can access the struct while this method runs. It&#x27;s changing something.&lt;&#x2F;p&gt;
&lt;p&gt;Python doesn&#x27;t model this distinction at all. You can write an observer method that quietly mutates state, and nothing stops you. In Rust, the signature is a contract: &lt;code&gt;&amp;amp;self&lt;&#x2F;code&gt; means you cannot mutate, full stop.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why this matters&lt;&#x2F;h2&gt;
&lt;p&gt;When I converted a function-based parser to use structs, I had to answer questions Python never made me ask:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Which methods need mutable access? Only &lt;code&gt;advance&lt;&#x2F;code&gt;. The position changes.&lt;&#x2F;li&gt;
&lt;li&gt;Which methods are pure observers? &lt;code&gt;peek&lt;&#x2F;code&gt; and &lt;code&gt;is_at_end&lt;&#x2F;code&gt;. They look but don&#x27;t touch.&lt;&#x2F;li&gt;
&lt;li&gt;What data does the struct actually need? Just the input and position. Nothing else.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In Python, I might have added a &lt;code&gt;self._current_token&lt;&#x2F;code&gt; field to signal &quot;internal only&quot;, but that&#x27;s still at the class level, so any method can modify it indistinguishably. Rust&#x27;s borrow checker would have forced me to think harder about whether that state was necessary.&lt;&#x2F;p&gt;
&lt;p&gt;What Rust structs give you is clarity. Data lives in one place. Behavior lives in another. And every method signature tells you exactly how it interacts with the data.&lt;&#x2F;p&gt;
&lt;p&gt;Python&#x27;s flexibility is often what you want, but Rust takes &lt;em&gt;explicit is better than implicit&lt;&#x2F;em&gt; further, and it changes the questions you ask back in Python. Does this method need to mutate state, or am I just being lazy? Could this be a standalone function? Am I bundling state because it&#x27;s necessary, or because &lt;code&gt;self.x&lt;&#x2F;code&gt; is easy to type?&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Rust&#x27;s struct + impl separation isn&#x27;t about syntax. It&#x27;s about forcing you to decide who owns data and who can change it. In our Rust cohort, this is often the first moment people think carefully about how data actually flows through a program. In Python, these decisions are often implicit. In Rust, they&#x27;re in the signatures and the compiler enforces them.&lt;&#x2F;p&gt;
&lt;p&gt;That explicitness is uncomfortable at first. Then it becomes a habit. Then you start applying it even when the compiler isn&#x27;t watching.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Build a Finite State Machine in Python</title>
        <published>2026-04-07T00:00:00+00:00</published>
        <updated>2026-04-07T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/build-finite-state-machine-python/"/>
        <id>https://belderbos.dev/blog/build-finite-state-machine-python/</id>
        <summary type="html">&lt;p&gt;Libraries like &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;pytransitions&#x2F;transitions&quot;&gt;transitions&lt;&#x2F;a&gt; make using finite state machines (FSMs) easy. Maybe too easy. You wire up states, transitions, and callbacks, and it &#x27;just&#x27; works. But when you need to customize behavior, debug a stuck workflow, or explain to a teammate why a transition silently failed, you need a deeper understanding. And what better way than building a simple FSM from scratch?&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Libraries like &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;pytransitions&#x2F;transitions&quot;&gt;transitions&lt;&#x2F;a&gt; make using finite state machines (FSMs) easy. Maybe too easy. You wire up states, transitions, and callbacks, and it &#x27;just&#x27; works. But when you need to customize behavior, debug a stuck workflow, or explain to a teammate why a transition silently failed, you need a deeper understanding. And what better way than building a simple FSM from scratch?&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;I&#x27;ve used the &lt;code&gt;transitions&lt;&#x2F;code&gt; library in production for years to manage content workflows: draft to review to approved to published, with callbacks that assign reviewers and send notifications. It looks something like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; enum&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; StrEnum, auto&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; transitions&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; Machine&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; States&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;StrEnum&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    DRAFT&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; auto()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    REVIEW&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; auto()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    APPROVED&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; auto()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    PUBLISHED&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; auto()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;TRANSITIONS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;trigger&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;submit&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;source&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DRAFT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;dest&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;REVIEW&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;after&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;assign_reviewer&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;trigger&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;source&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;REVIEW&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;dest&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;APPROVED&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;after&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;notify_author&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;trigger&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;publish&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;source&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;APPROVED&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;dest&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;PUBLISHED&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;after&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;clear_tasks&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Machine(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;model&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;model,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; states&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;list&lt;&#x2F;span&gt;&lt;span&gt;(States),&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; transitions&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;TRANSITIONS&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; initial&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;States.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;DRAFT&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;StrEnum&lt;&#x2F;code&gt; + &lt;code&gt;auto()&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; gives each member a value equal to its lowercased name (&lt;code&gt;States.DRAFT == &quot;draft&quot;&lt;&#x2F;code&gt;). Because &lt;code&gt;StrEnum&lt;&#x2F;code&gt; instances &lt;em&gt;are&lt;&#x2F;em&gt; strings, you can use them directly as dict keys, in comparisons, and as arguments; no &lt;code&gt;.value&lt;&#x2F;code&gt; unwrapping needed.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Powerful. But what&#x27;s actually happening when you call &lt;code&gt;model.submit()&lt;&#x2F;code&gt;? How does it validate transitions, run guards, fire callbacks? Building one from scratch answers those questions and makes you better at using (and extending) the library.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;build-a-simple-fsm-from-scratch&quot;&gt;Build a simple FSM from scratch&lt;&#x2F;h2&gt;
&lt;p&gt;Let&#x27;s model a GitHub pull request (PR) workflow, something every developer knows. Here&#x27;s the state diagram:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;fsm-state-diagram.png&quot; alt=&quot;FSM state diagram for GitHub PR workflow&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;PRs move through states with rules:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;You can&#x27;t merge without approval,&lt;&#x2F;li&gt;
&lt;li&gt;Closed PRs can reopen,&lt;&#x2F;li&gt;
&lt;li&gt;The merged state is terminal.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Start with the transition map. This is the core data structure: a dictionary where each state maps to its set of valid next states.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;TRANSITIONS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;draft&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;closed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;changes_requested&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;approved&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;closed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;changes_requested&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;closed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;approved&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;merged&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;closed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;merged&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; set&lt;&#x2F;span&gt;&lt;span&gt;(),&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # terminal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;closed&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;An empty set means terminal, done. This makes it easy to check, relying on Python&#x27;s truthiness: &lt;code&gt;if not transitions[state]:&lt;&#x2F;code&gt; means &quot;this state has no valid next states.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Next, let&#x27;s build the &lt;code&gt;StateMachine&lt;&#x2F;code&gt; class. I often say: start with functions, but FSMs bundle state and behavior in a way that makes classes the right fit. &lt;code&gt;sm.current&lt;&#x2F;code&gt;, &lt;code&gt;sm.transition(&quot;open&quot;)&lt;&#x2F;code&gt;, &lt;code&gt;sm.can_transition(&quot;merged&quot;)&lt;&#x2F;code&gt;: a clean interface that only a class gives you.&lt;&#x2F;p&gt;
&lt;p&gt;The transition map is passed in rather than hardcoded. This keeps the class reusable for any workflow and makes the dependency explicit.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; InvalidTransition&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;Exception&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    pass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; StateMachine&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, initial, transitions: dict[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;, set[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]],&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, guards&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, hooks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, context&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;None&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; initial&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._transitions&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; transitions&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._guards&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; guards&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; guards&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._hooks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; hooks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; hooks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._context&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; context&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; if&lt;&#x2F;span&gt;&lt;span&gt; context&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; is not&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; {}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._history: list[tuple[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;]]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;property&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; current&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._state&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;property&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; context&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._context&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;property&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; history&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; list&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;._history)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; can_transition&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, to):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; to&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._transitions.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;._state,&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; set&lt;&#x2F;span&gt;&lt;span&gt;())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; transition&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, to):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if not&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.can_transition(to):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span&gt; InvalidTransition(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Cannot go from &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-variable z-language&quot;&gt;{self&lt;&#x2F;span&gt;&lt;span&gt;._state&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; to &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;to&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        errors&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; []&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        for&lt;&#x2F;span&gt;&lt;span&gt; guard&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._guards:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            if not&lt;&#x2F;span&gt;&lt;span&gt; guard(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;, to):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                errors.append(guard.&lt;&#x2F;span&gt;&lt;span class=&quot;z-support z-variable&quot;&gt;__name__&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; errors:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            raise&lt;&#x2F;span&gt;&lt;span&gt; InvalidTransition(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;                f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Cannot go from &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-variable z-language&quot;&gt;{self&lt;&#x2F;span&gt;&lt;span&gt;._state&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; to &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;to&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;                f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;due to failed guards: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;#39;, &amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;.join(errors)&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        previous&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._state&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._state&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; to&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;._history.append((previous, to))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        for&lt;&#x2F;span&gt;&lt;span&gt; hook&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._hooks.get(to, []):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            hook(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;, previous, to)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Less than 50 lines. Let&#x27;s break down the four extension points:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Guards&lt;&#x2F;strong&gt; are functions that say &quot;not yet.&quot; They receive the machine and target state, returning a boolean. All must pass. For example, a PR can&#x27;t be merged without approvals:&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; require_approval&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(sm, to):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; to&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;merged&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; or&lt;&#x2F;span&gt;&lt;span&gt; sm.context.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;approvals&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sm&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; StateMachine(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;draft&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; TRANSITIONS&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; guards&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;[require_approval],&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; context&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;approvals&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sm.transition(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sm.transition(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;approved&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sm.transition(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;merged&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # raises InvalidTransition: failed guard&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;Hooks&lt;&#x2F;strong&gt; fire after a successful transition. They&#x27;re callbacks keyed by destination state:&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; notify_reviewers&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(sm, from_state, to_state):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    sm.context[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;notifications&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; sm.context.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;notifications&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sm&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; StateMachine(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;draft&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; TRANSITIONS&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; hooks&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [notify_reviewers]})&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;sm.transition(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;assert&lt;&#x2F;span&gt;&lt;span&gt; sm.context[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;notifications&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Context&lt;&#x2F;strong&gt; is a dict guards and hooks can read and write: approval counts, assigned reviewers, timestamps. Not needed in this example, but in production it&#x27;s where you track everything that doesn&#x27;t belong in state itself.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;History&lt;&#x2F;strong&gt; is an append-only audit trail. Every successful transition records &lt;code&gt;(from_state, to_state)&lt;&#x2F;code&gt;. Failed guard names surface in the &lt;code&gt;InvalidTransition&lt;&#x2F;code&gt; exception message. No instance state needed, no risk of stale data between calls.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The full implementation with tests is available as a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gist.github.com&#x2F;bbelderbos&#x2F;9f543590707298556a194aff80c85a22&quot;&gt;GitHub Gist&lt;&#x2F;a&gt;, a single file you can run with &lt;code&gt;uvx pytest fsm.py -v&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-functional-alternative&quot;&gt;The functional alternative&lt;&#x2F;h2&gt;
&lt;p&gt;If you don&#x27;t need guards, hooks, or history, Python&#x27;s &lt;code&gt;match&#x2F;case&lt;&#x2F;code&gt; can offer a simpler state dispatcher:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; next_state&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(current:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;, action:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    match&lt;&#x2F;span&gt;&lt;span&gt; (current, action):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;draft&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;open&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;approve&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;approved&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;approved&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;merge&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; return&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;merged&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        case&lt;&#x2F;span&gt;&lt;span&gt; _:&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; ValueError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Invalid: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;action&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; from &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;current&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Clean and readable for 3-4 states. But it doesn&#x27;t scale. Add guards, hooks, and history, and it gets messy fast. That&#x27;s when a class-based FSM is the better choice.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;FSMs enforce business logic around valid system transitions. No more &quot;how did this PR end up merged without approval?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;A simple implementation can be a class of less than 100 lines of code, and it makes you understand the patterns behind FSMs: the transition map, guards, hooks, context, and history. If you want to use another level of abstraction, the &lt;code&gt;transitions&lt;&#x2F;code&gt; library is great.&lt;&#x2F;p&gt;
&lt;p&gt;The next time you see a workflow with states and rules, implement your own FSM.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>How to Migrate Users Between Django Apps on Login</title>
        <published>2026-04-06T00:00:00+00:00</published>
        <updated>2026-04-06T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/login-triggered-user-migration-django/"/>
        <id>https://belderbos.dev/blog/login-triggered-user-migration-django/</id>
        <summary type="html">&lt;p&gt;Migrating users between two live Django apps sounds like it needs one big migration script and a maintenance window. It turns out it doesn&#x27;t.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Migrating users between two live Django apps sounds like it needs one big migration script and a maintenance window. It turns out it doesn&#x27;t.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;As a user on the Pybites platform, you never had to do anything: just log in as usual, and if you had a v1 account, your premium status, exercise progress, and token balances were automatically imported into the new platform. No scripts, no maintenance window, just a series of API calls triggered by the login signal.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s how it worked.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem-two-platforms-one-user-base&quot;&gt;The Problem: Two Platforms, One User Base&lt;&#x2F;h2&gt;
&lt;p&gt;October 2024 the original Pybites platform, codechalleng.es (v1), showed its age, the UX was no longer up to standards.
My first intuition was to go full API and build a separate front-end, but it soon became clear this would add a lot of complexity.&lt;&#x2F;p&gt;
&lt;p&gt;This was a major Django code base with many business logic decisions and hundreds of users with history. Complex user profiles; from free to exercise token access based to full premium access.&lt;&#x2F;p&gt;
&lt;p&gt;So I built a new Django app (v2) with a clean slate, migrating the exercises and meta data, and using Tailwind CSS + HTMX for a much better front-end experience. However, I needed to migrate users from v1 to v2. Users data: premium status, completed&#x2F;attempted bites, token balances (earned&#x2F;unlocked). Ideally this transition would happen without asking them to do anything.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-constraint-that-shaped-everything&quot;&gt;The constraint that shaped everything&lt;&#x2F;h3&gt;
&lt;p&gt;What worked really well was deciding early on to use separate databases and treat them as separate apps. This meant I had to solve the migration problem, but it also meant I had a clean slate to design the v2 data model without worrying about legacy constraints. And users could try v2 before making the switch, which made the transition smoother.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-naive-approach-and-why-i-rejected-it&quot;&gt;The naive approach and why I rejected it&lt;&#x2F;h3&gt;
&lt;p&gt;When you&#x27;re dealing with a massive migration, the first instinct is to write a big batch script: export all users from v1, transform the data, import into v2. But this has some downsides:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The data can become stale between export and import, especially if users are active during the migration window&lt;&#x2F;li&gt;
&lt;li&gt;Timing issues: you have to choose a cutover date, and any users who log in after the export but before the import will have inconsistent data&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Instead I chose to migrate lazily, on first login. Each user migrated themselves when they logged in, one at a time. The data was always fresh, there was no need for a maintenance window. Users who never came back simply never migrated, no harm done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-lazy-migration-upon-login-design&quot;&gt;The &quot;Lazy migration upon login&quot; design&lt;&#x2F;h2&gt;
&lt;p&gt;This is the high-level flow I came up with:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;User logs in on V2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Django user_logged_in signal fires&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Check: v1_migration_done == False?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;POST V1:&#x2F;api&#x2F;v2&#x2F;token&#x2F;   ← prove you&amp;#39;re the same user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;JWT access token returned&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;PUT V1:&#x2F;api&#x2F;v2&#x2F;bite-user-data&#x2F;  (Bearer token)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;V1 returns: {profile, bite_tokens, bite_saves}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;V1 marks user inactive&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        ↓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;V2 imports data → marks v1_migration_done = True&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The key part was this Django signal handler in v2&#x27;s &lt;code&gt;accounts&#x2F;models.py&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@receiver&lt;&#x2F;span&gt;&lt;span&gt;(user_logged_in)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; social_account_logged_in_handler&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(request, user,&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; **&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;kwargs):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; settings.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;V1_MIGRATION_DISABLED&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; user.profile.v1_migration_done:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    social&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; user.socialaccount_set.first()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    provider&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; social.provider&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # &amp;quot;github&amp;quot; or &amp;quot;google&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    uid&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; social.uid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    payload&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;provider&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: provider,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;uid&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: uid}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;    # ... POST to v1, get JWT, PUT for data, import&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;There are three early returns here. The first two are shown above; the third bails out if the user has no social accounts, which brings us to the email&#x2F;password case below. I ended up introducing an environment variable to temporarily re-open the migration process after the grace period expired. The &lt;code&gt;v1_migration_done&lt;&#x2F;code&gt; flag is the most important one; it ensures that once a user has migrated, they won&#x27;t try to do it again on every login.&lt;&#x2F;p&gt;
&lt;p&gt;Email&#x2F;password users needed a different hook. The signal only fired after a successful login, but these users didn&#x27;t exist in v2 yet, so they never got that far. Instead, I overrode &lt;code&gt;form_invalid&lt;&#x2F;code&gt; in a custom &lt;code&gt;LoginView&lt;&#x2F;code&gt;. When login fails and the user doesn&#x27;t exist in v2 yet, instead of immediately showing an error, it tries v1 with the same credentials. If v1 confirms them, it creates the account and imports the data on the spot.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; CustomLoginView&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;LoginView&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; form_invalid&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, form):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; settings.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;V1_MIGRATION_DISABLED&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; super&lt;&#x2F;span&gt;&lt;span&gt;().form_invalid(form)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        email&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.request.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;POST&lt;&#x2F;span&gt;&lt;span&gt;.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;login&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        password&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.request.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;POST&lt;&#x2F;span&gt;&lt;span&gt;.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;password&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;        # user not in v2 yet, try v1 with the same credentials&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; httpx.post(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;settings.&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;PYBITES_CC_API_URL}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;token&#x2F;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        if&lt;&#x2F;span&gt;&lt;span&gt; response.status_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 200&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            user&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; User.objects.create_user(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            import_user_v1_data_into_v2(user,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            login(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;self&lt;&#x2F;span&gt;&lt;span&gt;.request, user,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; super&lt;&#x2F;span&gt;&lt;span&gt;().form_valid(form)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; super&lt;&#x2F;span&gt;&lt;span&gt;().form_invalid(form)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both paths call the same V1 endpoint, which returns:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# V1: api_v2&#x2F;views.py&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;profile&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;premium&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;newbie_access&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    },&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;bite_tokens&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;earned&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;        &amp;quot;unlocked_bite_ids&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;42&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 77&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 203&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ...&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    },&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;bite_saves&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        {&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;bite_id&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;code&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;ok&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;added&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;2023-...&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Side effect: V1 user set inactive after successful data retrieval&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;error-handling&quot;&gt;Error Handling&lt;&#x2F;h2&gt;
&lt;p&gt;I had to handle 3 failure modes gracefully, since this was happening on login and I didn&#x27;t want to break the user experience:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;User not found on V1 (404):&lt;&#x2F;strong&gt; User never had a v1 account; mark migration done, move on, no noise&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Auth failure:&lt;&#x2F;strong&gt; Log to a new &lt;code&gt;MigrationErrorLog&lt;&#x2F;code&gt; table, email admin, show neutral message to user&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Data import exception:&lt;&#x2F;strong&gt; Wrapped in &lt;code&gt;transaction.atomic()&lt;&#x2F;code&gt;; rollback on any failure, log it&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;There was one silent failure that shows how the devil is always in the details. The Google provider name was different between v1 and v2 (&quot;google-oauth2&quot; vs &quot;google&quot;), causing every Google user to fail to match, which was a silent failure since the migration just wouldn&#x27;t happen, but the user would never know why. The fix was simply to add a normalizer in the v1 API endpoint: &lt;code&gt;if provider == &quot;google&quot;: return &quot;google-oauth2&quot;&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;what-made-it-work-in-practice&quot;&gt;What made it work in practice&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;The idempotency flag (&lt;code&gt;v1_migration_done&lt;&#x2F;code&gt;) is the simplest, most important thing. Without it, every login retries the migration.&lt;&#x2F;li&gt;
&lt;li&gt;Lazy migration meant we could run both platforms in parallel for weeks, no hard cutover date&lt;&#x2F;li&gt;
&lt;li&gt;Using the user&#x27;s own OAuth credential as the auth mechanism meant no separate credential store, no &quot;import token&quot; to manage&lt;&#x2F;li&gt;
&lt;li&gt;V1 side effect (inactivating the user) was intentional and semantic: PUT, not GET, because it changes state&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Users migrated on first login with no manual steps, no downtime, no stale data, and almost no support tickets.&lt;&#x2F;p&gt;
&lt;p&gt;When we shut down the process, users would still reach out asking why their progress didn&#x27;t migrate, but that was expected and was easily handled by toggling the &lt;code&gt;V1_MIGRATION_DISABLED&lt;&#x2F;code&gt; flag to allow exceptional late migrations (which also shows the power of the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;12factor.net&#x2F;config&quot;&gt;12 Factor App&#x27;s recommendation of separating config from code&lt;&#x2F;a&gt;).&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;What&#x27;s a challenging migration you&#x27;ve had to do, and how did you approach it? Reach out and let me know.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>How Modern Python Tooling Simplified My Setup</title>
        <published>2026-03-30T00:00:00+00:00</published>
        <updated>2026-03-30T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/modern-python-tooling-uv-ruff-ty/"/>
        <id>https://belderbos.dev/blog/modern-python-tooling-uv-ruff-ty/</id>
        <summary type="html">&lt;p&gt;A few years ago, setting up a Python project meant wiring together six or seven tools before writing a single line of code. Today that list has shrunk dramatically. The tools are faster, more ergonomic, and far simpler to configure.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;A few years ago, setting up a Python project meant wiring together six or seven tools before writing a single line of code. Today that list has shrunk dramatically. The tools are faster, more ergonomic, and far simpler to configure.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;the-old-python-tooling-setup&quot;&gt;The Old Python Tooling Setup&lt;&#x2F;h2&gt;
&lt;p&gt;A &quot;proper&quot; Python project circa 2021 looked something like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;pyenv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; install&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 3.10.4&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;           # manage Python versions&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;python&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -m&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; venv .venv&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;           # create virtualenv&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;pip&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; install&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; -e&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;.[dev]&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;        # install deps (slow)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;black&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; .&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                        # format&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;isort&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; .&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                        # sort imports (separate tool!)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;flake8&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; .&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                       # lint&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;mypy&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; .&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;                         # type check&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Seven commands, seven config sections. Each tool had its own config format. Sometimes tools needed a compatibility setting so they wouldn&#x27;t step on each other&#x27;s toes (for example &lt;code&gt;black&lt;&#x2F;code&gt; and &lt;code&gt;isort&lt;&#x2F;code&gt;). &lt;code&gt;flake8&lt;&#x2F;code&gt; didn&#x27;t support &lt;code&gt;pyproject.toml&lt;&#x2F;code&gt; without a plugin. Pre-commit required much config just to wire these together.&lt;&#x2F;p&gt;
&lt;p&gt;And releasing to PyPI? Even after pyproject.toml simplified packaging, you still need to choose a build backend like Hatchling, Flit, or Setuptools, and then, unless your tool has a built-in uploader, use Twine to publish.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;uv-ruff-and-ty-the-modern-stack&quot;&gt;uv, ruff, and ty: The Modern Stack&lt;&#x2F;h2&gt;
&lt;p&gt;Nowadays we can just do:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; init myproject&lt;&#x2F;span&gt;&lt;span&gt; &amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; cd&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; myproject&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; add fastapi sqlmodel&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; add&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --dev&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; ruff ty pytest&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run ruff check&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run ruff format&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run ty check&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;uv&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; run pytest&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;uv&lt;&#x2F;code&gt; handles Python versions, virtualenvs, dependency resolution, locking, and building. If you&#x27;ve written Rust, this will feel familiar: &lt;code&gt;uv&lt;&#x2F;code&gt; brings the &lt;code&gt;cargo&lt;&#x2F;code&gt; experience to Python. &lt;code&gt;ruff&lt;&#x2F;code&gt; replaces black, isort, and flake8 in a single binary. &lt;code&gt;ty&lt;&#x2F;code&gt; is the new type checker from the same Astral team and is much faster than mypy.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s a real &lt;code&gt;pyproject.toml&lt;&#x2F;code&gt; from &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;mdweaver&quot;&gt;one of my projects&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;project&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;name =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;mdweaver&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;version =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;0.2.0&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;requires-python =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;&amp;gt;=3.12&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;dependencies = [&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;markdown&amp;gt;=3.5&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;pygments&amp;gt;=2.17&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;    &amp;quot;weasyprint&amp;gt;=60.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;dependency-groups&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;dev = [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;pytest&amp;gt;=8.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;pytest-cov&amp;gt;=4.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;ruff&amp;gt;=0.9.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;tbump&amp;gt;=6.11.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;ty&amp;gt;=0.0.12&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;build-system&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;requires = [&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;uv_build&amp;gt;=0.10.8,&amp;lt;0.11.0&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;build-backend =&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;uv_build&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;[tool.black]&lt;&#x2F;code&gt;, no &lt;code&gt;[tool.isort]&lt;&#x2F;code&gt;, no &lt;code&gt;[tool.mypy]&lt;&#x2F;code&gt;. Ruff works with sensible defaults. One file configures everything. And notice the build backend: &lt;code&gt;uv_build&lt;&#x2F;code&gt; means even packaging is handled by uv, no separate &lt;code&gt;hatchling&lt;&#x2F;code&gt; or &lt;code&gt;setuptools&lt;&#x2F;code&gt; dependency needed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pre-commit-shrinks-too&quot;&gt;Pre-commit Shrinks Too&lt;&#x2F;h2&gt;
&lt;p&gt;The pre-commit config across my projects went from 30+ lines (black, isort, flake8, mypy hooks) to this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;repos&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt; repo&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; https:&#x2F;&#x2F;github.com&#x2F;pre-commit&#x2F;pre-commit-hooks&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;    rev&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; v4.6.0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;    hooks&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      -&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; trailing-whitespace&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      -&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; end-of-file-fixer&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt; repo&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; https:&#x2F;&#x2F;github.com&#x2F;astral-sh&#x2F;ruff-pre-commit&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;    rev&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; v0.6.4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;    hooks&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      -&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; ruff&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt;        args&lt;&#x2F;span&gt;&lt;span&gt;: [&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;--fix&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; --extend-select=I&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      -&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-tag&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; ruff-format&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One tool does what three used to.&lt;&#x2F;p&gt;
&lt;p&gt;And if you want the hook runner itself to be fast too, &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;prek.j178.dev&quot;&gt;prek&lt;&#x2F;a&gt; is a Rust-based drop-in replacement for pre-commit. Same &lt;code&gt;.pre-commit-config.yaml&lt;&#x2F;code&gt; config, faster execution.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;releasing-got-simple-too&quot;&gt;Releasing Got Simple Too&lt;&#x2F;h2&gt;
&lt;p&gt;Shipping a package to PyPI used to mean a build backend, &lt;code&gt;twine upload&lt;&#x2F;code&gt;, and API token management. Now I use &lt;code&gt;tbump&lt;&#x2F;code&gt; (ok this is a separate tool) and &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;docs.pypi.org&#x2F;trusted-publishers&#x2F;&quot;&gt;trusted publishing&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; uv run tbump&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.2.0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; Bumping from&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.1.0&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; to&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.2.0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; Would patch these files&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;-&lt;&#x2F;span&gt;&lt;span class=&quot;z-string z-punctuation z-definition z-string&quot;&gt; pyproject.toml:3 version = &amp;quot;0.1.0&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;+&lt;&#x2F;span&gt;&lt;span class=&quot;z-string z-punctuation z-definition z-string&quot;&gt; pyproject.toml:3 version = &amp;quot;0.2.0&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; Would run these git commands&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; git add&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --update&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; git commit&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --message&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; chore: bump version to&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.2.0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; git tag&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --annotate --message&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; v0.2.0 v0.2.0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;$&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; git push&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; --atomic&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; origin main v0.2.0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-support&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; Looking good?&lt;&#x2F;span&gt;&lt;span&gt; (y&#x2F;N)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; y&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One command bumps the version, commits, tags, and pushes. The tag triggers a &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bbelderbos&#x2F;mdweaver&#x2F;blob&#x2F;main&#x2F;.github&#x2F;workflows&#x2F;release.yml&quot;&gt;GitHub Action&lt;&#x2F;a&gt; that runs lint, tests, &lt;code&gt;uv build&lt;&#x2F;code&gt;, and publishes to PyPI. No API tokens to manage, no twine.&lt;&#x2F;p&gt;
&lt;p&gt;I learned this workflow from &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.loopwerk.io&#x2F;articles&#x2F;2025&#x2F;automate-python-releases&#x2F;&quot;&gt;Automate Python package releases&lt;&#x2F;a&gt;, worth reading if you publish packages.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-note-on-maturity&quot;&gt;A Note on Maturity&lt;&#x2F;h2&gt;
&lt;p&gt;Back to the &lt;em&gt;Astral stack&lt;&#x2F;em&gt;. These tools are still young: &lt;code&gt;ty&lt;&#x2F;code&gt; is in beta at the time of writing. The pace of development is fast, and they already work well for most projects. Since switching to uv I&#x27;ve not looked back.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;resources&quot;&gt;Resources&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pydevtools.com&#x2F;handbook&#x2F;&quot;&gt;Python Developer Tooling Handbook&lt;&#x2F;a&gt; has many useful short guides and tips, a resource to watch.&lt;&#x2F;p&gt;
&lt;p&gt;I spoke with creator Charlie Marsh on the Pybites Podcast about the design decisions behind these tools:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=byynvdS_7ac&quot;&gt;#175: Charlie Marsh on Ruff, uv and designing fast + ergonomic Python tooling&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=iiZgsKwiA9A&quot;&gt;#199: Charlie Marsh on ty, uv, and the Python tooling renaissance&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If you&#x27;re still wiring together six tools to start a Python project, try this stack for your next one. The setup time alone will convince you.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>The Repository Pattern: Swap Data Sources in One Line</title>
        <published>2026-03-27T00:00:00+00:00</published>
        <updated>2026-03-27T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/repository-pattern-swappable-data-sources/"/>
        <id>https://belderbos.dev/blog/repository-pattern-swappable-data-sources/</id>
        <summary type="html">&lt;p&gt;Your live data source just broke. The API provider changed their auth overnight, no warning, no migration path. Now your entire app is blocked.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;Your live data source just broke. The API provider changed their auth overnight, no warning, no migration path. Now your entire app is blocked.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;&lt;h2 id=&quot;when-the-live-feed-dies&quot;&gt;When the live feed dies&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;m coaching a developer building an F1 live race dashboard. The app streams driver positions in real time via Server-Sent (SSE) Events, using Datastar.&lt;&#x2F;p&gt;
&lt;p&gt;The original plan was to use OpenF1&#x27;s MQTT feed. Then, one day, F1 locked down their SignalR stream so this feed broke overnight. No warning, no migration path. If the app had been hardwired to that one data source, development would have stopped cold.&lt;&#x2F;p&gt;
&lt;p&gt;If your code is coupled to one source, you inherit all of its instability.&lt;&#x2F;p&gt;
&lt;p&gt;The fix: abstract your data source behind an &lt;em&gt;interface&lt;&#x2F;em&gt;. This allows you to swap implementations without touching your app code. This is the &lt;em&gt;repository pattern&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;define-the-contract&quot;&gt;Define the contract&lt;&#x2F;h2&gt;
&lt;p&gt;Start with an abstract base class. Keep it minimal: just the methods your app actually calls.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; abc&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; ABC&lt;&#x2F;span&gt;&lt;span&gt;, abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; dataclasses&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Driver&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    number:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    team:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@dataclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; Position&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    driver_name:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    driver_number:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    position:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    change:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; RaceDataSource&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;ABC&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_positions&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Position]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;        ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;    @abstractmethod&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Driver]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;        ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is your contract. Every data source returns the same types. Your app code never knows or cares where the data comes from. (For a deeper dive into the pattern itself, the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.cosmicpython.com&#x2F;book&#x2F;chapter_02_repository.html&quot;&gt;Cosmic Python&lt;&#x2F;a&gt; chapter on repositories is excellent.)&lt;&#x2F;p&gt;
&lt;p&gt;One honest caveat on naming: a textbook repository abstracts a &lt;em&gt;collection&lt;&#x2F;em&gt; of domain objects you persist to and query (&lt;code&gt;add&lt;&#x2F;code&gt;, &lt;code&gt;get&lt;&#x2F;code&gt;, &lt;code&gt;list&lt;&#x2F;code&gt;), like the &lt;a href=&quot;&#x2F;blog&#x2F;build-data-layer-before-llm&#x2F;&quot;&gt;expense repository&lt;&#x2F;a&gt; I cover elsewhere. This live, read-only feed is strictly closer to a &lt;em&gt;gateway&lt;&#x2F;em&gt;, an abstraction over an external system. I use &quot;repository&quot; here in the looser, everyday sense of a swappable data-source boundary; the swap mechanics are identical either way.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;ABC vs Protocol:&lt;&#x2F;strong&gt; An ABC enforces the contract through inheritance. Subclasses must implement the abstract methods or they fail at instantiation. Python&#x27;s &lt;code&gt;typing.Protocol&lt;&#x2F;code&gt; offers an alternative: &lt;em&gt;structural subtyping&lt;&#x2F;em&gt;. A class satisfies the protocol if it has the right methods, no inheritance required. For this example, ABC works well because we control all implementations. Protocol shines when you want third-party code to conform to your interface without inheriting from your base class.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;build-the-implementations&quot;&gt;Build the implementations&lt;&#x2F;h2&gt;
&lt;p&gt;After the OpenF1 breakage, we evaluated alternatives. Sportmonks turned out to be the most reliable option: REST-based, 10-15 second latency, and it handles the SignalR complexity on their end.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; SportmonksDataSource&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;RaceDataSource&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_positions&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Position]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        async with&lt;&#x2F;span&gt;&lt;span&gt; httpx.AsyncClient()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; client:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            resp&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; client.get(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;                f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{BASE_URL}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;fixtures&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;session_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;positions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                headers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Bearer &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-variable z-language&quot;&gt;{self&lt;&#x2F;span&gt;&lt;span&gt;.api_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; resp.json()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; [Position(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;**&lt;&#x2F;span&gt;&lt;span&gt;p)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; p&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;positions&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Driver]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        async with&lt;&#x2F;span&gt;&lt;span&gt; httpx.AsyncClient()&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; client:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            resp&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; client.get(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;                f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{BASE_URL}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;fixtures&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;session_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&#x2F;drivers&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;                headers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;{&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt; f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Bearer &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant z-variable z-language&quot;&gt;{self&lt;&#x2F;span&gt;&lt;span&gt;.api_key&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;},&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            data&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; resp.json()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            return&lt;&#x2F;span&gt;&lt;span&gt; [Driver(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;**&lt;&#x2F;span&gt;&lt;span&gt;d)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; for&lt;&#x2F;span&gt;&lt;span&gt; d&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; in&lt;&#x2F;span&gt;&lt;span&gt; data[&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;drivers&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But F1 races happen on weekends. Development happens anytime. You can&#x27;t iterate on a live dashboard without live-shaped data.&lt;&#x2F;p&gt;
&lt;p&gt;So we built a fake that simulates a race from recorded data:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;class&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; FakeDataSource&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity&quot;&gt;RaceDataSource&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    def&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; __init__&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, data_file: Path, delay_ms:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 100&lt;&#x2F;span&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.delay_ms&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; delay_ms&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;._load_drivers(data_file)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.simulator&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; RaceSimulator(&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt;drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;drivers)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_positions&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Position]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-language&quot;&gt;        self&lt;&#x2F;span&gt;&lt;span&gt;.simulator.tick()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.simulator.get_current_positions()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_drivers&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(self, session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; list[Driver]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-language&quot;&gt; self&lt;&#x2F;span&gt;&lt;span&gt;.simulator.drivers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Notice that &lt;code&gt;FakeDataSource&lt;&#x2F;code&gt; has an &lt;code&gt;__init__&lt;&#x2F;code&gt; while the ABC doesn&#x27;t. That&#x27;s intentional: the ABC defines the contract (which methods your app can call), not how each implementation is constructed. Each data source needs different setup (file paths, API keys, simulators) and no &lt;code&gt;super().__init__()&lt;&#x2F;code&gt; is needed because the base class has no state.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;RaceSimulator&lt;&#x2F;code&gt; randomly swaps adjacent driver positions every few seconds. The dashboard looks alive even without a real race. You can develop the UI, test the SSE streaming, and demo to stakeholders, all without a live event.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;wire-it-up-with-a-factory&quot;&gt;Wire it up with a factory&lt;&#x2F;h2&gt;
&lt;p&gt;A factory function selects the right implementation at startup based on an environment variable. We use the python-decouple package to read from &lt;code&gt;.env&lt;&#x2F;code&gt; files, but you could use any config system.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; decouple&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; import&lt;&#x2F;span&gt;&lt;span&gt; config&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_data_source&lt;&#x2F;span&gt;&lt;span&gt;() -&amp;gt; RaceDataSource:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;DATA_SOURCE&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;fake&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;fake&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; FakeDataSource(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            data_file&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;Path(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;data&#x2F;replay.json&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable&quot;&gt;            delay_ms&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;config(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;FAKE_DELAY_MS&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; default&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;100&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable&quot;&gt; cast&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    elif&lt;&#x2F;span&gt;&lt;span&gt; source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;sportmonks&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span&gt; SportmonksDataSource()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    else&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        raise&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; ValueError&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;Unknown data source: &lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;source_type&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And for local development, I have this in my &lt;code&gt;.env&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Development&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;DATA_SOURCE&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;fake&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;FAKE_DELAY_MS&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;100&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# Race day (swap the above)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# DATA_SOURCE=sportmonks&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;# API_KEY=your_sportmonks_api_key&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The FastAPI endpoint stays the same regardless:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;@app.get&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;&#x2F;live&#x2F;stream&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; live_endpoint&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;    request: Request,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;    session_key:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; str&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;    data_source: RaceDataSource&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; Depends(get_data_source),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    async def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; stream&lt;&#x2F;span&gt;&lt;span&gt;():&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        while&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; True&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            positions&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = await&lt;&#x2F;span&gt;&lt;span&gt; data_source.get_positions(session_key)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            yield&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; SSE&lt;&#x2F;span&gt;&lt;span&gt;.patch_elements(render_positions(positions))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            await&lt;&#x2F;span&gt;&lt;span&gt; asyncio.sleep(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;SLEEP_INTERVAL&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; DatastarResponse(stream())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same endpoint, same rendering code, same SSE streaming. The only thing that changes is where the positions come from.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-not-just-mock&quot;&gt;Why not just mock?&lt;&#x2F;h2&gt;
&lt;p&gt;Mocks verify that your code calls the right methods with the right arguments. They don&#x27;t verify that your app works with real-shaped data.&lt;&#x2F;p&gt;
&lt;p&gt;A fake implementation exercises the same code paths as the real one. Your SSE rendering, your position sorting, your lap counter, all running against data that looks like production. And unlike mocks, you can actually &lt;em&gt;see&lt;&#x2F;em&gt; it in action. Open the dashboard UI and watch fake drivers swap positions. It feels real, even if it&#x27;s not. This is genuinely useful for development and demos.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;adding-a-third-source-is-now-trivial&quot;&gt;Adding a third source is now trivial&lt;&#x2F;h2&gt;
&lt;p&gt;When OpenF1 eventually comes back, or a new provider appears, you add one class and one &lt;code&gt;elif&lt;&#x2F;code&gt;. No endpoint changes, no rendering changes, no test rewiring. That&#x27;s the payoff: the pattern absorbs instability so your application code doesn&#x27;t have to.&lt;&#x2F;p&gt;
&lt;p&gt;The repository pattern isn&#x27;t an abstract architecture concept. It&#x27;s what kept a developer I&#x27;m coaching shipping features the week their data source died. Get the abstraction right early and external instability becomes a config change, not a rewrite.&lt;&#x2F;p&gt;
</content>
    </entry>
    <entry xml:lang="en">
        <title>Learning Rust Made Me a Better Python Developer</title>
        <published>2026-03-23T00:00:00+00:00</published>
        <updated>2026-03-23T00:00:00+00:00</updated>
        <author>
            <name>Bob Belderbos</name>
        </author>
        <link rel="alternate" type="text/html" href="https://belderbos.dev/blog/rust-made-me-a-better-python-developer/"/>
        <id>https://belderbos.dev/blog/rust-made-me-a-better-python-developer/</id>
        <summary type="html">&lt;p&gt;You might be writing Python that silently mutates data it shouldn&#x27;t, swallows exceptions it shouldn&#x27;t, and relies on type annotations as suggestions rather than contracts. Not because you&#x27;re careless, because Python lets you.&lt;&#x2F;p&gt;</summary>
        <content type="html">&lt;p&gt;You might be writing Python that silently mutates data it shouldn&#x27;t, swallows exceptions it shouldn&#x27;t, and relies on type annotations as suggestions rather than contracts. Not because you&#x27;re careless, because Python lets you.&lt;&#x2F;p&gt;
&lt;span id=&quot;continue-reading&quot;&gt;&lt;&#x2F;span&gt;
&lt;p&gt;Rust doesn&#x27;t. And six months of having a compiler refuse to let me move on changed how I write Python in ways no linter had managed before.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;rust-is-already-in-your-stack&quot;&gt;Rust Is Already in Your Stack&lt;&#x2F;h2&gt;
&lt;p&gt;You&#x27;re probably using Rust without knowing it. Polars is the faster Pandas alternative. Ruff replaced flake8, isort, and black in a single tool. &lt;a href=&quot;&#x2F;blog&#x2F;modern-python-tooling-uv-ruff-ty&#x2F;&quot;&gt;Uv&lt;&#x2F;a&gt; now handles virtual environments, package installation, and Python versions, replacing five separate tools.&lt;&#x2F;p&gt;
&lt;p&gt;Then there&#x27;s Pydantic. Version 2 rebuilt its validation core in Rust and became 17 times faster. You still write Python. You still &lt;code&gt;uv add pydantic&lt;&#x2F;code&gt;. But the performance-critical path runs compiled Rust code.&lt;&#x2F;p&gt;
&lt;p&gt;Python for orchestration, Rust for speed. That&#x27;s why the two languages aren&#x27;t competitors. They&#x27;re partners. PyO3 makes it straightforward to write Rust that exposes a Python API. You don&#x27;t need to learn Rust to benefit from these tools, but understanding what&#x27;s happening underneath changes how you think about your own code.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;ownership-taught-me-data-flow&quot;&gt;Ownership Taught Me Data Flow&lt;&#x2F;h2&gt;
&lt;p&gt;In Python, you pass objects around without thinking about who owns them. A function can receive a list, mutate it, and the caller&#x27;s data changes. Nobody asked permission.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process_items&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(items: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt; list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    items.append(&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt;&amp;quot;extra&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # mutates the caller&amp;#39;s list&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; items&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Rust, you have to be explicit about this. Does the function borrow the data or take ownership? Can it mutate? The compiler won&#x27;t let you leave this ambiguous.&lt;&#x2F;p&gt;
&lt;p&gt;Now when I write Python, I ask myself more often: does this function own the right to mutate the caller&#x27;s data? The answer is usually no. So I return a new list instead of mutating the input:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; process_items&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(items: list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]) -&amp;gt; list[&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;]:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; [&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;items,&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-string z-string&quot;&gt; &amp;quot;extra&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;]&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # returns a new list&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This eliminates an entire class of bugs where mutation leaks across function boundaries.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;result-types-taught-me-error-handling&quot;&gt;Result Types Taught Me Error Handling&lt;&#x2F;h2&gt;
&lt;p&gt;Python lets you ignore exceptions. A function can raise anything at any time, and if you don&#x27;t catch it, your program might crash at runtime. Thinking about failure paths upfront comes down to your experience and discipline.&lt;&#x2F;p&gt;
&lt;p&gt;Rust&#x27;s &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;&#x2F;code&gt; makes the choice explicit: this function can succeed with &lt;code&gt;T&lt;&#x2F;code&gt; or fail with &lt;code&gt;E&lt;&#x2F;code&gt;. You must handle both before the compiler lets you proceed.&lt;&#x2F;p&gt;
&lt;p&gt;I can&#x27;t port &lt;code&gt;Result&lt;&#x2F;code&gt; to Python literally, but the mindset transfers. Instead of:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_user&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(user_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; User:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&#x2F;users&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; User(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;**&lt;&#x2F;span&gt;&lt;span&gt;response.json())&lt;&#x2F;span&gt;&lt;span class=&quot;z-punctuation z-definition z-comment z-comment&quot;&gt;  # what if this fails?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I now make failure explicit in the return type:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;def&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; get_user&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-parameter z-function&quot;&gt;(user_id:&lt;&#x2F;span&gt;&lt;span class=&quot;z-support&quot;&gt; int&lt;&#x2F;span&gt;&lt;span&gt;) -&amp;gt; User&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; |&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; client.get(&lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;f&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&#x2F;users&#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;{&lt;&#x2F;span&gt;&lt;span&gt;user_id&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;}&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span&gt; response.status_code&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 404&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        return&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; None&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    response.raise_for_status()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span&gt; User(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;**&lt;&#x2F;span&gt;&lt;span&gt;response.json())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;the-compiler-won-t-let-you-move-on&quot;&gt;The Compiler Won&#x27;t Let You Move On&lt;&#x2F;h2&gt;
&lt;p&gt;Rust&#x27;s compiler is famously strict, but because of that, also famously helpful. &quot;Fighting the borrow checker&quot; is a phrase you commonly hear from people learning Rust. But this &quot;fight&quot; teaches you so much: about data flow, lifetimes, who is responsible for what.&lt;&#x2F;p&gt;
&lt;p&gt;In Python, a type checker warning is a suggestion. You can ignore it and ship. In Rust, the code doesn&#x27;t compile. There&#x27;s no &quot;I&#x27;ll fix that later.&quot; That changes your relationship with tooling. After months of a compiler that refuses to let you proceed with a half-understood problem, you stop treating &lt;code&gt;ty&lt;&#x2F;code&gt; diagnostics as optional. Your Python toolchain becomes an approximation of Rust&#x27;s compiler feedback loop.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pattern-matching-taught-me-to-think-in-cases&quot;&gt;Pattern Matching Taught Me to Think in Cases&lt;&#x2F;h2&gt;
&lt;p&gt;Rust&#x27;s &lt;code&gt;match&lt;&#x2F;code&gt; is exhaustive. You handle every variant of an enum, or it won&#x27;t compile. Add a new variant six months later? The compiler finds every &lt;code&gt;match&lt;&#x2F;code&gt; that needs updating.&lt;&#x2F;p&gt;
&lt;p&gt;Python has &lt;code&gt;match&lt;&#x2F;code&gt; since 3.10, but it doesn&#x27;t enforce exhaustiveness. You can leave out a case and nothing warns you. Rust taught me to ask: what are all the states this can be in? Am I handling each one?&lt;&#x2F;p&gt;
&lt;p&gt;This applies beyond &lt;code&gt;match&lt;&#x2F;code&gt; statements. Kobzol&#x27;s article &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;kobzol.github.io&#x2F;rust&#x2F;python&#x2F;2023&#x2F;05&#x2F;20&#x2F;writing-python-like-its-rust.html&quot;&gt;&quot;Writing Python like it&#x27;s Rust&quot;&lt;&#x2F;a&gt; captures it well: model your states explicitly, make invalid states unrepresentable. Use small named types instead of &lt;code&gt;dict[str, Any]&lt;&#x2F;code&gt;. Prefer &lt;code&gt;Header | Payload | Trailer&lt;&#x2F;code&gt; over a string field that could be anything. We can do all of this in Python, but Rust&#x27;s compiler trains the instinct.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;you-don-t-need-rust-to-apply-this&quot;&gt;You Don&#x27;t Need Rust to Apply This&lt;&#x2F;h2&gt;
&lt;p&gt;Python&#x27;s type system can approximate a lot of this. Advanced type hints, &lt;code&gt;Protocol&lt;&#x2F;code&gt;, &lt;code&gt;TypeVar&lt;&#x2F;code&gt;, union types, &lt;code&gt;match&lt;&#x2F;code&gt; statements. Pair those with a type checker (&lt;code&gt;ty&lt;&#x2F;code&gt; or &lt;code&gt;mypy&lt;&#x2F;code&gt;), and you catch a lot of what Rust catches at compile time.&lt;&#x2F;p&gt;
&lt;p&gt;But there&#x27;s a difference between knowing you &lt;em&gt;should&lt;&#x2F;em&gt; handle edge cases and having a compiler that &lt;em&gt;forces you&lt;&#x2F;em&gt; to do so. Rust builds that reflex. You develop a new instinct for Python.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-ai-angle&quot;&gt;The AI Angle&lt;&#x2F;h2&gt;
&lt;p&gt;When you direct an AI to write code, vague thinking produces vague output. &quot;Add error handling&quot; gets you something that compiles and looks reasonable but silently swallows the failure cases you didn&#x27;t think to name. Rust trains you to think about those cases upfront and that precision transfers directly to how you prompt and how you review what comes back.&lt;&#x2F;p&gt;
&lt;p&gt;Rust trains that precision. Ownership, explicit errors, exhaustive matching. These aren&#x27;t Rust-specific concepts, they&#x27;re a way of thinking about what your code is actually doing. Once that thinking is a reflex, your AI prompts get tighter, your reviews of AI output get sharper, and you catch the places where the model glossed over a failure path.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;key-takeaways&quot;&gt;Key Takeaways&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Ownership thinking prevents mutation bugs. Ask &quot;who owns this data?&quot; before mutating it.&lt;&#x2F;li&gt;
&lt;li&gt;Model failure in the return type. &lt;code&gt;User | None&lt;&#x2F;code&gt; communicates more than a docstring that says &quot;may raise.&quot;&lt;&#x2F;li&gt;
&lt;li&gt;Treat your toolchain as a compiler. &lt;code&gt;ty&lt;&#x2F;code&gt; warnings aren&#x27;t suggestions if you stop treating them that way.&lt;&#x2F;li&gt;
&lt;li&gt;Think in cases. If a value has three states, handle three states.&lt;&#x2F;li&gt;
&lt;li&gt;You don&#x27;t need to write Rust at work. The mindset transfer alone is worth the investment.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;resources&quot;&gt;Resources&lt;&#x2F;h2&gt;
&lt;p&gt;I documented my early Rust learning journey at &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rsbit.es&#x2F;&quot;&gt;rsbit.es&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Try the &lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;rustplatform.com&#x2F;&quot;&gt;intro exercises on the Rust Platform&lt;&#x2F;a&gt; to get a feel for the language.&lt;&#x2F;p&gt;
&lt;p&gt;Related Pybites Podcast episodes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=P47JUMSQagU&quot;&gt;#169: Bridging Python and Rust: An Interview with PyO3 Maintainer David Hewitt&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;noopener external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=-5uLLBvWK5Q&quot;&gt;#218: Why Python developers are learning Rust&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Rust doesn&#x27;t replace Python for me, but learning it gave me a stricter lens to look at Python through. Same language, fewer surprises.&lt;&#x2F;p&gt;
</content>
    </entry>
</feed>
