<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://aviz92.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://aviz92.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-06-14T14:04:07+03:00</updated><id>https://aviz92.github.io/feed.xml</id><title type="html">Avi Zaguri</title><subtitle>Thoughts on AI, Python, infrastructure architecture, and building production-grade open-source tools.</subtitle><entry><title type="html">ai-agents-marketplace: Author AI Agent Context Once, Render It for Every Coding Agent</title><link href="https://aviz92.github.io/posts/ai-agents-marketplace/" rel="alternate" type="text/html" title="ai-agents-marketplace: Author AI Agent Context Once, Render It for Every Coding Agent" /><published>2026-06-12T01:00:00+03:00</published><updated>2026-06-12T01:00:00+03:00</updated><id>https://aviz92.github.io/posts/ai-agents-marketplace</id><content type="html" xml:base="https://aviz92.github.io/posts/ai-agents-marketplace/"><![CDATA[<p>Every AI coding agent wants its context in a different place and a different shape. Cursor reads <code class="language-plaintext highlighter-rouge">.cursor/rules/*.mdc</code> with <code class="language-plaintext highlighter-rouge">globs</code> and <code class="language-plaintext highlighter-rouge">alwaysApply</code> frontmatter. Copilot wants <code class="language-plaintext highlighter-rouge">.github/instructions/*.instructions.md</code> with an <code class="language-plaintext highlighter-rouge">applyTo</code> field. Claude Code, Codex, and Gemini each expect their own rules directory and their own top-level instructions file. The moment you work across more than one agent — or a team does — you end up hand-maintaining the same guidance in five incompatible formats, and they drift apart immediately.</p>

<p><code class="language-plaintext highlighter-rouge">ai-agents-marketplace</code> solves that with a single principle: <strong>author once, render many.</strong> It ships an interactive CLI (<code class="language-plaintext highlighter-rouge">agents-marketplace</code>) that distributes three kinds of reusable agent context — <strong>skills</strong> 🧠, <strong>plugins</strong> 🔌, and <strong>rules</strong> 📏 — into any project, rendering each artifact into the native format every supported agent actually reads.</p>

<hr />

<h2 id="the-core-idea">The core idea</h2>

<p>There are two distribution problems, and the tool treats them differently:</p>

<ul>
  <li><strong>Skills and plugins are universal.</strong> You author them once and install them into shared open-standard directories (<code class="language-plaintext highlighter-rouge">.claude/skills/</code>, <code class="language-plaintext highlighter-rouge">.agents/skills/</code>) that many agents already read natively. No per-agent rendering needed.</li>
  <li><strong>Rules are agent-specific.</strong> Each agent has its own rules format, frontmatter, and location. So one authored rule is rendered into up to <strong>five native formats</strong> at install time — Cursor MDC, Copilot instructions, and Markdown-plus-frontmatter for Claude, Codex, and Gemini.</li>
</ul>

<p>That split is the whole design. The universal artifacts ride on conventions that already exist; the fragmented ones get a render pipeline so you never write the same rule five times.</p>

<h2 id="quick-start">Quick start</h2>

<h3 id="1-fork-the-repo">1. Fork the repo</h3>

<p>Start by forking <a href="https://github.com/aviz92/ai-agents-marketplace"><code class="language-plaintext highlighter-rouge">ai-agents-marketplace</code></a> on GitHub. This gives you your own catalog — you can add your own skills and rules, customize existing ones, and still pull upstream when new artifacts are added. Running directly from the original repo works for a quick try, but a fork is the right setup for anything you plan to maintain.</p>

<h3 id="2-add-your-own-skills-and-rules">2. Add your own skills and rules</h3>

<p>The catalog lives in <code class="language-plaintext highlighter-rouge">skills/</code>, <code class="language-plaintext highlighter-rouge">rules/</code>, and <code class="language-plaintext highlighter-rouge">plugins/</code> at the repo root. Drop in a directory with a <code class="language-plaintext highlighter-rouge">metadata.yaml</code> and a <code class="language-plaintext highlighter-rouge">skill.md</code> (or <code class="language-plaintext highlighter-rouge">rule.md</code>) and it shows up in the picker automatically. The authoring format is covered in detail <a href="#authoring-your-own-artifacts">below</a>.</p>

<h3 id="3-run-the-cli">3. Run the CLI</h3>

<p>From the root of any project you want to set up:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uvx <span class="nt">--from</span> git+https://github.com/&lt;your-fork&gt;/ai-agents-marketplace agents-marketplace
</code></pre></div></div>

<p>The TUI walks you through the rest:</p>

<ol>
  <li>Pick artifacts from the catalog (it shows installed status and available updates).</li>
  <li>Review which AI tools were detected in your project and on your system.</li>
  <li>Pick install targets — pre-checked based on that detection.</li>
  <li>Confirm, and files are rendered and written into your project.</li>
  <li>Optionally save your selection to <code class="language-plaintext highlighter-rouge">agents-marketplace.yaml</code> for team sync.</li>
</ol>

<h2 id="what-actually-gets-written">What actually gets written</h2>

<p>This is the part that makes the “render many” claim concrete. A universal skill lands once per skill target; a single rule fans out across every agent format you selected:</p>

<table>
  <thead>
    <tr>
      <th>Target dir</th>
      <th>Format</th>
      <th>Written file</th>
      <th>Agents covered</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.claude/skills/</code></td>
      <td>Universal skill</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;/SKILL.md</code></td>
      <td>Claude Code, GitHub Copilot</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.agents/skills/</code></td>
      <td>Universal skill</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;/SKILL.md</code></td>
      <td>Cursor, Copilot, Codex, Gemini, Windsurf, Cline, and more</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.cursor/rules/</code></td>
      <td>Cursor MDC (<code class="language-plaintext highlighter-rouge">globs</code>, <code class="language-plaintext highlighter-rouge">alwaysApply</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;.mdc</code></td>
      <td>Cursor</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.github/instructions/</code></td>
      <td>Copilot instructions (<code class="language-plaintext highlighter-rouge">applyTo</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;.instructions.md</code></td>
      <td>GitHub Copilot</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.claude/rules/</code></td>
      <td>Markdown + frontmatter</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;.md</code></td>
      <td>Claude Code</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.codex/rules/</code></td>
      <td>Markdown + frontmatter</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;.md</code></td>
      <td>Codex CLI</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.gemini/rules/</code></td>
      <td>Markdown + frontmatter</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;id&gt;.md</code></td>
      <td>Gemini CLI</td>
    </tr>
  </tbody>
</table>

<p>Agents that read a single top-level instructions file — rather than auto-discovering a rules directory — get a <strong>reference injected idempotently</strong>, so it’s never duplicated on re-run: Claude rules append a note to <code class="language-plaintext highlighter-rouge">.claude/CLAUDE.md</code> or <code class="language-plaintext highlighter-rouge">AGENTS.md</code>, Codex appends to <code class="language-plaintext highlighter-rouge">AGENTS.md</code>, and Gemini appends to <code class="language-plaintext highlighter-rouge">GEMINI.md</code> (each created if missing). Cursor and Copilot auto-discover their directories, so they need no reference file.</p>

<h2 id="team-sync">Team sync</h2>

<p>This is where the tool earns its keep on a team. Commit an <code class="language-plaintext highlighter-rouge">agents-marketplace.yaml</code> to your project root — think of it like VS Code’s <code class="language-plaintext highlighter-rouge">.vscode/extensions.json</code> — and every teammate gets the same agent setup after cloning, with no install step:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uvx <span class="nt">--from</span> git+https://github.com/aviz92/ai-agents-marketplace agents-marketplace <span class="nb">sync</span>
</code></pre></div></div>

<p>The manifest is small and readable:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># agents-marketplace.yaml</span>
<span class="na">skills</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">python-code-review</span>
  <span class="pi">-</span> <span class="s">pre-push-checklist</span>
<span class="na">rules</span><span class="pi">:</span> <span class="s">all</span>                <span class="c1"># 'all' installs every rule in the catalog</span>
<span class="na">plugins</span><span class="pi">:</span> <span class="pi">[]</span>
<span class="na">targets</span><span class="pi">:</span>                  <span class="c1"># optional — defaults to detected agents</span>
  <span class="na">skills</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">claude</span><span class="pi">,</span> <span class="nv">agents</span><span class="pi">]</span>
  <span class="na">rules</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">cursor</span><span class="pi">,</span> <span class="nv">claude</span><span class="pi">]</span>
</code></pre></div></div>

<p>A few details that matter in practice:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">skills</code> / <code class="language-plaintext highlighter-rouge">plugins</code> / <code class="language-plaintext highlighter-rouge">rules</code> accept a list of artifact ids, or the string <code class="language-plaintext highlighter-rouge">all</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">targets</code> is optional — when omitted, sync installs to the agents it detects, falling back to <code class="language-plaintext highlighter-rouge">.agents/skills</code> plus all rule formats when nothing is detected.</li>
  <li>Unknown ids are reported and skipped; sync still installs the rest but <strong>exits non-zero so CI can catch drift.</strong> That last bit is what turns the manifest into a real guardrail rather than a suggestion.</li>
</ul>

<p>The easiest way to produce the file is to run the interactive TUI once and answer <strong>yes</strong> to “Save installed state to <code class="language-plaintext highlighter-rouge">agents-marketplace.yaml</code>?” at the end. The saved manifest is a snapshot of everything currently installed — all targets, not just your latest selection — so re-saving never silently drops previously installed artifacts.</p>

<h2 id="authoring-your-own-artifacts">Authoring your own artifacts</h2>

<p>The authoring format is deliberately boring, which is the point. A skill is a directory with two files:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># skills/&lt;skill-id&gt;/metadata.yaml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Human Readable Name</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">One-line description shown in the picker</span>
<span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">optional</span><span class="pi">,</span> <span class="nv">tags</span><span class="pi">]</span>
<span class="na">author</span><span class="pi">:</span> <span class="s">your-name</span>
<span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
</code></pre></div></div>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- skills/&lt;skill-id&gt;</span>/skill.md --&gt;
<span class="gh"># What this skill does</span>
Platform-agnostic markdown instructions...
</code></pre></div></div>

<p>It installs as <code class="language-plaintext highlighter-rouge">&lt;target&gt;/&lt;skill-id&gt;/SKILL.md</code> in every selected skill target. A skill can also bundle extra files — say <code class="language-plaintext highlighter-rouge">assets/template.md</code> — next to its <code class="language-plaintext highlighter-rouge">skill.md</code>; they’re copied as-is with relative paths preserved, so links like <code class="language-plaintext highlighter-rouge">[template.md](assets/template.md)</code> keep working after install. <strong>Plugins are identical</strong>, just under <code class="language-plaintext highlighter-rouge">plugins/&lt;plugin-id&gt;/</code> with the body in <code class="language-plaintext highlighter-rouge">plugin.md</code>.</p>

<p>Rules add two metadata keys that the per-agent renderers consume:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># rules/&lt;rule-id&gt;/metadata.yaml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Human Readable Name</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">One-line description</span>
<span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">optional</span><span class="pi">]</span>
<span class="na">author</span><span class="pi">:</span> <span class="s">your-name</span>
<span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">globs</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">src/**/*.py"</span><span class="pi">]</span>   <span class="c1"># Cursor `globs` / Copilot `applyTo`</span>
<span class="na">alwaysApply</span><span class="pi">:</span> <span class="kc">true</span>        <span class="c1"># Cursor: rule is always active</span>
</code></pre></div></div>

<p>That one authored rule produces the <code class="language-plaintext highlighter-rouge">.mdc</code>, <code class="language-plaintext highlighter-rouge">.instructions.md</code>, and three <code class="language-plaintext highlighter-rouge">.md</code> variants automatically.</p>

<h2 id="versioning-that-drives-the-update-prompt">Versioning that drives the update prompt</h2>

<p>There’s no separate update database — versioning is baked into the rendered output. Bump <code class="language-plaintext highlighter-rouge">version</code> in <code class="language-plaintext highlighter-rouge">metadata.yaml</code> on <strong>any</strong> change, and because every rendered file embeds <code class="language-plaintext highlighter-rouge">version:</code> in its frontmatter, the CLI compares that field on the next run to decide whether to show <code class="language-plaintext highlighter-rouge">↻ Update</code> or <code class="language-plaintext highlighter-rouge">✓ Installed</code>. Simple, self-contained, and impossible to forget to wire up.</p>

<hr />

<h2 id="why-i-built-it">Why I built it</h2>

<p>I maintain a growing set of reusable agent context — code-review skills, a pre-push checklist, Python and infrastructure rules — and I want the same guidance applied whether I’m in Claude Code on one machine or Cursor on another, and whether a teammate just cloned the repo or has been on it for months. Hand-syncing five formats doesn’t scale, and copy-paste drift quietly erodes the value of having written the rules at all.</p>

<p><code class="language-plaintext highlighter-rouge">ai-agents-marketplace</code> makes the authored artifact the single source of truth and pushes the per-agent fragmentation down into a render step where it belongs. Author once, render many — and let a committed manifest plus a non-zero CI exit keep the whole team honest.</p>

<p>The project is <a href="https://github.com/aviz92/ai-agents-marketplace">on GitHub</a> under an MIT license. <code class="language-plaintext highlighter-rouge">uvx --from git+https://github.com/aviz92/ai-agents-marketplace agents-marketplace</code> is the fastest way to try it.</p>]]></content><author><name></name></author><category term="AI" /><category term="DevTools" /><category term="ai" /><category term="cli" /><category term="automation" /><category term="python" /><category term="devtools" /><summary type="html"><![CDATA[agents-marketplace is an interactive CLI that distributes reusable AI-agent context — skills, plugins, and rules — into any project, rendering each artifact into the native format every supported AI coding agent expects. Author once, render many.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">pytest-only-markers: Isolate Any Test Instantly Without Touching Your CI</title><link href="https://aviz92.github.io/posts/pytest-only-markers/" rel="alternate" type="text/html" title="pytest-only-markers: Isolate Any Test Instantly Without Touching Your CI" /><published>2026-03-17T08:00:00+02:00</published><updated>2026-03-28T19:37:15+03:00</updated><id>https://aviz92.github.io/posts/pytest-only-markers</id><content type="html" xml:base="https://aviz92.github.io/posts/pytest-only-markers/"><![CDATA[<p>There’s a specific kind of frustration that comes up when debugging a flaky test in a large suite. You don’t want to run all 800 tests. You don’t want to fiddle with <code class="language-plaintext highlighter-rouge">-k</code> expressions. You definitely don’t want to temporarily comment out <code class="language-plaintext highlighter-rouge">pytestmark</code> at the top of the file and forget to put it back. You just want to run this test, and only this test, exactly as it would run in CI — markers and all. <code class="language-plaintext highlighter-rouge">pytest-only-markers</code> solves that with one decorator and one flag.</p>

<h2 id="why-i-built-this">Why I Built This</h2>

<p>Most pytest projects accumulate module-level <code class="language-plaintext highlighter-rouge">pytestmark</code> lists over time. CI runs <code class="language-plaintext highlighter-rouge">pytest -m regression</code> — a generic command that touches the whole suite. That’s fine for most tests. But inside those modules there are always two or three tests that can only safely run under one specific marker condition. The existing options — move the file, add a <code class="language-plaintext highlighter-rouge">skipif</code>, change the module <code class="language-plaintext highlighter-rouge">pytestmark</code>, add <code class="language-plaintext highlighter-rouge">-k</code> exclusions to CI — are all bad for different reasons. I wanted the test itself to declare where it belongs, with the plugin enforcing it at collection time.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>pytest-only-markers
</code></pre></div></div>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv add pytest-only-markers
</code></pre></div></div>

<h2 id="setup--configuration">Setup &amp; Configuration</h2>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[pytest]</span><span class="w">
</span><span class="py">addopts</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">--only-markers-prefix</span>
<span class="py">markers</span><span class="w"> </span><span class="p">=</span>
<span class="w">    </span><span class="na">ONLY_smoke:</span><span class="w"> </span><span class="na">Run</span><span class="w"> </span><span class="na">only</span><span class="w"> </span><span class="na">smoke</span><span class="w"> </span><span class="na">tests</span><span class="w">
    </span><span class="na">ONLY_api:</span><span class="w"> </span><span class="na">Run</span><span class="w"> </span><span class="na">only</span><span class="w"> </span><span class="na">API</span><span class="w"> </span><span class="na">tests</span><span class="w">
</span></code></pre></div></div>

<p>Or pass the flag directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pytest <span class="nt">--only-markers-prefix</span> tests/
</code></pre></div></div>

<p>The plugin is opt-in — without <code class="language-plaintext highlighter-rouge">--only-markers-prefix</code>, all <code class="language-plaintext highlighter-rouge">ONLY_*</code> markers are inert and the suite runs normally.</p>

<h2 id="quick-start">Quick Start</h2>

<p>Decorate the test that has constraints, run with the flag:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pytestmark</span> <span class="o">=</span> <span class="p">[</span><span class="n">pytest</span><span class="p">.</span><span class="n">mark</span><span class="p">.</span><span class="n">regression</span><span class="p">,</span> <span class="n">pytest</span><span class="p">.</span><span class="n">mark</span><span class="p">.</span><span class="n">e2e</span><span class="p">]</span>

<span class="k">def</span> <span class="nf">test_full_flow</span><span class="p">():</span>
    <span class="bp">...</span>

<span class="nd">@pytest.mark.ONLY_smoke</span>
<span class="k">def</span> <span class="nf">test_health_check</span><span class="p">():</span>
    <span class="bp">...</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pytest <span class="nt">--only-markers-prefix</span> tests/
<span class="c"># Only test_health_check runs. test_full_flow is deselected.</span>
<span class="c"># pytestmark (regression, e2e) is stripped from test_health_check.</span>
</code></pre></div></div>

<h2 id="real-world-example">Real-World Example</h2>

<p>Say your CI has two pipeline commands — a full regression suite and a fast smoke suite:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pytest <span class="nt">-m</span> regression          <span class="c"># full suite</span>
pytest <span class="nt">-m</span> <span class="s2">"smoke or ONLY_smoke"</span>  <span class="c"># smoke suite</span>
</code></pre></div></div>

<p>A module has tests that participate in regression, but one test should only ever run as part of smoke:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pytestmark</span> <span class="o">=</span> <span class="p">[</span><span class="n">pytest</span><span class="p">.</span><span class="n">mark</span><span class="p">.</span><span class="n">regression</span><span class="p">,</span> <span class="n">pytest</span><span class="p">.</span><span class="n">mark</span><span class="p">.</span><span class="n">e2e</span><span class="p">]</span>

<span class="k">def</span> <span class="nf">test_full_flow</span><span class="p">():</span>
    <span class="bp">...</span>

<span class="k">def</span> <span class="nf">test_checkout</span><span class="p">():</span>
    <span class="bp">...</span>

<span class="nd">@pytest.mark.ONLY_smoke</span>
<span class="k">def</span> <span class="nf">test_health_check</span><span class="p">():</span>
    <span class="bp">...</span>
</code></pre></div></div>

<p>With this setup:</p>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th><code class="language-plaintext highlighter-rouge">test_full_flow</code></th>
      <th><code class="language-plaintext highlighter-rouge">test_checkout</code></th>
      <th><code class="language-plaintext highlighter-rouge">test_health_check</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pytest -m regression</code></td>
      <td>✅ runs</td>
      <td>✅ runs</td>
      <td>❌ skipped</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pytest -m "smoke or ONLY_smoke"</code></td>
      <td>❌ skipped</td>
      <td>❌ skipped</td>
      <td>✅ runs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pytest --only-markers-prefix</code></td>
      <td>❌ deselected</td>
      <td>❌ deselected</td>
      <td>✅ isolated, markers stripped</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">ONLY_smoke</code> does double duty: in CI it’s a scoped inclusion tag, locally it’s an isolation switch. No changes to <code class="language-plaintext highlighter-rouge">pytest.ini</code>. No <code class="language-plaintext highlighter-rouge">-k</code> hacks. No <code class="language-plaintext highlighter-rouge">skipif</code> conditions.</p>

<h2 id="key-features">Key Features</h2>

<p>When <code class="language-plaintext highlighter-rouge">--only-markers-prefix</code> is active and any test in the collected suite carries an <code class="language-plaintext highlighter-rouge">ONLY_*</code> marker, only those tests run. Everything else is deselected — appearing in pytest’s <code class="language-plaintext highlighter-rouge">x deselected</code> summary, never silently dropped. The plugin is transparent about what it’s doing.</p>

<p>Marker isolation is the part that makes this different from <code class="language-plaintext highlighter-rouge">-k</code>. Non-<code class="language-plaintext highlighter-rouge">ONLY_*</code> markers — including the entire module <code class="language-plaintext highlighter-rouge">pytestmark</code> — are stripped from matching tests at the item level. Downstream plugins, reporters, and hooks see only the <code class="language-plaintext highlighter-rouge">ONLY_*</code> markers for that test, giving you a genuinely clean execution environment. <code class="language-plaintext highlighter-rouge">-k</code> by contrast doesn’t strip inherited markers; it just filters by name.</p>

<p>The prefix match is case-insensitive — <code class="language-plaintext highlighter-rouge">ONLY_smoke</code> and <code class="language-plaintext highlighter-rouge">only_smoke</code> are treated identically, which matters on teams where engineers have different habits:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@pytest.mark.only_smoke</span>   <span class="c1"># lowercase — works
</span><span class="k">def</span> <span class="nf">test_ping</span><span class="p">():</span>
    <span class="k">assert</span> <span class="bp">True</span>

<span class="nd">@pytest.mark.ONLY_smoke</span>   <span class="c1"># uppercase — equivalent
</span><span class="k">def</span> <span class="nf">test_pong</span><span class="p">():</span>
    <span class="k">assert</span> <span class="bp">True</span>
</code></pre></div></div>

<p>Stack multiple <code class="language-plaintext highlighter-rouge">ONLY_*</code> markers freely — all are preserved, everything else is stripped:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@pytest.mark.ONLY_api</span>
<span class="nd">@pytest.mark.ONLY_smoke</span>
<span class="k">def</span> <span class="nf">test_create_user</span><span class="p">():</span>
    <span class="k">assert</span> <span class="bp">True</span>
<span class="c1"># Effective markers: ONLY_api + ONLY_smoke
# pytestmark (regression, slow) stripped entirely
</span></code></pre></div></div>

<p>For local debugging loops, decorate the test you’re working on and iterate without touching CI config:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@pytest.mark.ONLY_debug</span>
<span class="k">def</span> <span class="nf">test_payment_edge_case</span><span class="p">():</span>
    <span class="bp">...</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pytest <span class="nt">--only-markers-prefix</span> tests/payments/
</code></pre></div></div>

<p>Remove the decorator when you’re done — or leave it. Without <code class="language-plaintext highlighter-rouge">--only-markers-prefix</code> in CI’s <code class="language-plaintext highlighter-rouge">addopts</code>, the marker is inert.</p>

<h2 id="goes-well-with">Goes Well With</h2>

<ul>
  <li><a href="/posts/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — the logger used in the examples; <code class="language-plaintext highlighter-rouge">self.logger</code> output flows into the report automatically</li>
  <li><a href="/posts/pytest-plugins"><code class="language-plaintext highlighter-rouge">pytest-plugins</code></a> — CI reporting layer that works alongside the requirements manifest in pipeline workflows</li>
  <li><a href="/posts/pytest-depends-on"><code class="language-plaintext highlighter-rouge">pytest-depends-on</code></a> — run a specific dependency chain in isolation without triggering unrelated tests</li>
  <li><a href="/posts/pytest-reporter-html"><code class="language-plaintext highlighter-rouge">pytest-reporter-html</code></a> — isolated runs produce tight, single-test HTML reports that are easy to share</li>
  <li><a href="/posts/pytest-dynamic-parameterize"><code class="language-plaintext highlighter-rouge">pytest-dynamic-parameterize</code></a> — isolate one parameterized variant without running the full parameter set</li>
  <li><a href="/posts/pytest-collect-requirements"><code class="language-plaintext highlighter-rouge">pytest-collect-requirements</code></a> — collect test requirements from the same dynamic sources as your parameters for fully data-driven pipelines</li>
</ul>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>PyPI</strong>: <a href="https://pypi.org/project/pytest-only-markers">pypi.org/project/pytest-only-markers</a></li>
  <li><strong>GitHub</strong>: <a href="https://github.com/aviz92/pytest-only-markers">github.com/aviz92/pytest-only-markers</a></li>
</ul>

<p>Decorate the test. Run the flag. Everything else stays out of the way.</p>]]></content><author><name></name></author><category term="Testing" /><category term="Pytest" /><category term="python" /><category term="pytest" /><category term="testing" /><category term="automation" /><category term="ci-cd" /><category term="markers" /><category term="devtools" /><summary type="html"><![CDATA[pytest-only-markers lets you flag individual tests with ONLY_* markers and run only those — stripping inherited pytestmark noise and deselecting everything else. One decorator, one flag, full isolation.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">pytest-reporter-html: Rich, Interactive HTML Reports for pytest — Zero Config</title><link href="https://aviz92.github.io/posts/pytest-reporter-html/" rel="alternate" type="text/html" title="pytest-reporter-html: Rich, Interactive HTML Reports for pytest — Zero Config" /><published>2026-03-13T21:00:00+02:00</published><updated>2026-03-28T19:37:15+03:00</updated><id>https://aviz92.github.io/posts/pytest-reporter-html</id><content type="html" xml:base="https://aviz92.github.io/posts/pytest-reporter-html/"><![CDATA[<p>There’s a moment every QA or automation engineer knows well: a test fails in CI, the logs are a wall of text, and you spend more time finding the failure than fixing it. Not a raw log dump. Not a terminal screenshot pasted into Slack. Something you can open in a browser, filter by status, expand the failure, and immediately understand what went wrong.</p>

<h2 id="why-i-built-this">Why I Built This</h2>

<p>I’ve built a lot of test infrastructure over the years — pytest plugins, CI integrations, custom reporters — and the one thing every team I’ve worked with has asked for eventually is a <em>readable</em> test report. The existing options were either too basic (<code class="language-plaintext highlighter-rouge">pytest-html</code>) or too heavy. Together with <a href="https://github.com/YevgenyFarber">Yevgeny Farber</a>, I built the reporter I actually wanted to use: fully interactive, zero code changes to get started, and structured around how test failures are actually debugged.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>pytest-reporter-html
</code></pre></div></div>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv add pytest-reporter-html
</code></pre></div></div>

<h2 id="quick-start">Quick Start</h2>

<p>Add <code class="language-plaintext highlighter-rouge">--report-html</code> to your pytest options:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># pytest.ini
</span><span class="nn">[pytest]</span><span class="w">
</span><span class="py">addopts</span><span class="w"> </span><span class="p">=</span>
<span class="w">    </span><span class="na">--report-html</span><span class="w">
    </span><span class="py">--output-dir</span><span class="p">=</span><span class="s">logs</span>
</code></pre></div></div>

<p>After the run, open the report:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>logs/test-reports/TestReport_Latest.html
</code></pre></div></div>

<p>No fixtures, no code changes, no config files to maintain. The plugin attaches to Python’s root logger automatically — every <code class="language-plaintext highlighter-rouge">logging.*()</code> call your tests already make is captured and surfaced in the report without any wiring.</p>

<h2 id="real-world-example">Real-World Example</h2>

<p>Here’s an API test with named steps. The report turns a flat log stream into a structured story about what the test actually did:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">custom_python_logger</span> <span class="kn">import</span> <span class="n">get_logger</span>
<span class="kn">from</span> <span class="n">pytest_reporter_html</span> <span class="kn">import</span> <span class="n">step</span>

<span class="n">logger</span> <span class="o">=</span> <span class="nf">get_logger</span><span class="p">(</span><span class="n">__name__</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">test_user_lifecycle</span><span class="p">():</span>
    <span class="k">with</span> <span class="nf">step</span><span class="p">(</span><span class="sh">"</span><span class="s">Create user</span><span class="sh">"</span><span class="p">):</span>
        <span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Creating a new user with role </span><span class="sh">'</span><span class="s">user</span><span class="sh">'"</span><span class="p">)</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">api</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sh">"</span><span class="s">/users</span><span class="sh">"</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">})</span>
        <span class="k">assert</span> <span class="n">response</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">201</span>

    <span class="k">with</span> <span class="nf">step</span><span class="p">(</span><span class="sh">"</span><span class="s">Update profile</span><span class="sh">"</span><span class="p">):</span>
        <span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Updating user</span><span class="sh">'</span><span class="s">s role to </span><span class="sh">'</span><span class="s">admin</span><span class="sh">'"</span><span class="p">)</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">api</span><span class="p">.</span><span class="nf">patch</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">/users/</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">admin</span><span class="sh">"</span><span class="p">})</span>
        <span class="k">assert</span> <span class="n">response</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">200</span>

    <span class="k">with</span> <span class="nf">step</span><span class="p">(</span><span class="sh">"</span><span class="s">Verify changes</span><span class="sh">"</span><span class="p">):</span>
        <span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Verifying that the role change persisted</span><span class="sh">"</span><span class="p">)</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">api</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">/users/</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">assert</span> <span class="n">response</span><span class="p">.</span><span class="nf">json</span><span class="p">()[</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">]</span> <span class="o">==</span> <span class="sh">"</span><span class="s">admin</span><span class="sh">"</span>
</code></pre></div></div>

<p>The report renders this as:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Test: test_user_lifecycle                                            PASSED
 ├── Step 01: Create user                               PASSED    120ms
 ├── Step 02: Update profile                            PASSED     45ms
 └── Step 03: Verify changes                            PASSED     30ms
</code></pre></div></div>

<p>When a step fails, the report auto-expands that test and highlights the failing step. The assertion message, stack trace, and every log event leading up to the failure are visible in context — no grepping through logs required.</p>

<h2 id="key-features">Key Features</h2>

<p>Zero-config log capture was a non-negotiable design decision. Every team already has logging in their tests. By attaching to the root logger, the plugin works with <code class="language-plaintext highlighter-rouge">logging.info(...)</code>, <code class="language-plaintext highlighter-rouge">custom_python_logger</code>, <code class="language-plaintext highlighter-rouge">structlog</code>, or anything else that feeds the standard logging chain — no fixture swaps, no special calls.</p>

<p>Named steps are the main quality-of-life feature. The <code class="language-plaintext highlighter-rouge">step</code> context manager wraps any block of code into a collapsible, timed section in the report. Each step shows its duration and pass/fail status at a glance. Without manual steps, Setup, test body, and Teardown are created automatically for every test. <code class="language-plaintext highlighter-rouge">step</code> also works as a decorator — both sync and async:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@step</span><span class="p">(</span><span class="sh">"</span><span class="s">Fetch user data</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">get_user</span><span class="p">(</span><span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">api</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">/users/</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="sh">"</span><span class="p">).</span><span class="nf">json</span><span class="p">()</span>

<span class="nd">@step</span><span class="p">(</span><span class="sh">"</span><span class="s">Send notification</span><span class="sh">"</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">notify</span><span class="p">(</span><span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">await</span> <span class="n">notification_service</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="n">user_id</span><span class="p">)</span>
</code></pre></div></div>

<p>The HTML report ships with live search, status filters (Passed / Failed), and log-level filters (TRACE → ERROR) — all client-side, no server required. The report is a single self-contained file you can share via Slack, attach to a Jira ticket, or archive in S3 without deploying anything.</p>

<p>JSON content is syntax-highlighted inline. HTTP requests are rendered with a generated cURL command and copy button — which matters a lot when debugging API test failures and you want to reproduce a specific request outside the test.</p>

<p>Per-test JSON files are written alongside the HTML, one per test. If you want to post results to a dashboard, run a flakiness analysis, or build an alert on repeated failures, the structured JSON gives you the raw data independently of the HTML rendering. The report doesn’t have to be a dead end.</p>

<h2 id="goes-well-with">Goes Well With</h2>

<ul>
  <li><a href="/posts/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — the logger used in the examples; <code class="language-plaintext highlighter-rouge">self.logger</code> output flows into the report automatically</li>
  <li><a href="/posts/pytest-plugins"><code class="language-plaintext highlighter-rouge">pytest-plugins</code></a> — CI reporting layer that works alongside the requirements manifest in pipeline workflows</li>
  <li><a href="/posts/pytest-depends-on"><code class="language-plaintext highlighter-rouge">pytest-depends-on</code></a> — run a specific dependency chain in isolation without triggering unrelated tests</li>
  <li><a href="/posts/pytest-reporter-html"><code class="language-plaintext highlighter-rouge">pytest-reporter-html</code></a> — isolated runs produce tight, single-test HTML reports that are easy to share</li>
  <li><a href="/posts/pytest-dynamic-parameterize"><code class="language-plaintext highlighter-rouge">pytest-dynamic-parameterize</code></a> — isolate one parameterized variant without running the full parameter set</li>
  <li><a href="/posts/pytest-collect-requirements"><code class="language-plaintext highlighter-rouge">pytest-collect-requirements</code></a> — collect test requirements from the same dynamic sources as your parameters for fully data-driven pipelines</li>
</ul>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>PyPI</strong>: <a href="https://pypi.org/project/pytest-reporter-html">pypi.org/project/pytest-reporter-html</a></li>
  <li><strong>GitHub</strong>: <a href="https://github.com/YevgenyFarber/pytest-reporter-html">github.com/YevgenyFarber/pytest-reporter-html</a></li>
</ul>

<p>Built together with <a href="https://github.com/YevgenyFarber">Yevgeny Farber</a>. If your team is still debugging CI failures from terminal output, this is the upgrade.</p>]]></content><author><name></name></author><category term="Testing" /><category term="Pytest" /><category term="python" /><category term="pytest" /><category term="testing" /><category term="ci-cd" /><category term="html" /><category term="reporting" /><category term="automation" /><category term="devops" /><summary type="html"><![CDATA[pytest-reporter-html generates rich, interactive HTML test reports with zero-config log capture, named step tracking, exception rendering, and real-time filtering — open the file in any browser and start debugging.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Python Project Templates: Clone and Build, Not Configure from Scratch</title><link href="https://aviz92.github.io/posts/python-project-templates/" rel="alternate" type="text/html" title="My Python Project Templates: Clone and Build, Not Configure from Scratch" /><published>2026-03-10T09:00:00+02:00</published><updated>2026-06-13T09:41:44+03:00</updated><id>https://aviz92.github.io/posts/python-project-templates</id><content type="html" xml:base="https://aviz92.github.io/posts/python-project-templates/"><![CDATA[<p>Every project starts with the same questions. Where do the environment variables go? How is the CI wired up? Where does the logging config live? What’s the folder structure?</p>

<h2 id="the-foundation-dev-template-repository">The Foundation: dev-template-repository</h2>

<p>Everything starts from <a href="https://github.com/aviz92/dev-template-repository"><code class="language-plaintext highlighter-rouge">dev-template-repository</code></a> — my main base template. It’s not a starter for a specific type of project; it’s the skeleton every project starts from, regardless of what it does. Pre-commit hooks, CI workflows, IDE config, <code class="language-plaintext highlighter-rouge">env.template</code>, Go-Task commands, CODEOWNERS — all wired up before a single line of business logic is written.</p>

<p>I covered it in detail in <a href="/posts/how-i-structure-python-projects/">How I Structure Python Projects</a>. Read that first if you haven’t.</p>

<p>The specialized templates below all build on top of this foundation. They inherit the base structure and add the domain-specific decisions on top.</p>

<h2 id="the-philosophy">The Philosophy</h2>

<p>A template isn’t documentation. It’s a frozen set of opinions you don’t have to revisit. When I clone a template, I’m not asking “how should I structure this?” — that question was answered when the template was built. I’m asking “what am I building?”</p>

<p>Each template here is production-ready on day one: pre-commit hooks, structured logging via <code class="language-plaintext highlighter-rouge">custom-python-logger</code>, environment variable configuration, and a working example you can run immediately.</p>

<h2 id="a-few-examples">A Few Examples</h2>

<h3 id="fastapi-template">FastAPI Template</h3>

<p>The FastAPI starter comes with authentication wired up out of the box — register, login, JWT token issuance, and a protected route. Most FastAPI projects need this eventually and it’s always the same boilerplate.</p>

<p>Clone it, delete the example routes, and start on your actual endpoints.</p>

<p>→ <a href="https://github.com/aviz92/fast-api-template">github.com/aviz92/fast-api-template</a></p>

<hr />

<h3 id="django-basic-app">Django Basic App</h3>

<p>Built on top of <code class="language-plaintext highlighter-rouge">drf-easy-crud</code> and <code class="language-plaintext highlighter-rouge">django-versioned-models</code> — the same packages I covered in earlier posts. You get enterprise-grade CRUD with advanced filtering, pagination, bulk operations, and versioned data models without wiring any of it up yourself.</p>

<p>The interesting part: most Django projects end up needing all of these within the first few weeks. Starting with them already in place means you’re building features on a solid foundation from day one, not retrofitting infrastructure later.</p>

<p>→ <a href="https://github.com/aviz92/django-basic-app">github.com/aviz92/django-basic-app</a></p>

<hr />

<h3 id="docker-mcp-service-template">Docker MCP Service Template</h3>

<p>The most recent addition. As AI coding agents have become part of daily development, the ability to expose custom tools to them via the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> has become genuinely useful. This template gives you a Dockerized MCP server with a service layer pattern — add a class to <code class="language-plaintext highlighter-rouge">mcp_services/</code>, register a tool in <code class="language-plaintext highlighter-rouge">server.py</code>, and your logic is immediately available to Claude Desktop or any MCP-compatible client.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@mcp.tool</span><span class="p">()</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_deployment_status</span><span class="p">(</span><span class="n">service_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">DeploymentService</span><span class="p">.</span><span class="nf">get_status</span><span class="p">(</span><span class="n">service_name</span><span class="p">)</span>
</code></pre></div></div>

<p>That’s all you add. The rest — server lifecycle, Docker build, Claude Desktop config — is already handled.</p>

<p>→ <a href="https://github.com/aviz92/docker-mcp-service-template">github.com/aviz92/docker-mcp-service-template</a></p>

<hr />

<hr />

<h3 id="a-note-on-ai-agents-marketplace">A Note on ai-agents-marketplace</h3>

<p><a href="https://github.com/aviz92/ai-agents-marketplace"><code class="language-plaintext highlighter-rouge">ai-agents-marketplace</code></a> deserves a mention here even though it’s not a project template in the traditional sense. It’s a CLI that distributes reusable AI-agent context — skills, plugins, and rules — into any project, rendering each artifact into the native format every supported coding agent expects.</p>

<p>The reason it belongs in this section: if you’re building a project and want AI coding agents to have proper context about your codebase, you’d run <code class="language-plaintext highlighter-rouge">agents-marketplace</code> after cloning your template. It’s the last step of the setup flow.</p>

<p>One important usage note: <strong>fork the repo before running it in a project you plan to maintain long-term.</strong> The marketplace ships with a curated set of skills and rules, and new ones get added over time. If you fork it, you can pull upstream changes to get new features while keeping any customizations you’ve made to the rules for your specific project. Running directly from the original repo works fine for a quick setup, but a fork gives you the ability to sync selectively as the marketplace evolves.</p>

<p>→ <a href="https://github.com/aviz92/ai-agents-marketplace">github.com/aviz92/ai-agents-marketplace</a></p>

<hr />

<h2 id="the-full-collection">The Full Collection</h2>

<p>The templates above are examples from a growing set. There are also starters for Telegram bots, Slack bots, and more being added over time. Rather than maintain an exhaustive list here that goes stale, the current full collection lives on GitHub:</p>

<p>→ <a href="https://github.com/aviz92?tab=repositories&amp;q=template">github.com/aviz92</a></p>

<p>Each repo is a GitHub template — hit <strong>Use this template</strong> and you have a new repository with the full starter, ready to clone.</p>]]></content><author><name></name></author><category term="Architecture" /><category term="Python" /><category term="python" /><category term="templates" /><category term="fastapi" /><category term="django" /><category term="mcp" /><category term="architecture" /><category term="devops" /><category term="best-practices" /><summary type="html"><![CDATA[A growing collection of production-ready Python project starters — each one encodes the opinionated decisions for a specific domain so you start building, not configuring.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">python-simple-email-sender: Send Emails from Python Without the Boilerplate</title><link href="https://aviz92.github.io/posts/python-simple-email-sender/" rel="alternate" type="text/html" title="python-simple-email-sender: Send Emails from Python Without the Boilerplate" /><published>2026-03-09T09:00:00+02:00</published><updated>2026-03-09T09:00:00+02:00</updated><id>https://aviz92.github.io/posts/python-simple-email-sender</id><content type="html" xml:base="https://aviz92.github.io/posts/python-simple-email-sender/"><![CDATA[<p>Sending an email from Python shouldn’t require knowing the difference between <code class="language-plaintext highlighter-rouge">MIMEMultipart</code>, <code class="language-plaintext highlighter-rouge">MIMEText</code>, and <code class="language-plaintext highlighter-rouge">MIMEApplication</code>. But it does — unless you’ve done it before, every time feels like archaeology through the stdlib docs.</p>

<p>I needed email notifications in several automation scripts: pipeline failures, scheduled report delivery, monitoring alerts. The code was always the same fifteen lines. So I packaged it.</p>

<h2 id="why-i-built-this">Why I Built This</h2>

<p>The Python <code class="language-plaintext highlighter-rouge">smtplib</code> + <code class="language-plaintext highlighter-rouge">email.mime</code> stack works fine but it’s verbose for something conceptually simple. You want to send an email — you shouldn’t need to construct a MIME tree to do it. <code class="language-plaintext highlighter-rouge">python-simple-email-sender</code> reduces it to one call with sensible defaults.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>python-simple-email-sender
</code></pre></div></div>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv add python-simple-email-sender
</code></pre></div></div>

<h2 id="setup">Setup</h2>

<p>Credentials come from environment variables — no hardcoded passwords:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">EMAIL_ADDRESS</span><span class="o">=</span><span class="s2">"your.email@gmail.com"</span>
<span class="nb">export </span><span class="nv">EMAIL_PASSWORD</span><span class="o">=</span><span class="s2">"your-app-password"</span>
</code></pre></div></div>

<p>For Gmail, use an App Password rather than your account password if you have 2FA enabled. Generate one at <a href="https://myaccount.google.com/apppasswords">myaccount.google.com/apppasswords</a>.</p>

<h2 id="usage">Usage</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">python_simple_email_sender</span> <span class="kn">import</span> <span class="n">EmailSender</span>

<span class="n">sender</span> <span class="o">=</span> <span class="nc">EmailSender</span><span class="p">()</span>

<span class="n">sender</span><span class="p">.</span><span class="nf">send_email</span><span class="p">(</span>
    <span class="n">to_email</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">team@company.com</span><span class="sh">"</span><span class="p">],</span>
    <span class="n">subject</span><span class="o">=</span><span class="sh">"</span><span class="s">Nightly pipeline complete</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">All 1,204 tests passed. Report attached.</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">attachment_file</span><span class="o">=</span><span class="sh">"</span><span class="s">reports/nightly.pdf</span><span class="sh">"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>That’s the whole interface. <code class="language-plaintext highlighter-rouge">to_email</code> accepts a string or a list. <code class="language-plaintext highlighter-rouge">attachment_file</code> is optional. Credentials are picked up from the environment automatically.</p>

<p>For a non-Gmail SMTP server, pass <code class="language-plaintext highlighter-rouge">server_name</code> and <code class="language-plaintext highlighter-rouge">server_port</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sender</span> <span class="o">=</span> <span class="nc">EmailSender</span><span class="p">(</span><span class="n">server_name</span><span class="o">=</span><span class="sh">"</span><span class="s">smtp.company.com</span><span class="sh">"</span><span class="p">,</span> <span class="n">server_port</span><span class="o">=</span><span class="mi">587</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="real-world-use-case">Real-World Use Case</h2>

<p>In CI pipelines and scheduled automation scripts, email is often the simplest reliable notification channel — no Slack bot to configure, no webhook endpoint to maintain. Here’s how I use it for a nightly report script:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">python_simple_email_sender</span> <span class="kn">import</span> <span class="n">EmailSender</span>

<span class="k">def</span> <span class="nf">send_nightly_report</span><span class="p">(</span><span class="n">report_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">recipients</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">sender</span> <span class="o">=</span> <span class="nc">EmailSender</span><span class="p">()</span>
    <span class="n">sender</span><span class="p">.</span><span class="nf">send_email</span><span class="p">(</span>
        <span class="n">to_email</span><span class="o">=</span><span class="n">recipients</span><span class="p">,</span>
        <span class="n">subject</span><span class="o">=</span><span class="sh">"</span><span class="s">[Nightly] Infrastructure Health Report</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">Automated report attached. Contact the infra team for questions.</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">attachment_file</span><span class="o">=</span><span class="n">report_path</span><span class="p">,</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>One function, one import, works anywhere Python runs.</p>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>PyPI</strong>: <a href="https://pypi.org/project/python-simple-email-sender">pypi.org/project/python-simple-email-sender</a></li>
  <li><strong>GitHub</strong>: <a href="https://github.com/aviz92/python-simple-email-sender">github.com/aviz92/python-simple-email-sender</a></li>
</ul>

<p>If you’ve typed <code class="language-plaintext highlighter-rouge">MIMEMultipart()</code> more than twice in your life, you don’t need to type it again.</p>]]></content><author><name></name></author><category term="Backend" /><category term="Core Foundations" /><category term="python" /><category term="email" /><category term="smtp" /><category term="automation" /><category term="devops" /><category term="notifications" /><summary type="html"><![CDATA[python-simple-email-sender wraps Python's SMTP stack into a single send_email() call — credentials from env vars, optional attachments, zero boilerplate.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">cast-studio: Turn asciinema Recordings into GIF and MP4</title><link href="https://aviz92.github.io/posts/cast-studio/" rel="alternate" type="text/html" title="cast-studio: Turn asciinema Recordings into GIF and MP4" /><published>2026-03-08T10:00:00+02:00</published><updated>2026-03-28T19:29:17+03:00</updated><id>https://aviz92.github.io/posts/cast-studio</id><content type="html" xml:base="https://aviz92.github.io/posts/cast-studio/"><![CDATA[<p>After publishing enough packages, you start noticing what you always skip. For me it was the demo GIF — every project had a README, tests, CI, but the terminal recording was either missing, outdated, or recorded once and never touched again. A flag gets renamed, an output format changes, a new command is added — and suddenly the demo is showing something the tool no longer does. Keeping demos accurate across 15+ packages stops being a minor inconvenience and becomes a real maintenance problem.</p>

<h2 id="why-i-built-this">Why I Built This</h2>

<p>I wanted re-recording to be as simple as running a single command, with the same structure and output every time. The issue isn’t the recording tool — <code class="language-plaintext highlighter-rouge">asciinema</code> is great. The issue is that the recording is a manual, ad-hoc session that nobody remembers how to reproduce. I wanted to commit the demo script alongside the code so that re-recording after a release is one command, not a half-hour of typing into a terminal hoping it looks right.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>cast-studio
</code></pre></div></div>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv add cast-studio
</code></pre></div></div>

<blockquote>
  <p>Requires <code class="language-plaintext highlighter-rouge">ffmpeg</code> installed on the system.</p>
</blockquote>

<h2 id="quick-start">Quick Start</h2>

<p>Three commands from zero to GIF:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Scaffold the demo config into your project</span>
cast-init <span class="nt">--dest</span> demo/

<span class="c"># 2. Record the session driven by your config</span>
asciinema rec <span class="nt">-c</span> <span class="s2">"cast-run demo/demo.cfg"</span> assets/demo/demo.cast

<span class="c"># 3. Render to GIF (or MP4)</span>
cast-render assets/demo/demo.cast assets/demo/demo <span class="nt">--gif-only</span> <span class="nt">--title</span> <span class="s2">"my-library demo"</span>
<span class="c"># → assets/demo/demo.gif</span>
</code></pre></div></div>

<h2 id="real-world-example">Real-World Example</h2>

<p>Here’s the <code class="language-plaintext highlighter-rouge">demo.cfg</code> for <code class="language-plaintext highlighter-rouge">cast-studio</code> itself — the config that drives its own demo recording:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ── project metadata ──────────────────────────────────────────────────────────</span>
<span class="nv">PROJECT</span><span class="o">=</span><span class="s2">"cast-studio"</span>
<span class="nv">SUBTITLE</span><span class="o">=</span><span class="s2">"Convert asciinema .cast files to GIF and MP4"</span>
<span class="nv">INSTALL_CMD</span><span class="o">=</span><span class="s2">"uv add cast-studio"</span>
<span class="nv">REPO_URL</span><span class="o">=</span><span class="s2">"github.com/aviz92/cast-studio"</span>
<span class="nv">PYPI_URL</span><span class="o">=</span><span class="s2">"pypi.org/project/cast-studio"</span>

<span class="c"># ── timing (seconds) ─────────────────────────────────────────────────────────</span>
<span class="nv">PAUSE_INTRO</span><span class="o">=</span>2
<span class="nv">PAUSE_BETWEEN</span><span class="o">=</span>2
<span class="nv">PAUSE_OUTRO</span><span class="o">=</span>3

<span class="c"># ── runs ──────────────────────────────────────────────────────────────────────</span>
define_runs<span class="o">()</span> <span class="o">{</span>
  add_run <span class="se">\</span>
    <span class="s2">"STEP 1 — cast-init  │  scaffold demo scripts into your project"</span> <span class="se">\</span>
    <span class="s2">"Creates run_demo.sh (the engine) and demo.cfg (your project config).|One command sets up the full demo recording workflow."</span> <span class="se">\</span>
    <span class="s2">"cast-init --dest /tmp/cast-studio-demo --force"</span>

  add_run <span class="se">\</span>
    <span class="s2">"STEP 2 — demo.cfg  │  inspect the generated config"</span> <span class="se">\</span>
    <span class="s2">"Edit PROJECT, SUBTITLE, INSTALL_CMD, and define_runs().|Add any shell command — pytest, scripts, CLIs — using add_run."</span> <span class="se">\</span>
    <span class="s2">"cat /tmp/cast-studio-demo/demo.cfg"</span>

  add_run <span class="se">\</span>
    <span class="s2">"STEP 3 — cast-render  │  render a .cast file to GIF"</span> <span class="se">\</span>
    <span class="s2">"Renders each frame as a PNG using Pillow (Catppuccin Mocha theme).|Then encodes a high-quality 256-colour GIF via ffmpeg palette pass."</span> <span class="se">\</span>
    <span class="s2">"cast-render demo.cast assets/demo --gif-only --title </span><span class="se">\"</span><span class="s2">cast-studio demo</span><span class="se">\"</span><span class="s2">"</span>

  add_run <span class="se">\</span>
    <span class="s2">"STEP 4 — cast-render  │  render to MP4"</span> <span class="se">\</span>
    <span class="s2">"H.264/x264 CRF-18 encode — ready for GitHub Releases or Twitter.|Use --hold to extend the last frame so viewers can read the outro."</span> <span class="se">\</span>
    <span class="s2">"cast-render demo.cast assets/demo --mp4-only --hold 5.0 --title </span><span class="se">\"</span><span class="s2">cast-studio demo</span><span class="se">\"</span><span class="s2">"</span>
<span class="o">}</span>
</code></pre></div></div>

<p>You commit <code class="language-plaintext highlighter-rouge">demo.cfg</code> alongside your code. After the next release, re-recording is:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>asciinema rec <span class="nt">-c</span> <span class="s2">"cast-run demo/demo.cfg"</span> assets/demo/demo.cast
cast-render assets/demo/demo.cast assets/demo/demo <span class="nt">--gif-only</span> <span class="nt">--title</span> <span class="s2">"my-library demo"</span>
</code></pre></div></div>

<p>Same config. Same structure. Updated output.</p>

<h2 id="key-features">Key Features</h2>

<p>The demo engine is the core idea. <code class="language-plaintext highlighter-rouge">demo.cfg</code> is a shell file that defines every step of your terminal session — what command to run, what title and description to display, how long to pause between steps. <code class="language-plaintext highlighter-rouge">cast-run</code> executes it to drive the <code class="language-plaintext highlighter-rouge">asciinema</code> recording. The session isn’t typed manually; it’s scripted and repeatable. Each <code class="language-plaintext highlighter-rouge">add_run</code> call adds one step, and multi-line descriptions use <code class="language-plaintext highlighter-rouge">|</code> as a separator.</p>

<p><code class="language-plaintext highlighter-rouge">cast-init</code> scaffolds <code class="language-plaintext highlighter-rouge">demo.cfg</code> and the runner script into any project with one command — so adding a reproducible demo to an existing package takes minutes, not an afternoon.</p>

<p>The rendering pipeline produces two formats. GIFs use Pillow to render each frame as a PNG with a custom ANSI terminal emulator and the Catppuccin Mocha color scheme, then encode through a two-pass ffmpeg palette for sharp 256-colour output. MP4 is H.264 CRF-18 — high quality, small file, ready for GitHub Releases, Notion, or Slack. Both formats are driven by the same <code class="language-plaintext highlighter-rouge">.cast</code> file.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># GIF — default 10 fps, sharp and compact</span>
cast-render demo.cast assets/demo <span class="nt">--gif-only</span> <span class="nt">--title</span> <span class="s2">"my-library demo"</span>

<span class="c"># MP4 — default 30 fps, hold last frame for 5 seconds</span>
cast-render demo.cast assets/demo <span class="nt">--mp4-only</span> <span class="nt">--hold</span> 5.0 <span class="nt">--title</span> <span class="s2">"my-library demo"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">cast</code> unified entry point auto-discovers all sub-commands — <code class="language-plaintext highlighter-rouge">cast render</code>, <code class="language-plaintext highlighter-rouge">cast init</code>, <code class="language-plaintext highlighter-rouge">cast --help</code> — one entry point for everything.</p>

<blockquote>
  <p>If you just need a quick one-off GIF and don’t care about reproducibility, <code class="language-plaintext highlighter-rouge">agg</code> is simpler. <code class="language-plaintext highlighter-rouge">cast-studio</code> is for demos that need to stay accurate across releases.</p>
</blockquote>

<h2 id="goes-well-with">Goes Well With</h2>

<ul>
  <li><a href="/posts/python-base-command"><code class="language-plaintext highlighter-rouge">python-base-command</code></a> — the CLI pattern I use in the libraries that get demoed with this tool</li>
  <li><a href="/posts/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — colored terminal output that looks great in recordings</li>
</ul>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>PyPI</strong>: <a href="https://pypi.org/project/cast-studio">pypi.org/project/cast-studio</a></li>
  <li><strong>GitHub</strong>: <a href="https://github.com/aviz92/cast-studio">github.com/aviz92/cast-studio</a></li>
</ul>

<p>A demo that lives in the repo gets updated. One that lives in someone’s terminal history doesn’t.</p>]]></content><author><name></name></author><category term="Backend" /><category term="DevTools" /><category term="python" /><category term="asciinema" /><category term="gif" /><category term="mp4" /><category term="ffmpeg" /><category term="pillow" /><category term="cli" /><category term="terminal" /><category term="demo" /><summary type="html"><![CDATA[cast-studio converts asciinema .cast files to GIF and MP4 — and ships a demo engine that makes every terminal recording reproducible.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">django-versioned-models: Drop-in Release Management for Django</title><link href="https://aviz92.github.io/posts/django-versioned-models/" rel="alternate" type="text/html" title="django-versioned-models: Drop-in Release Management for Django" /><published>2026-03-08T00:00:00+02:00</published><updated>2026-06-12T22:20:38+03:00</updated><id>https://aviz92.github.io/posts/django-versioned-models</id><content type="html" xml:base="https://aviz92.github.io/posts/django-versioned-models/"><![CDATA[<p>Every time I needed to manage versioned configuration data in Django — feature flags, test
datasets, infrastructure definitions — I ended up reinventing the same pattern. Lock a
dataset, branch it, let users edit safely while CI stays stable, approve for testing,
ship.</p>

<p><code class="language-plaintext highlighter-rouge">django-versioned-models</code> packages that pattern into a single mixin. Inherit from
<code class="language-plaintext highlighter-rouge">VersionedModel</code>, run a migration, and your models get full release management, a
three-stage data-status workflow, and CI-ready management commands — automatically.</p>

<hr />

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>django-versioned-models
</code></pre></div></div>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv add django-versioned-models
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># settings.py
</span><span class="n">INSTALLED_APPS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="bp">...</span>
    <span class="sh">'</span><span class="s">django_versioned_models</span><span class="sh">'</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python manage.py migrate
</code></pre></div></div>

<hr />

<h2 id="quick-start">Quick Start</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">django_versioned_models.mixins</span> <span class="kn">import</span> <span class="n">VersionedModel</span>

<span class="k">class</span> <span class="nc">Product</span><span class="p">(</span><span class="n">VersionedModel</span><span class="p">):</span>
    <span class="n">name</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>

    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
        <span class="n">unique_together</span> <span class="o">=</span> <span class="p">[(</span><span class="sh">'</span><span class="s">release</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">name</span><span class="sh">'</span><span class="p">)]</span>  <span class="c1"># unique per release, not globally
</span></code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python manage.py makemigrations <span class="o">&amp;&amp;</span> python manage.py migrate

python manage.py create_release <span class="nt">--release-version</span> v1.0.0
<span class="c"># → add data via Admin or API</span>
python manage.py lock_release <span class="nt">--release-version</span> v1.0.0
</code></pre></div></div>

<hr />

<h2 id="the-core-three-fields-one-workflow">The Core: Three Fields, One Workflow</h2>

<p>Every model that inherits from <code class="language-plaintext highlighter-rouge">VersionedModel</code> gets three fields added automatically:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">release</code> — FK to a <code class="language-plaintext highlighter-rouge">Release</code> record. Every row belongs to exactly one release.</li>
  <li><code class="language-plaintext highlighter-rouge">status</code> — the data-readiness stage: <code class="language-plaintext highlighter-rouge">DRAFT</code>, <code class="language-plaintext highlighter-rouge">FUTURE</code>, or <code class="language-plaintext highlighter-rouge">APPROVED</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">active</code> — soft-delete flag (covered below).</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">release</code> and <code class="language-plaintext highlighter-rouge">status</code> fields together are the heart of the system.</p>

<hr />

<h2 id="the-three-status-workflow">The Three-Status Workflow</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DRAFT  &lt;--&gt;  FUTURE  --&gt;  APPROVED
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>Audience</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DRAFT</code></td>
      <td>users</td>
      <td>Being worked on. Not visible to CI.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">FUTURE</code></td>
      <td>users</td>
      <td>Planned but parked — not for the current cycle.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">APPROVED</code></td>
      <td>CI only</td>
      <td>Stable. This is what tests run against.</td>
    </tr>
  </tbody>
</table>

<p>The transition rules enforce the intended workflow:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">DRAFT → FUTURE</code> via <code class="language-plaintext highlighter-rouge">mark_future()</code> — user parks the row for a later cycle</li>
  <li><code class="language-plaintext highlighter-rouge">FUTURE → DRAFT</code> via <code class="language-plaintext highlighter-rouge">mark_draft()</code> — user pulls it back for rework</li>
  <li><code class="language-plaintext highlighter-rouge">DRAFT or FUTURE → APPROVED</code> via <code class="language-plaintext highlighter-rouge">approve()</code> — one-way, CI only</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">APPROVED</code> is a terminal state. There is no transition back. If a row needs to change after
approval, the correct action is to create a new release (or a patch release) and update the
row there.</p>

<h3 id="why-three-statuses">Why Three Statuses?</h3>

<p>Two statuses (<code class="language-plaintext highlighter-rouge">DRAFT</code> / <code class="language-plaintext highlighter-rouge">APPROVED</code>) are enough for a single sprint, but they break down as
soon as users are working across multiple future cycles at the same time. <code class="language-plaintext highlighter-rouge">FUTURE</code> gives
them a holding area for rows that are correct and intentional but shouldn’t be approved yet
— without mixing them up with rows that are still actively being worked on (<code class="language-plaintext highlighter-rouge">DRAFT</code>).</p>

<h3 id="status-transitions-in-code">Status Transitions in Code</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">row</span><span class="p">.</span><span class="nf">mark_future</span><span class="p">()</span>  <span class="c1"># DRAFT → FUTURE
</span><span class="n">row</span><span class="p">.</span><span class="nf">mark_draft</span><span class="p">()</span>   <span class="c1"># FUTURE → DRAFT
</span><span class="n">row</span><span class="p">.</span><span class="nf">approve</span><span class="p">()</span>      <span class="c1"># DRAFT or FUTURE → APPROVED (one-way, CI only)
</span></code></pre></div></div>

<h3 id="querying-by-status">Querying by Status</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">django_versioned_models.models</span> <span class="kn">import</span> <span class="n">Release</span>

<span class="n">release</span> <span class="o">=</span> <span class="n">Release</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">version</span><span class="o">=</span><span class="sh">'</span><span class="s">v1.1.0</span><span class="sh">'</span><span class="p">)</span>

<span class="n">Product</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">approved</span><span class="p">(</span><span class="n">release</span><span class="p">)</span>                        <span class="c1"># CI — APPROVED rows only
</span><span class="n">Product</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">for_release</span><span class="p">(</span><span class="n">release</span><span class="p">)</span>                     <span class="c1"># all rows for users
</span><span class="n">Product</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">release</span><span class="o">=</span><span class="n">release</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="sh">'</span><span class="s">future</span><span class="sh">'</span><span class="p">)</span> <span class="c1"># just the parked rows
</span></code></pre></div></div>

<hr />

<h2 id="the-release-lifecycle">The Release Lifecycle</h2>

<p>Releases are the outer container. Every row belongs to a release. Releases move through
their own lifecycle independently of individual rows:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Branch from the last locked release</span>
python manage.py create_release <span class="nt">--release-version</span> v1.1.0 <span class="nt">--based-on</span> v1.0.0

<span class="c"># 2. users edit data in DRAFT. Park anything not ready in FUTURE.</span>

<span class="c"># 3. CI approves all DRAFT rows (FUTURE rows are intentionally left alone)</span>
python manage.py approve_release <span class="nt">--release-version</span> v1.1.0

<span class="c"># 4. Run tests against approved data only</span>
pytest <span class="nt">--release-version</span> v1.1.0

<span class="c"># 5. Lock and ship</span>
python manage.py lock_release <span class="nt">--release-version</span> v1.1.0

<span class="c"># 6. Bug found after deployment? Always patch — never touch a locked release</span>
python manage.py create_release <span class="nt">--release-version</span> v1.1.1 <span class="nt">--based-on</span> v1.1.0
</code></pre></div></div>

<p>The separation between <code class="language-plaintext highlighter-rouge">FUTURE</code> rows and <code class="language-plaintext highlighter-rouge">approve_release</code> is deliberate: bulk approval
only touches <code class="language-plaintext highlighter-rouge">DRAFT</code> rows. <code class="language-plaintext highlighter-rouge">FUTURE</code> rows are not accidentally approved when CI runs
<code class="language-plaintext highlighter-rouge">approve_release</code> — users have to explicitly <code class="language-plaintext highlighter-rouge">mark_draft()</code> them first.</p>

<hr />

<h2 id="lock-enforcement">Lock Enforcement</h2>

<p>Locked releases are immutable at the model level. Any <code class="language-plaintext highlighter-rouge">save()</code> or <code class="language-plaintext highlighter-rouge">delete()</code> call on a row
in a locked release raises <code class="language-plaintext highlighter-rouge">ValidationError</code> — from the Admin, the API, or the shell.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Both raise ValidationError if release is locked
</span><span class="n">product</span><span class="p">.</span><span class="nf">save</span><span class="p">()</span>
<span class="n">product</span><span class="p">.</span><span class="nf">delete</span><span class="p">()</span>
</code></pre></div></div>

<p>This is enforced in <code class="language-plaintext highlighter-rouge">VersionedModel.save()</code> and <code class="language-plaintext highlighter-rouge">VersionedModel.delete()</code> — there is no
way to bypass it in application code. If a row needs to change after locking, the answer
is always a new patch release branched from the locked one.</p>

<hr />

<h2 id="the-active-field--soft-deletion-that-respects-the-status-workflow">The <code class="language-plaintext highlighter-rouge">active</code> Field — Soft Deletion That Respects the Status Workflow</h2>

<p>On top of the three-status system, each row also carries an <code class="language-plaintext highlighter-rouge">active</code> flag. This is a
soft-delete mechanism, but it is integrated with the status workflow rather than being
independent from it.</p>

<p>The key rule: <strong>deactivating a row that was <code class="language-plaintext highlighter-rouge">APPROVED</code> resets its status back to <code class="language-plaintext highlighter-rouge">DRAFT</code>.</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>deactivate()  →  active=False,  status → DRAFT  (if it was APPROVED)
reactivate()  →  active=True,   status stays DRAFT
</code></pre></div></div>

<p><strong>CI only runs against rows where <code class="language-plaintext highlighter-rouge">status=APPROVED</code> AND <code class="language-plaintext highlighter-rouge">active=True</code>.</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">product</span><span class="p">.</span><span class="nf">deactivate</span><span class="p">()</span>
<span class="c1"># Before: active=True,  status="approved"
# After:  active=False, status="draft"   ← CI can no longer see it
</span>
<span class="n">product</span><span class="p">.</span><span class="nf">reactivate</span><span class="p">()</span>
<span class="c1"># After:  active=True,  status="draft"   ← visible to users, not yet to CI
# Must go through approve() again before CI sees it
</span></code></pre></div></div>

<p>You cannot change the status of an inactive row — all transition methods (<code class="language-plaintext highlighter-rouge">mark_future</code>,
<code class="language-plaintext highlighter-rouge">mark_draft</code>, <code class="language-plaintext highlighter-rouge">approve</code>) raise <code class="language-plaintext highlighter-rouge">ValidationError</code> if <code class="language-plaintext highlighter-rouge">active=False</code>. You must <code class="language-plaintext highlighter-rouge">reactivate()</code>
first.</p>

<p>When a new release is branched from a locked one, inactive rows are carried over too. This
preserves the historical record and lets users choose to <code class="language-plaintext highlighter-rouge">reactivate()</code> a soft-deleted
row in the new release if the need arises.</p>

<hr />

<h2 id="auto-discovery-and-topological-copy">Auto-Discovery and Topological Copy</h2>

<p>When <code class="language-plaintext highlighter-rouge">create_release --based-on</code> runs, it discovers all models that inherit from
<code class="language-plaintext highlighter-rouge">VersionedModel</code> automatically — no manual registration. FK dependencies between those
models are resolved via topological sort, so models are copied in the correct order.
No ordering required on the developer’s part.</p>

<hr />

<h2 id="management-commands-reference">Management Commands Reference</h2>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create_release --release-version v1.0.0</code></td>
      <td>Bootstrap a standalone release</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create_release --release-version v1.1.0 --based-on v1.0.0</code></td>
      <td>Branch from a locked release</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">approve_release --release-version v1.1.0</code></td>
      <td>Approve all DRAFT rows (FUTURE left untouched)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lock_release --release-version v1.1.0</code></td>
      <td>Lock a release — immutable from this point</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">unlock_release --release-version v1.1.0</code></td>
      <td>Unlock (only valid before deployment)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deprecate_release --release-version v1.0.0</code></td>
      <td>Soft-hide old release (data preserved)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deprecate_release --release-version v1.0.0 --undo</code></td>
      <td>Restore a deprecated release</td>
    </tr>
  </tbody>
</table>

<h2 id="goes-well-with">Goes Well With</h2>

<ul>
  <li><a href="/posts/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — logging I wire up alongside the client for request tracing</li>
  <li><a href="/posts/drf-easy-crud"><code class="language-plaintext highlighter-rouge">drf-easy-crud</code></a> — the DRF API layer on the server side that <code class="language-plaintext highlighter-rouge">pyrest-model-client</code> talks to in my projects</li>
  <li><a href="/posts/django-versioned-models"><code class="language-plaintext highlighter-rouge">django-versioned-models</code></a> — versioned model queries I consume through this client</li>
</ul>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>PyPI</strong>: <a href="https://pypi.org/project/django-versioned-models">django-versioned-models</a></li>
  <li><strong>GitHub</strong>: <a href="https://github.com/aviz92/django-versioned-models">aviz92/django-versioned-models</a></li>
</ul>

<p>Feedback, issues, and PRs are welcome.</p>]]></content><author><name></name></author><category term="Backend" /><category term="Django" /><category term="django" /><category term="python" /><category term="versioning" /><category term="ci" /><category term="open-source" /><summary type="html"><![CDATA[django-versioned-models packages release management into a single mixin — inherit from VersionedModel and get dataset locking, a three-stage data-status workflow, and CI-ready management commands automatically.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How I Structure Python Projects: A Practical Guide</title><link href="https://aviz92.github.io/posts/how-i-structure-python-projects/" rel="alternate" type="text/html" title="How I Structure Python Projects: A Practical Guide" /><published>2026-03-07T06:00:00+02:00</published><updated>2026-03-08T13:03:32+02:00</updated><id>https://aviz92.github.io/posts/how-i-structure-python-projects</id><content type="html" xml:base="https://aviz92.github.io/posts/how-i-structure-python-projects/"><![CDATA[<p>After building and maintaining 20+ Python open-source projects, I’ve converged on a consistent structure that I apply to every new project. This post walks through the full picture — from day zero setup to production-ready patterns.</p>

<hr />

<h2 id="the-foundation-start-from-a-template">The Foundation: Start From a Template</h2>

<p>Every project I start comes from <a href="https://github.com/aviz92/dev-template-repository"><code class="language-plaintext highlighter-rouge">dev-template-repository</code></a> — a GitHub template that wires up everything before I write a single line of business logic:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>my-project/
├── .github/
│   ├── workflows/
│   │   └── publish_to_pypi.yml     # automated PyPI publishing
│   ├── CODEOWNERS                  # default reviewers per path
│   ├── ISSUE_TEMPLATE/             # bug reports, feature requests
│   └── pull_request_template.md    # contribution checklist
├── .vscode/
│   ├── settings.json               # format on save, lint integration
│   ├── extensions.json             # recommended extensions
│   └── launch.json                 # debug configurations
├── .cursor/                        # Cursor IDE config
├── requirements.txt
├── MANIFEST.in
├── env.template                    # .env structure, committed to git
├── Taskfile.yml                    # Go-Task for common dev commands
├── .pre-commit-config.yaml         # Black, Ruff, Pylint
├── CHANGELOG.md
├── LICENSE
└── README.md
</code></pre></div></div>

<p><strong>Key decisions baked in:</strong></p>

<ul>
  <li><strong>Pre-commit hooks</strong> (Black + Ruff + Pylint) run on every <code class="language-plaintext highlighter-rouge">git commit</code>. Code style is never a PR discussion.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">env.template</code></strong> is committed. <code class="language-plaintext highlighter-rouge">.env</code> is gitignored. New team members know exactly what variables to fill in.</li>
  <li><strong>Go-Task</strong> standardizes <code class="language-plaintext highlighter-rouge">task lint</code>, <code class="language-plaintext highlighter-rouge">task test</code>, <code class="language-plaintext highlighter-rouge">task build</code> across every project.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">CODEOWNERS</code></strong> means the right person gets tagged on every PR automatically.</li>
</ul>

<p>Setup takes 5 minutes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/aviz92/my-new-project.git
<span class="nb">cd </span>my-new-project
uv venv <span class="o">&amp;&amp;</span> <span class="nb">source</span> .venv/bin/activate
uv <span class="nb">sync
cp </span>env.template .env
pre-commit <span class="nb">install</span>
</code></pre></div></div>

<hr />

<h2 id="logging-one-call-everywhere">Logging: One Call, Everywhere</h2>

<p>Every project uses <a href="https://pypi.org/project/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — configured once at the entry point, then imported wherever needed:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># main.py or app.py
</span><span class="kn">from</span> <span class="n">custom_python_logger</span> <span class="kn">import</span> <span class="n">build_logger</span>

<span class="n">logger</span> <span class="o">=</span> <span class="nf">build_logger</span><span class="p">(</span>
    <span class="n">project_name</span><span class="o">=</span><span class="sh">'</span><span class="s">my-project</span><span class="sh">'</span><span class="p">,</span>
    <span class="n">log_file</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">utc</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">extra</span><span class="o">=</span><span class="p">{</span><span class="sh">'</span><span class="s">env</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">production</span><span class="sh">'</span><span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># anywhere else in the codebase
</span><span class="kn">from</span> <span class="n">custom_python_logger</span> <span class="kn">import</span> <span class="n">get_logger</span>

<span class="n">logger</span> <span class="o">=</span> <span class="nf">get_logger</span><span class="p">(</span><span class="sh">'</span><span class="s">my-module</span><span class="sh">'</span><span class="p">)</span>
<span class="n">logger</span><span class="p">.</span><span class="nf">step</span><span class="p">(</span><span class="sh">"</span><span class="s">Starting phase 2...</span><span class="sh">"</span><span class="p">)</span>   <span class="c1"># custom level for pipeline steps
</span><span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Connected to DB</span><span class="sh">"</span><span class="p">)</span>
<span class="n">logger</span><span class="p">.</span><span class="nf">exception</span><span class="p">(</span><span class="sh">"</span><span class="s">Failed to parse response</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Two custom levels that I use constantly:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">STEP</code></strong> — marks a named phase in a pipeline. Makes logs scannable at a glance.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">EXCEPTION</code></strong> — dedicated level for caught exceptions, separate from <code class="language-plaintext highlighter-rouge">ERROR</code>. Easy to grep in production.</li>
</ul>

<hr />

<h2 id="cli-tools-django-style-without-django">CLI Tools: Django-Style Without Django</h2>

<p>When a project needs a CLI — scripts, automation tools, data pipelines — I use <a href="https://pypi.org/project/python-base-command"><code class="language-plaintext highlighter-rouge">python-base-command</code></a>.</p>

<p>The pattern is identical to Django management commands, but with zero Django dependency:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>my-project/
├── cli.py              # 2-line entry point
└── commands/
    ├── __init__.py
    ├── sync.py
    ├── export.py
    └── validate.py
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># cli.py
</span><span class="kn">from</span> <span class="n">python_base_command</span> <span class="kn">import</span> <span class="n">Runner</span>
<span class="nc">Runner</span><span class="p">(</span><span class="n">commands_dir</span><span class="o">=</span><span class="sh">"</span><span class="s">commands</span><span class="sh">"</span><span class="p">).</span><span class="nf">run</span><span class="p">()</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># commands/sync.py
</span><span class="kn">from</span> <span class="n">python_base_command</span> <span class="kn">import</span> <span class="n">BaseCommand</span><span class="p">,</span> <span class="n">CommandError</span>

<span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">BaseCommand</span><span class="p">):</span>
    <span class="nb">help</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Sync data from upstream source</span><span class="sh">"</span>
    <span class="n">version</span> <span class="o">=</span> <span class="sh">"</span><span class="s">1.0.0</span><span class="sh">"</span>

    <span class="k">def</span> <span class="nf">add_arguments</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">parser</span><span class="p">):</span>
        <span class="n">parser</span><span class="p">.</span><span class="nf">add_argument</span><span class="p">(</span><span class="sh">"</span><span class="s">--env</span><span class="sh">"</span><span class="p">,</span> <span class="n">choices</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">staging</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">prod</span><span class="sh">"</span><span class="p">],</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
        <span class="n">parser</span><span class="p">.</span><span class="nf">add_argument</span><span class="p">(</span><span class="sh">"</span><span class="s">--dry-run</span><span class="sh">"</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="sh">"</span><span class="s">store_true</span><span class="sh">"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">kwargs</span><span class="p">[</span><span class="sh">"</span><span class="s">dry_run</span><span class="sh">"</span><span class="p">]:</span>
            <span class="n">self</span><span class="p">.</span><span class="n">logger</span><span class="p">.</span><span class="nf">warning</span><span class="p">(</span><span class="sh">"</span><span class="s">Dry run — no changes written.</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">return</span>

        <span class="n">self</span><span class="p">.</span><span class="n">logger</span><span class="p">.</span><span class="nf">step</span><span class="p">(</span><span class="sh">"</span><span class="s">Connecting to upstream...</span><span class="sh">"</span><span class="p">)</span>
        <span class="c1"># logic here
</span>        <span class="n">self</span><span class="p">.</span><span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Sync complete for env=</span><span class="si">{</span><span class="n">kwargs</span><span class="p">[</span><span class="sh">'</span><span class="s">env</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 cli.py <span class="nt">--help</span>
python3 cli.py <span class="nb">sync</span> <span class="nt">--env</span> staging <span class="nt">--dry-run</span>
python3 cli.py <span class="nb">export</span> <span class="nt">--format</span> json
</code></pre></div></div>

<p>Every command gets <code class="language-plaintext highlighter-rouge">--version</code>, <code class="language-plaintext highlighter-rouge">--verbosity</code>, <code class="language-plaintext highlighter-rouge">--traceback</code> for free. <code class="language-plaintext highlighter-rouge">self.logger</code> is wired up automatically. Testing uses <code class="language-plaintext highlighter-rouge">call_command()</code> — identical to Django.</p>

<hr />

<h2 id="rest-api-two-templates-for-two-use-cases">REST API: Two Templates for Two Use Cases</h2>

<h3 id="django-rest-framework--data-heavy-apis">Django REST Framework — Data-Heavy APIs</h3>

<p>For APIs that need versioned data, complex filtering, or admin interfaces: <a href="https://github.com/aviz92/django-basic-app"><code class="language-plaintext highlighter-rouge">django-basic-app</code></a>.</p>

<p>Out of the box it includes:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">CRUDUtils</code></strong> — one static class handles all five CRUD operations with wildcard filtering, pagination, and FK lookups</li>
  <li><strong><code class="language-plaintext highlighter-rouge">VersionedModel</code></strong> — every model participates in release management automatically</li>
  <li><strong>Data status workflow</strong> — <code class="language-plaintext highlighter-rouge">DRAFT → FUTURE → APPROVED</code>, where CI always queries approved data</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># One mixin, full versioning
</span><span class="k">class</span> <span class="nc">MyModel</span><span class="p">(</span><span class="n">VersionedModel</span><span class="p">):</span>
    <span class="n">name</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
    <span class="c1"># That's it. Versioned, locked, auto-discovered.
</span></code></pre></div></div>

<p>The CI flow:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create_release → architects edit data → approve_release → run tests → lock_release
</code></pre></div></div>

<h3 id="fastapi--lightweight-services">FastAPI — Lightweight Services</h3>

<p>For simpler services, microservices, or prototypes: <a href="https://github.com/aviz92/fast-api-template"><code class="language-plaintext highlighter-rouge">fast-api-template</code></a>.</p>

<p>Pre-configured with JWT authentication, SQLite (swappable), and example routes ready to extend.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/aviz92/fast-api-template.git
<span class="nb">cd </span>fast-api-template
uv <span class="nb">sync
</span>uv run uvicorn main:app <span class="nt">--reload</span>
<span class="c"># → http://localhost:8000/docs</span>
</code></pre></div></div>

<hr />

<h2 id="mcp-services-ai-tool-integration">MCP Services: AI Tool Integration</h2>

<p>For projects that expose tools to LLMs (Claude Desktop, etc.): <a href="https://github.com/aviz92/docker-mcp-service-template"><code class="language-plaintext highlighter-rouge">docker-mcp-service-template</code></a>.</p>

<p>The pattern: a <code class="language-plaintext highlighter-rouge">FastMCP</code> server + service layer + Docker:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mcp-service/
├── server.py           # MCP tool definitions
├── mcp_services/
│   ├── weather.py      # business logic, separate from MCP layer
│   └── ping.py
├── Dockerfile
└── .env
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server.py
</span><span class="nd">@mcp.tool</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">get_weather</span><span class="p">(</span><span class="n">city</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Fetch current weather for a given city.</span><span class="sh">"""</span>
    <span class="k">return</span> <span class="n">weather_service</span><span class="p">.</span><span class="nf">fetch_weather</span><span class="p">(</span><span class="n">city</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">//</span><span class="w"> </span><span class="err">Claude</span><span class="w"> </span><span class="err">Desktop</span><span class="w"> </span><span class="err">config</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"my-service"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"docker"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"run"</span><span class="p">,</span><span class="w"> </span><span class="s2">"--rm"</span><span class="p">,</span><span class="w"> </span><span class="s2">"-i"</span><span class="p">,</span><span class="w"> </span><span class="s2">"my-mcp-service"</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Business logic stays in service classes. MCP tool definitions stay thin. Adding a new tool means adding one service file and registering one decorator.</p>

<hr />

<h2 id="the-full-picture">The Full Picture</h2>

<p>Here’s how the layers stack across a typical project:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────┐
│         dev-template-repository          │  base structure, CI, pre-commit
├─────────────────────────────────────────┤
│         custom-python-logger             │  logging everywhere
├─────────────────────────────────────────┤
│         python-base-command              │  CLI tools and scripts
├─────────────────────────────────────────┤
│    django-basic-app / fast-api-template  │  API layer
├─────────────────────────────────────────┤
│         docker-mcp-service-template      │  LLM tool exposure (if needed)
└─────────────────────────────────────────┘
</code></pre></div></div>

<p>Every layer is optional — a simple automation script might only use the base template and the logger. A full data platform uses all of them.</p>

<hr />

<h2 id="what-this-gives-you">What This Gives You</h2>

<ul>
  <li><strong>Zero setup time</strong> — clone the template and you have CI, linting, and pre-commit from minute one</li>
  <li><strong>Consistent structure</strong> — any project looks familiar to anyone on the team</li>
  <li><strong>Opinionated defaults</strong> — you don’t decide formatting style on every PR</li>
  <li><strong>Testable by design</strong> — <code class="language-plaintext highlighter-rouge">call_command()</code> for CLI, standard pytest for APIs</li>
  <li><strong>Production logging from day one</strong> — no <code class="language-plaintext highlighter-rouge">print()</code> debugging that leaks into production</li>
</ul>

<p>The templates are all open source and available on <a href="https://github.com/aviz92">GitHub</a>. Clone any of them, use the template button, or just steal the patterns that work for you.</p>

<hr />

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/posts/drf-easy-crud/">drf-easy-crud: Enterprise-Grade CRUD for Django REST Framework</a></li>
  <li><a href="/posts/custom-python-logger/">custom-python-logger: Colored, Contextual Logging for Python Projects</a></li>
  <li><a href="/posts/python-base-command/">python-base-command: Django-Style CLI Commands Without Django</a></li>
  <li><a href="/posts/django-versioned-models/">django-versioned-models: Drop-in Release Management for Django Models</a></li>
</ul>]]></content><author><name></name></author><category term="Architecture" /><category term="Python" /><category term="python" /><category term="architecture" /><category term="project-structure" /><category term="best-practices" /><category term="devops" /><category term="cli" /><category term="django" /><category term="fastapi" /><summary type="html"><![CDATA[After building 20+ open-source Python projects, here's the opinionated structure I use for every new project — from the base template to logging, CLI commands, API layers, and CI workflows.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">ticket-assist: AI-Powered Slack Bot for Customer Support Ticket Classification</title><link href="https://aviz92.github.io/posts/ticket-assist/" rel="alternate" type="text/html" title="ticket-assist: AI-Powered Slack Bot for Customer Support Ticket Classification" /><published>2026-03-06T11:30:00+02:00</published><updated>2026-03-28T19:39:26+03:00</updated><id>https://aviz92.github.io/posts/ticket-assist</id><content type="html" xml:base="https://aviz92.github.io/posts/ticket-assist/"><![CDATA[<p>Support teams spend a lot of time doing the same triage: read the ticket, figure out the category, assess urgency, draft a first response. It’s repetitive, it’s slow, and it’s where response times slip. <code class="language-plaintext highlighter-rouge">ticket-assist</code> is a production-ready Slack bot that does that triage automatically — mention it with a support message and it replies with a structured classification and a ready-to-send answer, powered by your choice of LLM.</p>

<h2 id="why-i-built-this">Why I Built This</h2>

<p>I wanted to see how far structured LLM output could go in a real support workflow. Not a chatbot that gives vague answers — something that commits to a category, an urgency level, a confidence score, and an actual response, all in a validated Pydantic schema. Slack was the right channel because that’s where support requests actually land. The result is something you can drop into an existing support workflow and start getting AI-classified tickets immediately.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/aviz92/ticket-assist.git
<span class="nb">cd </span>ticket-assist
uv <span class="nb">sync</span>
</code></pre></div></div>

<h2 id="setup--configuration">Setup &amp; Configuration</h2>

<pre><code class="language-env">SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_BOT_USER_ID=U0XXXXXXXXX

LLM_PROVIDER=claude          # options: claude | gemini | openai
ANTHROPIC_API_KEY=your-anthropic-key
GEMINI_API_KEY=your-gemini-key
OPENAI_API_KEY=your-openai-key
</code></pre>

<p>Switch providers with a single env var — no code changes:</p>

<table>
  <thead>
    <tr>
      <th>Provider</th>
      <th><code class="language-plaintext highlighter-rouge">LLM_PROVIDER</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Anthropic Claude</td>
      <td><code class="language-plaintext highlighter-rouge">claude</code></td>
    </tr>
    <tr>
      <td>Google Gemini</td>
      <td><code class="language-plaintext highlighter-rouge">gemini</code></td>
    </tr>
    <tr>
      <td>OpenAI</td>
      <td><code class="language-plaintext highlighter-rouge">openai</code></td>
    </tr>
  </tbody>
</table>

<h2 id="quick-start">Quick Start</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Install dependencies</span>
uv <span class="nb">sync</span>

<span class="c"># 2. Configure environment</span>
<span class="nb">cp</span> .env.example .env

<span class="c"># 3. Get your bot user ID</span>
uv run python slack_bot/user_id.py

<span class="c"># 4. Start the Flask server</span>
uv run python <span class="nt">-m</span> slack_bot.app

<span class="c"># 5. Expose with ngrok</span>
ngrok http 5000
</code></pre></div></div>

<p>Set <code class="language-plaintext highlighter-rouge">https://&lt;your-ngrok-url&gt;/slack/events</code> as the Event Subscriptions URL in your Slack app settings, then invite the bot to a channel with <code class="language-plaintext highlighter-rouge">/invite @YourBotName</code>.</p>

<h2 id="real-world-example">Real-World Example</h2>

<p>A customer messages in Slack: <code class="language-plaintext highlighter-rouge">@ticket-assist My order #12345 hasn't arrived yet and I needed it for an event tomorrow</code>.</p>

<p>The bot runs the classification pipeline and replies immediately:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">response</span><span class="p">.</span><span class="n">category</span>    <span class="c1"># TicketCategory.ORDER_ISSUE
</span><span class="n">response</span><span class="p">.</span><span class="n">urgency</span>     <span class="c1"># TicketUrgency.HIGH
</span><span class="n">response</span><span class="p">.</span><span class="n">sentiment</span>   <span class="c1"># "frustrated, time-sensitive"
</span><span class="n">response</span><span class="p">.</span><span class="n">confidence</span>  <span class="c1"># 0.94
</span><span class="n">response</span><span class="p">.</span><span class="n">ticket_complete</span>  <span class="c1"># True
</span><span class="n">response</span><span class="p">.</span><span class="n">answer</span>      <span class="c1"># "I'm sorry to hear your order hasn't arrived..."
</span></code></pre></div></div>

<p>The answer field contains a ready-to-send customer reply — the support agent can post it directly or edit it first. Category, urgency, and sentiment give the team everything they need to route and prioritize without reading the full message.</p>

<h2 id="key-features">Key Features</h2>

<p>The core of the bot is a Pydantic schema that defines exactly what the LLM must return. There’s no parsing, no brittle string matching — the model either returns a valid <code class="language-plaintext highlighter-rouge">TicketClassification</code> or the request retries up to three times:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">response</span> <span class="o">=</span> <span class="n">llm</span><span class="p">.</span><span class="nf">completions_create</span><span class="p">(</span>
    <span class="n">response_model</span><span class="o">=</span><span class="n">TicketClassification</span><span class="p">,</span>
    <span class="n">temperature</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
    <span class="n">max_retries</span><span class="o">=</span><span class="mi">3</span><span class="p">,</span>
    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">system</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">SYSTEM_PROMPT</span><span class="p">},</span>
        <span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">user_input</span><span class="p">},</span>
    <span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Using Pydantic as the response model means every field is typed and validated before the bot replies. A classification without a confidence score or a missing urgency level is caught before it reaches the user — not after.</p>

<p>The event handling is minimal by design. Built on Slack Bolt and Flask, the bot exposes a <code class="language-plaintext highlighter-rouge">/slack/events</code> webhook that receives <code class="language-plaintext highlighter-rouge">app_mention</code> events. When mentioned, it strips the mention tag, runs the AI pipeline, and calls <code class="language-plaintext highlighter-rouge">say()</code> with the generated answer:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@app.event</span><span class="p">(</span><span class="sh">"</span><span class="s">app_mention</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">handle_mentions</span><span class="p">(</span><span class="n">body</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">say</span><span class="p">:</span> <span class="n">Say</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">body</span><span class="p">[</span><span class="sh">"</span><span class="s">event</span><span class="sh">"</span><span class="p">][</span><span class="sh">"</span><span class="s">text</span><span class="sh">"</span><span class="p">].</span><span class="nf">replace</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">&lt;@</span><span class="si">{</span><span class="n">SLACK_BOT_USER_ID</span><span class="si">}</span><span class="s">&gt;</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">).</span><span class="nf">strip</span><span class="p">()</span>
    <span class="nf">say</span><span class="p">(</span><span class="sh">"</span><span class="s">thinking...</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">response</span> <span class="o">=</span> <span class="nf">ai_function</span><span class="p">(</span><span class="n">llm</span><span class="o">=</span><span class="n">llm</span><span class="p">,</span> <span class="n">user_input</span><span class="o">=</span><span class="n">text</span><span class="p">)</span>
    <span class="nf">say</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">answer</span><span class="p">)</span>
</code></pre></div></div>

<p>Multi-LLM support lets you swap providers without touching the classification logic. <code class="language-plaintext highlighter-rouge">temperature=0</code> keeps responses deterministic regardless of which model is behind it.</p>

<h2 id="goes-well-with">Goes Well With</h2>

<ul>
  <li><a href="/posts/python-jira-plus"><code class="language-plaintext highlighter-rouge">python-jira-plus</code></a> — create a Jira ticket automatically from the classification output</li>
  <li><a href="/posts/python-vault"><code class="language-plaintext highlighter-rouge">python-vault</code></a> — store Slack and LLM API tokens in Vault instead of plain env vars</li>
  <li><a href="/posts/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — structured logging for every classification event and LLM call</li>
</ul>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>GitHub</strong>: <a href="https://github.com/aviz92/ticket-assist">github.com/aviz92/ticket-assist</a></li>
</ul>

<p>Mention the bot. Get a classification. Route the ticket. Done.</p>]]></content><author><name></name></author><category term="AI" /><category term="Support" /><category term="python" /><category term="ai" /><category term="llm" /><category term="slack" /><category term="automation" /><category term="support" /><category term="pydantic" /><summary type="html"><![CDATA[A production-ready Slack bot that listens for mentions, classifies support tickets using structured LLM output via Pydantic, and replies with AI-generated answers — powered by Claude, Gemini, or OpenAI.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">think-drop: A Personal AI Note-Taking Bot for Telegram and Notion</title><link href="https://aviz92.github.io/posts/think-drop/" rel="alternate" type="text/html" title="think-drop: A Personal AI Note-Taking Bot for Telegram and Notion" /><published>2026-03-06T11:00:00+02:00</published><updated>2026-03-28T19:39:26+03:00</updated><id>https://aviz92.github.io/posts/think-drop</id><content type="html" xml:base="https://aviz92.github.io/posts/think-drop/"><![CDATA[<p>The best ideas arrive at inconvenient moments. The worst ones end up in a long WhatsApp message to yourself that you never read again. <code class="language-plaintext highlighter-rouge">think-drop</code> is a personal Telegram bot that captures your raw thoughts, classifies them by category, generates a clean summary, and saves everything to your Notion workspace — powered by whichever LLM you prefer. Send a message, close your phone, move on.</p>

<h2 id="why-i-built-this">Why I Built This</h2>

<p>I kept losing thoughts. Not because I wasn’t writing them down — I was, in five different places. A notes app, a Telegram draft, a napkin, a sticky note on the monitor. None of it was searchable later and none of it was organized. I wanted one place where everything landed automatically, with the AI doing the classification and summarization I never got around to doing manually. Notion was already where I worked. Telegram was already on my phone. I just needed something to connect them.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/aviz92/think-drop.git
<span class="nb">cd </span>think-drop
uv <span class="nb">sync</span>
</code></pre></div></div>

<p>Or with Docker:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker-compose up <span class="nt">--build</span> <span class="nt">-d</span>
</code></pre></div></div>

<h2 id="setup--configuration">Setup &amp; Configuration</h2>

<p>Create a <code class="language-plaintext highlighter-rouge">.env</code> file with your credentials:</p>

<pre><code class="language-env">TELEGRAM_BOT_TOKEN=your_telegram_bot_token

NOTION_TOKEN=your_notion_internal_integration_secret
NOTION_DB_ID=your_notion_database_id

LLM_PROVIDER=gemini          # options: gemini | openai | claude
GEMINI_API_KEY=your_gemini_api_key
OPENAI_API_KEY=your_openai_api_key
ANTHROPIC_API_KEY=your_anthropic_api_key
</code></pre>

<p>Only the API key for your chosen provider is required. Switch providers by changing <code class="language-plaintext highlighter-rouge">LLM_PROVIDER</code> — no code changes:</p>

<table>
  <thead>
    <tr>
      <th>Provider</th>
      <th><code class="language-plaintext highlighter-rouge">LLM_PROVIDER</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Google Gemini</td>
      <td><code class="language-plaintext highlighter-rouge">gemini</code></td>
    </tr>
    <tr>
      <td>OpenAI</td>
      <td><code class="language-plaintext highlighter-rouge">openai</code></td>
    </tr>
    <tr>
      <td>Anthropic Claude</td>
      <td><code class="language-plaintext highlighter-rouge">claude</code></td>
    </tr>
  </tbody>
</table>

<p>Create a Notion database with these properties and connect your integration:</p>

<table>
  <thead>
    <tr>
      <th>Property</th>
      <th>Type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Title</td>
      <td>title</td>
    </tr>
    <tr>
      <td>Summary</td>
      <td>text</td>
    </tr>
    <tr>
      <td>Raw</td>
      <td>text</td>
    </tr>
    <tr>
      <td>Category</td>
      <td>select</td>
    </tr>
    <tr>
      <td>Source</td>
      <td>select</td>
    </tr>
    <tr>
      <td>Date</td>
      <td>date</td>
    </tr>
  </tbody>
</table>

<h2 id="quick-start">Quick Start</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Clone and install</span>
git clone https://github.com/aviz92/think-drop.git
<span class="nb">cd </span>think-drop
uv <span class="nb">sync</span>

<span class="c"># 2. Configure</span>
<span class="nb">cp</span> .env.example .env  <span class="c"># fill in your credentials</span>

<span class="c"># 3. Run</span>
uv run python think_drop/main.py
</code></pre></div></div>

<p>Open Telegram, find your bot, send <code class="language-plaintext highlighter-rouge">/start</code>.</p>

<h2 id="real-world-example">Real-World Example</h2>

<p>Send “remind me to buy a birthday gift for Sarah next week” and here’s what happens end-to-end:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFO  [a3f2c1b8] Message received | user_id=123456 username=avi | chars=51
INFO  [a3f2c1b8] LLM classification started | provider=gemini
DEBUG [a3f2c1b8] LLM response received | response_chars=98
INFO  [a3f2c1b8] LLM classification done | title='Gift for Sarah' category=Shopping
INFO  [a3f2c1b8] Notion write started | title='Gift for Sarah' category=Shopping
INFO  [a3f2c1b8] Note saved to Notion | url=https://notion.so/abc123...
INFO  [a3f2c1b8] Confirmation sent to user
</code></pre></div></div>

<p>Your Notion database gets a new row: title <code class="language-plaintext highlighter-rouge">Gift for Sarah</code>, category <code class="language-plaintext highlighter-rouge">Shopping</code>, raw text preserved, summary generated, date stamped. Every incoming message gets a unique 8-character session ID so the full pipeline is traceable in logs from receive to confirmation.</p>

<h2 id="key-features">Key Features</h2>

<p>The pipeline is fully automatic — there’s nothing to configure per-note. Send a message, the AI classifies it into one of eight categories (Work, Home, Ideas, Shopping, Meetings, Reading, Decisions, Personal), generates a title and summary, and writes it to Notion with full metadata. The raw text is preserved alongside the summary so nothing is lost.</p>

<p>Multi-LLM support means you’re not locked to one provider. <code class="language-plaintext highlighter-rouge">LLM_PROVIDER=gemini</code> today, <code class="language-plaintext highlighter-rouge">LLM_PROVIDER=claude</code> tomorrow — the pipeline is identical. Useful when you want to compare output quality or when a provider has an outage.</p>

<p>Session-scoped log tracing gives every incoming message a unique 8-character ID shared across all log lines for that request. When something goes wrong — a failed Notion write, a malformed LLM response — you can grep for the session ID and see the exact sequence of events, with no log lines from other concurrent messages mixed in.</p>

<p>Async processing keeps the bot responsive under load. Each message is processed asynchronously so a slow LLM response on one note doesn’t block others.</p>

<h2 id="goes-well-with">Goes Well With</h2>

<ul>
  <li><a href="/posts/python-notion-plus"><code class="language-plaintext highlighter-rouge">python-notion-plus</code></a> — the Notion client layer under the hood</li>
  <li><a href="/posts/python-vault"><code class="language-plaintext highlighter-rouge">python-vault</code></a> — store Telegram and Notion tokens in Vault instead of a plain <code class="language-plaintext highlighter-rouge">.env</code></li>
  <li><a href="/posts/custom-python-logger"><code class="language-plaintext highlighter-rouge">custom-python-logger</code></a> — the logger powering session-scoped tracing</li>
</ul>

<hr />

<h2 id="links">Links</h2>

<ul>
  <li><strong>GitHub</strong>: <a href="https://github.com/aviz92/think-drop">github.com/aviz92/think-drop</a></li>
</ul>

<p>One Telegram message. One Notion row. Zero friction.</p>]]></content><author><name></name></author><category term="AI" /><category term="Productivity" /><category term="python" /><category term="ai" /><category term="llm" /><category term="telegram" /><category term="notion" /><category term="automation" /><category term="productivity" /><summary type="html"><![CDATA[Send a voice note or text to Telegram, and think-drop automatically classifies it, generates a summary, and writes it to your Notion workspace — powered by Gemini, OpenAI, or Claude.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aviz92.github.io/assets/img/og-default.png" /><media:content medium="image" url="https://aviz92.github.io/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>