<?xml version="1.0"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">

<channel>
	<title>Planet Python</title>
	<link>http://planetpython.org/</link>
	<language>en</language>
	<description>Planet Python - http://planetpython.org/</description>

<item>
	<title>LernerPython blog, from Reuven Lerner: Announcing: Vibe to valid</title>
	<guid>https://lernerpython.com/2026/11/12/announcing-vibe-to-valid/</guid>
	<link>https://lernerpython.com/2026/11/12/announcing-vibe-to-valid/</link>
	<description>&lt;p class=&quot;wp-block-paragraph&quot;&gt;Vibe coding is fun: You can go from an idea to a working app in a weekend.&lt;/p&gt;



&lt;img width=&quot;1024&quot; height=&quot;1024&quot; src=&quot;https://lernerpython.com/wp-content/uploads/2026/11/vibetovalid-avatar.png&quot; alt=&quot;&quot; class=&quot;wp-image-18844&quot; /&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;But if you don’t have a background in software, how can you be sure your app is secure? Tested? Backed up? Ready for real customers, or for an investor’s technical review? &lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Today I’m launching Vibe to Valid, an audit service for vibe-coded Python apps. &lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Here’s how it works:&lt;/p&gt;



&lt;ol class=&quot;wp-block-list&quot;&gt;
&lt;li&gt;You share your repo.&lt;/li&gt;



&lt;li&gt;I review it and send you a plain-English report: what’s wrong, what’s right, and what to fix first.&lt;/li&gt;



&lt;li&gt;If you want, I fix the problems, and set up your AI coding tools so you can keep vibe coding with the AI on a short leash. &lt;/li&gt;
&lt;/ol&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;There’s also a due diligence audit for investors and acquirers, and a walkthrough session for your team. I’ve been a Python trainer and consultant since 1995, and Claude Code is a core part of my workflow. But every finding is reviewed and explained by me. You’re getting an experienced human, not an automated scan. &lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Learn more at &lt;a href=&quot;https://VibeToValid.com&quot;&gt;https://VibeToValid.com&lt;/a&gt;, where you can also book a free intro call.&lt;/p&gt;
&lt;p&gt;The post &lt;a href=&quot;https://lernerpython.com/2026/11/12/announcing-vibe-to-valid/&quot;&gt;Announcing: Vibe to valid&lt;/a&gt; appeared first on &lt;a href=&quot;https://lernerpython.com&quot;&gt;LernerPython&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Thu, 12 Nov 2026 10:01:13 +0000</pubDate>
</item>
<item>
	<title>PyPy: 20 Years of Pyrolog</title>
	<guid>https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html</guid>
	<link>https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html</link>
	<description>&lt;p&gt;On November 12, 2006 I did the &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/cfbolz/pyrolog/commit/6653a22&quot;&gt;first surviving commit&lt;/a&gt; of my RPython based
Prolog implementation &lt;strong&gt;Pyrolog&lt;/strong&gt; into the PyPy repository. I had been working
on it since spring 2006 already. In that year we were finishing up the first
PyPy EU research project and I was finishing up my Bachelor studies at Uni
Düsseldorf. Since it's now 20 years later I wanted to write a personal
retrospective post on this project. Pyrolog never had any real users, but it
still led to some interesting research and influenced a number of other RPython
projects.&lt;/p&gt;

&lt;p class=&quot;topic-title&quot;&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#top&quot;&gt;Contents&lt;/a&gt;&lt;/p&gt;
&lt;ul class=&quot;simple&quot;&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#context&quot; id=&quot;toc-entry-1&quot;&gt;Context&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#the-first-year&quot; id=&quot;toc-entry-2&quot;&gt;2006/2007 The first year&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#jit-developments-on-the-pypy-side&quot; id=&quot;toc-entry-3&quot;&gt;2008/2009 JIT developments on the PyPy side&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#new-pyrolog-architecture-more-continuations&quot; id=&quot;toc-entry-4&quot;&gt;2009-2010 New Pyrolog Architecture: More Continuations&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#jit-support&quot; id=&quot;toc-entry-5&quot;&gt;JIT support&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#widening-prolog-support&quot; id=&quot;toc-entry-6&quot;&gt;2011/2012 Widening Prolog support&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#compressed-terms&quot; id=&quot;toc-entry-7&quot;&gt;2012/2013 Compressed Terms&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#unipycation&quot; id=&quot;toc-entry-8&quot;&gt;2013 Unipycation&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#pycket&quot; id=&quot;toc-entry-9&quot;&gt;2013 Pycket&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rpython-implementation-of-effekt&quot; id=&quot;toc-entry-10&quot;&gt;2022-2024 RPython implementation of Effekt&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#git-conversion&quot; id=&quot;toc-entry-11&quot;&gt;2026 Git Conversion&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#conclusion&quot; id=&quot;toc-entry-12&quot;&gt;Conclusion&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a class=&quot;reference internal&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bibliography&quot; id=&quot;toc-entry-13&quot;&gt;Bibliography&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;Context&lt;/h2&gt;
&lt;p&gt;As part of the EU project we had promised lots of things, including
implementing some constraint programming in PyPy. For this strand of the work I
learned Prolog by reading Clocksin and Mellish's Programming Prolog &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#cm2003&quot; id=&quot;citation-reference-1&quot;&gt;[CM2003]&lt;/a&gt;
and later the Art of Prolog &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#ss1994&quot; id=&quot;citation-reference-2&quot;&gt;[SS1994]&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In March 2006, the PyPy project partner &lt;a class=&quot;reference external&quot; href=&quot;https://www.logilab.fr/&quot;&gt;Logilab&lt;/a&gt; organized a sprint in UCLouvain
in Louvain-la-Neuve (Belgium), with &lt;a class=&quot;reference external&quot; href=&quot;https://webperso.info.ucl.ac.be/~pvr/&quot;&gt;Peter van Roy&lt;/a&gt; and some of the researchers
working on &lt;a class=&quot;reference external&quot; href=&quot;https://www.gecode.org/&quot;&gt;Gecode&lt;/a&gt;. The plan was to add some kind of unification and constraint
solving to PyPy. The Logilab people were quite inspired by the Van Roy/Haridi
book &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#vh2004&quot; id=&quot;citation-reference-3&quot;&gt;[VH2004]&lt;/a&gt; and I later studied his Prolog implementation survey paper
&lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#vanroy1994&quot; id=&quot;citation-reference-4&quot;&gt;[VanRoy1994]&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In 2006 I also attended my advisor &lt;a class=&quot;reference external&quot; href=&quot;https://www.cs.hhu.de/en/research-groups/software-engineering-and-programming-languages/our-team/team/michael-leuschel/&quot;&gt;Michael Leuschel's&lt;/a&gt; Prolog lectures in
Düsseldorf. During that I started working on a Prolog interpreter in RPython,
later called Pyrolog. Much of the early commit history is lost unfortunately,
it didn't make the &lt;a class=&quot;reference external&quot; href=&quot;https://pypy.org/posts/2010/12/pypy-migrates-to-mercurial-3308736161543832134.html&quot;&gt;Subversion-&amp;gt;Mercurial&lt;/a&gt; jump correctly and I don't know
where the codespeak archives are now.&lt;/p&gt;


&lt;h2&gt;2006/2007 The first year&lt;/h2&gt;
&lt;p&gt;Pyrolog's first incarnation had a fairly straightforward implementation of
unification in object-oriented RPython code. Logical variables stored an index,
with their bindings stored in a global array at that index.&lt;/p&gt;

&lt;img alt=&quot;Memory layout of the interpreter&quot; src=&quot;https://www.pypy.org/images/2026-pyrolog-memory-orig.svg&quot; /&gt;

&lt;p&gt;Pyrolog Memory Layout from &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bolz2007&quot; id=&quot;citation-reference-5&quot;&gt;[Bolz2007]&lt;/a&gt;&lt;/p&gt;


&lt;p&gt;That version represented choice points by having exception handlers on the
interpreter level, and backtracking via throwing exceptions, catching them
further up. It used a continuation-based architecture for storing the calls
left to do. Those success continuations were stored on the heap and
continuously allocated and consumed. This approach was taken from Paul Tarau's
BinProlog (&lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#tb1990&quot; id=&quot;citation-reference-6&quot;&gt;[TB1990]&lt;/a&gt;, &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#tarau1992&quot; id=&quot;citation-reference-7&quot;&gt;[Tarau1992]&lt;/a&gt;) and also based on some other Prolog papers
&lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#lindgren1994&quot; id=&quot;citation-reference-8&quot;&gt;[Lindgren1994]&lt;/a&gt; &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#neumerkel1995&quot; id=&quot;citation-reference-9&quot;&gt;[Neumerkel1995]&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;To deal with deep recursion that Prolog programs typically do, that version of
Pyrolog relied on RPython's &lt;em&gt;stackless transformation&lt;/em&gt; &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#br2007d071&quot; id=&quot;citation-reference-10&quot;&gt;[BR2007D071]&lt;/a&gt;, which
would unwind the host stack (i.e. the C stack) and store its information on the
host heap if host stack overflow was getting close. When storing C stack frames
on the heap in this way, we also did some amount of tail call optimization. We
were super into this idea at the time. However, it turned out not to be too
practical, making the binary twice as large and slower, so eventually we
removed it again.&lt;/p&gt;
&lt;p&gt;I also experimented with using the first RPython JIT on that version of
Pyrolog. That JIT was a partial-evaluation-based method JIT and managed to
speed up some small Python programs. I don't think it did very much for Pyrolog
though.&lt;/p&gt;
&lt;p&gt;The performance of that version of Pyrolog was quite disappointing. In my B.Sc.
thesis &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bolz2007&quot; id=&quot;citation-reference-11&quot;&gt;[Bolz2007]&lt;/a&gt; I did some measurements against &lt;a class=&quot;reference external&quot; href=&quot;https://www.swi-prolog.org/&quot;&gt;SWI&lt;/a&gt; and &lt;a class=&quot;reference external&quot; href=&quot;https://sicstus.sics.se/&quot;&gt;Sicstus Prolog&lt;/a&gt;.
Sicstus was often an order of magnitude faster than Pyrolog at this point.&lt;/p&gt;
&lt;p&gt;In the thesis I also implemented a way to call into Prolog from Python code and
had a case study where I implemented a Tic-Tac-Toe GUI in Python, with the game
logic in Prolog.&lt;/p&gt;
&lt;p&gt;Shortly after finishing my thesis, I &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/cfbolz/pyrolog/commit/04307d2&quot;&gt;changed the variable representation&lt;/a&gt;:
instead of indexing into a global binding array, each variable object stored
its own binding. The exception-based backtracking architecture stayed in place
for another two years.&lt;/p&gt;


&lt;h2&gt;2008/2009 JIT developments on the PyPy side&lt;/h2&gt;
&lt;p&gt;After the EU project finished we started working on various new architectural
ideas for the RPython meta-JIT. Armin Rigo, me and some others wrote two new
prototypes. We weren't happy with them, so in 2008 Armin and I started
experimenting with a prototype for a new trace-based JIT &lt;a class=&quot;reference external&quot; href=&quot;https://pypy.org/posts/2008/06/hi-all-some-news-from-jit-front-7534695765973581706.html&quot;&gt;written in Prolog&lt;/a&gt;. We
used Prolog because its pattern matching and ease of meta-programming allowed
us to experiment more easily with different designs.&lt;/p&gt;
&lt;p&gt;Towards the end of 2008 and
throughout 2009 Armin, Maciej Fijałkowski, me and others started porting the
design back to RPython and called it &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/pypy/pypy/commit/bbb1996550b&quot;&gt;pyjitpl5&lt;/a&gt; (the &quot;pl&quot; in the name stands for
&quot;Prolog&quot;). This is still the base of the current PyPy meta-JIT compiler and
became the subject of the second (smaller) PyPy EU project in the &lt;a class=&quot;reference external&quot; href=&quot;https://pypy.org/posts/2010/12/oh-and-btw-pypy-gets-funding-through.html&quot;&gt;Eurostars&lt;/a&gt;
program.&lt;/p&gt;
&lt;p&gt;We eventually published a paper about the Prolog version of the JIT &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#blr2010&quot; id=&quot;citation-reference-12&quot;&gt;[BLR2010]&lt;/a&gt;
at LOPSTR 2009.&lt;/p&gt;


&lt;h2&gt;2009-2010 New Pyrolog Architecture: More Continuations&lt;/h2&gt;
&lt;p&gt;After working mostly on PyPy and the new JIT in 2008 and 2009 I started working
on Pyrolog again over the 2009 Christmas break. Since the stackless
transformation wasn't compatible with the new JIT I had to switch to a
different architecture, still used today. The first version of the Pyrolog
architecture had already stored success continuations on the heap. The goal of this
refactoring was to also store the &lt;em&gt;failure continuations&lt;/em&gt; as &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/cfbolz/pyrolog/commit/fc1a4f9&quot;&gt;reified RPython
objects&lt;/a&gt;.&lt;/p&gt;
&lt;div class=&quot;code&quot;&gt;&lt;pre class=&quot;code python&quot;&gt;&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-1&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-1&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-1&quot;&gt;&lt;/a&gt;&lt;span class=&quot;c1&quot;&gt;# the simplified main interpreter loop&lt;/span&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-2&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-2&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-2&quot;&gt;&lt;/a&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-3&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-3&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-3&quot;&gt;&lt;/a&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;interpret&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-4&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-4&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-4&quot;&gt;&lt;/a&gt;    &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sc&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_done&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-5&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-5&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-5&quot;&gt;&lt;/a&gt;        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-6&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-6&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-6&quot;&gt;&lt;/a&gt;            &lt;span class=&quot;n&quot;&gt;sc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trail&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sc&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;activate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-7&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-7&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-7&quot;&gt;&lt;/a&gt;        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UnificationFailed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
&lt;a id=&quot;rest_code_deee6085586249c1a01f682d695abfe8-8&quot; name=&quot;rest_code_deee6085586249c1a01f682d695abfe8-8&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#rest_code_deee6085586249c1a01f682d695abfe8-8&quot;&gt;&lt;/a&gt;            &lt;span class=&quot;n&quot;&gt;sc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trail&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fc&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This had the effect that the host stack use is completely flat when
interpreting Prolog calls. The state needed to continue with Prolog execution is always
stored in continuation objects on the heap. The interpreter is therefore
dealing with two continuations, one activated upon success, and one on failure.&lt;/p&gt;
&lt;p&gt;Additionally, there is a (segmented) trail object which stores the variable
bindings that need to be undone when backtracking. Failure continuations have a
reference to the part of the trail they need to backtrack to, when activated.&lt;/p&gt;
&lt;p&gt;The downside of this approach is that the interpreter continuously has to
allocate heap data for all the success and failure continuations. This puts a
lot of pressure on the RPython GC. Also, term unification still uses the host
stack, so unifying very deep terms can still be a problem and lead to
implementation-level recursion errors.&lt;/p&gt;

&lt;h3&gt;JIT support&lt;/h3&gt;
&lt;p&gt;The real goal of the refactoring was to make the interpreter more JIT-friendly.
Since the interpreter doesn't use recursion for Prolog calls any more, the loops at the Prolog
level (expressed as recursion) can be traced by the JIT. All the continuous
allocations of continuations can be optimized by the allocation removal of the
RPython JIT. If all this works correctly, the performance of Pyrolog becomes
very good, often beating Sicstus by a significant factor on suitable
microbenchmarks.&lt;/p&gt;

&lt;img alt=&quot;/images/2026-pyrolog-vmgen.svg&quot; src=&quot;https://www.pypy.org/images/2026-pyrolog-vmgen.svg&quot; /&gt;

&lt;p&gt;Pyrolog VM Generation vs. Runtime from &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bls2010&quot; id=&quot;citation-reference-13&quot;&gt;[BLS2010]&lt;/a&gt;&lt;/p&gt;


&lt;p&gt;Unfortunately this is mainly the case for code with very predictable control
flow, ideally with nice loops. Since the main appeal of Prolog is to do
backtracking search, control flow is typically not very predictable for
interesting more Prolog-like programs. As soon as the control flow becomes too
complicated and unpredictable, the continuations survive beyond the region that
the JIT optimizes and have to be allocated on the heap again. Therefore Pyrolog has
quite extreme performance cliffs.&lt;/p&gt;
&lt;p&gt;We wrote a paper about this version of Pyrolog for PPDP 2010 &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bls2010&quot; id=&quot;citation-reference-14&quot;&gt;[BLS2010]&lt;/a&gt; (together with
Michael Leuschel and my later colleague David Schneider). That conference
was super interesting and important for me. I met Paul Tarau (who invented
BinProlog, one of the inspirations of Pyrolog. He later gave an excellent &lt;a class=&quot;reference external&quot; href=&quot;https://www.youtube.com/watch?v=SRYAMt8iQSw&quot;&gt;Prolog
talk&lt;/a&gt; at the virtual machine summer school in 2016), Olivier Danvy (whose
paper &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bd2004&quot; id=&quot;citation-reference-15&quot;&gt;[BD2004]&lt;/a&gt; I should have cited. Genuinely one of the nicest people),
Kenichi Asai (who had worked on partial evaluation and reflection) and several
others. At that point during my PhD I was feeling relatively demotivated, and
my chats with these researchers were incredibly encouraging.&lt;/p&gt;

&lt;img alt=&quot;https://www3.risc.jku.at/conferences/ppdp2010/photos/img_8487.jpg&quot; src=&quot;https://www3.risc.jku.at/conferences/ppdp2010/photos/img_8487.jpg&quot; /&gt;

&lt;p&gt;&lt;a class=&quot;reference external&quot; href=&quot;https://www3.risc.jku.at/conferences/ppdp2010/photos/index.html&quot;&gt;Group photo at PPDP 2010&lt;/a&gt;&lt;/p&gt;


&lt;p&gt;On the bus trip to Gmunden, where the conference dinner took place, somebody
explained how to implement unification of cyclic terms (i.e. rational trees) to
me. Unfortunately I am not quite sure who it was any more, I think it might
have been Thierry Martinez. This led to me &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/cfbolz/pyrolog/commit/8d51938&quot;&gt;implementing support for cyclic
term unification&lt;/a&gt;. I still like that trick a lot and tried to explain it to
&lt;a class=&quot;reference external&quot; href=&quot;https://www.philipzucker.com/&quot;&gt;Phil Zucker&lt;/a&gt; earlier this year. I hope to blog about it eventually. For a fun
use case see my blog post on &lt;a class=&quot;reference external&quot; href=&quot;https://cfbolz.de/posts/dfa-minimization-using-rational-trees-in-prolog/&quot;&gt;rational tree unification for dfa minimization&lt;/a&gt;.&lt;/p&gt;



&lt;h2&gt;2011/2012 Widening Prolog support&lt;/h2&gt;
&lt;p&gt;During 2011 and 2012 I supervised several Bachelor/Master theses that worked on
adding features to Pyrolog. In the process we added support for Prolog modules,
&lt;a class=&quot;reference external&quot; href=&quot;https://www.swi-prolog.org/pldoc/man?section=DCG&quot;&gt;DCGs&lt;/a&gt;, &lt;a class=&quot;reference external&quot; href=&quot;https://www.swi-prolog.org/pldoc/man?section=attvar&quot;&gt;attributed variables&lt;/a&gt;, &lt;code class=&quot;docutils literal&quot;&gt;freeze&lt;/code&gt; and &lt;code class=&quot;docutils literal&quot;&gt;when&lt;/code&gt;. All these were done by my
excellent students Sven Hager and René Bartelmus. This left the fundamental
execution model intact (with some small additions). I also spent some time
improving the error messages of Pyrolog to for example show backtraces on
Prolog exceptions.&lt;/p&gt;
&lt;p&gt;Pyrolog also served as an &lt;a class=&quot;reference external&quot; href=&quot;https://www.pypy.org/posts/2011/01/jit-backend-for-arm-processors-5994810755839586463.html&quot;&gt;early test case&lt;/a&gt; for David Schneider's ARM JIT
backend. In January 2011, he could already run Pyrolog microbenchmarks with the
JIT on a BeagleBoard, while the backend still needed debugging before running
more complex Python benchmarks.&lt;/p&gt;
&lt;p&gt;In 2012 I mostly wrote my PhD thesis &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bolz2012&quot; id=&quot;citation-reference-16&quot;&gt;[Bolz2012]&lt;/a&gt;. It contains a chapter on Pyrolog based
on the PPDP 2010 paper. That chapter argues that the fact that Pyrolog works
relatively well demonstrates that the RPython meta-JIT can be used for quite a
wide variety of languages.&lt;/p&gt;
&lt;p&gt;There was also another chapter that compared how a meta-tracing JIT works with
more classical partial evaluation approaches. The code of that was again
written in Prolog and &lt;a class=&quot;reference external&quot; href=&quot;https://www.pypy.org/posts/2012/01/simple-tracer-for-flow-graph-language-6930951890987229484.html&quot;&gt;originally&lt;/a&gt; &lt;a class=&quot;reference external&quot; href=&quot;https://www.pypy.org/posts/2012/02/optimizing-traces-of-flow-graph-4169388883059419385.html&quot;&gt;presented&lt;/a&gt; &lt;a class=&quot;reference external&quot; href=&quot;https://www.pypy.org/posts/2012/02/larger-example-for-flow-graph-language-6139699450091061040.html&quot;&gt;here on the blog&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;At this point I left Düsseldorf. In the winter semester 2012/13 I spent half a
year in Berlin at &lt;a class=&quot;reference external&quot; href=&quot;https://hpi.de/&quot;&gt;Hasso-Plattner-Institute&lt;/a&gt; with my PhD co-advisor &lt;a class=&quot;reference external&quot; href=&quot;https://hirschfeld.org/&quot;&gt;Robert
Hirschfeld&lt;/a&gt; as a postdoc. In 2013 I started working with &lt;a class=&quot;reference external&quot; href=&quot;https://tratt.net/laurie/&quot;&gt;Laurie Tratt&lt;/a&gt; at
King's College.&lt;/p&gt;
&lt;p&gt;At this point the main Pyrolog story kind of ends. There were only some small
improvements happening in the succeeding years. However, Pyrolog ideas led to
followup research in other projects, and I want to discuss some of them.&lt;/p&gt;


&lt;h2&gt;2012/2013 Compressed Terms&lt;/h2&gt;
&lt;p&gt;One experiment that I had tried to get working in Pyrolog in 2010 and 2011, but
ended up never merging, was compressed terms. The idea comes from the
observation that certain patterns of terms occur repeatedly. For example an
association list has terms of the form &lt;code class=&quot;docutils literal&quot;&gt;[A/B|C]&lt;/code&gt; repeated. Instead of
allocating separate list and pair objects for every entry, the plan was to
share a description of that structure, the &quot;shape&quot;, and store each entry's keys
and values in a flat storage data structure. This would make every entry have
less indirection and use fewer words of memory.&lt;/p&gt;
&lt;p&gt;However, I had never managed to get that working in Prolog, because variable
bindings can create these patterns after the fact. A field that initially
contained an unbound variable could later refer to a whole structured term, so
the opportunities for compression changed after construction, and again on
backtracking.&lt;/p&gt;
&lt;p&gt;When I was at HPI for six months in winter 2012/2013 I worked with Tobias Pape to
implement the same compression idea for a simpler functional language called
lamb, later &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/shiplift/theseus/&quot;&gt;Theseus&lt;/a&gt;. Tobias pushed this approach much further in the following years
&lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#pbh2015&quot; id=&quot;citation-reference-17&quot;&gt;[PBH2015]&lt;/a&gt;, &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#pbh2017&quot; id=&quot;citation-reference-18&quot;&gt;[PBH2017]&lt;/a&gt;. My hope was to eventually understand the problem well
enough to be able to port it back to Pyrolog. But that part never happened. We
did eventually try those ideas in Pycket though.&lt;/p&gt;


&lt;h2&gt;2013 Unipycation&lt;/h2&gt;
&lt;p&gt;In 2013 I started working in London. The goal of our research project there was
&quot;language composition&quot;. Edd Barrett, Laurie Tratt and I implemented combined
RPython implementations of PyPy plus another language, with a way to integrate
the two involved languages. The first pair we tried was combining PyPy and
Pyrolog in a project called Unipycation &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#bbt2013&quot; id=&quot;citation-reference-19&quot;&gt;[BBT2013]&lt;/a&gt;. The project made it
possible to call into Prolog from Python and vice versa. The RPython meta-JIT
can optimize across this language boundary and even perform cross-language
inlining. Unfortunately we never found super compelling use-cases for this
combination.&lt;/p&gt;


&lt;h2&gt;2013 Pycket&lt;/h2&gt;
&lt;p&gt;Also in 2013 I started working on &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/pycket/pycket&quot;&gt;Pycket&lt;/a&gt;, an RPython implementation of Racket,
together with &lt;a class=&quot;reference external&quot; href=&quot;https://samth.github.io/&quot;&gt;Sam Tobin-Hochstadt&lt;/a&gt; and &lt;a class=&quot;reference external&quot; href=&quot;https://luddy.iu.edu/people/siek-jeremy.html&quot;&gt;Jeremy Siek&lt;/a&gt;. Later Tobias Pape and
Spenser Bauman also joined. The Pycket architecture was indirectly inspired by
the Pyrolog continuation approach. We implemented the CEK machine relatively
directly in RPython. This also used a flat host stack and it made implementing
things like &lt;code class=&quot;docutils literal&quot;&gt;call/cc&lt;/code&gt; relatively straightforward &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#pycket2015&quot; id=&quot;citation-reference-20&quot;&gt;[Pycket2015]&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One of the great new ideas we adopted in Pycket was &quot;false loop filtering&quot;,
originally from IBM Research &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#hwisn2011&quot; id=&quot;citation-reference-21&quot;&gt;[HWISN2011]&lt;/a&gt;. This takes calling context into
account when tracing function calls and prevents the tracing JIT from
recognizing &quot;false loops&quot;, which would never run for more than one iteration. I
always wanted to port this idea back to Pyrolog and implemented a prototype,
but never really polished it enough to be able to merge it.&lt;/p&gt;
&lt;p&gt;Another implementation technique in Pycket was to make continuation objects
much smaller, to make the case of them escaping to the heap less bad. This
approach &lt;em&gt;did&lt;/em&gt; &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/cfbolz/pyrolog/commit/59fb825&quot;&gt;make it back to Pyrolog&lt;/a&gt;.&lt;/p&gt;


&lt;h2&gt;2022-2024 RPython implementation of Effekt&lt;/h2&gt;
&lt;p&gt;I left King's College in 2016 and have been back at Uni Düsseldorf (half-time)
since 2017. In 2022 I started working with &lt;a class=&quot;reference external&quot; href=&quot;https://pl.cs.uni-tuebingen.de/team/gaissert/&quot;&gt;Marcial Gaißert&lt;/a&gt; and &lt;a class=&quot;reference external&quot; href=&quot;https://b-studios.de/about&quot;&gt;Jonathan
Brachthäuser&lt;/a&gt; on an RPython implementation of &lt;a class=&quot;reference external&quot; href=&quot;https://effekt-lang.org/&quot;&gt;Effekt&lt;/a&gt; and related languages.
This was an implementation of a JIT for &lt;a class=&quot;reference external&quot; href=&quot;https://effekt-lang.org/docs/concepts/effect-handlers&quot;&gt;effect handlers&lt;/a&gt; in RPython &lt;a class=&quot;citation-reference&quot; href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#gbb2025&quot; id=&quot;citation-reference-22&quot;&gt;[GBB2025]&lt;/a&gt;.
Again, it uses the approach of having reified stack frames/continuations which
can then be manipulated by the interpreter, adding effect handlers on top of it.&lt;/p&gt;


&lt;h2&gt;2026 Git Conversion&lt;/h2&gt;
&lt;p&gt;This fall, I got an email by a researcher about Pyrolog and realized that it
was 20 years old this year. Subsequently I merged various strands of the
surviving mercurial history into a new &lt;a class=&quot;reference external&quot; href=&quot;https://github.com/cfbolz/pyrolog&quot;&gt;Github repo&lt;/a&gt;, fixed some bugs and
implemented some convenience features.&lt;/p&gt;

&lt;img alt=&quot;/images/2026-pyrolog-screenshot.png&quot; src=&quot;https://www.pypy.org/images/2026-pyrolog-screenshot.png&quot; /&gt;

&lt;p&gt;Screenshot of Pyrolog&lt;/p&gt;




&lt;h2&gt;Conclusion&lt;/h2&gt;

&lt;img alt=&quot;/images/2026-pyrolog-activity-by-month-authors.svg&quot; src=&quot;https://www.pypy.org/images/2026-pyrolog-activity-by-month-authors.svg&quot; /&gt;

&lt;p&gt;commits per month on the main branch, by author&lt;/p&gt;


&lt;p&gt;Pyrolog has always been a much more researchy project than PyPy itself. While
PyPy tries to be an open source project and &quot;product&quot;, Pyrolog never had any
users and even I never used it for anything beyond small demos, some
research prototypes, and benchmarking. While some quite interesting research
came out of Pyrolog, I think having some real applications running on top of it
would have given me a much more concrete goal to improve it towards.&lt;/p&gt;
&lt;p&gt;Implementation wise, Pyrolog inherited some of the problems of PyPy. In PyPy we
were quite strongly focused on peak performance in the first few years, leading
to performance cliffs. Pyrolog also had those in even stronger form, due to the
constant allocations of the interpreter.&lt;/p&gt;
&lt;p&gt;Some very interesting interaction between Pyrolog, various Prolog JIT
prototypes and the PyPy/RPython meta-JIT emerged. Pyrolog forced the RPython
JIT to support quite different execution models which made it applicable to
other languages than just object-oriented imperative ones.&lt;/p&gt;
&lt;p&gt;I still have no concrete use case for Pyrolog so it's kind of unlikely that I
will work much more on it right now. But my development on this project was
always quite bursty, so I am bound to return to it at some point in the future.&lt;/p&gt;
&lt;p&gt;I would like to end with thanking all the contributors, my other collaborators
and the long line of Prolog researchers that inspired and encouraged me.&lt;/p&gt;


&lt;h2&gt;Bibliography&lt;/h2&gt;
&lt;div class=&quot;citation-list&quot;&gt;
&lt;div class=&quot;citation&quot; id=&quot;tb1990&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-6&quot;&gt;TB1990&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;P. Tarau, M. Boyer.
&lt;em&gt;Elementary logic programs&lt;/em&gt;. PLILP 1990, LNCS 456, pp. 159–173.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1007/BFb0024183&quot;&gt;DOI&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;tarau1992&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-7&quot;&gt;Tarau1992&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Paul Tarau.
&lt;em&gt;BinProlog: a continuation passing style Prolog engine&lt;/em&gt;.
PLILP 1992, LNCS 631, pp. 479–480. Poster.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;ss1994&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-2&quot;&gt;SS1994&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Leon S. Sterling, Ehud Y. Shapiro.
&lt;em&gt;The Art of Prolog: Advanced Programming Techniques&lt;/em&gt;.
2nd edition, MIT Press, 1994.
&lt;a class=&quot;reference external&quot; href=&quot;https://mitpress.mit.edu/9780262691635/the-art-of-prolog/&quot;&gt;Publisher page&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;lindgren1994&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-8&quot;&gt;Lindgren1994&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Thomas Lindgren.
&lt;em&gt;A Continuation-Passing Style for Prolog&lt;/em&gt;.
International Logic Programming Symposium, 1994, pp. 603–617.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;vanroy1994&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-4&quot;&gt;VanRoy1994&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Peter Van Roy.
&lt;em&gt;1983–1993: The Wonder Years of Sequential Prolog Implementation&lt;/em&gt;.
Journal of Logic Programming 19–20, 1994, pp. 385–441.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1016/0743-1066(94)90031-0&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://webperso.info.ucl.ac.be/~pvr/PRL-TR-36.pdf&quot;&gt;author-hosted technical report PDF&lt;/a&gt;
(DEC Paris Research Laboratory report 36, December 1993).&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;neumerkel1995&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-9&quot;&gt;Neumerkel1995&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Ulrich Neumerkel.
&lt;em&gt;Continuation Prolog: A new intermediary language for WAM and BinWAM
code generation&lt;/em&gt;. Post-ILPS 1995 Workshop on Implementation of Logic
Programming Languages, 1995.
&lt;a class=&quot;reference external&quot; href=&quot;https://www.complang.tuwien.ac.at/ulrich/papers/PDF/contp95.pdf&quot;&gt;PDF&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;cm2003&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-1&quot;&gt;CM2003&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;William F. Clocksin, Christopher S. Mellish.
&lt;em&gt;Programming in Prolog: Using the ISO Standard&lt;/em&gt;.
5th edition, Springer, 2003.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1007/978-3-642-55481-0&quot;&gt;DOI&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;vh2004&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-3&quot;&gt;VH2004&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Peter Van Roy, Seif Haridi.
&lt;em&gt;Concepts, Techniques, and Models of Computer Programming&lt;/em&gt;.
MIT Press, 2004. ISBN 9780262220699.
&lt;a class=&quot;reference external&quot; href=&quot;https://mitpress.mit.edu/9780262220699/concepts-techniques-and-models-of-computer-programming/&quot;&gt;Publisher page&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://webperso.info.ucl.ac.be/~pvr/book.html&quot;&gt;author's book page&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;bd2004&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-15&quot;&gt;BD2004&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Dariusz Biernacki, Olivier Danvy.
&lt;em&gt;From Interpreter to Logic Engine by Defunctionalization&lt;/em&gt;.
LOPSTR 2003, revised selected papers, published 2004, pp. 143–159.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1007/978-3-540-25938-1_13&quot;&gt;DOI&lt;/a&gt;,
&lt;a class=&quot;reference external&quot; href=&quot;https://ii.uni.wroc.pl/~dabi/publications/LOPSTR03/biernacki-danvy-lopstr03.pdf&quot;&gt;PDF&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;br2007d071&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-10&quot;&gt;BR2007D071&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;CF Bolz, Armin Rigo.
&lt;em&gt;Support for massive parallelism, optimisation results, practical
usages and approaches for translation aspects&lt;/em&gt;.
PyPy Consortium, EU deliverable D07.1, 28 February 2007.
Listed by PyPy as &lt;em&gt;Massive Parallelism and Translation Aspects&lt;/em&gt;.
&lt;a class=&quot;reference external&quot; href=&quot;https://doc.pypy.org/index-report.html&quot;&gt;Official report index&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://foss.heptapod.net/pypy/extradoc/-/blob/branch/extradoc/eu-report/D07.1_Massive_Parallelism_and_Translation_Aspects-2007-02-28.pdf&quot;&gt;report PDF&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;bolz2007&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;Bolz2007&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;backrefs&quot;&gt;(&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-5&quot;&gt;1&lt;/a&gt;,&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-11&quot;&gt;2&lt;/a&gt;)&lt;/span&gt;
&lt;p&gt;CF Bolz.
&lt;em&gt;A Prolog Interpreter in Python&lt;/em&gt;. Bachelor thesis,
Heinrich Heine University Düsseldorf, 2007.
&lt;a class=&quot;reference external&quot; href=&quot;https://cfbolz.de/Bolz2007-Bachelorarbeit.pdf&quot;&gt;PDF&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;blr2010&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-12&quot;&gt;BLR2010&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;CF Bolz, Michael Leuschel, Armin Rigo.
&lt;em&gt;Towards Just-In-Time Partial Evaluation of Prolog&lt;/em&gt;.
LOPSTR 2009, revised selected papers, LNCS 6037, 2010, pp. 158–172.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1007/978-3-642-12592-8_12&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://stups.hhu-hosting.de/downloads/pdf/BoLeRi09_235.pdf&quot;&gt;author manuscript&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;bls2010&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;BLS2010&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;backrefs&quot;&gt;(&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-13&quot;&gt;1&lt;/a&gt;,&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-14&quot;&gt;2&lt;/a&gt;)&lt;/span&gt;
&lt;p&gt;CF Bolz, Michael Leuschel, David Schneider.
&lt;em&gt;Towards a Jitting VM for Prolog Execution&lt;/em&gt;. PPDP 2010, pp. 99–108.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1145/1836089.1836102&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://stups.hhu-hosting.de/downloads/pdf/BoLeSch2010.pdf&quot;&gt;author manuscript&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;hwisn2011&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-21&quot;&gt;HWISN2011&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Hiroshige Hayashizaki, Peng Wu, Hiroshi Inoue,
Mauricio J. Serrano, Toshio Nakatani.
&lt;em&gt;Improving the Performance of Trace-based Systems by False Loop Filtering&lt;/em&gt;.
ASPLOS XVI, 2011, pp. 405–418.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1145/1950365.1950412&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://research.ibm.com/publications/improving-the-performance-of-trace-based-systems-by-false-loop-filtering&quot;&gt;IBM Research publication page&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;bolz2012&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-16&quot;&gt;Bolz2012&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;CF Bolz.
&lt;em&gt;Meta-Tracing Just-in-Time Compilation for RPython&lt;/em&gt;.
PhD thesis, Heinrich-Heine-Universität Düsseldorf, September 2012.
&lt;a class=&quot;reference external&quot; href=&quot;https://cfbolz.de/phdthesis/&quot;&gt;Author's thesis page&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://cfbolz.de/stuff/cfbolz-meta-tracing.pdf&quot;&gt;PDF&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://docserv.uni-duesseldorf.de/servlets/DocumentServlet?id=30464&quot;&gt;university repository&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;bbt2013&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-19&quot;&gt;BBT2013&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Edd Barrett, CF Bolz, Laurence Tratt.
&lt;em&gt;Unipycation: A Case Study in Cross-Language Tracing&lt;/em&gt;.
VMIL 2013, pp. 31–40.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1145/2542142.2542146&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://tratt.net/laurie/research/pubs/papers/barrett_bolz_tratt__unipycation_a_study_in_cross_language_tracing.pdf&quot;&gt;author manuscript&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;pycket2015&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-20&quot;&gt;Pycket2015&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Spenser Bauman, CF Bolz, Robert Hirschfeld,
Vasily Kirilichev, Tobias Pape, Jeremy G. Siek, Sam Tobin-Hochstadt.
&lt;em&gt;Pycket: A Tracing JIT for a Functional Language&lt;/em&gt;.
ICFP 2015, pp. 22–34.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1145/2784731.2784740&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://soft-dev.org/pubs/pdf/bauman_bolz_hirschfeld_kirilichev_pape_siek_tobin_hochstadt__pycket_a_tracing_jit_for_a_functional_language.pdf&quot;&gt;author manuscript&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;pbh2015&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-17&quot;&gt;PBH2015&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Tobias Pape, CF Bolz, Robert Hirschfeld.
&lt;em&gt;Adaptive Just-in-time Value Class Optimization: Transparent Data
Structure Inlining for Fast Execution&lt;/em&gt;. SAC 2015.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1145/2695664.2695837&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://www.hirschfeld.org/writings/media/PapeBolzHirschfeld_2015_AdaptiveJustInTimeValueClassOptimization_AcmDL.pdf&quot;&gt;PDF&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;pbh2017&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-18&quot;&gt;PBH2017&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Tobias Pape, CF Bolz, Robert Hirschfeld.
&lt;em&gt;Adaptive just-in-time value class optimization for lowering memory
consumption and improving execution time performance&lt;/em&gt;.
Science of Computer Programming 140, 2017, pp. 17–29.
Published online in 2016.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1016/j.scico.2016.08.003&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://www.hirschfeld.org/writings/media/PapeBolzHirschfeld_2017_AdaptiveJustInTimeValueClassOptimizationForLoweringMemoryConsumptionAndImprovingExecutionTimePerformance_Elsevier.pdf&quot;&gt;PDF&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;citation&quot; id=&quot;gbb2025&quot;&gt;
&lt;span class=&quot;label&quot;&gt;&lt;span class=&quot;fn-bracket&quot;&gt;[&lt;/span&gt;&lt;a href=&quot;https://www.pypy.org/posts/2026/11/20-years-of-pyrolog.html#citation-reference-22&quot;&gt;GBB2025&lt;/a&gt;&lt;span class=&quot;fn-bracket&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;p&gt;Marcial Gaißert, CF Bolz-Tereick,
Jonathan Immanuel Brachthäuser.
&lt;em&gt;Tracing Just-in-Time Compilation for Effects and Handlers&lt;/em&gt;.
Proceedings of the ACM on Programming Languages 9 (OOPSLA2),
Article 307, October 2025.
&lt;a class=&quot;reference external&quot; href=&quot;https://doi.org/10.1145/3763085&quot;&gt;DOI&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://pl.cs.uni-tuebingen.de/publications/gaissert2025tracing/&quot;&gt;author publication page&lt;/a&gt;;
&lt;a class=&quot;reference external&quot; href=&quot;https://pl.cs.uni-tuebingen.de/publications/gaissert2025tracing.pdf&quot;&gt;technical report&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
	<pubDate>Thu, 12 Nov 2026 08:16:45 +0000</pubDate>
</item>
<item>
	<title>Tryton News: Release 0.3.0 of tryton-filestore-cellar</title>
	<guid>https://discuss.tryton.org/t/release-0-3-0-of-tryton-filestore-cellar/9467</guid>
	<link>https://discuss.tryton.org/t/release-0-3-0-of-tryton-filestore-cellar/9467</link>
	<description>&lt;p&gt;We are proud to announce the release of &lt;a href=&quot;https://pypi.org/project/tryton-filestore-cellar/&quot;&gt;tryton-filestore-cellar&lt;/a&gt; version 0.3.0.&lt;/p&gt;
&lt;p&gt;tryton-filestore-cellar is Tryton’s filestore implementation based on the &lt;a href=&quot;https://www.clever-cloud.com/doc/deploy/addon/cellar/&quot;&gt;Cellar&lt;/a&gt; storage service.&lt;/p&gt;
&lt;p&gt;In addition to bug fixes, this release contains the following improvements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Implement delete&lt;/li&gt;
&lt;li&gt;Upgrade dependency to boto3&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The package is available at &lt;a href=&quot;https://pypi.org/project/tryton-filestore-cellar/0.3.0/&quot; class=&quot;inline-onebox&quot;&gt;tryton-filestore-cellar · PyPI&lt;/a&gt;&lt;/p&gt;
            &lt;p&gt;&lt;small&gt;1 post - 1 participant&lt;/small&gt;&lt;/p&gt;
            &lt;p&gt;&lt;a href=&quot;https://discuss.tryton.org/t/release-0-3-0-of-tryton-filestore-cellar/9467&quot;&gt;Read full topic&lt;/a&gt;&lt;/p&gt;</description>
	<pubDate>Thu, 12 Nov 2026 07:00:13 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: Muru: releases, not restarts</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/muru-releases-not-restarts/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/muru-releases-not-restarts/</link>
	<description>&lt;p&gt;With the &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-wsgi-and-asgi-together/&quot;&gt;previous post&lt;/a&gt; I finished with development mode. Something I kept coming back to in those posts was that when &lt;code&gt;muructl run&lt;/code&gt; reloads after a change, it isn't restarting anything. It starts a new release beside the one serving, and only moves traffic across once the new release is ready. In &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-how-it-fits-together/&quot;&gt;how it fits together&lt;/a&gt; I said the same mechanism is what the daemon uses for deploying an application to a server. Time to show that.&lt;/p&gt;
&lt;p&gt;This is where Muru departs most from how a Python web application is usually deployed. With gunicorn, deploying new code typically means putting the new code in place and sending the master process a HUP signal. Going by its own description, that reloads the configuration, starts new worker processes and gracefully shuts down the old ones, with the new workers loading the application code afresh unless &lt;code&gt;preload_app&lt;/code&gt; is on, in which case the old code stays. A USR2 signal goes further, starting a second master and set of workers from the same command line beside the first, with the old master left around so a change can be backed out by sending more signals. Either way, there is one copy of the code on disk, both sets of workers share the one listening socket while they overlap, and anything more than that, such as keeping the previous version running so you can switch straight back to it, is left to a load balancer and a deploy script you write yourself. I can hardly point the finger, as mod_wsgi was no better, with a deploy there meaning a restart of Apache, or a similar mix of signals and gymnastics to get a daemon process to reload against different code.&lt;/p&gt;
&lt;p&gt;Under the Muru daemon, each deploy is a release of its own, started beside the one serving, given traffic only once it is ready, and kept warm afterwards in case you want it back.&lt;/p&gt;
&lt;h2&gt;Starting a daemon&lt;/h2&gt;
&lt;p&gt;The daemon runs in the background as your own user, or under systemd as a service. For trying things out, starting it as your own user is enough:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl daemon start
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: daemon started (pid 31018), serving on http://127.0.0.1:8080, log /Users/graham/Library/Logs/muru/daemon.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;muructl status --processes&lt;/code&gt; shows the processes the daemon is looking after, which for now is only the edge it has started, listening on port 8080. The daemon's own row says whose daemon it is, here one run as my own user, and where it keeps its state:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl status --processes
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;edge  0 req/s  0 err/s  p50 0.0 ms  p95 0.0 ms  in flight 0  ws 0  conns 0  tls 0/s  in 0B/s  out 0B/s

PID     ROLE      WHAT                                                    USER     UP    CPU      RSS   REQ/S   BUSY   QUEUE   P95   GIL/LAG
31018   daemon    user daemon, state ~/Library/Application Support/muru   graham   1s   0.0%   32.5MB
31019   └─ edge   listening on 127.0.0.1:8080                             graham   1s   0.0%   26.0MB       0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing has been deployed yet, so the edge answers every request with a 503 and says why:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;503 Service Unavailable
reason: no_workers
request id: 01M6TDFVFPFWF4JKE4TJ1G86E2

No worker is available to handle the request.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A release is a directory&lt;/h2&gt;
&lt;p&gt;What gets deployed is a source directory. For this post the application is a small shop, kept in a directory per version under &lt;code&gt;releases&lt;/code&gt;, as a deploy script might lay out checkouts of each tag. Each version is its own uv project with a &lt;code&gt;pyproject.toml&lt;/code&gt; and a &lt;code&gt;uv.lock&lt;/code&gt;, and the first version has no dependencies at all:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[project]
name = &amp;quot;shop&amp;quot;
version = &amp;quot;1.0&amp;quot;
requires-python = &amp;quot;&amp;gt;=3.15&amp;quot;
dependencies = []

[tool.muru]
entrypoint = &amp;quot;app:application&amp;quot;

[tool.muru.commands.migrate]
description = &amp;quot;Apply database migrations&amp;quot;
run = [&amp;quot;python&amp;quot;, &amp;quot;migrate.py&amp;quot;]
exclusive = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application in &lt;code&gt;app.py&lt;/code&gt; reports how many orders are in an SQLite database, and which release answered, using &lt;code&gt;muru.context()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
import sqlite3

import muru

DATABASE = os.environ[&amp;quot;SHOP_DATABASE&amp;quot;]


def application(environ, start_response):
    where = muru.context()
    with sqlite3.connect(DATABASE) as db:
        (count,) = db.execute(&amp;quot;SELECT COUNT(*) FROM orders&amp;quot;).fetchone()
    body = f&amp;quot;{count} orders, served by {where.app}@{where.release}\n&amp;quot;.encode()
    start_response(&amp;quot;200 OK&amp;quot;, [(&amp;quot;Content-Type&amp;quot;, &amp;quot;text/plain&amp;quot;)])
    return [body]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;migrate&lt;/code&gt; command declared in &lt;code&gt;pyproject.toml&lt;/code&gt; runs a small script which applies any SQL files in a &lt;code&gt;migrations&lt;/code&gt; directory that haven't been applied yet. I will come back to commands later, as they matter once two releases share a database.&lt;/p&gt;
&lt;p&gt;The first deploy names the application and the source directory, and gives the settings the release needs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl deploy shop releases/v1 --processes 2 --env SHOP_DATABASE=/Users/graham/shop/data/shop.db
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: release shop@r1 is ready and serving
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database starts out empty, so before anything else its tables need creating, by running the declared command with the release:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl command run shop migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;applied 0001_orders.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;after which the shop answers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;3 orders, served by shop@r1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is a fair bit hidden behind that first deploy, and &lt;code&gt;muructl events&lt;/code&gt; tells the story:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;     2 07:10:12 info     release.created      shop@r1                  from /Users/graham/shop/releases/v1
     3 07:10:12 info     release.building     shop@r1                  from /Users/graham/shop/releases/v1/uv.lock
     4 07:10:12 info     release.built        shop@r1                  /Users/graham/Library/Application Support/muru/envs/shop/r1 in 0.1s from uv.lock 8c788a3a641a
     5 07:10:12 info     release.state_changed shop@r1                  fetched the runtime library muru-1.0.0-cp315-cp315t-macosx_11_0_arm64.whl from PyPI, once
     6 07:10:13 info     release.ready        shop@r1                  2 workers ready in 0.7 s
     7 07:10:13 notice   traffic.switched     shop@r1                  traffic moved to the release
     8 07:10:13 info     command.started      shop@r1                  migrate: started (pid 31050)
     9 07:10:13 info     command.finished     shop@r1                  migrate: exited with status 0 after 60ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because the source directory has a &lt;code&gt;uv.lock&lt;/code&gt; and no &lt;code&gt;.venv&lt;/code&gt;, the daemon built a virtual environment for the release from the lock file, in a directory belonging to that release alone. The application doesn't need to depend on Muru, as the daemon adds its own runtime library to the environment. Only once both workers were ready did traffic move to the release.&lt;/p&gt;
&lt;p&gt;Asking for the processes again shows what the deploy added:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;edge  0 req/s  0 err/s  p50 0.0 ms  p95 0.0 ms  in flight 0  ws 0  conns 0  tls 0/s  in 0B/s  out 0B/s

PID     ROLE           WHAT                                                    USER     UP    CPU      RSS   REQ/S   BUSY   QUEUE   P95   GIL/LAG
31018   daemon         user daemon, state ~/Library/Application Support/muru   graham   2s   0.0%   38.9MB
31019   ├─ edge        listening on 127.0.0.1:8080                             graham   2s   0.0%   26.5MB       0
31045   └─ host        shop@r1 serving                                         graham   0s   0.0%   54.5MB            0/8       0
31044      ├─ relay    shop@r1 output to apps/shop/r1.log                      graham   0s   0.0%   17.5MB
31047      ├─ worker   group default, worker 0                                 graham   0s   0.0%   20.3MB            0/4
31048      └─ worker   group default, worker 1                                 graham   0s   0.0%   19.5MB            0/4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The release is a host process, which imported the application, with the two workers forked from it, each with four threads. The host's row also says what part the release has in routing, which for now is that it is serving. Beside the host is a log relay, which takes what the host and its workers write and puts it in the release's own log, so that a log which is slow to write never holds up a request. The relay is shown under the host it serves, although it is the daemon which starts it. The columns on the right, for requests per second, busy threads, queueing, response times and time spent waiting on the GIL, stay mostly empty until there is some traffic.&lt;/p&gt;
&lt;p&gt;That each release gets an environment of its own is more important than it might look. If releases shared one virtual environment, installing a new dependency for the next release would change what the running release sees underneath it. Giving each release its own means a release really is fixed once started, which is what makes the rest of this work.&lt;/p&gt;
&lt;h2&gt;A release that can't start&lt;/h2&gt;
&lt;p&gt;The second version of the shop renders its page with the &lt;code&gt;markdown&lt;/code&gt; package, so where the first version returned plain text, it returns the count in bold as a line of HTML. Whoever made the change imported the package but forgot to add it to the project's dependencies, so it isn't in &lt;code&gt;uv.lock&lt;/code&gt;. Deploying it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl deploy shop releases/v2
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Error: the host exited with exit status: 1 before its workers were ready: cannot load app:application: ModuleNotFoundError: No module named 'markdown'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The site carries on as if nothing happened, still answering from the first release:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;3 orders, served by shop@r1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;muructl release list&lt;/code&gt; shows the new release as failed beside the one still serving:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RELEASE                  STATE      SERVING  WORKERS               DISK SOURCE
shop@r2                  failed              - (2x4)              4.7MB /Users/graham/shop/releases/v2
                         the host exited with exit status: 1 before its workers were ready: cannot load app:application: ModuleNotFoundError: No module named 'markdown'
shop@r1                  ready      yes      2 (2x4)              4.7MB /Users/graham/shop/releases/v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the release's own log, from &lt;code&gt;muructl logs shop@r2&lt;/code&gt;, has the same reason in context, after the lines from building its environment:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026-11-07T07:10:13.431Z [build] Creating virtual environment at: /Users/graham/Library/Application Support/muru/envs/shop/r2
2026-11-07T07:10:13.478Z [build] Bytecode compiled 0 files in 42ms
2026-11-07T07:10:13.886Z muru.host[31069]: cannot load app:application: ModuleNotFoundError: No module named 'markdown'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nor did the failed release leave anything running, with &lt;code&gt;muructl status --processes&lt;/code&gt; showing the same processes as before the deploy. Had the new code been put in place over the old and the server reloaded, the new workers would have been trying to import the broken code from where the working code used to be. Here, the release that couldn't start cost nothing but the attempt.&lt;/p&gt;
&lt;h2&gt;Moving traffic without refusing requests&lt;/h2&gt;
&lt;p&gt;The third version adds &lt;code&gt;markdown&lt;/code&gt; to the project properly, with &lt;code&gt;uv add markdown&lt;/code&gt;, so it ends up in the lock file.&lt;/p&gt;
&lt;p&gt;Each deploy after the first takes what it isn't told from the release before it, so the two processes and the &lt;code&gt;SHOP_DATABASE&lt;/code&gt; variable given to the first deploy carry through. A deploy with &lt;code&gt;--dry-run&lt;/code&gt; shows what the deploy would do, with where each setting would come from, and changes nothing. Trimmed down a bit, that looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl deploy shop releases/v3 --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: plan (nothing changed): release shop@r3, app:application 2x4 from /Users/graham/shop/releases/v3
  entrypoint = app:application          ([tool.muru])
  python = /Users/graham/.local/bin/python3.15t  (the previous release)
  build.when = auto                     (default)
  ...
  processes = 2                         (the previous release)
  threads = 4                           (the previous release)
  ...
  env SHOP_DATABASE (the previous release)
  ...
  settings nothing else gives come from shop@r1; --fresh takes none
  an environment would be built from the source's uv.lock
  the release would take over from shop@r1, which would stay warm for 1800s with 1 worker to a group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Environment variables carry across by name, which is why &lt;code&gt;SHOP_DATABASE&lt;/code&gt; doesn't need to be given again. Where you would rather a deploy be described entirely by its own command line, &lt;code&gt;--fresh&lt;/code&gt; ignores the previous release.&lt;/p&gt;
&lt;p&gt;To see whether the switch refuses anything, I ran a small script which requests the page every 10 milliseconds, prints a line whenever the response changes, and counts the status codes it got back. With it running, I deployed the third version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl deploy shop releases/v3
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: release shop@r3 is ready and serving
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output of the script was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;18:10:14 200 3 orders, served by shop@r1
18:10:16 200 &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;3 orders&amp;lt;/strong&amp;gt;, served by shop@r3&amp;lt;/p&amp;gt;
{200: 695}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every one of the 695 requests got a 200, with the responses changing over from one release to the other without a gap. The events show the order things happened in:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    14 07:10:15 info     release.created      shop@r3                  from /Users/graham/shop/releases/v3
    15 07:10:15 info     release.building     shop@r3                  from /Users/graham/shop/releases/v3/uv.lock
    16 07:10:16 info     release.built        shop@r3                  /Users/graham/Library/Application Support/muru/envs/shop/r3 in 0.2s from uv.lock 62c319f2cb1f
    17 07:10:16 info     release.ready        shop@r3                  2 workers ready in 0.6 s
    18 07:10:16 notice   traffic.switched     shop@r3                  traffic moved to the release
    19 07:10:16 info     release.draining     shop@r1                  draining
    20 07:10:16 info     release.retained     shop@r1                  kept warm for 1800s with 1 worker per group
    21 07:10:17 info     worker.exited        shop@r1                  worker 1 (pid 31048) stopped: scaled in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new release was built and started while the old one carried on serving. Traffic moved only once the new release was ready, and the old release then drained, finishing the requests it already had. What happened next to the old release is the interesting part. It wasn't stopped, but scaled in to a single worker and kept warm for 30 minutes, which &lt;code&gt;muructl release list&lt;/code&gt; notes against it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RELEASE                  STATE      SERVING  WORKERS               DISK SOURCE
shop@r3                  ready      yes      2 (2x4)              6.4MB /Users/graham/shop/releases/v3
shop@r2                  failed              - (2x4)              4.7MB /Users/graham/shop/releases/v2
                         the host exited with exit status: 1 before its workers were ready: cannot load app:application: ModuleNotFoundError: No module named 'markdown'
shop@r1                  retained            1 (2x4)              4.7MB /Users/graham/shop/releases/v1
                         warm until 07:40 UTC; a rollback to it is instant
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The switch can also be watched as it happens, by leaving &lt;code&gt;muructl status --processes --watch&lt;/code&gt; running in another terminal during the deploy. The third release's host, log relay and workers appear beside those of the first, the requests per second move across from one to the other, and the first then drops to a single worker.&lt;/p&gt;
&lt;h2&gt;Rolling back is a routing change&lt;/h2&gt;
&lt;p&gt;Say the new page turns out to be a mistake. With the script running again:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl rollback shop
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: rolled back to shop@r1 (ready)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;18:10:24 200 &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;3 orders&amp;lt;/strong&amp;gt;, served by shop@r3&amp;lt;/p&amp;gt;
18:10:26 200 3 orders, served by shop@r1
{200: 363}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command took a fifth of a second. Nothing was deployed, built or imported. The retained release still had its host and a worker running, with the application already loaded, so rolling back was only a matter of telling the edge to send requests there instead. The events show what followed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    22 07:10:26 notice   traffic.switched     shop@r1                  traffic moved to the release
    23 07:10:26 notice   app.rolled-back      shop                     rolled back to r1
    24 07:10:26 info     release.draining     shop@r3                  draining
    25 07:10:26 info     release.retained     shop@r3                  kept warm for 1800s with 1 worker per group
    26 07:10:26 info     worker.started       shop@r1                  worker 1 started as pid 31226
    27 07:10:26 info     worker.ready         shop@r1                  worker 1 (pid 31226) is ready
    28 07:10:27 info     worker.exited        shop@r3                  worker 1 (pid 31122) stopped: scaled in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once it had traffic again, the first release grew back to its two workers by forking from its host, which still had the application imported, as described in &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-import-once-fork-many/&quot;&gt;import once, fork many&lt;/a&gt;. The processes show this, with the first release's second worker only a few seconds old under a host which has been running all along, and the third release, now the retained one, down to the one worker it is kept warm with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;edge  0 req/s  0 err/s  p50 0.0 ms  p95 0.0 ms  in flight 0  ws 0  conns 0  tls 0/s  in 0B/s  out 0B/s

PID     ROLE           WHAT                                                    USER      UP    CPU      RSS   REQ/S   BUSY   QUEUE   P95   GIL/LAG
31018   daemon         user daemon, state ~/Library/Application Support/muru   graham   17s   0.1%   40.7MB
31019   ├─ edge        listening on 127.0.0.1:8080                             graham   17s   0.0%   29.7MB       0
31045   ├─ host        shop@r1 serving                                         graham   15s   0.0%   54.5MB       0    0/8       0
31044   │  ├─ relay    shop@r1 output to apps/shop/r1.log                      graham   15s   0.0%   17.6MB
31047   │  ├─ worker   group default, worker 0                                 graham   15s   0.0%   48.8MB       0    0/4
31226   │  └─ worker   group default, worker 1                                 graham    3s   0.1%   29.4MB       0    0/4
31117   └─ host        shop@r3 retained until 18:40                            graham   13s   0.1%   56.2MB       0
31116      ├─ relay    shop@r3 output to apps/shop/r3.log                      graham   13s   0.0%   17.5MB
31121      └─ worker   group default, worker 0                                 graham   12s   0.0%   58.1MB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The release rolled back from is now the retained one, so running &lt;code&gt;muructl rollback shop&lt;/code&gt; a second time goes forward to it again. Having decided the new page was fine after all, that is what I did, putting the third release back in front of the first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl rollback shop
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: rolled back to shop@r3 (ready)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The third release grew back to two workers in the same way, and the first went back to being the retained release with one.&lt;/p&gt;
&lt;p&gt;Rolling back to a release which has already been stopped, because its 30 minutes are up, also works. The release then has to be started again first, with traffic moving once it is ready. When I tried that with this application, it took about a second rather than a fifth of one, which for a larger application would be however long its import takes.&lt;/p&gt;
&lt;h2&gt;Restarting the daemon&lt;/h2&gt;
&lt;p&gt;The other thing which has to happen to a server from time to time is the hosting software itself being restarted or upgraded. As I explained in &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-how-it-fits-together/&quot;&gt;how it fits together&lt;/a&gt;, the daemon is never on the path a request takes, which means it can go away while requests carry on. With the script running once more, I stopped the daemon:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl daemon stop
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: daemon detached; hosts and the edge keep serving
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the daemon gone there is nothing for &lt;code&gt;muructl status --processes&lt;/code&gt; to ask, so this time it has to be &lt;code&gt;ps&lt;/code&gt;. Each of Muru's processes sets a title saying what it is, so &lt;code&gt;ps&lt;/code&gt; is still readable, and shows the edge, the release hosts and their log relays are all still there, now with process 1 as their parent, and the workers still children of their hosts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;31019     1 muru edge user
31044     1 muru relay shop@r1
31045     1 muru host shop@r1
31047 31045 muru worker shop@r1 default/0
31116     1 muru relay shop@r3
31117     1 muru host shop@r3
31121 31117 muru worker shop@r3 default/0
31266 31117 muru worker shop@r3 default/1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Starting the daemon again, it finds them and takes them over:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    36 07:10:31 info     daemon.detached      daemon                   detached; hosts and the edge keep serving
    37 07:10:34 info     daemon.started       daemon                   serving on http://127.0.0.1:8080, admin socket /tmp/muru-501/admin.sock
    38 07:10:34 info     release.adopted      shop@r3                  re-adopted the host (pid 31117)
    39 07:10:34 info     release.adopted      shop@r1                  re-adopted the host (pid 31045)
    40 07:10:34 notice   traffic.switched     shop@r3                  traffic moved to the release
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and &lt;code&gt;muructl status --processes&lt;/code&gt; has them back in the tree under the new daemon, noting against each process it re-adopted that the operating system now has process 1 as its parent:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;edge  0 req/s  0 err/s  p50 0.0 ms  p95 0.0 ms  in flight 0  ws 0  conns 0  tls 0/s  in 0B/s  out 0B/s

PID     ROLE           WHAT                                                           USER      UP    CPU      RSS   REQ/S   BUSY   QUEUE   P95   GIL/LAG
31301   daemon         user daemon, state ~/Library/Application Support/muru          graham    1s   0.0%   35.8MB
31019   ├─ edge        listening on 127.0.0.1:8080 (re-adopted, OS parent 1)          graham   25s   8.4%   30.2MB       0
31045   ├─ host        shop@r1 retained until 18:40 (re-adopted, OS parent 1)         graham   23s   0.0%   54.5MB
31044   │  ├─ relay    shop@r1 output to apps/shop/r1.log (re-adopted, OS parent 1)   graham   23s   0.0%   17.6MB
31047   │  └─ worker   group default, worker 0                                        graham   23s   0.0%   48.9MB
31117   └─ host        shop@r3 serving (re-adopted, OS parent 1)                      graham   21s   0.0%   56.2MB            0/8       0          0.1 ms
31116      ├─ relay    shop@r3 output to apps/shop/r3.log (re-adopted, OS parent 1)   graham   21s   0.0%   17.5MB
31121      ├─ worker   group default, worker 0                                        graham   20s   5.8%   68.5MB            0/4
31266      └─ worker   group default, worker 1                                        graham    7s   5.3%   63.1MB            0/4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The daemon is a second old, while everything under it has been running since before it was stopped. Meanwhile the script didn't notice anything had happened:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;18:10:29 200 &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;3 orders&amp;lt;/strong&amp;gt;, served by shop@r3&amp;lt;/p&amp;gt;
{200: 703}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Upgrading Muru works along similar lines. &lt;code&gt;muructl daemon upgrade&lt;/code&gt; re-executes the daemon from the newly installed &lt;code&gt;muructl&lt;/code&gt; and replaces the edge without refusing connections, while the releases keep the runtime library they started with until the next deploy replaces them.&lt;/p&gt;
&lt;h2&gt;Two releases, one database&lt;/h2&gt;
&lt;p&gt;Being able to run two releases side by side has a consequence which is easy to overlook. While the new release is starting, and for as long as the old one is retained for a rollback, both are running against the same database. A schema change made for the new release therefore has to keep working with the code of the old one.&lt;/p&gt;
&lt;p&gt;The usual answer is to split such a change into steps. The first step only adds things, such as a new table or a new column with a default, which the old code won't notice. Then the release which uses the new schema is deployed. Only once no release that relies on the old schema is serving or retained does a later release remove what is no longer needed. That is general advice, whatever you host with. What Muru adds is a way of running each step with the release it belongs to.&lt;/p&gt;
&lt;p&gt;The fourth version of the shop adds a &lt;code&gt;quantity&lt;/code&gt; column, in a new file &lt;code&gt;migrations/0002_quantity.sql&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE orders ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and changes the page to report the number of items as well. If this were deployed like the others, the release would start, be ready, take all the traffic, and then fail every request because the column doesn't exist yet. Instead, it is deployed with &lt;code&gt;--hold&lt;/code&gt;, which starts the release but leaves traffic where it is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl deploy shop releases/v4 --hold
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: release shop@r4 is ready and held: not serving
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the script from earlier still sending requests, the processes show the fourth release fully started and held, with its host, log relay and two workers, while every request is still going to the workers of the third:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;edge  37 req/s  0 err/s  p50 1.8 ms  p95 2.7 ms  in flight 0  ws 0  conns 0  tls 0/s  in 0B/s  out 1.9KB/s

PID     ROLE           WHAT                                                           USER      UP    CPU      RSS   REQ/S   BUSY   QUEUE      P95   GIL/LAG
31301   daemon         user daemon, state ~/Library/Application Support/muru          graham    4s   0.0%   38.4MB
31019   ├─ edge        listening on 127.0.0.1:8080 (re-adopted, OS parent 1)          graham   28s   0.0%   30.3MB      37
31045   ├─ host        shop@r1 retained until 18:40 (re-adopted, OS parent 1)         graham   27s   0.0%   54.5MB       0
31044   │  ├─ relay    shop@r1 output to apps/shop/r1.log (re-adopted, OS parent 1)   graham   27s   0.0%   17.6MB
31047   │  └─ worker   group default, worker 0                                        graham   26s   0.0%   48.9MB
31117   ├─ host        shop@r3 serving (re-adopted, OS parent 1)                      graham   24s   0.0%   56.2MB      37    0/8       0   2.7 ms    0.1 ms
31116   │  ├─ relay    shop@r3 output to apps/shop/r3.log (re-adopted, OS parent 1)   graham   24s   0.0%   17.5MB
31121   │  ├─ worker   group default, worker 0                                        graham   24s   0.0%   70.1MB      19    0/4           3.3 ms
31266   │  └─ worker   group default, worker 1                                        graham   11s   0.0%   63.5MB      19    0/4           2.5 ms
31345   └─ host        shop@r4 held                                                   graham    0s   0.0%   56.9MB            0/8       0
31344      ├─ relay    shop@r4 output to apps/shop/r4.log                             graham    0s   0.0%   17.4MB
31346      ├─ worker   group default, worker 0                                        graham    0s   0.0%   20.7MB            0/4
31347      └─ worker   group default, worker 1                                        graham    0s   0.0%   19.8MB            0/4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A held release can still be reached by naming it in a &lt;code&gt;Muru-Preview&lt;/code&gt; header. Doing that now shows the problem the migration needs to fix, while ordinary requests are still served by the third release:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -H 'Muru-Preview: r4' http://127.0.0.1:8080/
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;500 Internal Server Error
reason: app_error
request id: 01M6TDGPENFWF4JKE4TJ1G86E2

The application raised an exception.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with &lt;code&gt;muructl logs shop@r4&lt;/code&gt; showing why:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026-11-07T07:10:40.086Z muru.worker[0]:     (count, items) = db.execute(&amp;quot;SELECT COUNT(*), SUM(quantity) FROM orders&amp;quot;).fetchone()
2026-11-07T07:10:40.086Z muru.worker[0]:                      ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2026-11-07T07:10:40.086Z muru.worker[0]: sqlite3.OperationalError: no such column: quantity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The migration is then run with the held release:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl command run shop@r4 migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;applied 0002_quantity.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Naming the release matters here. A command runs with the release's own source directory, interpreter, environment variables and secrets, so this ran the fourth release's &lt;code&gt;migrate.py&lt;/code&gt; against its own &lt;code&gt;migrations&lt;/code&gt; directory, which is the only one with the new file in it. Commands are declared in the project and are part of each release, so the same command name always runs the version of the command which goes with that release's code. With &lt;code&gt;exclusive = true&lt;/code&gt;, a second run of the command for the application is refused while one is under way, and &lt;code&gt;muructl command run&lt;/code&gt; exits with the command's status, so a deploy script can stop at a failed migration before any traffic moves.&lt;/p&gt;
&lt;p&gt;With the migration applied, the held release works, and the third release, which knows nothing about the new column, carries on serving ordinary requests:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;3 orders&amp;lt;/strong&amp;gt; for 3 items, served by shop@r4&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;3 orders&amp;lt;/strong&amp;gt;, served by shop@r3&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All that remains is to move the traffic:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl rollout bluegreen shop@r4
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: release shop@r4 is ready and serving
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The third release then becomes the one kept warm for a rollback, and the first, no longer needed for one, is stopped. Because the migration only added a column, rolling back to the third release afterwards still works against the new schema, which I checked by rolling back and forward again. The rollback doesn't touch the schema, which is the reason for doing changes like this in steps.&lt;/p&gt;
&lt;h2&gt;What's next&lt;/h2&gt;
&lt;p&gt;With gunicorn and similar servers, all of this is something you build yourself around the server. Here it is what a deploy does.&lt;/p&gt;
&lt;p&gt;That said, a deploy only protects you from a release which can't start. The fourth release, deployed without first holding it, would have been ready as far as Muru was concerned, and would then have failed every request it was given. The same goes for a release which starts fine but has a bug in only some of its pages. In the next post I will look at canaries, where a new release is given a small share of the traffic first and is judged against the release already serving, using what the workers report about the requests they handle, before it gets any more.&lt;/p&gt;</description>
	<pubDate>Thu, 12 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>The Python Coding Stack: Use Your Initiative</title>
	<guid>https://www.thepythoncodingstack.com/p/use-your-initiative</guid>
	<link>https://www.thepythoncodingstack.com/p/use-your-initiative</link>
	<description>&lt;div class=&quot;captioned-image-container&quot;&gt;&lt;a class=&quot;image-link image2 is-viewable-img&quot; target=&quot;_blank&quot; href=&quot;https://substackcdn.com/image/fetch/$s_!JfdP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ee0113c-9b19-404e-ae4e-1d727d69ee20_1254x1254.png&quot;&gt;&lt;div class=&quot;image2-inset&quot;&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!JfdP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ee0113c-9b19-404e-ae4e-1d727d69ee20_1254x1254.png&quot; width=&quot;1254&quot; height=&quot;1254&quot; src=&quot;src&quot; /&gt;&lt;div class=&quot;image-link-expand&quot;&gt;&lt;div class=&quot;pencraft pc-display-flex pc-gap-8 pc-reset&quot;&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;&lt;p class=&quot;button-wrapper&quot;&gt;&lt;a class=&quot;button primary&quot; href=&quot;https://www.thepythoncodingstack.com/subscribe&quot;&gt;&lt;span&gt;Subscribe now&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;em&gt;&lt;a href=&quot;http://stephengruppetta.com&quot;&gt;stephengruppetta.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</description>
	<pubDate>Wed, 11 Nov 2026 16:31:00 +0000</pubDate>
</item>
<item>
	<title>LernerPython blog, from Reuven Lerner: Pandas option_context: Temporarily change display settings</title>
	<guid>https://lernerpython.com/2026/11/11/pandas-option-context-temporarily-change-display-settings/</guid>
	<link>https://lernerpython.com/2026/11/11/pandas-option-context-temporarily-change-display-settings/</link>
	<description>&lt;img src=&quot;https://lernerpython.com/wp-content/uploads/2026/11/2026-11-11.png&quot; alt=&quot;Pandas option_context: Temporarily change display settings&quot; class=&quot;wp-image-18841&quot; width=&quot;1623&quot; height=&quot;1031&quot; /&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Want to set an option in Python Pandas, but just temporarily? You can use option_context:&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;with pd.option_context('display.precision', 2):
    print(s/3)&lt;/code&gt;&lt;/pre&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Within the &amp;#8220;with&amp;#8221; block, the display precision is 2. But outside &amp;#8212; before or after &amp;#8212; it is the default.&lt;/p&gt;
&lt;p&gt;The post &lt;a href=&quot;https://lernerpython.com/2026/11/11/pandas-option-context-temporarily-change-display-settings/&quot;&gt;Pandas option_context: Temporarily change display settings&lt;/a&gt; appeared first on &lt;a href=&quot;https://lernerpython.com&quot;&gt;LernerPython&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Wed, 11 Nov 2026 07:00:00 +0000</pubDate>
</item>
<item>
	<title>Python GUIs: Keeping Your Main Window Open After Closing a Dialog in PyQt6 — Why your app closes when you dismiss a dialog, and how to fix it</title>
	<guid>https://www.pythonguis.com/faq/dialogs-question-accept-cancel/</guid>
	<link>https://www.pythonguis.com/faq/dialogs-question-accept-cancel/</link>
	<description>&lt;blockquote&gt;
&lt;p&gt;I'm working with dialogs in PyQt6, and when I press either Accept or Cancel, the entire application closes. How can I prevent this?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When you create and display a dialog (for example, using &lt;code&gt;QDialog.exec()&lt;/code&gt;), the dialog runs its own local event loop. Once you click &lt;strong&gt;OK&lt;/strong&gt; or &lt;strong&gt;Cancel&lt;/strong&gt;, the dialog closes and &lt;code&gt;exec()&lt;/code&gt; returns a result. At that point, your code continues executing whatever comes after the dialog call.&lt;/p&gt;
&lt;p&gt;If there's no main window being shown and no main event loop running, the application simply has nothing left to do &amp;mdash; so it exits.&lt;/p&gt;
&lt;p&gt;In other words, the dialog &lt;em&gt;is&lt;/em&gt; your entire application. Once it's gone, everything shuts down.&lt;/p&gt;
&lt;h2 id=&quot;the-solution-run-a-main-window-with-an-event-loop&quot;&gt;The Solution: Run a Main Window with an Event Loop&lt;/h2&gt;
&lt;p&gt;To keep your application alive after the dialog closes, you need a main window that stays open and a properly running event loop. The dialog should be launched &lt;em&gt;from&lt;/em&gt; that main window, so that when the dialog is dismissed, control returns to the main window which is still visible and active.&lt;/p&gt;
&lt;p&gt;Here's a minimal complete example that demonstrates the correct setup:&lt;/p&gt;
&lt;div class=&quot;code-block&quot;&gt;
&lt;span class=&quot;code-block-language code-block-python&quot;&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class=&quot;python&quot;&gt;import sys

from PyQt6.QtWidgets import (
    QApplication, QDialog, QDialogButtonBox,
    QMainWindow, QPushButton, QVBoxLayout,
)


class CustomDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle(&quot;My Dialog&quot;)

        buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        self.button_box = QDialogButtonBox(buttons)
        self.button_box.accepted.connect(self.accept)
        self.button_box.rejected.connect(self.reject)

        layout = QVBoxLayout()
        layout.addWidget(self.button_box)
        self.setLayout(layout)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(&quot;Main Window&quot;)

        button = QPushButton(&quot;Open Dialog&quot;)
        button.clicked.connect(self.open_dialog)
        self.setCentralWidget(button)

    def open_dialog(self):
        dialog = CustomDialog(self)
        result = dialog.exec()

        if result == QDialog.DialogCode.Accepted:
            print(&quot;User clicked OK&quot;)
        else:
            print(&quot;User clicked Cancel&quot;)


if __name__ == &quot;__main__&quot;:
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;Let's walk through what's happening here.&lt;/p&gt;
&lt;h2 id=&quot;the-main-window&quot;&gt;The Main Window&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;MainWindow&lt;/code&gt; class creates a simple window with a single button. When you click that button, it calls &lt;code&gt;open_dialog()&lt;/code&gt;, which creates an instance of &lt;code&gt;CustomDialog&lt;/code&gt; and displays it using &lt;code&gt;exec()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Once you accept or cancel the dialog, &lt;code&gt;exec()&lt;/code&gt; returns and you're back inside the &lt;code&gt;open_dialog&lt;/code&gt; method. The main window is still visible and responsive &amp;mdash; you can click the button again to reopen the dialog as many times as you like. If you're new to building windows in PyQt6, see our tutorial on &lt;a href=&quot;https://www.pythonguis.com/tutorials/pyqt6-creating-your-first-window/&quot;&gt;creating your first window&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-event-loop&quot;&gt;The Event Loop&lt;/h2&gt;
&lt;p&gt;The block at the bottom of the script is what keeps everything running:&lt;/p&gt;
&lt;div class=&quot;code-block&quot;&gt;
&lt;span class=&quot;code-block-language code-block-python&quot;&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class=&quot;python&quot;&gt;if __name__ == &quot;__main__&quot;:
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;code&gt;QApplication&lt;/code&gt; manages the main event loop for your application. Calling &lt;code&gt;app.exec()&lt;/code&gt; starts that loop, which keeps the application alive, processing user input, repainting widgets, and handling &lt;a href=&quot;https://www.pythonguis.com/tutorials/pyqt6-signals-slots-events/&quot;&gt;signals and slots&lt;/a&gt;. The application will only exit when you close the main window (or explicitly call &lt;code&gt;app.quit()&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Without this event loop, your script runs top-to-bottom and exits as soon as it reaches the end &amp;mdash; which is exactly the behavior you'd see if a dialog was the only thing in your script.&lt;/p&gt;
&lt;h2 id=&quot;passing-self-as-parent&quot;&gt;Passing &lt;code&gt;self&lt;/code&gt; as Parent&lt;/h2&gt;
&lt;p&gt;You might have noticed that we pass &lt;code&gt;self&lt;/code&gt; when creating the dialog:&lt;/p&gt;
&lt;div class=&quot;code-block&quot;&gt;
&lt;span class=&quot;code-block-language code-block-python&quot;&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class=&quot;python&quot;&gt;dialog = CustomDialog(self)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;This sets the main window as the dialog's parent. It means the dialog will appear centered over the main window and will be properly tied to it in terms of window management. It's a good habit to set a parent for your dialogs whenever you can.&lt;/p&gt;
&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;If your app is closing when you dismiss a dialog, make sure you have:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;QApplication&lt;/code&gt; instance with its event loop running via &lt;code&gt;app.exec()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A main window that is shown using &lt;code&gt;.show()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your dialog launched from within that main window, so control returns to it after the dialog is dismissed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;With this structure in place, your &lt;a href=&quot;https://www.pythonguis.com/tutorials/pyqt6-dialogs/&quot;&gt;dialogs&lt;/a&gt; will open and close without affecting the rest of your application. If you want to open additional windows beyond dialogs, take a look at our guide on &lt;a href=&quot;https://www.pythonguis.com/tutorials/pyqt6-creating-multiple-windows/&quot;&gt;creating multiple windows&lt;/a&gt; in PyQt6.&lt;/p&gt;
            &lt;p&gt;For an in-depth guide to building Python GUIs with PyQt6 see my book, &lt;a href=&quot;https://www.pythonguis.com/pyqt6-book/&quot;&gt;Create GUI Applications with Python &amp;amp; Qt6.&lt;/a&gt;&lt;/p&gt;</description>
	<pubDate>Wed, 11 Nov 2026 06:00:00 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: AI as a lifestyle choice</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/ai-as-a-lifestyle-choice/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/ai-as-a-lifestyle-choice/</link>
	<description>&lt;p&gt;When I wrote about &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/twenty-years-of-mod-wsgi/&quot;&gt;twenty years of mod_wsgi&lt;/a&gt;, I mentioned that I was on sabbatical, that AI coding tools had changed what I was prepared to take on, and that I was hoping people would tell me what they think is missing from the current options for hosting Python web applications. Each of those only got a sentence or two in a post which was mostly about Apache, and I want to expand on them here.&lt;/p&gt;
&lt;p&gt;I should start by clarifying my situation, or at least try to. I would like to call it retirement. My better half would like to see me still working. Nothing is official either way, and negotiations continue. Some people in my position would buy a caravan and head off around Australia, but I already travelled the outback a lot when I was much younger. What I seem to have chosen to do instead is spend my days building software with AI, and I am having about as much fun creating software as I have ever had.&lt;/p&gt;
&lt;h2&gt;What the lifestyle looks like&lt;/h2&gt;
&lt;p&gt;It is only a month since I decided I would start on Muru. There were a few days of planning and thinking before any code got written, and since then I have been working on it seven days a week, days and evenings. Nobody is making me do this. I can't remember the last time something was engaging, fulfilling and fun all at the same time.&lt;/p&gt;
&lt;p&gt;Before that sounds too alarming, it doesn't mean I am chained to a desk. AI coding tools have turned out to fit quite well around the rest of life. I can set something going, then head out for a walk, do some gardening, or deal with whatever else needs doing around the house, and come back to see how it went. When I was writing all the code myself, stepping away meant the work stopped. My better half may even be prepared to count that in my favour during the negotiations.&lt;/p&gt;
&lt;p&gt;That is what I mean by a lifestyle choice. I said in that earlier post that the coding is the part I find a chore these days, which might seem odd coming from someone who has never really stopped creating software. The making of things was always the point though, not the typing. AI gets rid of the grunt work that can be so frustrating, and leaves me with the ideas and the satisfaction of seeing them work.&lt;/p&gt;
&lt;h2&gt;Getting away from the meter&lt;/h2&gt;
&lt;p&gt;It took me a while to realise how much the price of these tools was affecting how I used them. When I was paying $20 a month I was always worried I would run out, and as a result I actually held back from relying on it. Moving up to $200 a month produced the opposite problem. Paying that much, I felt I had to make the most of it, and so would end up using it for a crazy number of hours just to get my money's worth. Neither is a great way to go about something you are meant to be doing for fun.&lt;/p&gt;
&lt;p&gt;Right now I don't have that problem, because I have been accepted into the Claude for Open Source Program for a second round. I very much appreciate that, and it has made a huge difference. More than anything it has released me from worrying about the cost, and I feel free to do whatever I want without feeling constrained.&lt;/p&gt;
&lt;p&gt;Obviously there is the question of what happens when I go back to having to pay for it, and I don't have a good answer to that yet. For now I am taking as much advantage of it as I can.&lt;/p&gt;
&lt;h2&gt;Everything starts out as mud&lt;/h2&gt;
&lt;p&gt;I will acknowledge that AI may not always generate the best code out of the box. To be frank though, neither did I when I started on mod_wsgi. The early versions had plenty wrong with them. I made lots of mistakes and had to go back and do things differently, and it took years of fixing and rethinking to get it to where it is now.&lt;/p&gt;
&lt;p&gt;All developers are like this when they start out on something. People seem to forget that as soon as AI is involved, and expect instant perfection of a kind they would never have been able to achieve themselves.&lt;/p&gt;
&lt;p&gt;Writing software is an incremental process of improvement, and in some respects can be likened to making a dorodango. This is a Japanese craft where you take ordinary dirt and water and form it into a ball. It starts out as a crude lump of mud. You shape it, let it dry, add layers of fine dust and keep working at the surface, over and over, until you end up with a sphere with a shine on it that looks nothing like what it was made from.&lt;/p&gt;
&lt;p&gt;I &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/10/ai-slop-is-the-new-tldr/&quot;&gt;wrote recently&lt;/a&gt; about how calling something &amp;quot;AI slop&amp;quot; is often just a way of saying you didn't read it. Well, slop is more or less what a dorodango starts out as. The insult only sticks if you stop there. The AI may be doing a lot of the work on the surface, but deciding what isn't good enough yet is still down to me, and twenty years of getting things wrong with mod_wsgi turns out to be useful for that. I am prepared to put in the hard work to keep polishing Muru until it is something I would trust, and something you would too.&lt;/p&gt;
&lt;h2&gt;Tinkering in the back room&lt;/h2&gt;
&lt;p&gt;I have been programming for over 40 years now, and have been involved in free or open source software for nearly all of that time. Even as I head towards retirement, or whatever we end up calling it, I still want to do more. The Python community has always been the most welcoming community I have ever interacted with, and Muru is my way of giving something back to it.&lt;/p&gt;
&lt;p&gt;There are many ways of contributing to a community like that. A lot of people give their time to the PSF and to running Python conferences, and we are all better off for the work they do. It just wouldn't suit me, as bureaucracy and organising things are not where my strengths lie. Back room tinkering and creating software has always been my thing, and I expect always will be.&lt;/p&gt;
&lt;p&gt;I also know how badly AI written code being handed to open source projects can go, with maintainers left to deal with what lands on them. The difference here, I hope, is that I am not throwing anything over the fence for someone else to look after. If Muru has problems, they are my problems.&lt;/p&gt;
&lt;h2&gt;The things you never had time to build&lt;/h2&gt;
&lt;p&gt;I know how a month old project written largely by AI sounds. Muru is not a weekend of vibe coding that will be forgotten about by Christmas though. The code may be new, but the thinking behind it has been accumulating for twenty years. Muru is my thing for the foreseeable future, and I intend to commit to it and see how far I can take it.&lt;/p&gt;
&lt;p&gt;I am not doing this only to see what I can achieve based on my own ideas. Part of the reason for putting it out there is so people can see those ideas and come back with their own, about what they would like to see in a system for hosting Python web applications.&lt;/p&gt;
&lt;p&gt;A few people have already left comments on my social media posts about the series, showing an interest in specific things Muru is doing. That has been encouraging, and suggests I am at least on the right track. I would like a lot more of that feedback though, especially about what isn't there yet.&lt;/p&gt;
&lt;p&gt;If you work with Python web applications for a living, I suspect you have a list. There will be points of friction in your day to day development work that you would love to see gone. There will be things you need to do in production which are much harder than they should be. Some of it you have wanted to fix for years, but you never had the time, or your employer wasn't interested. I know that list well, as I had one of my own for most of my working life.&lt;/p&gt;
&lt;p&gt;That is where my situation is now different. Not having a job, I have the time. Open source has always said &amp;quot;patches welcome&amp;quot;, which isn't much use when lack of time is the whole problem. So don't send me a patch, just describe the problem.&lt;/p&gt;
&lt;p&gt;If you aren't sure where to start, think about the scripts you have written around your deployments that you wish you didn't need, or the thing you end up explaining to every new person who joins the team. Maybe there is something that keeps getting pushed down the backlog, or a production task you put off because of how painful it is. I can't promise everything will make it in, as an idea needs to fit within the overall system I am trying to develop, but if it does then I am happy to look at it.&lt;/p&gt;
&lt;p&gt;In that earlier post I said I wasn't going to point to where Muru lives just yet. It is time to change that. To see what Muru can already do, which is a lot more than the posts so far have covered, have a look through the &lt;a href=&quot;https://docs.muruhq.dev/&quot;&gt;documentation&lt;/a&gt;. The place to tell me what you would like to see is the &lt;a href=&quot;https://github.com/muruhq/muru/discussions/categories/ideas&quot;&gt;ideas section of the Muru discussions on GitHub&lt;/a&gt;. I am not a huge fan of online discussion forums like this, but there don't seem to be any other good choices these days. If they aren't your thing either, then just leave a comment on one of my social media posts when something sounds like it is what you would use or need. Even that much helps me work out where to put the effort.&lt;/p&gt;
&lt;h2&gt;Polishing in public&lt;/h2&gt;
&lt;p&gt;You will have to put up with me posting about Muru every day for a while yet. Sorry about that. The posts are actually part of the polishing process, as are the online interactive workshops I have been creating for it using the &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/09/introducing-jupyterlab-workshop/&quot;&gt;workshop extension I wrote for JupyterLab&lt;/a&gt;. If I can't write a post which explains something easily, or create a workshop which makes it usable for the people who may want to use it, then its design and implementation were bad to start with. Having to explain it is what shows me where it needs more work.&lt;/p&gt;
&lt;p&gt;The ball is still pretty muddy in places. I have the time though, I am having fun, and whatever my better half and I end up deciding to call this, I plan to keep polishing. Fingers crossed it ends up with a shine.&lt;/p&gt;</description>
	<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: Muru: WSGI and ASGI together</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/muru-wsgi-and-asgi-together/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/muru-wsgi-and-asgi-together/</link>
	<description>&lt;p&gt;The &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-https-in-development/&quot;&gt;previous post&lt;/a&gt; was about serving HTTPS from a development run. This time I want to get back to something I mentioned in passing when I &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/twenty-years-of-mod-wsgi/&quot;&gt;first talked about Muru&lt;/a&gt;, which is that it isn't limited to WSGI, and that WSGI and ASGI can be mixed in the one application.&lt;/p&gt;
&lt;p&gt;The case where this matters most is an existing WSGI application which now needs websockets. WSGI has no way of doing websockets, so the usual answer is to stand up a separate ASGI server beside the WSGI one, put a proxy in front to route the websocket paths to it, and then work out how the two halves are going to share things like sessions and configuration. The other answer is to port the whole application to ASGI, which for a large application is rarely something anyone wants to do just to get one feature.&lt;/p&gt;
&lt;p&gt;With Muru, neither is necessary. The WSGI application can hand a request over to an ASGI application running in the same worker process.&lt;/p&gt;
&lt;h2&gt;An existing WSGI application&lt;/h2&gt;
&lt;p&gt;To show how this works I will use a small Flask application, standing in for an application which has been around for years. The project has Flask installed with &lt;code&gt;uv add flask&lt;/code&gt;, and the &lt;code&gt;pyproject.toml&lt;/code&gt; file names the entrypoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru]
entrypoint = &amp;quot;shop.app:app&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application itself has the existing page, plus a new &lt;code&gt;/chat&lt;/code&gt; view where the websocket is to live:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from flask import Flask, request

from shop.chat import chat

app = Flask(__name__)


@app.route(&amp;quot;/&amp;quot;)
def index():
    return &amp;quot;Welcome to the shop\n&amp;quot;


@app.route(&amp;quot;/chat&amp;quot;)
def chat_view():
    bridge = request.environ[&amp;quot;muru.wsgi_to_asgi&amp;quot;]
    if not bridge.is_websocket:
        return &amp;quot;websocket required\n&amp;quot;, 426
    return bridge.handoff(chat)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every WSGI request Muru passes to an application has a &lt;code&gt;muru.wsgi_to_asgi&lt;/code&gt; object in the WSGI environ. This is the bridge across to the ASGI side. It can say whether the request is a websocket upgrade, and its &lt;code&gt;handoff()&lt;/code&gt; method takes an ASGI application, not a WSGI one. Calling &lt;code&gt;handoff()&lt;/code&gt; marks the request so that, once the WSGI side has returned, Muru carries on with the request by passing it to that ASGI application.&lt;/p&gt;
&lt;p&gt;What &lt;code&gt;handoff()&lt;/code&gt; returns is there for convenience, and is designed to suit both raw WSGI applications and web frameworks. It is an empty iterable, which is what a WSGI application returns for a response with no body, so a raw WSGI application can return it straight away. The same object can also be called as if it were a WSGI application, in which case it responds with a 200 status and returns itself as an empty body. Between them, these cover what web frameworks will accept from a view. Current versions of Flask take an iterator returned from a view as the body of a response, while older versions instead run a callable returned from a view as a WSGI application. Either way, a Flask view can return what &lt;code&gt;handoff()&lt;/code&gt; gives back, as the example above does.&lt;/p&gt;
&lt;p&gt;For any other framework, how you return from the handler doesn't matter. Once a request has been handed off, Muru ignores whatever response the WSGI side produces, so all that is needed is to call &lt;code&gt;handoff()&lt;/code&gt; and then return whatever response the framework expects.&lt;/p&gt;
&lt;h2&gt;Websockets as callbacks&lt;/h2&gt;
&lt;p&gt;What the request is handed off to, &lt;code&gt;chat&lt;/code&gt;, has to be an ASGI application. You could write that as a raw ASGI coroutine, but if your application is WSGI, the people working on it are probably more at home with synchronous code. For that Muru provides &lt;code&gt;muru.asgi.websocket.Handler&lt;/code&gt;, an ASGI application which turns the events of a websocket into method calls on an object of your own. Here is a minimal chat room in &lt;code&gt;shop/chat.py&lt;/code&gt;, where every message received is sent on to everyone connected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from muru.asgi import websocket


class Chat:
    def __init__(self):
        self.sockets = set()

    def on_connect(self, ws):
        self.sockets.add(ws)

    def on_receive(self, ws, message):
        for peer in list(self.sockets):
            try:
                peer.send(message)
            except websocket.ConnectionClosed:
                self.sockets.discard(peer)

    def on_disconnect(self, ws, code, reason):
        self.sockets.discard(ws)


chat = websocket.Handler(Chat())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The methods are called on the worker's application threads, the same threads the WSGI application uses. Calls for any one socket happen one at a time and in order, while different sockets can be handled at the same time. The important part, which I will come back to, is that a socket sitting there waiting for its next message isn't holding on to a thread while it waits.&lt;/p&gt;
&lt;h2&gt;Turning handoff on&lt;/h2&gt;
&lt;p&gt;Start this with &lt;code&gt;muructl run&lt;/code&gt; and the ordinary page works, but a websocket client connecting to &lt;code&gt;/chat&lt;/code&gt; is turned away. The access log shows why, with the view having answered with a 426 status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;426 GET /chat 19B 0.5ms queue=0.0ms app=0.5ms worker=0 id=01M6JWP0E8W9FQ721K81F9AJ9H
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Handing requests to ASGI has to be enabled, as it means the worker needs to run an event loop beside its threads. Without the setting, a websocket upgrade is just an ordinary request, so &lt;code&gt;is_websocket&lt;/code&gt; is false and the view turns it away. Enabling it is one more line in &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru]
entrypoint = &amp;quot;shop.app:app&amp;quot;
handoff = &amp;quot;asgi&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it again and the start-up output confirms the worker now has an event loop as well as its threads:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru: serving shop.app:app on http://127.0.0.1:8080 with 1 worker of 4 threads and an event loop of concurrency 1000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To check the chat room really works, I used a small client written with the &lt;code&gt;websockets&lt;/code&gt; package, installed into a separate virtual environment, which connects as two users and has each of them say something:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio

from websockets.asyncio.client import connect

URL = &amp;quot;ws://127.0.0.1:8080/chat&amp;quot;


async def main():
    async with connect(URL) as alice, connect(URL) as bob:
        await alice.send(&amp;quot;hello from alice&amp;quot;)
        print(&amp;quot;alice got:&amp;quot;, await alice.recv())
        print(&amp;quot;bob got:&amp;quot;, await bob.recv())
        await bob.send(&amp;quot;hi alice, bob here&amp;quot;)
        print(&amp;quot;alice got:&amp;quot;, await alice.recv())
        print(&amp;quot;bob got:&amp;quot;, await bob.recv())


asyncio.run(main())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running this the output is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;alice got: hello from alice
bob got: hello from alice
alice got: hi alice, bob here
bob got: hi alice, bob here
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The access log has a line for each websocket when it is accepted, and another when it closes, recording who closed it, how, and how many messages went each way. Both are marked with &lt;code&gt;handoff=asgi&lt;/code&gt; so you can tell the request was handed across:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;101 GET /chat 0B 1.8ms queue=0.0ms app=0.5ms worker=0 handoff=asgi id=01M6JWPC691F8KMJB3TQH8CKSK
101 GET /chat 0B 0.3ms queue=0.1ms app=0.2ms worker=0 handoff=asgi id=01M6JWPC6B1F8KMJB3TQH8CKSK
101 GET /chat 34B 1.7ms websocket=closed by=client code=1000 received=1 sent=2 worker=0 handoff=asgi id=01M6JWPC6B1F8KMJB3TQH8CKSK
101 GET /chat 34B 2.9ms websocket=closed by=client code=1000 received=1 sent=2 worker=0 handoff=asgi id=01M6JWPC691F8KMJB3TQH8CKSK
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Sockets don't hold threads&lt;/h2&gt;
&lt;p&gt;The reason this isn't just a party trick is what happens to the thread the WSGI view ran on. Once the view hands the request off and returns, the thread goes back to the pool to handle other requests. The websocket now lives on the worker's event loop, and only borrows a thread for the brief time it takes to run one of the callback methods.&lt;/p&gt;
&lt;p&gt;That matters, because this worker only has four threads. If each open websocket tied up a thread, the fifth person to open the chat would stop the rest of the site from responding. To check, I changed the client to open fifty websockets to &lt;code&gt;/chat&lt;/code&gt;, leave them all open, and then make an ordinary request to the home page:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;50 websockets open
GET / answered b'Welcome to the shop\n' in 6.2ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fifty open websockets on a worker with four threads, and the rest of the site carries on as normal.&lt;/p&gt;
&lt;h2&gt;Side by side by path&lt;/h2&gt;
&lt;p&gt;Handing off in code is the right tool when the decision about which side a request goes to belongs in the application, such as wanting the Flask view to check the user is logged in before accepting the websocket. When the decision is simply about the path, Muru can instead mount an ASGI application beside the WSGI one.&lt;/p&gt;
&lt;p&gt;Say newer parts of the shop are being written as async code under &lt;code&gt;/api&lt;/code&gt;. Here is a stand-in for that in &lt;code&gt;shop/api.py&lt;/code&gt;, where the sleep pretends to be waiting on some other service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio
import json


async def app(scope, receive, send):
    if scope[&amp;quot;type&amp;quot;] != &amp;quot;http&amp;quot;:
        return

    await asyncio.sleep(1)  # stand in for waiting on another service

    body = json.dumps({&amp;quot;root_path&amp;quot;: scope[&amp;quot;root_path&amp;quot;], &amp;quot;path&amp;quot;: scope[&amp;quot;path&amp;quot;]}).encode()
    await send({&amp;quot;type&amp;quot;: &amp;quot;http.response.start&amp;quot;, &amp;quot;status&amp;quot;: 200, &amp;quot;headers&amp;quot;: [(b&amp;quot;content-type&amp;quot;, b&amp;quot;application/json&amp;quot;)]})
    await send({&amp;quot;type&amp;quot;: &amp;quot;http.response.body&amp;quot;, &amp;quot;body&amp;quot;: body})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mounting it at &lt;code&gt;/api&lt;/code&gt; is a table in &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru.mounts.api]
path = &amp;quot;/api&amp;quot;
entrypoint = &amp;quot;shop.api:app&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On start-up, Muru says what each entrypoint is being served as, and where each mount is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[89570]: shop.app:app is served as WSGI
muru.host[89570]: mount api: shop.api:app is served as ASGI
...
muru: mount api serves /api from shop.api:app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anything under &lt;code&gt;/api&lt;/code&gt; now goes to the ASGI application, and everything else to Flask. The ASGI application sees the mount path as its &lt;code&gt;root_path&lt;/code&gt;, as the ASGI specification asks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl http://127.0.0.1:8080/api/orders
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{&amp;quot;root_path&amp;quot;: &amp;quot;/api&amp;quot;, &amp;quot;path&amp;quot;: &amp;quot;/api/orders&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both applications run in the same worker processes, imported by the same preload, from the one project.&lt;/p&gt;
&lt;p&gt;Requests to the ASGI application run on the worker's event loop, so they aren't held back by the four threads the WSGI application has. Firing off twenty requests to &lt;code&gt;/api&lt;/code&gt; at once, each awaiting that one second sleep, they all finished together in 1.06 seconds, rather than queuing up four at a time behind the threads.&lt;/p&gt;
&lt;p&gt;Left alone, a mount shares the worker's event loop, and so its concurrency, with everything else running on it, such as the websockets handed off from Flask. If one part of the site shouldn't be able to take all of that, a mount can be given a concurrency of its own:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru.mounts.api]
path = &amp;quot;/api&amp;quot;
entrypoint = &amp;quot;shop.api:app&amp;quot;
concurrency = 500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Muru confirms this when it starts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru: mount api serves /api from shop.api:app with concurrency 500 of its own
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is a similar &lt;code&gt;max_connections&lt;/code&gt; setting to put a limit on how many websockets an ASGI mount can hold open.&lt;/p&gt;
&lt;h2&gt;And back the other way&lt;/h2&gt;
&lt;p&gt;Handing off works in the other direction too. An ASGI application can hand a request to WSGI code, which is useful when moving an application to ASGI a piece at a time, leaving the parts not yet converted as they are. In the scope given to an ASGI application, Muru's extensions include an &lt;code&gt;asgi_to_wsgi&lt;/code&gt; bridge, whose &lt;code&gt;handoff()&lt;/code&gt; runs a WSGI application for the rest of the request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def legacy_report(environ, start_response):
    body = f&amp;quot;report generated by WSGI code for {environ['SCRIPT_NAME']}{environ['PATH_INFO']}\n&amp;quot;.encode()
    start_response(&amp;quot;200 OK&amp;quot;, [(&amp;quot;Content-Type&amp;quot;, &amp;quot;text/plain&amp;quot;)])
    return [body]


async def app(scope, receive, send):
    if scope[&amp;quot;type&amp;quot;] != &amp;quot;http&amp;quot;:
        return

    if scope[&amp;quot;path&amp;quot;].startswith(&amp;quot;/api/reports/&amp;quot;):
        return scope[&amp;quot;extensions&amp;quot;][&amp;quot;muru&amp;quot;][&amp;quot;asgi_to_wsgi&amp;quot;].handoff(legacy_report)

    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As before, this has to be enabled, and if you forget, the error says exactly what to add:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.HandoffNotEnabled: mount api does not enable handing requests to WSGI; set handoff = &amp;quot;wsgi&amp;quot; on the mount
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WSGI code needs threads to run on, so as well as setting &lt;code&gt;handoff&lt;/code&gt;, the mount here is given two threads of its own:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru.mounts.api]
path = &amp;quot;/api&amp;quot;
entrypoint = &amp;quot;shop.api:app&amp;quot;
concurrency = 500
handoff = &amp;quot;wsgi&amp;quot;
threads = 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The start-up output now shows the mount with its own threads as well as its own concurrency:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru: mount api serves /api from shop.api:app on 2 threads of its own with concurrency 500 of its own
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now a request for a report goes to the old WSGI code, while everything else under &lt;code&gt;/api&lt;/code&gt; stays with the ASGI application:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl http://127.0.0.1:8080/api/reports/daily
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;report generated by WSGI code for /api/reports/daily
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with the access log marking it with &lt;code&gt;handoff=wsgi&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;200 GET /api/reports/daily 53B 0.4ms queue=0.0ms app=0.4ms mount=api worker=0 handoff=wsgi id=01M6JWRJ0DQ38194C2WQX2KDCJ
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;gRPC as well&lt;/h2&gt;
&lt;p&gt;The same mount mechanism also takes care of the third kind of service I mentioned in the first post, which is gRPC. A gRPC mount doesn't have a path. Instead, calls are recognised by their content type and service name, so gRPC and ordinary HTTP share the same port. The entrypoint is a function which registers the servicers generated by &lt;code&gt;grpcio-tools&lt;/code&gt;, just as you would with a grpcio server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class Greeter(greeter_pb2_grpc.GreeterServicer):
    def SayHello(self, request, context):
        return greeter_pb2.HelloReply(message=f&amp;quot;Hello {request.name}&amp;quot;)


def register(server):
    greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru.mounts.api]
interface = &amp;quot;grpc&amp;quot;
entrypoint = &amp;quot;svc.app:register&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru.host[91221]: mount api: svc.app:register is served as gRPC
...
muru: mount api serves the gRPC services of svc.app:register
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that, a grpcio client calling &lt;code&gt;SayHello&lt;/code&gt; and a browser fetching the home page can talk to the same port of the same development run, and both show up in the one access log:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;200 GET / 30B 0.2ms queue=0.0ms app=0.2ms worker=0 id=01M6JWS8HV7NK43EP3VYEA225P
200 POST /greet.Greeter/SayHello 14B 0.9ms queue=0.0ms app=0.8ms worker=0 id=01M6JWS8KT7NK43EP3VYEA225P
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One catch is that grpcio doesn't provide wheels for free-threaded Python, so for gRPC I had to use a regular build of Python. There is a lot more to gRPC in Muru, such as streaming calls and gRPC-Web, which I may come back to in a post of its own.&lt;/p&gt;
&lt;h2&gt;What's next&lt;/h2&gt;
&lt;p&gt;What I like about all of this is that you don't have to make a big decision up front. An application can start as WSGI, take websockets when it needs them, grow async parts under a path, and move across to ASGI piece by piece if that ever makes sense, all without becoming more than one deployment.&lt;/p&gt;
&lt;p&gt;That wraps up what I wanted to show of Muru in development mode. Next it is time to move on to the daemon, starting with deploying an application to a server, where the releases that a reload creates in development mode become deploys that can run side by side, take a share of the traffic, and be rolled back.&lt;/p&gt;</description>
	<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>Seth Michael Larson: Creating for yourself</title>
	<guid>https://sethmlarson.dev/creating-for-yourself?utm_campaign=rss</guid>
	<link>https://sethmlarson.dev/creating-for-yourself?utm_campaign=rss</link>
	<description>&lt;p&gt;Today marks the 7th anniversary of starting this blog. If you've been following along for a while: &lt;em&gt;thank you!&lt;/em&gt; 😊&lt;/p&gt;

&lt;p&gt;After observing &lt;a href=&quot;https://sethmlarson.dev/observing-volunteer-amnesty-day&quot;&gt;Volunteer Responsibility Amnesty Day&lt;/a&gt; in 2025
and stopping most of my volunteer OSS contributions, I really leaned hard into
using my creative leisure time &quot;for me&quot;. This has helped protect
my flame from burn-out and focus on areas I'm excited about, like writing,
video-games (retro, modding, ...developing? 👀), and keeping my volunteer OSS focus on
mostly a single project: &lt;a href=&quot;https://github.com/sethmlarson/truststore/&quot;&gt;truststore&lt;/a&gt;. This year was the first time &lt;a href=&quot;https://github.com/urllib3/urllib3/graphs/contributors?from=1%2F1%2F2016&amp;to=11%2F7%2F2026&quot;&gt;since 2016&lt;/a&gt;
that I was uninvolved in the latest release of urllib3, which felt both deeply rewarding and
strange as I read the &lt;a href=&quot;https://github.com/urllib3/urllib3/releases/tag/2.8.0&quot;&gt;v2.8.0 release notes&lt;/a&gt;.&lt;/p&gt;

&lt;!-- more --&gt;

&lt;h2&gt;Writing&lt;/h2&gt;

&lt;p&gt;This year I &lt;a href=&quot;https://mastodon.social/@sethmlarson/117554489864114380&quot;&gt;published 54 articles to my blog&lt;/a&gt;, an average of once per week across a year.
This wasn't a specific goal I had in mind, looking through the past year of articles has me
thinking &quot;&lt;em&gt;oh, that was this year??&lt;/em&gt;&quot;. Writing continued to be a focus of my role at the PSF, too, where I &lt;a href=&quot;https://blog.python.org/authors/seth-larson/&quot;&gt;wrote 14 articles&lt;/a&gt;
for &lt;a href=&quot;https://blog.python.org/authors/seth-larson/&quot;&gt;Python Insider&lt;/a&gt;,
&lt;a href=&quot;https://pyfound.blogspot.com&quot;&gt;5 for the PSF blog&lt;/a&gt;, and &lt;a href=&quot;https://blog.pypi.org/author/sethmlarson/&quot;&gt;2 for the Python Package Index blog&lt;/a&gt; (&lt;em&gt;that's a lot of yapping!&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;The piece I'm most proud of from this year is “&lt;a href=&quot;https://sethmlarson.dev/food-jpegs-in-super-smash-bros-and-kirby-air-riders&quot;&gt;Food JPEGs in Super Smash Bros. and Kirby Air Riders&lt;/a&gt;”.
This took &lt;em&gt;tons&lt;/em&gt; of research, buying the Japanese Kirby Air Ride, &lt;a href=&quot;https://sethmlarson.dev/cutting-spritesheets-like-cookies-with-python-and-pillow&quot;&gt;writing some custom software&lt;/a&gt;,
and I think this work really showed through in the end. This was my most-visited article of the year (mostly from the &lt;a href=&quot;https://news.ycombinator.com/item?id=46516807&quot;&gt;Hacker News front page&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My under-dog favorite is “&lt;a href=&quot;https://sethmlarson.dev/animal-crossing-calendar&quot;&gt;Adding Animal Crossing events to your digital calendar&lt;/a&gt;” where
I got to deep-dive into how Animal Crossing events work, differences between regions and games, the Animal Crossing
decompilation project, and bought a physical Animal Crossing calendar too. I
learned about the ICS calendar format and which features are supported across
different platforms like iOS and Android.&lt;/p&gt;

&lt;h2&gt;Software&lt;/h2&gt;

&lt;p&gt;I took a much more fast-and-loose approach to sharing software I've worked on this year, opting to use
this blog and Gists instead of each project needing a forge, issue tracker,
and to be published to PyPI. I found that this approach let me
spend more time on what actually mattered to me: being able to tell the
story!&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/lan-party-calculator-for-mario-kart-kirby-air-riders-and-f-zero&quot;&gt;LAN Party calculator for Mario Kart, Kirby Air Riders, F-Zero&lt;/a&gt; (&lt;a href=&quot;https://gist.github.com/sethmlarson/8ab1b0c7524ac2c642d56cd56c1bbb72&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/animal-crossing-calendar&quot;&gt;Adding Animal Crossing events to your digital calendar&lt;/a&gt; (&lt;a href=&quot;https://github.com/sethmlarson/animal-crossing-calendar/&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/sms-bcc&quot;&gt;Blind Carbon Copy (BCC) for SMS&lt;/a&gt; (&lt;a href=&quot;https://gist.github.com/sethmlarson/57002c2304eb5ce1f78f964721a7cc7e&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/cutting-spritesheets-like-cookies-with-python-and-pillow&quot;&gt;sugarcookie: a sprite-sheet cutting tool&lt;/a&gt; (&lt;a href=&quot;https://gist.github.com/sethmlarson/92dabc8420b0d2e2e6e8c688269fe5fd&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I continued building tools allowing you to extract and archive ROMs from legally owned
physical copies of games:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/dumping-nintendo-ereader-card-roms&quot;&gt;Nintendo e-Reader cards&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/extracting-billy-hatcher-joycarry-game-roms-with-python&quot;&gt;GC-to-GBA Joyboot&lt;/a&gt;, such as Billy Hatcher &quot;Joycarry&quot; games&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/extracting-n64-roms-from-wind-waker-bonus-disk&quot;&gt;Ocarina of Time: Master Quest Bonus Disc&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/mgba-to-dolphin-not-working-you-need-a-gba-bios&quot;&gt;GBA BIOS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally, I created tools that track my play activity across all the platforms I care
about. Can't improve what you don't measure, this data comes up in the next section:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/nintendo-switch-play-activity-ocr&quot;&gt;Nintendo Play Activity (OCR)&lt;/a&gt; (&lt;a href=&quot;https://github.com/sethmlarson/nintendo-play-activity-ocr&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/sethmlarson/retroachievements-play-activity&quot;&gt;Retroachievements Play Activity&lt;/a&gt; (&lt;a href=&quot;https://github.com/sethmlarson/retroachievements-play-activity&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/sethmlarson/gamecube-play-activity-memcardgc&quot;&gt;GameCube Play Activity (Memcard Pro GC)&lt;/a&gt; (&lt;a href=&quot;https://github.com/sethmlarson/gamecube-play-activity-memcardgc&quot;&gt;Source&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Gaming&lt;/h2&gt;

&lt;p&gt;Ever since 2023 I've been “in my feels” about gaming. I considered myself
a gamer, but I wasn't &lt;em&gt;actually playing games&lt;/em&gt;. I set a goal to get back into
the hobby more seriously, and figure out how I can integrate play back
into my week. 2025 was successful already, and 2026 was even more so.&lt;/p&gt;

&lt;p&gt;I ended up really experimenting this year by writing about
a slice of my experience for four of the games I completed. I don't
think I'll want to write about every game I play, but
thinking deeply about my feelings
while playing the game and &lt;em&gt;especially&lt;/em&gt; the time between play sessions
has been an interesting lens to look through. &lt;em&gt;Maybe more to come here?&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/links-awakening&quot;&gt;“The Legend of Zelda: Link’s Awakening” respects your time&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/the-frog-for-whom-the-bell-tolls&quot;&gt;The Frog for Whom the Bell Tolls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/bowsers-fury&quot;&gt;Bowser’s Fury outshines Sunshine, SA2B, and Billy Hatcher&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sethmlarson.dev/mario-kart-world-and-seamless-media&quot;&gt;Mario Kart World and “seamless” media&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all the games that are &quot;completable&quot; that I started and mostly finished this year:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;Started&lt;/th&gt;
  &lt;th&gt;Game&lt;/th&gt;
  &lt;th&gt;Play Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;2026-01-01&lt;/td&gt;
  &lt;td&gt;The Legend of Zelda: Link's Awakening&lt;/td&gt;
  &lt;td&gt;12 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-03-17&lt;/td&gt;
  &lt;td&gt;The Legend of Zelda: Oracle of Seasons &lt;strong&gt;(DNF)&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;6.5 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-03-31&lt;/td&gt;
  &lt;td&gt;Kaeru no Tame ni Kane wa Naru&lt;/td&gt;
  &lt;td&gt;5 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-04-13&lt;/td&gt;
  &lt;td&gt;Sonic Mania&lt;/td&gt;
  &lt;td&gt;3.5 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-05-03&lt;/td&gt;
  &lt;td&gt;Super Mario 3D World + Bowser's Fury&lt;/td&gt;
  &lt;td&gt;17 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-05-28&lt;/td&gt;
  &lt;td&gt;Mina the Hollower &lt;strong&gt;(DNF)&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;5 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-08-16&lt;/td&gt;
  &lt;td&gt;Big Walk&lt;/td&gt;
  &lt;td&gt;6.75 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-09-11&lt;/td&gt;
  &lt;td&gt;Paper Mario: The Origami King&lt;/td&gt;
  &lt;td&gt;20.5 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;2026-11-04&lt;/td&gt;
  &lt;td&gt;The Legend of Zelda: Ocarina of Time &lt;strong&gt;(WIP!)&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;6.75 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Total&lt;/td&gt;
  &lt;td&gt;&lt;/td&gt;
  &lt;td&gt;~83 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Using the three play activity tracking projects I have data about
play activity from the Nintendo Switch, GameCube, and the Delta emulator.
This data showed I played &lt;strong&gt;83&lt;/strong&gt; hours of games this year compared to &lt;a href=&quot;https://sethmlarson.dev/nintendo-gamecube-and-switch-wrapped-2025&quot;&gt;78
in 2025&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm ending this year playing “Ocarina of Time” for the first time and &lt;em&gt;oh my is
this game incredible!&lt;/em&gt; I cannot stop thinking about it having immersed myself
completely in the design, discussion, and themes prior to starting.
I'm part-way through the Forest Temple and all I can think is that there
are 4 more temples to complete, &lt;em&gt;what a game!&lt;/em&gt;&lt;/p&gt;
&lt;br /&gt;&lt;hr /&gt;&lt;p&gt;Thanks for reading ♥ I would love to hear your thoughts! Contact me via &lt;a href=&quot;https://mastodon.social/@sethmlarson&quot;&gt;Mastodon&lt;/a&gt;, &lt;a href=&quot;https://bsky.app/profile/sethmlarson.dev&quot;&gt;Bluesky&lt;/a&gt;, or &lt;a href=&quot;mailto:sethmichaellarson@gmail.com&quot;&gt;email&lt;/a&gt;. Browse the &lt;a href=&quot;https://sethmlarson.dev/&quot;&gt;blog archive&lt;/a&gt;. Check out my &lt;a href=&quot;https://sethmlarson.dev/blogroll&quot;&gt;blogroll&lt;/a&gt;.&lt;/p&gt;&lt;hr /&gt;&lt;br /&gt;</description>
	<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>PyCoder’s Weekly: Issue #760: Type Checkers, Claude Code Hooks, MarkItDown, and More (2026-11-10)</title>
	<guid>https://pycoders.com/issues/760</guid>
	<link>https://pycoders.com/issues/760</link>
	<description>&lt;p&gt; &lt;span&gt;#760 – NOVEMBER 10, 2026&lt;/span&gt;&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/issues/760/feed&quot;&gt;View in Browser »&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;p&gt;&lt;a href=&quot;https://pycoders.com&quot;&gt;&lt;img alt=&quot;The PyCoder&amp;rsquo;s Weekly Logo&quot; src=&quot;https://cdn.pycoders.com/37bdf31dc645f968ffb90196e5d38ff5&quot; /&gt;&lt;/a&gt;&lt;/p&gt; &lt;hr /&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17364/feed&quot; target=&quot;_blank&quot;&gt;How a Python Type Checker Decides a Variable Is Unused&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; A variable is unused only if no read resolves to its binding and in Python that read can sit inside a nested function or comprehension. This post walks through how ty, Astral’s Python type checker, finds local bindings nobody reads.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17364/feed&quot; target=&quot;_blank&quot;&gt;DENYSZHAK.COM&lt;/a&gt; • Shared by &lt;a href=&quot;https://pycoders.com/link/17351/feed&quot; target=&quot;_blank&quot;&gt;Denys Zhak&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17352/feed&quot; target=&quot;_blank&quot;&gt;How to Automate Your Workflow With Claude Code Hooks&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Learn how to use Claude Code hooks that auto-format your Python code after every edit, enforce your chosen tools, and ping you when a task finishes.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17352/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17385/feed&quot; target=&quot;_blank&quot;&gt;Why Your Python Agent Returns Outdated Memories&lt;/a&gt;&lt;/h3&gt; &lt;a href=&quot;https://pycoders.com/link/17385/feed&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://cdn.pycoders.com/65d3203f283df33b2b383dfa33aeeebe&quot; alt=&quot;alt&quot; /&gt;&lt;/a&gt; &lt;p&gt; A user changes a preference, but vector search still surfaces the old one alongside the new. Link the new memory to the one it replaces and mark the old one historical, so your agent retrieves what’s current and keeps the history. &lt;a href=&quot;https://pycoders.com/link/17385/feed&quot; target=&quot;_blank&quot;&gt;Explore the walkthrough and runnable notebook.&lt;/a&gt;&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17385/feed&quot; target=&quot;_blank&quot;&gt;ORACLE&lt;/a&gt;&lt;/span&gt; &lt;span&gt;sponsor&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17368/feed&quot; target=&quot;_blank&quot;&gt;Converting Documents to Markdown With MarkItDown&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Learn how to use MarkItDown to convert Excel files, PDFs, and images into clean Markdown that&amp;rsquo;s ready for LLMs and AI workflows.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17368/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;span&gt;course&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17374/feed&quot; target=&quot;_blank&quot;&gt;Quiz: Converting Documents to Markdown With MarkItDown&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17374/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17357/feed&quot; target=&quot;_blank&quot;&gt;Django Security Releases Issued: 6.1.3, 6.0.10, and 5.2.19&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17357/feed&quot; target=&quot;_blank&quot;&gt;DJANGO SOFTWARE FOUNDATION&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17386/feed&quot; target=&quot;_blank&quot;&gt;Agentic Coding With Codex CLI: A Live Course for Python Developers&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; If you pay for ChatGPT Plus, you already have Codex CLI, an agent that runs in your terminal, edits your files, and runs your tests. Over two live half-days on November 14–15, you&amp;rsquo;ll use it to build a Python CLI app from an empty directory to GitHub, write an AGENTS.md file so it follows your project&amp;rsquo;s rules, and practice debugging the code it writes. You keep the recordings. &lt;a href=&quot;https://pycoders.com/link/17386/feed&quot; target=&quot;_blank&quot;&gt;Registration closes Friday, November 13 →&lt;/a&gt;&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17386/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;h2&gt;Discussions&lt;/h2&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17373/feed&quot; target=&quot;_blank&quot;&gt;Pull Request Limits for CPython Repo&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17373/feed&quot; target=&quot;_blank&quot;&gt;PYTHON.ORG&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;h2&gt;Articles &amp;amp; Tutorials&lt;/h2&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17360/feed&quot; target=&quot;_blank&quot;&gt;Soft Delete in Django Without Making a Mess&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Soft delete sounds simple: add a &lt;code&gt;deleted_at&lt;/code&gt; field and stop calling &lt;code&gt;DELETE&lt;/code&gt;. The trouble starts a little later, when deleted rows still show up in queries. This tutorial shows a soft-delete setup that handles those cases without hiding too much magic in the model layer.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17360/feed&quot; target=&quot;_blank&quot;&gt;STEIN HELSET&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17370/feed&quot; target=&quot;_blank&quot;&gt;Hello Python 3.15, Goodbye 3.10, and Now I Can Use 3.11&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Simon covers the latest features in Python 3.15. If you&amp;rsquo;ve seen that already, the second half of the article is also worth reading: now that 3.10 is end-of-life, what Python 3.11 features can you use everywhere?&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17370/feed&quot; target=&quot;_blank&quot;&gt;SIMON WILLISON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17377/feed&quot; target=&quot;_blank&quot;&gt;Parsing JSON at Tens of Gigabytes Per Second&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; This article talks about &lt;code&gt;fastsimdjson&lt;/code&gt;, a drop-in replacement for the &lt;code&gt;json&lt;/code&gt; module in the standard library. It has been optimized for speed, demonstrated by reading large amounts of JSON GitHub commit data.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17377/feed&quot; target=&quot;_blank&quot;&gt;DANIEL LEMIRE&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17358/feed&quot; target=&quot;_blank&quot;&gt;Why Python Devs Should Know About the ABI&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Why should every Python developer know about the CPython ABI? This week on the show, we speak with Nathan Goldbaum about free-threaded Python and the changes to the Python Application Binary Interface.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17358/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;span&gt;podcast&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17372/feed&quot; target=&quot;_blank&quot;&gt;Building a Lift Scheduling Algorithm in Python&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; This article walks you through the logic needed to code a scheduling algorithm for elevators, starting with a single car to a more complex multi-car system.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17372/feed&quot; target=&quot;_blank&quot;&gt;PIETER CLAERHOUT&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17362/feed&quot; target=&quot;_blank&quot;&gt;Histogram Plotting: NumPy, Matplotlib, pandas &amp;amp; Seaborn&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; In this tutorial, you&amp;rsquo;ll be equipped to make production-quality, presentation-ready Python histogram plots with a range of choices and features.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17362/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17366/feed&quot; target=&quot;_blank&quot;&gt;Python Exceptions: An Introduction&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Learn what Python exceptions are, how to raise them, how to handle them with try, except, else, and finally, and how to create custom ones.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17366/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17378/feed&quot; target=&quot;_blank&quot;&gt;Python Program Lexical Structure&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Dig into the Python program structure: the syntactic elements that make up statements, and how statements group into larger blocks.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17378/feed&quot; target=&quot;_blank&quot;&gt;REAL PYTHON&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17356/feed&quot; target=&quot;_blank&quot;&gt;Announcing New Security Developer at the PSF&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; The Python Software Foundation welcomes Brett Smith as an additional security developer.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17356/feed&quot; target=&quot;_blank&quot;&gt;PYTHON SOFTWARE FOUNDATION&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17382/feed&quot; target=&quot;_blank&quot;&gt;Python&amp;rsquo;s Binary Wheels and Supply Chain Attacks&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; Why precompiled Python wheels are a weak spot in the supply chain security.&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17382/feed&quot; target=&quot;_blank&quot;&gt;ARTEM GOLUBIN&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;h2&gt;Projects &amp;amp; Code&lt;/h2&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17359/feed&quot; target=&quot;_blank&quot;&gt;pgcli: Postgres CLI With Autocomple &amp;amp; Syntax Highlighting&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17359/feed&quot; target=&quot;_blank&quot;&gt;GITHUB.COM/DBCLI&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17363/feed&quot; target=&quot;_blank&quot;&gt;django-mcpz: Easy Peasy MCP Servers in Django&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17363/feed&quot; target=&quot;_blank&quot;&gt;GITHUB.COM/ADAMCHAINZ&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17353/feed&quot; target=&quot;_blank&quot;&gt;Ghostman: Postman Alternative Built in Python With Textual&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17353/feed&quot; target=&quot;_blank&quot;&gt;GITHUB.COM/4YK5&lt;/a&gt; • Shared by &lt;a href=&quot;https://pycoders.com/link/17365/feed&quot; target=&quot;_blank&quot;&gt;4yk5&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17375/feed&quot; target=&quot;_blank&quot;&gt;VizTracer: Visualize Code Debugging/Profiling/Logging&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17375/feed&quot; target=&quot;_blank&quot;&gt;GITHUB.COM/GAOGAOTIANTIAN&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17354/feed&quot; target=&quot;_blank&quot;&gt;django-ses: Django Backend for Amazon&amp;rsquo;s Simple Email Service&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17354/feed&quot; target=&quot;_blank&quot;&gt;GITHUB.COM/DJANGO-SES&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;h2&gt;Events&lt;/h2&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17361/feed&quot; target=&quot;_blank&quot;&gt;Weekly Real Python Office Hours Q&amp;amp;A (Virtual)&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 11, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17361/feed&quot; target=&quot;_blank&quot;&gt;REALPYTHON.COM&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17355/feed&quot; target=&quot;_blank&quot;&gt;Python Help&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 12, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17355/feed&quot; target=&quot;_blank&quot;&gt;MEETUP.COM&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17369/feed&quot; target=&quot;_blank&quot;&gt;Python Leiden User Group&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 12, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17369/feed&quot; target=&quot;_blank&quot;&gt;PYTHONLEIDEN.NL&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17371/feed&quot; target=&quot;_blank&quot;&gt;Python Atlanta&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 13, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17371/feed&quot; target=&quot;_blank&quot;&gt;MEETUP.COM&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17381/feed&quot; target=&quot;_blank&quot;&gt;PyCon Chile 2026&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 14 to November 16, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17381/feed&quot; target=&quot;_blank&quot;&gt;PYCON.CL&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17379/feed&quot; target=&quot;_blank&quot;&gt;PyCon Hong Kong 2026&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 14 to November 16, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17379/feed&quot; target=&quot;_blank&quot;&gt;PYCON.HK&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17376/feed&quot; target=&quot;_blank&quot;&gt;Wagtail Space 2026&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 18 to November 21, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17376/feed&quot; target=&quot;_blank&quot;&gt;WAGTAIL.ORG&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17380/feed&quot; target=&quot;_blank&quot;&gt;PyCon Wroclaw 2026&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 20 to November 21, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17380/feed&quot; target=&quot;_blank&quot;&gt;PYCONWROCLAW.COM&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;div&gt; &lt;h3&gt;&lt;a href=&quot;https://pycoders.com/link/17367/feed&quot; target=&quot;_blank&quot;&gt;PyCon Ireland 2026&lt;/a&gt;&lt;/h3&gt; &lt;p&gt; November 21 to November 22, 2026&lt;br /&gt; &lt;span&gt;&lt;a href=&quot;https://pycoders.com/link/17367/feed&quot; target=&quot;_blank&quot;&gt;PYCON.IE&lt;/a&gt;&lt;/span&gt; &lt;/p&gt; &lt;/div&gt; &lt;hr /&gt; &lt;p&gt;Happy Pythoning!&lt;br /&gt;This was PyCoder&amp;rsquo;s Weekly Issue #760.&lt;br /&gt;&lt;a href=&quot;https://pycoders.com/issues/760/feed&quot;&gt;View in Browser »&lt;/a&gt;&lt;/p&gt; &lt;img src=&quot;https://pycoders.com/issues/760/open/feed&quot; width=&quot;1&quot; height=&quot;1&quot; alt=&quot;alt&quot; /&gt; 
        &lt;hr /&gt;
        &lt;p&gt;&lt;em&gt;[ Subscribe to 🐍 PyCoder&amp;rsquo;s Weekly 💌 – Get the best Python news, articles, and tutorials delivered to your inbox once a week &lt;a href=&quot;https://pycoders.com/?utm_source=pycoders&amp;utm_medium=feed&amp;utm_campaign=footer&quot;&gt;&amp;gt;&amp;gt; Click here to learn more&lt;/a&gt; ]&lt;/em&gt;&lt;/p&gt;</description>
	<pubDate>Tue, 10 Nov 2026 19:30:00 +0000</pubDate>
</item>
<item>
	<title>Python Bytes: #504 Who turned the GIL back on?</title>
	<guid>https://pythonbytes.fm/episodes/show/504/who-turned-the-gil-back-on</guid>
	<link>https://pythonbytes.fm/episodes/show/504/who-turned-the-gil-back-on</link>
	<description>&amp;lt;strong&amp;gt;Topics covered in this episode:&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;

&amp;lt;ul&amp;gt;
	&amp;lt;li&amp;gt;&amp;lt;strong&amp;gt;&amp;lt;a href=&quot;https://blog.pypi.org/posts/2026-11-06-ui-updates-complete/?featured_on=pythonbytes&quot;&amp;gt;PyPI's redesigned project pages&amp;lt;/a&amp;gt; now warn when provenance changes&amp;lt;/strong&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;strong&amp;gt;&amp;lt;a href=&quot;https://mkennedy.codes/posts/catch-the-import-name-the-culprit-keep-threads-free-introducing-gilguard/?featured_on=pythonbytes&quot;&amp;gt;gilguard catches the import that turns the GIL back on&amp;lt;/a&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;strong&amp;gt;&amp;lt;a href=&quot;https://pip.pypa.io/en/stable/news/#v26-3&quot;&amp;gt;pip 26.3:&amp;lt;/a&amp;gt; pylock extras, and pip says plainly that untrusted wheels aren't safe&amp;lt;/strong&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;strong&amp;gt;&amp;lt;a href=&quot;https://heyutils.com/tools/?featured_on=pythonbytes&quot;&amp;gt;All developer tools&amp;lt;/a&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;strong&amp;gt;Extras&amp;lt;/strong&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;strong&amp;gt;Joke&amp;lt;/strong&amp;gt;&amp;lt;/li&amp;gt;

&amp;lt;/ul&amp;gt;&amp;lt;a href='https://www.youtube.com/watch?v=rPczSyhinpc' style='font-weight: bold;' data-umami-event=&quot;Livestream-Past&quot; data-umami-event-episode=&quot;504&quot;&amp;gt;Watch on YouTube&amp;lt;/a&amp;gt;&amp;lt;br&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;About the show&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Sponsored by &amp;lt;a href=&quot;https://pythonbytes.fm/longlink&quot;&amp;gt;&amp;lt;strong&amp;gt;LongLink&amp;lt;/strong&amp;gt;&amp;lt;/a&amp;gt;: Open-source platform for building and running custom business applications with Python and FastAPI
&amp;lt;strong&amp;gt;Connect with the hosts&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;Michael: &amp;lt;a href=&quot;https://fosstodon.org/@mkennedy&quot;&amp;gt;Mastodon&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://bsky.app/profile/mkennedy.codes?featured_on=pythonbytes&quot;&amp;gt;BlueSky&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://x.com/mkennedy?featured_on=pythonbytes&quot;&amp;gt;X&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://www.linkedin.com/in/mkennedy/?featured_on=pythonbytes&quot;&amp;gt;LinkedIn&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Calvin: &amp;lt;a href=&quot;https://sixfeetup.social/@calvin?featured_on=pythonbytes&quot;&amp;gt;Mastodon&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://bsky.app/profile/calvinhp.com?featured_on=pythonbytes&quot;&amp;gt;BlueSky&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://x.com/calvinhp?featured_on=pythonbytes&quot;&amp;gt;X&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://www.linkedin.com/in/calvinhp/?featured_on=pythonbytes&quot;&amp;gt;LinkedIn&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Show: &amp;lt;a href=&quot;https://fosstodon.org/@pythonbytes&quot;&amp;gt;Mastodon&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://bsky.app/profile/pythonbytes.fm&quot;&amp;gt;BlueSky&amp;lt;/a&amp;gt; / &amp;lt;a href=&quot;https://x.com/PythonBytes?featured_on=pythonbytes&quot;&amp;gt;X&amp;lt;/a&amp;gt;
Join us on YouTube at &amp;lt;a href=&quot;https://pythonbytes.fm/stream/live&quot;&amp;gt;&amp;lt;strong&amp;gt;pythonbytes.fm/live&amp;lt;/strong&amp;gt;&amp;lt;/a&amp;gt; to be part of the audience. Usually &amp;lt;strong&amp;gt;Tuesday at 7am PT&amp;lt;/strong&amp;gt;. Older video versions available there too.
Finally, if you want an artisanal, hand-crafted digest of every week of the show notes in email form? Add your name and email to &amp;lt;a href=&quot;https://pythonbytes.fm/friends-of-the-show&quot;&amp;gt;our friends of the show list&amp;lt;/a&amp;gt;, we'll never share it.&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Calvin #1: &amp;lt;a href=&quot;https://blog.pypi.org/posts/2026-11-06-ui-updates-complete/?featured_on=pythonbytes&quot;&amp;gt;PyPI's redesigned project pages&amp;lt;/a&amp;gt; now warn when provenance changes&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;Live now on &amp;lt;a href=&quot;http://pypi.org/?featured_on=pythonbytes&quot;&amp;gt;pypi.org&amp;lt;/a&amp;gt; (Nov 6). Announced in July, shaped by user research, funded by OpenSSF and built by Kabu Creative.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;New Security tab on every project page shows each release's provenance state and per-file attestations, with a link to the transparency log.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Two attestation types: PyPI Publish (uploaded by a Trusted Publisher) and SLSA Build Provenance (ties a file to its source commit, build platform and instructions).&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&quot;No provenance&quot; isn't a malware flag; it means the file's origin can't be cryptographically verified. That's typical for older releases or projects without Trusted Publishing.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Four new provenance warnings: Incomplete (e.g. wheels via Trusted Publishing but sdist via API token), Inconsistent (mixed build systems or repos), Change (different repo or workflow than last release), Loss (stopped publishing attestations).&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Warnings are meant to catch files that may not have come from the maintainers, such as after an account or publishing-workflow takeover. PyPI says a Change of Provenance &quot;can also indicate a compromised pipeline or account takeover,&quot; and an unexpected Loss of Provenance should be treated &quot;with skepticism until verified.”&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Warnings now sit at the top of the page with yanked, archived and pre-release banners. A quarantine banner overrides all of them. Sidebar metadata is labeled &quot;PyPI data&quot; or &quot;Data verified by PyPI.&quot;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Release files tab gets search, filters, a per-file provenance column and orange icons for &quot;late&quot; files (uploaded more than 14 days after release). Those only appear on releases from before July 2026, when PyPI started rejecting new files after 14 days.&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Michael #2: &amp;lt;a href=&quot;https://mkennedy.codes/posts/catch-the-import-name-the-culprit-keep-threads-free-introducing-gilguard/?featured_on=pythonbytes&quot;&amp;gt;gilguard catches the import that turns the GIL back on&amp;lt;/a&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;gilguard catches a nasty free-threading surprise: importing a C extension without declared free-threading support can turn the GIL back on for the rest of the process. Import gilguard first and it raises a detailed error naming the extension, distribution/version, import chain, and the line of your code responsible.&amp;lt;/p&amp;gt;

&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;A transitive dependency can undo your move to free-threaded Python. Fail at the offending import instead of discovering lost parallelism in a benchmark.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Uses import hooks plus CPython's warning path to catch GIL activation; GILEnabledError inherits BaseException, so even except Exception won't swallow it.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;python -m gilguard audits installed top-level packages in fresh interpreters. Supports targeted modules and JSON output; exits 1 when it finds an offender for CI.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;The audit misses extensions loaded lazily on first use. Exercise those paths in tests with gilguard installed early; one process can identify only its first GIL-flipping import.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;GILGUARD_ACTION=warn reports without stopping; off permits standard builds. Default mode raises on standard Python or an already-enabled GIL. PYTHON_GIL=0 prevents activation, so the guard cannot detect offenders there.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Published on &amp;lt;a href=&quot;https://pypi.org/project/gilguard/?featured_on=pythonbytes&quot;&amp;gt;PyPI&amp;lt;/a&amp;gt; as 0.1.0. Install with uv pip install gilguard. Python 3.13+, MIT; no runtime dependencies.&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Calvin #3: &amp;lt;a href=&quot;https://pip.pypa.io/en/stable/news/#v26-3&quot;&amp;gt;pip 26.3:&amp;lt;/a&amp;gt; pylock extras, and pip says plainly that untrusted wheels aren't safe&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;pip 26.3 released Nov 2. Its security policy now says pip &quot;has never considered installing untrusted wheels safe,&quot; because Python's import system is dynamic, so pip can't guarantee installing a wheel won't execute third-party code (#14412).&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;The change: pip blocks unexpected imports during wheel installation &quot;to avoid loading code from packages being replaced.&quot; It loads what it needs for installing and error reporting up front (PR #14247, merged Nov 2: https://github.com/pypa/pip/pull/14247).&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;CLI vs. library use: from the command line, imports stay blocked until pip exits. Code that calls pip programmatically gets warnings after control returns.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Rolled out in steps: pip 26.1 started warning (&quot;Unexpected import of '...' after pip install started. pip 26.3 will enforce this behavior change&quot;), and 26.3 turns it into an error. Tracking issue #13842 collected reports before enforcement: https://github.com/pypa/pip/issues/13842&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;What tripped it in the wild: a &amp;lt;code&amp;gt;.pth&amp;lt;/code&amp;gt; startup hook at Pradyun Gedam's workplace that registers an atexit handler to report interpreter usage metrics over HTTP (https://github.com/pypa/pip/issues/13842#issuecomment-4613463933), plus warnings involving &amp;lt;code&amp;gt;lark_oapi&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;rich._emoji_codes&amp;lt;/code&amp;gt;.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Escape-hatch debate: the author, Damian Shaw (notatallshaw), suggested appending module names to &amp;lt;code&amp;gt;pip._internal.commands.install._EAGER_IMPORTS&amp;lt;/code&amp;gt;. Pradyun objected that this requires importing from &amp;lt;code&amp;gt;pip._internal&amp;lt;/code&amp;gt;, that importing pip's install command is expensive (about 689 ms with no .pyc files and a cold filesystem cache), and that pip isn't necessarily installed in every environment. Shaw's reply: it &quot;just moves where the import cost is,&quot; and turning the warning into an error is &quot;crucial.&quot;&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Interrupted installs: pip now writes recovery metadata and reports recovery failures and where it kept backups. An interrupted uninstall may need manual recovery, and an interrupted --target install can leave a partial target directory.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Also in 26.3: &amp;lt;code&amp;gt;-r pylock.toml[extra1,extra2]&amp;lt;/code&amp;gt; for extras, &amp;lt;code&amp;gt;--group pylock.toml:docs&amp;lt;/code&amp;gt; for non-default groups, &amp;lt;code&amp;gt;--no-binary&amp;lt;/code&amp;gt; with pylock files, and pip refuses to follow an HTTP redirect to a file: URL.&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Michael #4: &amp;lt;a href=&quot;https://heyutils.com/tools/?featured_on=pythonbytes&quot;&amp;gt;All developer tools&amp;lt;/a&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;HeyUtils is a collection of 306 free developer utilities in 41 categories, with no account or installation required. The full catalog covers data conversion, APIs, networking, DevOps, frontend work and binary protocols. Python developers get model generation and packaging helpers alongside the everyday formatters and decoders worth bookmarking.&amp;lt;/p&amp;gt;

&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;One bookmark for the tiny jobs that interrupt coding: inspect a payload, compare configs, decode a token or figure out a cron schedule.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Data tools include JSON/YAML/TOML conversion, CSV joins, JSONPath and JMESPath testers, a jq playground, and a SQLite viewer with read-only queries.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Python-specific options go beyond regex: generate dataclasses or Pydantic models from JSON, check PEP 440 versions, and inspect requirements.txt or pyproject.toml dependencies.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;The collection also covers JWT/OAuth debugging, DNS and TLS checks, Docker Compose, Kubernetes YAML, GitHub Actions, and frontend helpers such as color contrast checks.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Most tools process input locally, according to the site. Network tools make requests after you start them and explain what they send; don't assume every page is offline.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Generated models need review: sample data cannot describe a complete validation contract, and dataclass type hints do not validate incoming values.&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Extras&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Calvin:&amp;lt;/p&amp;gt;

&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;a href=&quot;https://github.com/hedgerow-dev/coldframe?featured_on=pythonbytes&quot;&amp;gt;Coldframe&amp;lt;/a&amp;gt;: a stdlib-only honeypot that poses as a supermarket fridge controller**
Michael:&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;&amp;lt;a href=&quot;https://pyfound.blogspot.com/2026/10/announcing-new-security-developer-2026.html?featured_on=pythonbytes&quot;&amp;gt;Announcing new Security Developer at the PSF&amp;lt;/a&amp;gt;
&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;Brett Smith is joining the PSF as a Security Developer, a new role funded by Anthropic. He'll work with Seth Larson and Mike Fiedler on vulnerability response and PyPI malware removal, with more time for proactive work as the team's workload grows.
&amp;lt;ul&amp;gt;
&amp;lt;li&amp;gt;More paid security capacity for the Python and PyPI infrastructure we depend on every day.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;The PSF reports 3-4x the volume of reports and CVEs for Python and pip compared with a year ago.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Malware published and removed from PyPI is up 60% year over year. That's a workload measure, not a claim that 60% more packages are malicious.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Planned work includes scaling PyPI's autonomous quarantine program and streamlining how the Python Security Response Team handles reports.&amp;lt;/li&amp;gt;
&amp;lt;li&amp;gt;Smith brings software development and Linux administration experience, including work at W3C and Software Freedom Conservancy.&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Joke: &amp;lt;a href=&quot;https://x.com/PR0GRAMMERHUM0R/status/2016768305395040692?featured_on=pythonbytes&quot;&amp;gt;No papa&amp;lt;/a&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;</description>
	<pubDate>Tue, 10 Nov 2026 19:04:44 +0000</pubDate>
</item>
<item>
	<title>eGenix.com: PyDDF Python Herbst Sprint 2026</title>
	<guid>https://www.egenix.com/company/news/PyDDF-Herbst-Sprint-2026</guid>
	<link>https://www.egenix.com/company/news/PyDDF-Herbst-Sprint-2026</link>
	<description>&lt;p&gt;&lt;span&gt;The following text is in German, since we're announcing a Python sprint in Düsseldorf, Germany.&lt;/span&gt;&lt;br /&gt;
&lt;/p&gt;
&lt;h2&gt;Ankündigung&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://pyddf.de/python-meeting-dusseldorf-spring-sprint-2026/&quot;&gt;Python Meeting Herbst Sprint 2026&lt;/a&gt; in&lt;br /&gt;
&lt;a href=&quot;http://www.duesseldorf.de/&quot;&gt;Düsseldorf&lt;/a&gt;&lt;br /&gt;
&lt;/p&gt;

&lt;p&gt;Samstag, 14.11.2026, 10:00-18:00 Uhr&lt;br /&gt;
Sonntag, 15.11.2026. 10:00-18:00 Uhr&lt;/p&gt;
&lt;p align=&quot;left&quot;&gt;
&lt;em&gt;&lt;a href=&quot;https://atos.net/de/deutschland&quot; target=&quot;_blank&quot; class=&quot;link&quot; rel=&quot;nofollow ugc&quot;&gt;Atos Information Technology GmbH&lt;/a&gt;&lt;/em&gt;, Am Seestern 1, 40547 Düsseldorf&lt;/p&gt;


&lt;h2&gt;Informationen&lt;/h2&gt;
Das &lt;a href=&quot;http://pyddf.de/&quot;&gt;Python Meeting Düsseldorf (PyDDF)&lt;/a&gt; veranstaltet mit freundlicher Unterstützung von &lt;a href=&quot;https://eviden.com/de-de/&quot;&gt;Atos Deutschland &lt;/a&gt;ein Python Sprint Wochenende.&lt;br /&gt;
&lt;br /&gt;
Der Sprint findet am Wochenende 14/15.11.2026 in der Atos Niederlassung, Am Seestern 1, in Düsseldorf statt.&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://goo.gl/maps/urWcf3HxXuXxQskf7&quot;&gt;Sprint Ort in Google Maps&lt;/a&gt;&lt;br /&gt;
&lt;/li&gt;
&lt;/ul&gt;Folgende Themengebiete sind als Anregung bereits angedacht:&lt;br /&gt;
&lt;ul&gt;
  &lt;/ul&gt;
&lt;ul&gt;
  
&lt;li&gt;&lt;b&gt;Exo-dus&lt;/b&gt; – Wenn Kollegen gehen, bleibt die Rechenleistung&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Schattenparker &lt;/b&gt;– Wie meine Vibe-Coded-App aus der Shadow AI ins Licht der Compliance fuhr bzw hoffentlich bald fährt&lt;/li&gt;
&lt;li&gt;&lt;b&gt;pyinfra &lt;/b&gt;– Umstellung von Ansible auf pyinfra&lt;/li&gt;
&lt;li&gt;&lt;b&gt;remarkable &lt;/b&gt;(e-ink tablet) in andere Infrastruktur wie Obsidian / etc integrieren&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Entenhausen &lt;/b&gt;– ein S3 System auf DuckDB/DuckLake Basis&lt;br /&gt;
&lt;/li&gt;
&lt;li&gt;Weitere Themen können folgen&lt;br /&gt;
&lt;/li&gt;
&lt;/ul&gt;Natürlich können die Teilnehmenden weitere Themen vorschlagen und umsetzen.&lt;ul&gt;
&lt;/ul&gt;
&lt;h2&gt;Anmeldung, Kosten und weitere Infos&lt;br /&gt;
&lt;/h2&gt;
&lt;p&gt;Alles weitere und die Anmeldung findet Ihr auf unserer Blog Seite:&lt;/p&gt;
&lt;p&gt;
&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://pyddf.de/python-meeting-dusseldorf-herbst-sprint-2026/&quot;&gt;&lt;b&gt;Python Herbst Sprint &amp;amp; Hackathon in Düsseldorf&lt;/b&gt;&lt;/a&gt;&lt;br /&gt;
&lt;/li&gt;
&lt;/ul&gt;&lt;b&gt;WICHTIG&lt;/b&gt;: Ohne Anmeldung können wir den Gebäudezugang nicht vorbereiten. Eine spontane Anmeldung am Sprint 
Tag wird daher vermutlich nicht funktionieren.&lt;br /&gt;
&lt;br /&gt;
Teilnehmer sollten sich zudem in der PyDDF Telegram Gruppe registrieren, da wir uns dort koordinieren:&lt;br /&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;b&gt;&lt;a href=&quot;https://t.me/pyddf&quot;&gt;PyDDF Telegram Gruppe&lt;/a&gt;&lt;/b&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;ul&gt;

&lt;/ul&gt;

&lt;h2&gt;Über das Python Meeting Düsseldorf&lt;/h2&gt;


&lt;p&gt;Das &lt;a href=&quot;http://pyddf.de/&quot;&gt;Python Meeting Düsseldorf&lt;/a&gt; ist eine regelmäßige Veranstaltung in Düsseldorf, die sich an Python-Begeisterte aus der Region wendet.&lt;/p&gt;Einen guten Überblick über die Vorträge bietet unser &lt;a href=&quot;http://www.youtube.com/pyddf/&quot;&gt;PyDDF YouTube-Kanal&lt;/a&gt;, auf dem wir Videos der Vorträge nach den Meetings veröffentlichen.&lt;br /&gt;



&lt;p&gt;Veranstaltet wird das Meeting von der &lt;a href=&quot;https://www.egenix.com/&quot;&gt;eGenix.com GmbH&lt;/a&gt;, Langenfeld, in Zusammenarbeit mit &lt;a href=&quot;http://www.clark-consulting.eu/&quot;&gt;Clark Consulting &amp;amp; Research&lt;/a&gt;, Düsseldorf.&lt;br /&gt;
&lt;/p&gt;Viel Spaß !
&lt;p&gt;Marc-André Lemburg, eGenix.com&lt;/p&gt;</description>
	<pubDate>Tue, 10 Nov 2026 09:00:00 +0000</pubDate>
</item>
<item>
	<title>The Python Coding Stack: Generalist vs Specialist in the After AI Era</title>
	<guid>https://www.thepythoncodingstack.com/p/generalists-specialists-ai</guid>
	<link>https://www.thepythoncodingstack.com/p/generalists-specialists-ai</link>
	<description>&lt;p&gt;I always struggled to identify whether I was a specialist or a generalist.&lt;/p&gt;&lt;p&gt;During my studies, I narrowed my focus at every step. I studied maths, physics, and chemistry at A level, then maths and physics during my undergrad, and finally physics during my PhD. And the PhD wasn't in &lt;em&gt;all&lt;/em&gt; of physics. It was in optics, and specifically in a narrow field within optics. I then spent over a decade working in this specialised field.&lt;/p&gt;&lt;p&gt;That settles it, then.&lt;/p&gt;&lt;p&gt;But was I a true specialist? I got quite good at the branch of optics I needed for my work, but others around me were better. I got reasonably proficient with coding to model physical processes and analyse data, but I had plenty of gaps in my programming knowledge. I quickly learnt how to become an effective lecturer and communicate with students as I designed and delivered undergraduate courses. Being an expert in a subject doesn't automatically make you good at teaching it.&lt;/p&gt;&lt;p&gt;When I left academia and started teaching Python, I wasn't an expert programmer and I was relatively new to Python. Sure, I got a lot better relatively quickly. But my strength was in teaching and communication, not in my programming expertise.&lt;/p&gt;&lt;p&gt;To run my business, I had to know or learn a bit of lots of stuff: accounting, marketing, sales, customer service, and managing staff. But I lacked a lot in all of these areas, too.&lt;/p&gt;&lt;p&gt;I'm a scientist by training and a technical educator by profession. But I enjoy languages, history, geography, sports, economics. The list goes on. I like to know stuff about any topic.&lt;/p&gt;&lt;p&gt;Perhaps I am a generalist and I've always been, even though I thought I was a specialist. Maybe it's time to embrace my generalist nature.&lt;/p&gt;&lt;p&gt;In the last few months, I've found a good reason to do just that. AI is helping me do more with knowledge and skills I already had, in several different fields. Things I knew something about, but not enough to carry through on my own, are becoming things I can actually do.&lt;/p&gt;&lt;p&gt;And I'm loving it.&lt;/p&gt;&lt;p class=&quot;button-wrapper&quot;&gt;&lt;a class=&quot;button primary&quot; href=&quot;https://www.thepythoncodingstack.com/subscribe&quot;&gt;&lt;span&gt;Subscribe now&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;h2&gt;Generalist vs Specialist in the Before AI Era&lt;/h2&gt;&lt;p&gt;The debate is not new. We need people who know a lot about a narrow field, and we need people who know enough about several fields to connect them and put that knowledge to use.&lt;/p&gt;&lt;p&gt;Knowledge in fields such as science and medicine has expanded so much that it's impossible to master a field without dedicating large amounts of time and effort to it. There's good reason for specialisation.&lt;/p&gt;&lt;p&gt;But depth takes time. And time spent going deeper into one field is time you can't spend learning about another.&lt;/p&gt;&lt;p&gt;That's not a criticism. It's a constraint.&lt;/p&gt;&lt;p&gt;A generalist makes a different trade-off. They have a broader base, but they run into the limits of their knowledge in more places. Knowing something about marketing isn't the same as being able to put together an effective marketing campaign. Understanding programming doesn't mean you're equally comfortable building every kind of software.&lt;/p&gt;&lt;p&gt;You can learn more, of course. Or work with someone who has the expertise you lack. But both require resources, whether that's time, money, or access to the right people.&lt;/p&gt;&lt;p&gt;So you can have interests, ideas, and useful knowledge that you never quite manage to turn into anything.&lt;/p&gt;&lt;p&gt;AI is changing that.&lt;/p&gt;&lt;h2&gt;Generalists and Specialists During the Transition&lt;/h2&gt;&lt;p&gt;To get from Before AI to After AI, we need to go through a transition. A period when things change. And boy oh boy we're in turbulent, changing, uncertain, unstable times &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;&lt;p&gt;When the situation changes and what worked yesterday no longer works today, it helps to have more than one way forward. A generalist has a large toolbox and more possibilities for using the tools differently. They're typically also more used to making connections across different fields.&lt;/p&gt;&lt;p&gt;A specialist often sees things through the lens of their speciality, and their skillset is optimised to go deeper and further within their speciality. Change can be challenging.&lt;/p&gt;&lt;p&gt;That doesn't mean specialists can't adapt. And being interested in lots of things doesn't automatically make you good at dealing with change.&lt;/p&gt;&lt;p&gt;But having several useful starting points gives you options. AI makes some of those options easier to act on.&lt;/p&gt;&lt;p&gt;That's the part I find most exciting. Not just having somewhere else to turn when one kind of work changes, but being able to do more in areas I already care about.&lt;/p&gt;&lt;h2&gt;Generalist vs Specialist in the After AI Era&lt;/h2&gt;&lt;p&gt;Before we go further, let's tear down a simplification I've been using in this post so far.&lt;/p&gt;&lt;p&gt;No one is really just a specialist or a generalist.&lt;/p&gt;&lt;p&gt;A pure specialist could be represented by this:&lt;/p&gt;&lt;div class=&quot;captioned-image-container&quot;&gt;&lt;a class=&quot;image-link image2 is-viewable-img&quot; target=&quot;_blank&quot; href=&quot;https://substackcdn.com/image/fetch/$s_!gwyJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdebaebff-30de-49d2-b141-b88865888a7e_1800x900.png&quot;&gt;&lt;div class=&quot;image2-inset&quot;&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!gwyJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdebaebff-30de-49d2-b141-b88865888a7e_1800x900.png&quot; width=&quot;1456&quot; height=&quot;728&quot; src=&quot;src&quot; /&gt;&lt;div class=&quot;image-link-expand&quot;&gt;&lt;div class=&quot;pencraft pc-display-flex pc-gap-8 pc-reset&quot;&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;Don't worry about the maths-y bits there if you're not familiar with delta functions. An &quot;ideal&quot; specialist, or a 100% specialist, has expertise in one narrow topic and no knowledge of anything else. A generalist could look like this:&lt;/p&gt;&lt;div class=&quot;captioned-image-container&quot;&gt;&lt;a class=&quot;image-link image2 is-viewable-img&quot; target=&quot;_blank&quot; href=&quot;https://substackcdn.com/image/fetch/$s_!1B4I!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff2ae9bbf-01c7-4cf7-866d-bf7ab36f2bbd_1800x900.png&quot;&gt;&lt;div class=&quot;image2-inset&quot;&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!1B4I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff2ae9bbf-01c7-4cf7-866d-bf7ab36f2bbd_1800x900.png&quot; width=&quot;1456&quot; height=&quot;728&quot; src=&quot;src&quot; /&gt;&lt;div class=&quot;image-link-expand&quot;&gt;&lt;div class=&quot;pencraft pc-display-flex pc-gap-8 pc-reset&quot;&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;The 100% generalist has a constant amount of knowledge about everything.&lt;/p&gt;&lt;p&gt;You can tell I'm a physicist by nature. Physicists love to start from idealised assumptions. You've probably heard the &amp;#8220;let's assume we have a spherical cow&amp;#8221; joke.&lt;/p&gt;&lt;p&gt;A person who knows everything about one narrow topic and absolutely nothing about anything else would be a useful starting point for a model. But not a person you'll ever meet.&lt;/p&gt;&lt;p&gt;And a generalist doesn't know exactly the same amount about every topic, either.&lt;/p&gt;&lt;p&gt;There aren't any 100% specialists or 100% generalists.&lt;/p&gt;&lt;p&gt;Here's a typical sketch comparing specialists and generalists. The bars represent different skill areas, and their heights represent depth of expertise:&lt;/p&gt;&lt;div class=&quot;captioned-image-container&quot;&gt;&lt;a class=&quot;image-link image2 is-viewable-img&quot; target=&quot;_blank&quot; href=&quot;https://substackcdn.com/image/fetch/$s_!UM7n!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b61ae13-8f81-4560-9ae6-261212c85c49_1800x900.png&quot;&gt;&lt;div class=&quot;image2-inset&quot;&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!UM7n!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b61ae13-8f81-4560-9ae6-261212c85c49_1800x900.png&quot; width=&quot;1456&quot; height=&quot;728&quot; src=&quot;src&quot; /&gt;&lt;div class=&quot;image-link-expand&quot;&gt;&lt;div class=&quot;pencraft pc-display-flex pc-gap-8 pc-reset&quot;&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;But there's an infinite number of variants. Some people have two or more high bars among the low ones. Others have a speciality but a good generalist level across many other skills. And not all generalists are the same. Some can have high bars across the board, even if not a single bar is at specialist level.&lt;/p&gt;&lt;p&gt;You don't have to choose between depth and breadth as though you can only have one. You can have both, in different proportions and in different fields.&lt;/p&gt;&lt;p&gt;So, rather than asking which of two boxes a person belongs in, we can look at the shape of their knowledge and skills. The labels are shorthand. The profile is more interesting.&lt;/p&gt;&lt;p&gt;AI helps fill gaps where you're not an expert. But I think the best use of AI is when a person and an AI agent collaborate (and I'm writing about this in a future post, so stay tuned), rather than when the agent just gets an instruction and works without input from the human.&lt;/p&gt;&lt;p&gt;If you know something about a topic, you can bring more to that collaboration. You have a better idea of what you want, you can ask more useful questions, and you have some basis for questioning the answers.&lt;/p&gt;&lt;p&gt;Let me give you a personal example that has nothing to do with programming.&lt;/p&gt;&lt;h3&gt;From coaching theory to a training programme&lt;/h3&gt;&lt;p&gt;I'm a newly qualified athletics (track and field) coach. I have plenty of theoretical knowledge, because I've been interested in this field for a long time, but limited coaching experience.&lt;/p&gt;&lt;p&gt;I worked with my AI agent to create a full training programme for myself. I didn't ask it to choose everything for me. I knew the philosophy I wanted to follow: build speed first, with a focus on sprinting form (running technique), then extend to speed endurance.&lt;/p&gt;&lt;p&gt;The focus on form mattered to me. I wanted to prioritise maintaining it, even if that meant delaying speed endurance. I also had views on how to organise the training programme across various phases.&lt;/p&gt;&lt;p&gt;AI helped me translate that direction into individual sessions, including the details of how the different exercises should fit together. It helped me plan the progression across the season and balance the different training demands.&lt;/p&gt;&lt;p&gt;I brought knowledge and priorities. AI helped me work through the detail needed to turn them into a programme.&lt;/p&gt;&lt;p&gt;Does that make me an experienced specialist coach? No. Creating a programme isn't the same as gaining years of experience coaching athletes, observing how they respond, and adjusting what you do.&lt;/p&gt;&lt;p&gt;But I could put more of what I knew into practice than I could have done alone. And I learnt more about the details of season and session planning along the way.&lt;/p&gt;&lt;p&gt;That's the opportunity I'm talking about.&lt;/p&gt;&lt;h3&gt;More than one place to start&lt;/h3&gt;&lt;p&gt;The same opportunity exists in other fields where I have some knowledge but lack the skills or experience to do everything I'd like to do. But it's not limited just to those fields. AI also helps me take some of my strongest skills further.&lt;/p&gt;&lt;p&gt;In some areas, it helps me bridge gaps in expertise. In others, it helps me carry through ideas I wouldn't otherwise have the time or resources to pursue.&lt;/p&gt;&lt;p&gt;I'll share more about some of the work my AI agent and I have been doing recently, spanning many of my strong and not-so-strong skills, in the bonus section at the end of this post.&lt;/p&gt;&lt;p&gt;Sometimes, several of those skills come together in one project. But they don't have to. My coaching knowledge is useful when I'm planning athletics training, whether or not I use my programming or writing skills alongside it.&lt;/p&gt;&lt;p&gt;A broad base gives me several places where I can work with AI to take things further. Here's a sketch of what that could look like:&lt;/p&gt;&lt;div class=&quot;captioned-image-container&quot;&gt;&lt;a class=&quot;image-link image2 is-viewable-img&quot; target=&quot;_blank&quot; href=&quot;https://substackcdn.com/image/fetch/$s_!Tng-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03b42937-60be-460e-8f6a-067966061c97_1800x900.png&quot;&gt;&lt;div class=&quot;image2-inset&quot;&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!Tng-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F03b42937-60be-460e-8f6a-067966061c97_1800x900.png&quot; width=&quot;1456&quot; height=&quot;728&quot; src=&quot;src&quot; /&gt;&lt;div class=&quot;image-link-expand&quot;&gt;&lt;div class=&quot;pencraft pc-display-flex pc-gap-8 pc-reset&quot;&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;The lighter parts represent what working with AI might add to what a person can do. They're not extra years of experience magically appearing.&lt;/p&gt;&lt;p&gt;Nor should we read the drawing as a promise that AI adds the same amount in every field or for every person. It's a simple illustration of the opportunity. The specialist can benefit, too. The generalist has several areas with a useful foundation to build on.&lt;/p&gt;&lt;p&gt;And the starting point matters. Knowing nothing about a subject doesn't stop you from using AI. It can help you learn. But that's different from using knowledge you already have to direct the work and question what comes back.&lt;/p&gt;&lt;p&gt;Nor does knowing a little guarantee you'll spot every mistake. AI can be wrong, and so can I. The need to check the work doesn't disappear because we produced something impressive together.&lt;/p&gt;&lt;p&gt;My first post in the &lt;em&gt;&lt;a href=&quot;https://www.thepythoncodingstack.com/s/agent-and-co&quot;&gt;Agent &amp;amp; Co.&lt;/a&gt;&lt;/em&gt; section pokes fun at this idea:&lt;/p&gt;&lt;div class=&quot;captioned-image-container&quot;&gt;&lt;a class=&quot;image-link image2 is-viewable-img&quot; target=&quot;_blank&quot; href=&quot;https://substackcdn.com/image/fetch/$s_!ih5e!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb542974f-61ca-4aeb-8eb5-2174cd6156e1_1254x1254.png&quot;&gt;&lt;div class=&quot;image2-inset&quot;&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!ih5e!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb542974f-61ca-4aeb-8eb5-2174cd6156e1_1254x1254.png&quot; width=&quot;1254&quot; height=&quot;1254&quot; src=&quot;src&quot; /&gt;&lt;div class=&quot;image-link-expand&quot;&gt;&lt;div class=&quot;pencraft pc-display-flex pc-gap-8 pc-reset&quot;&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;button tabindex=&quot;0&quot; type=&quot;button&quot; class=&quot;pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M&quot;&gt;&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://www.thepythoncodingstack.com/p/thoroughly-checked&quot;&gt;Read &amp;#8220;Thoroughly Checked&amp;#8221;&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;&lt;p&gt;Still, the gap between &amp;#8220;I know something about this&amp;#8221; and &amp;#8220;I can do something useful with it&amp;#8221; is becoming easier to cross.&lt;/p&gt;&lt;p class=&quot;button-wrapper&quot;&gt;&lt;a class=&quot;button primary&quot; href=&quot;https://www.thepythoncodingstack.com/subscribe&quot;&gt;&lt;span&gt;Subscribe now&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;h2&gt;So, Is This the Age of the Generalist?&lt;/h2&gt;&lt;p&gt;AAI, After AI, is the Age of the Generalist. I think so. But not because specialists are doomed.&lt;/p&gt;&lt;p&gt;Specialists can use AI to go further in their own fields, and to explore others. Deep knowledge still matters. My years in physics didn't become worthless when I started teaching Python, and they haven't become worthless now that I work with AI. They help shape how I think and how I approach problem-solving.&lt;/p&gt;&lt;p&gt;What excites me is that breadth can now take me further, too.&lt;/p&gt;&lt;p&gt;Before, I could look at some of my interests and see mostly what I lacked. I knew a bit, but there was a lot more I'd need to learn before I could do what I wanted.&lt;/p&gt;&lt;p&gt;There still is plenty to learn. But I don't always have to fill every gap myself before I can get started.&lt;/p&gt;&lt;p&gt;A generalist with strong skills across several fields has several opportunities to do more with those skills. In some tasks, working with AI may bring the result closer to what previously required specialist help. That doesn't mean the person has become a specialist in all those fields.&lt;/p&gt;&lt;p&gt;But it does change what's possible.&lt;/p&gt;&lt;p&gt;And AI can also help the generalist with bespoke learning to push those skills further.&lt;/p&gt;&lt;p&gt;Perhaps being interested in lots of things wasn't such a distraction after all.&lt;/p&gt;&lt;h2&gt;Bonus: How I'm Enjoying Being a Generalist in This AAI Era&lt;/h2&gt;&lt;p&gt;I always liked the idea of being a specialist. That's probably what led me to scientific research in my first career. But over the years, I gradually realised I like lots of things and I'm not too bad at several of them.&lt;/p&gt;&lt;p&gt;I didn't realise until recently that I'm more of a generalist than a specialist.&lt;/p&gt;&lt;p&gt;That's a shame, because I could have capitalised more on my strengths as a generalist, perhaps. But never mind, because in the last few months, I got it.&lt;/p&gt;&lt;p&gt;Here are a few places where I've been putting that breadth to use. Not just what I asked AI to do, but what I brought to the work and how we worked through it together.&lt;/p&gt;&lt;p&gt;These aren't examples of handing over a task and coming back when it's finished. The conversation is part of the work.&lt;/p&gt;&lt;p&gt;&lt;em&gt;The bonus section is exclusively available to premium subscribers.&lt;/em&gt;&lt;/p&gt;&lt;div class=&quot;subscription-widget-wrap-editor&quot;&gt;&lt;div class=&quot;subscription-widget show-subscribe&quot;&gt;&lt;div class=&quot;preamble&quot;&gt;&lt;p class=&quot;cta-caption&quot;&gt;This post has bonus content for paid subscribers. Upgrade to get full access.&lt;/p&gt;&lt;/div&gt;&lt;form class=&quot;subscription-widget-subscribe&quot;&gt;&lt;input type=&quot;email&quot; class=&quot;email-input&quot; name=&quot;email&quot; tabindex=&quot;-1&quot; /&gt;&lt;input type=&quot;submit&quot; class=&quot;button primary&quot; value=&quot;Upgrade&quot; /&gt;&lt;div class=&quot;fake-input-wrapper&quot;&gt;&lt;div class=&quot;fake-input&quot;&gt;&lt;/div&gt;&lt;div class=&quot;fake-button&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/form&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;em&gt;&lt;a href=&quot;http://stephengruppetta.com&quot;&gt;stephengruppetta.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</description>
	<pubDate>Tue, 10 Nov 2026 08:05:55 +0000</pubDate>
</item>
<item>
	<title>LernerPython blog, from Reuven Lerner: Pandas options: Setting display precision with pd.set_option</title>
	<guid>https://lernerpython.com/2026/11/10/pandas-options-setting-display-precision-with-pd-set-option/</guid>
	<link>https://lernerpython.com/2026/11/10/pandas-options-setting-display-precision-with-pd-set-option/</link>
	<description>&lt;img src=&quot;https://lernerpython.com/wp-content/uploads/2026/11/2026-11-10.png&quot; alt=&quot;Pandas options: Setting display precision with pd.set_option&quot; class=&quot;wp-image-18818&quot; width=&quot;1892&quot; height=&quot;1242&quot; /&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;There are tons of config options in Python Pandas. Set them with pd.set_option:&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;pd.set_option('display.precision', 2)&lt;/code&gt;&lt;/pre&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Now floats in a series or data frame will have 2 digits after the decimal.&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Retrieve the value with &amp;#8220;get_option&amp;#8221;:&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;pd.get_option('display.precision')&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The post &lt;a href=&quot;https://lernerpython.com/2026/11/10/pandas-options-setting-display-precision-with-pd-set-option/&quot;&gt;Pandas options: Setting display precision with pd.set_option&lt;/a&gt; appeared first on &lt;a href=&quot;https://lernerpython.com&quot;&gt;LernerPython&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Tue, 10 Nov 2026 07:00:00 +0000</pubDate>
</item>
<item>
	<title>Python Insider: Python 3.16.0 alpha 2 is now available</title>
	<guid>https://blog.python.org/2026/11/python-3160-alpha-2-is-now-available/</guid>
	<link>https://blog.python.org/2026/11/python-3160-alpha-2-is-now-available/</link>
	<description>The second developer preview of Python 3.16 is ready for testing.</description>
	<pubDate>Tue, 10 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: Muru: HTTPS in development</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/muru-https-in-development/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/muru-https-in-development/</link>
	<description>&lt;p&gt;Everything I have shown of Muru so far has been served over plain HTTP on port 8080. That is how almost everyone runs a Python web application while developing it, and most of the time it is fine. The application in production is going to be behind HTTPS though, and there are some things which behave differently when it is, which you only find out about once it is deployed.&lt;/p&gt;
&lt;p&gt;The usual way of testing those things locally is to put a proxy such as nginx or Caddy in front of the development server, generate a certificate for it, and then configure the application to trust the headers the proxy adds to say the original request was HTTPS. It is enough of a hassle that most people never bother. With a Muru development run it is one option.&lt;/p&gt;
&lt;h2&gt;Turning it on&lt;/h2&gt;
&lt;p&gt;Given a WSGI application in &lt;code&gt;shop/wsgi.py&lt;/code&gt;, add &lt;code&gt;--https&lt;/code&gt; when starting it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl run shop.wsgi:application --https
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru.host[61235]: preloaded shop.wsgi:application in 1 ms
muru.host[61235]: shop.wsgi:application is served as WSGI
muru.host[61235]: worker 0 started as pid 61241
muru.host[61235]: worker 0 (pid 61241) is ready
muru: serving shop.wsgi:application on http://127.0.0.1:8080 and https://127.0.0.1:8443 with 1 worker of 4 threads
...
muru: certificate for localhost, 127.0.0.1, ::1 issued by the mkcert authority in /Users/graham/Library/Application Support/mkcert (created, /Users/graham/Library/Caches/muru/certs/dev.pem)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The run now listens for HTTPS on port 8443 as well as HTTP on 8080, and it has created a certificate to use.&lt;/p&gt;
&lt;p&gt;How that certificate was made depends on what you have installed. If you use &lt;a href=&quot;https://github.com/FiloSottile/mkcert&quot;&gt;mkcert&lt;/a&gt; and have run &lt;code&gt;mkcert -install&lt;/code&gt; at some point, there is a local certificate authority on your machine which your browsers already trust. Muru finds it and issues the certificate from it, so the browser accepts the certificate without any warnings. If mkcert isn't installed, Muru falls back to creating a self-signed certificate, and prints out how to trust it, or how to install mkcert instead.&lt;/p&gt;
&lt;p&gt;Either way, the certificate is kept in your user cache directory rather than in the project, so there is no risk of a private key ending up committed. It is reused on later runs, which the same line says by showing &lt;code&gt;reused&lt;/code&gt; rather than &lt;code&gt;created&lt;/code&gt;, and is only replaced when it nears expiry or is asked to cover a name it doesn't already. If you already have a certificate of your own you would prefer to use, &lt;code&gt;--tls-cert&lt;/code&gt; and &lt;code&gt;--tls-key&lt;/code&gt; will take that instead.&lt;/p&gt;
&lt;h2&gt;Why bother&lt;/h2&gt;
&lt;p&gt;To see what difference HTTPS makes to the application, here is a WSGI application which reports back the URL scheme and protocol of the request, and the host it was sent to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def application(environ, start_response):
    body = f&amp;quot;{environ['wsgi.url_scheme']} {environ['SERVER_PROTOCOL']} {environ['HTTP_HOST']}\n&amp;quot;.encode()
    start_response(&amp;quot;200 OK&amp;quot;, [(&amp;quot;Content-Type&amp;quot;, &amp;quot;text/plain&amp;quot;)])
    return [body]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Making a request to each of the two ports:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl http://127.0.0.1:8080/
curl https://127.0.0.1:8443/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the result being:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http HTTP/1.1 127.0.0.1:8080
https HTTP/2.0 127.0.0.1:8443
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Over HTTPS, the application sees &lt;code&gt;wsgi.url_scheme&lt;/code&gt; set to &lt;code&gt;https&lt;/code&gt;, and the request arrived as HTTP/2. Neither of these can be tested over plain HTTP. Browsers only use HTTP/2 over TLS, so if you want to know how your application behaves when a page pulls in dozens of assets over the one connection, this is the only way to find out locally. Any code which uses &lt;code&gt;wsgi.url_scheme&lt;/code&gt; to construct absolute URLs, for redirects or links in emails, will get them wrong unless the request really did arrive over HTTPS. And cookies marked as &lt;code&gt;Secure&lt;/code&gt;, which any session cookie in production should be, aren't sent back by the browser over plain HTTP at all. An application which sets &lt;code&gt;SESSION_COOKIE_SECURE&lt;/code&gt; in its production settings can behave quite differently to the one you have been developing against.&lt;/p&gt;
&lt;p&gt;What makes this simple to provide is how Muru is put together, which I went through in &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-how-it-fits-together/&quot;&gt;how it fits together&lt;/a&gt;. TLS and HTTP/2 are handled by the edge, which is written in Rust and runs no Python code. By the time a request reaches the worker it has already been decrypted and parsed, and the application gets an ordinary WSGI &lt;code&gt;environ&lt;/code&gt; with the scheme already set correctly. There is no proxy to configure, and no need to tell the application to trust an &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; header. It is also the same code which terminates TLS when running under the daemon in production, rather than something which only exists for development.&lt;/p&gt;
&lt;h2&gt;Names other than localhost&lt;/h2&gt;
&lt;p&gt;Using &lt;code&gt;localhost&lt;/code&gt; for everything gets awkward once you work on more than one project, since cookies are tied to the host name and not the port. Log in to one project on &lt;code&gt;localhost&lt;/code&gt; and its session cookie gets sent to every other project you run on &lt;code&gt;localhost&lt;/code&gt; too.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--host&lt;/code&gt; option adds names to the certificate:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl run shop.wsgi:application --https --host shop.localhost
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: certificate for localhost, 127.0.0.1, ::1, shop.localhost issued by the mkcert authority in /Users/graham/Library/Application Support/mkcert (created, /Users/graham/Library/Caches/muru/certs/dev.pem)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Modern browsers such as Chrome and Firefox, as well as &lt;code&gt;curl&lt;/code&gt;, resolve any name ending in &lt;code&gt;.localhost&lt;/code&gt; to the loopback address, so there is no need to edit &lt;code&gt;/etc/hosts&lt;/code&gt; to use them. The project can then be reached at &lt;code&gt;https://shop.localhost:8443/&lt;/code&gt;, with its cookies kept apart from those of any other project, and with a certificate the browser trusts for that name.&lt;/p&gt;
&lt;h2&gt;HTTPS only&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;--https&lt;/code&gt;, both HTTP and HTTPS are served, which can let a problem slip by if some page or redirect is quietly sending you back to plain HTTP. If you want to be sure everything is going over HTTPS, as it will be in production, use &lt;code&gt;--https-only&lt;/code&gt; instead:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl run shop.wsgi:application --https-only --host shop.localhost
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: serving shop.wsgi:application on https://127.0.0.1:8443 (http://127.0.0.1:8080 redirects to it) with 1 worker of 4 threads
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Port 8080 is still listened on, but now answers every request with a redirect to the same path on the HTTPS port:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -i 'http://shop.localhost:8080/cart?item=42'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 307 Temporary Redirect
location: https://shop.localhost:8443/cart?item=42
server: muru
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The redirect is a 307 rather than a 301 for two reasons. A 307 keeps the method, so a form posted to the HTTP port is posted again to the HTTPS port rather than turning into a &lt;code&gt;GET&lt;/code&gt;. And browsers don't cache a 307, so if you later go back to running without HTTPS on the same port, the browser won't keep sending you off to a port where nothing is listening any more.&lt;/p&gt;
&lt;p&gt;So you don't have to remember to type it each time, the same can be turned on for the project in &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru.run]
https_only = true
hosts = [&amp;quot;shop.localhost&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These settings only apply to &lt;code&gt;muructl run&lt;/code&gt;. They have no effect on how the application is served by the daemon in production, where certificates are a different matter altogether.&lt;/p&gt;
&lt;h2&gt;What's next&lt;/h2&gt;
&lt;p&gt;None of this is especially complicated, but it is the sort of thing which only gets tested if it is easy, and getting it for free is a consequence of the edge doing all the work of facing clients. Next up I will look at what else Muru lets a single application do, which is to mix WSGI and ASGI, including handing a request from one to the other so an existing WSGI application can take on websockets.&lt;/p&gt;</description>
	<pubDate>Tue, 10 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>LernerPython blog, from Reuven Lerner: Pandas 3.1 CSV loading: Multi-threaded, but PyArrow is still faster</title>
	<guid>https://lernerpython.com/2026/11/09/pandas-3-1-csv-loading-multi-threaded-but-pyarrow-is-still-faster/</guid>
	<link>https://lernerpython.com/2026/11/09/pandas-3-1-csv-loading-multi-threaded-but-pyarrow-is-still-faster/</link>
	<description>&lt;img src=&quot;https://lernerpython.com/wp-content/uploads/2026/11/2026-11-09.png&quot; alt=&quot;Pandas 3.1 CSV loading: Multi-threaded, but PyArrow is still faster&quot; class=&quot;wp-image-18809&quot; width=&quot;1356&quot; height=&quot;438&quot; /&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Reading a CSV into a Python Pandas data frame?&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Before version 3.1, loading CSV was single-threaded and slow. My test file took 1.03s.&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;In 3.1, it&amp;#8217;s now multi-threaded and much faster, taking 252 ms.&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;However, it&amp;#8217;s still far slower than PyArrow, which took 25.5 ms.&lt;/p&gt;
&lt;p&gt;The post &lt;a href=&quot;https://lernerpython.com/2026/11/09/pandas-3-1-csv-loading-multi-threaded-but-pyarrow-is-still-faster/&quot;&gt;Pandas 3.1 CSV loading: Multi-threaded, but PyArrow is still faster&lt;/a&gt; appeared first on &lt;a href=&quot;https://lernerpython.com&quot;&gt;LernerPython&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Mon, 09 Nov 2026 07:00:00 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: Muru: import once, fork many</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/muru-import-once-fork-many/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/muru-import-once-fork-many/</link>
	<description>&lt;p&gt;Every time Muru has started an application in this series, starting with &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/a-first-look-at-muru/&quot;&gt;a first look at Muru&lt;/a&gt;, one of the first lines of output has been something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[88644]: preloaded app:application in 1 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-how-it-fits-together/&quot;&gt;how it fits together&lt;/a&gt; I described the host as the Python process which imports the application once and forks the workers from it, but skipped over what that involves. It is time to come back to it, because how an application gets loaded into worker processes is one of those things that matters a lot for Python, and is easy to get wrong.&lt;/p&gt;
&lt;h2&gt;Why import once&lt;/h2&gt;
&lt;p&gt;To see what is going on, here is an application which records the process ID at the point the module was imported, and then reports that along with the process ID handling each request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os

IMPORTED_IN = os.getpid()

def application(environ, start_response):
    body = f&amp;quot;imported in {IMPORTED_IN}, serving from {os.getpid()}\n&amp;quot;.encode()
    start_response(&amp;quot;200 OK&amp;quot;, [(&amp;quot;Content-Type&amp;quot;, &amp;quot;text/plain&amp;quot;), (&amp;quot;Content-Length&amp;quot;, str(len(body)))])
    return [body]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running it with two worker processes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl run app:application --processes 2
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru.host[88644]: preloaded app:application in 1 ms
muru.host[88644]: app:application is served as WSGI
muru.host[88644]: worker 0 started as pid 88645
muru.host[88644]: worker 1 started as pid 88646
muru.host[88644]: worker 0 (pid 88645) is ready
muru.host[88644]: worker 1 (pid 88646) is ready
muru: serving app:application on http://127.0.0.1:8080 with 2 workers of 4 threads
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and making a few requests, the result being:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;imported in 88644, serving from 88646
imported in 88644, serving from 88645
imported in 88644, serving from 88646
imported in 88644, serving from 88645
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application was imported once, in the host process, and both workers were then forked from the host. Neither worker ever imported the application itself. Each started out as a copy of the host with every module already loaded.&lt;/p&gt;
&lt;p&gt;For a toy application this makes no difference. For a large Django application, where importing everything can take several seconds and a lot of memory, it makes a big difference. Forked workers share the memory pages holding those modules with the host, and with each other, until something writes to them. Starting a new worker, whether to replace one that crashed, to recycle one that has grown too large, or to add capacity, costs a fork rather than a full import. Muru also takes care to freeze the garbage collector's view of the preloaded objects before forking, so that collections in the workers don't go touching, and therefore copying, all those shared pages.&lt;/p&gt;
&lt;p&gt;This was one of the things I always wanted for mod_wsgi daemon mode but couldn't have. The daemon processes in mod_wsgi are forked by the Apache parent process, and that isn't a place where it is safe to load application code. Each daemon process therefore has to import the application itself, and restarting one means paying for that import again.&lt;/p&gt;
&lt;h2&gt;What doesn't survive a fork&lt;/h2&gt;
&lt;p&gt;Preloading isn't a new idea, and other Python WSGI servers have had an option for it for a long time. The reason it is usually an option, rather than the default, is that it can break applications in subtle ways.&lt;/p&gt;
&lt;p&gt;Forking copies a process's memory, but some things live outside of memory. A socket is a kernel object, so after a fork both the parent and child hold the same connection, each thinking it is theirs alone. A thread exists only in the process which started it, so a background thread started during import simply doesn't exist in the workers, even though all its data structures do. A child process has one parent, so the workers can't manage a helper process the host launched.&lt;/p&gt;
&lt;p&gt;So if importing the application opens a database connection, starts a thread to flush metrics, or launches a helper process, preloading produces workers that are quietly broken. Worse, these problems tend to be intermittent, and show up in production under load rather than on the first request you make in development.&lt;/p&gt;
&lt;h2&gt;The import check&lt;/h2&gt;
&lt;p&gt;What Muru does differently is that it doesn't just trust you to know whether your application is safe to preload. When the host imports the application, it records any sockets, pipes, threads and child processes that were left behind by the import.&lt;/p&gt;
&lt;p&gt;To show this, here is a module standing in for a database client which connects when it is imported, as some do:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import socket

# Pretend this is a database client connecting when the module is imported.
connection = socket.create_connection((&amp;quot;127.0.0.1&amp;quot;, 9340))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The WSGI application in &lt;code&gt;shop/wsgi.py&lt;/code&gt; imports it with &lt;code&gt;from shop import db&lt;/code&gt;. Running it gives:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[88997]: preloaded shop.wsgi:application in 2 ms
muru.host[88997]: shop.wsgi:application is served as WSGI
muru.host[88997]: preload: the import of shop.db left a connected socket (fd 3, to 127.0.0.1:9340); close it in a pre_fork hook and reopen it in post_fork, or open it lazily on first use
muru.host[88997]: preload: falling back to per-worker imports, which costs slower worker replacement and no shared memory; set preload to false to silence this
muru.host[88997]: worker 0 started as pid 89031
muru.host[88997]: worker 1 started as pid 89032
muru.worker[0]: worker 0 imported shop.wsgi:application in 2 ms
muru.worker[0]: worker 0 serves shop.wsgi:application as WSGI
muru.host[88997]: worker 0 (pid 89031) is ready
muru.worker[1]: worker 1 imported shop.wsgi:application in 2 ms
muru.worker[1]: worker 1 serves shop.wsgi:application as WSGI
muru.host[88997]: worker 1 (pid 89032) is ready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Muru found the connected socket, said where it goes, and decided not to fork from the host. Instead it fell back to having each worker import the application itself, which is always safe. The application still works, it just loses the benefits of preloading, and the message tells you what you would need to change to get them back.&lt;/p&gt;
&lt;p&gt;Threads are caught the same way. Here is a module which starts a background thread when imported, to periodically report how many requests have been handled:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import threading
import time

counts = {&amp;quot;requests&amp;quot;: 0}

def flush():
    while True:
        time.sleep(5)
        print(f&amp;quot;flushed {counts['requests']} requests&amp;quot;, flush=True)

threading.Thread(target=flush, daemon=True).start()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under a naive preload this thread would run in the host process, which never handles a request, and so would forever report zero. The workers doing the actual work would have no thread at all. Muru spots it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[89680]: preload: the import of stats left a thread running; start it from a post_fork hook instead
muru.host[89680]: preload: falling back to per-worker imports, which costs slower worker replacement and no shared memory; set preload to false to silence this
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Doing things at the right time&lt;/h2&gt;
&lt;p&gt;Most things which are wrong to do at import time are perfectly fine to do a moment later, once the worker exists. For that Muru supports a hooks module, named with the &lt;code&gt;--hooks&lt;/code&gt; option or with &lt;code&gt;hooks&lt;/code&gt; in the &lt;code&gt;[tool.muru]&lt;/code&gt; table of &lt;code&gt;pyproject.toml&lt;/code&gt;. The module can define functions to be called at different points in the life of the host and its workers.&lt;/p&gt;
&lt;p&gt;The one you will need most is &lt;code&gt;post_fork&lt;/code&gt;, which is called in each worker immediately after it is forked. For the metrics thread, the module is changed to provide a function to start the thread rather than starting it on import:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def start():
    threading.Thread(target=flush, daemon=True).start()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and a &lt;code&gt;hooks.py&lt;/code&gt; calls it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import stats

def post_fork(context):
    print(f&amp;quot;worker {context.worker_id} starting the stats thread&amp;quot;, flush=True)
    stats.start()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each hook is passed a context object which says where it is running, including the index of the worker. Running with the hooks module and two processes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl run app:application --hooks hooks --processes 2
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru.host[90139]: preloaded app:application in 1 ms
muru.host[90139]: app:application is served as WSGI
muru.host[90139]: worker 0 started as pid 90140
muru.host[90139]: worker 1 started as pid 90141
muru.worker[0].stdout: worker 0 starting the stats thread
muru.host[90139]: worker 0 (pid 90140) is ready
muru.worker[1].stdout: worker 1 starting the stats thread
muru.host[90139]: worker 1 (pid 90141) is ready
...
muru.worker[0].stdout: flushed 3 requests
muru.worker[1].stdout: flushed 3 requests
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The import check passes, the host preloads, and each worker gets its own thread. Note that each worker reports its own count. The &lt;code&gt;counts&lt;/code&gt; dictionary started out shared from the host, but the moment a worker writes to it, it has its own copy. Anything that needs to be shared across workers has to live somewhere else, such as a database or a metrics system.&lt;/p&gt;
&lt;p&gt;The database connection is dealt with in the same way. The module provides a &lt;code&gt;connect()&lt;/code&gt; function rather than connecting on import, and a &lt;code&gt;post_fork&lt;/code&gt; hook calls it, so that each worker opens its own connection. Alternatively, the connection could be opened lazily on first use, which many database libraries already do.&lt;/p&gt;
&lt;p&gt;Beyond &lt;code&gt;post_fork&lt;/code&gt;, there is &lt;code&gt;on_load&lt;/code&gt; for things to run once in the host after the import, such as warming caches that are safe to share, and &lt;code&gt;pre_fork&lt;/code&gt; which runs in the host before the first fork. In the workers, &lt;code&gt;on_ready&lt;/code&gt; runs just before a worker starts taking requests, &lt;code&gt;on_drain&lt;/code&gt; when it has been told to stop, and &lt;code&gt;on_exit&lt;/code&gt; after its last request. When the fallback to per-worker imports is in effect, &lt;code&gt;on_load&lt;/code&gt; and &lt;code&gt;pre_fork&lt;/code&gt; are run in each worker before &lt;code&gt;post_fork&lt;/code&gt;, so the same hooks module works either way.&lt;/p&gt;
&lt;h2&gt;Choosing how strict to be&lt;/h2&gt;
&lt;p&gt;What happens when the import check fails is controlled by the &lt;code&gt;preload&lt;/code&gt; setting, which can be given as an option or set in &lt;code&gt;[tool.muru]&lt;/code&gt;. The default is &lt;code&gt;auto&lt;/code&gt;, which is what you have seen so far, preloading when the check passes and falling back with a warning when it doesn't.&lt;/p&gt;
&lt;p&gt;Setting it to &lt;code&gt;true&lt;/code&gt; makes a failed check an error. Going back to the version of the shop which connected on import:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl run shop.wsgi:application --preload true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru.host[89241]: preload: the import of shop.db left a connected socket (fd 3, to 127.0.0.1:9340); close it in a pre_fork hook and reopen it in post_fork, or open it lazily on first use
muru.host[89241]: preload is true, so the import check failing stops the release
Error: the host exited with exit status: 1 before its workers were ready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is what you want in production. Once an application has been made safe to preload, you don't want a change by someone that breaks that to quietly fall back to per-worker imports and go unnoticed until someone wonders why memory use went up.&lt;/p&gt;
&lt;p&gt;Setting it to &lt;code&gt;false&lt;/code&gt; skips preloading and the check altogether, with each worker importing the application:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[89381]: worker 0 started as pid 89382
muru.host[89381]: worker 1 started as pid 89384
muru.worker[1]: worker 1 imported shop.wsgi:application in 5 ms
muru.worker[1]: worker 1 serves shop.wsgi:application as WSGI
muru.worker[0]: worker 0 imported shop.wsgi:application in 13 ms
muru.worker[0]: worker 0 serves shop.wsgi:application as WSGI
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is for applications which can't be made fork safe, or where you have decided it isn't worth the effort.&lt;/p&gt;
&lt;h2&gt;Processes and threads&lt;/h2&gt;
&lt;p&gt;Preloading is about how workers come to exist. The other half of the process model is what runs inside each worker. By default a WSGI worker has four threads, each of which handles one request at a time, and you choose how many worker processes to have and how many threads each gets.&lt;/p&gt;
&lt;p&gt;For most web applications, which spend their time waiting on databases and other services, threads work well. On a regular Python interpreter though, only one thread can be running Python code at a time because of the global interpreter lock (GIL). Waiting on a database releases the GIL so other threads can get on with things, but code that is busy computing holds it.&lt;/p&gt;
&lt;p&gt;To show the effect, here is an application which does some pure Python computation for each request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import sys

def work(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

def application(environ, start_response):
    work(10_000_000)
    gil = &amp;quot;enabled&amp;quot; if sys._is_gil_enabled() else &amp;quot;disabled&amp;quot;
    body = f&amp;quot;done, GIL {gil}\n&amp;quot;.encode()
    start_response(&amp;quot;200 OK&amp;quot;, [(&amp;quot;Content-Type&amp;quot;, &amp;quot;text/plain&amp;quot;), (&amp;quot;Content-Length&amp;quot;, str(len(body)))])
    return [body]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running this on a regular build of Python 3.15, with the default of one worker and four threads, and firing off four requests at the same time, all four had finished after about 1.3 seconds. The access log shows each request taking most of that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;200 GET / 18B 1.19s queue=0.0ms app=1.19s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5N
200 GET / 18B 1.21s queue=0.0ms app=1.21s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5J
200 GET / 18B 1.27s queue=0.1ms app=1.27s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5K
200 GET / 18B 1.28s queue=0.1ms app=1.28s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5M
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There were enough threads, so nothing waited in the queue, but the four threads had to take turns holding the GIL, and so each request took about four times as long as it would on its own. This is a good illustration of why the &lt;code&gt;queue&lt;/code&gt; and &lt;code&gt;app&lt;/code&gt; times aren't the whole story. Here the time is being charged to the application, yet the code isn't any slower, it is waiting for the GIL. Muru does keep track of time spent waiting for the GIL separately, which I will come back to in a later post.&lt;/p&gt;
&lt;p&gt;The traditional answer for CPU bound work is to use processes rather than threads. Running the same thing with &lt;code&gt;--processes 4 --threads 1&lt;/code&gt;, so each request gets a process of its own, all four requests finished in about 0.5 seconds.&lt;/p&gt;
&lt;h2&gt;Free-threaded Python&lt;/h2&gt;
&lt;p&gt;Python 3.14 brought official support for free-threaded builds of the interpreter, where the GIL is removed and threads can run Python code in parallel. The uv tool knows them by names like &lt;code&gt;3.15t&lt;/code&gt;, so a project only needs a &lt;code&gt;.python-version&lt;/code&gt; file containing &lt;code&gt;3.15t&lt;/code&gt; for &lt;code&gt;uv venv&lt;/code&gt; to create its virtual environment from a free-threaded build of Python 3.15.&lt;/p&gt;
&lt;p&gt;Muru supports these builds from the start. Its runtime library was written for them rather than adapted later, and it fetches the right build of the library for whichever kind of interpreter the project uses. Running the same application on the free-threaded build, again with one worker and four threads, all four requests were done in about 0.36 seconds:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;200 GET / 19B 331ms queue=0.1ms app=331ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W5
200 GET / 19B 331ms queue=0.1ms app=331ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W7
200 GET / 19B 334ms queue=0.1ms app=334ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W6
200 GET / 19B 336ms queue=0.0ms app=336ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The four threads ran in parallel within the one process, each request taking only as long as it would have on its own. These are rough numbers from my laptop rather than a proper benchmark, but the difference is clear enough.&lt;/p&gt;
&lt;p&gt;This changes the trade-off between processes and threads. Processes still give you isolation, so that a crash, a memory leak or a stuck request takes down one worker rather than all of them. But you no longer need lots of processes just to make use of every CPU core, and fewer processes with more threads means sharing caches and connection pools rather than duplicating them.&lt;/p&gt;
&lt;p&gt;One thing to watch for is that importing an extension module which hasn't declared support for free-threading causes the interpreter to turn the GIL back on. That is why the example reports whether the GIL is enabled. Your application can end up running on a free-threaded build with the GIL on and not realise it, which is something Muru keeps an eye on as well.&lt;/p&gt;
&lt;h2&gt;What's next&lt;/h2&gt;
&lt;p&gt;Having an application imported once, with the check to make sure that is safe, and with workers forked from it, is the foundation for much of what Muru does later, such as starting new releases quickly and scaling the number of workers up and down. The next post will be a shorter one, on something a development run does which most development servers don't, which is serve HTTPS with a certificate your browser will trust.&lt;/p&gt;</description>
	<pubDate>Mon, 09 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>Armin Ronacher: AI and Irontown</title>
	<guid>https://lucumr.pocoo.org/2026/11/9/irontown/</guid>
	<link>https://lucumr.pocoo.org/2026/11/9/irontown/</link>
	<description>&lt;p&gt;For our last movie night we watched &lt;a href=&quot;https://en.wikipedia.org/wiki/Princess_Mononoke&quot;&gt;Princess
Mononoke&lt;/a&gt;.  I remembered loving
that movie when I was younger, but watching it as an adult made me once again
realize how Miyazaki has a way of expressing diverging sides without picking
one.  The film shows people, animals and spirits with legitimate and largely
incompatible needs, and each faction is further engaged in infighting.&lt;/p&gt;
&lt;p&gt;A central arc of the story is Lady Eboshi&amp;#8217;s Irontown, which gives people who
were largely rejected by society work, protection and dignity.  She gives
shelter and work to those who have leprosy and to former sex workers.  The mine
and the weapon factory provide them with a livelihood, but they also destroy the
forest around them.  The forest&amp;#8217;s inhabitants, in particular the boars,
response choose war against the humans to defend themselves.  Eboshi is not
portrayed as a villain.  She does real good for the people of Irontown, but
also is an arms manufacturer who is destroying the forest around it.&lt;/p&gt;
&lt;p&gt;The film anchors around a character called Ashitaka, who has been condemned to
death by an unfortunate encounter with a god that was turned into a demon after
being fatally wounded by one of Eboshi&amp;#8217;s guns.  He doesn&amp;#8217;t take sides, but he
wants to understand and find the truth.  Another character is San (whom they
call Mononoke), a girl who was raised by wolves and, despite being human,
considers herself part of the forest.&lt;/p&gt;
&lt;p&gt;This movie&amp;#8217;s backdrop is human progress, and it reminded me so much of the
challenges we are facing today with AI.  AI is useful, empowering and
consequential beyond the people who benefit from it.  It&amp;#8217;s similar to the iron
in the story.  Irontown gives the outcast purpose and power.  There are people
today who see themselves in an environment just like that: people who were
never let in before and who can now build things that were out of reach.  They
are wielding AI to their benefit.  But there are negative externalities.  In
Mononoke it&amp;#8217;s not just the spirits and animals in the forest who bear the
consequences, it&amp;#8217;s also other people.  Irontown produces guns, and its people
are engaged in fights with other humans.  There is conflict and power seeking
everywhere.&lt;/p&gt;
&lt;p&gt;Another, unseen character is the power-hungry emperor, who wants the head of
the forest spirit, a god, because he believes it will make him immortal.
Eboshi is willing to support that cause, not because she thinks it&amp;#8217;s right, but
because she is at war with the forest&amp;#8217;s gods who are preventing her expansion,
so they have a common interest.  But there really is no need for anyone to kill
the forest spirit, in the same way there is no need for any one country to &amp;#8220;win
at AI&amp;#8221;.  What drives it is the fear of what happens if someone else wins first.
And the emperor is willing to have a god killed for it.  The people who help him
with that desire, Eboshi included, don&amp;#8217;t understand the chaos it will unleash.&lt;/p&gt;
&lt;p&gt;There it also shows the dangers of seeking overwhelming power without
comprehension of how to control its consequences.  The pursuit of power itself
becomes destructive.  In the end, the gods die, and what grows back is not the
old forest anymore.  Irontown is destroyed.  Eboshi wants to rebuild it, and to
build a better town this time.  Ashitaka stays with the people of Irontown after
his curse is lifted by the dying forest spirit.  San cannot forgive the humans
and stays with the wolves.  In the end, nothing is truly resolved, but there is
a truce that came at great cost.&lt;/p&gt;
&lt;p&gt;Ashitaka&amp;#8217;s role in the movie is one of a neutral actor.  He was wronged both by
a spirit turned demon in the forest, who put a curse on him, and, indirectly, by
Eboshi, who caused the spirit to turn evil.  Yet he harbors no ill will towards
either side.  He sets out to &amp;#8220;see with eyes unclouded by hate,&amp;#8221; and in the
course of the movie he is seen as an unaligned actor.  He helps people on both
sides, and stands in their way to prevent further conflict.  He doesn&amp;#8217;t feel
like a pacifist, but he&amp;#8217;s the closest to one there is.  He&amp;#8217;s a truth seeker in
a changing world.  Because &lt;a href=&quot;https://lucumr.pocoo.org/2026/4/11/the-center-has-a-bias/&quot;&gt;he engages&lt;/a&gt; he&amp;#8217;s
seen as suspicious by both sides, and ends up shot by the people of Irontown
when he tries to rescue San.&lt;/p&gt;
&lt;p&gt;I use AI every day and find it genuinely empowering.  But I also recognize the
pain it causes some, and the moral problems it poses.  It&amp;#8217;s genuine progress
for humanity, but it comes at significant cost.  And many of those costs will be
carried by people who aren&amp;#8217;t going to benefit from it.&lt;/p&gt;
&lt;p&gt;So it&amp;#8217;s no surprise that AI feels like a threat to many today.  Communities
currently &lt;a href=&quot;https://lucumr.pocoo.org/2026/6/6/communities-of-not/&quot;&gt;strongly align&lt;/a&gt; themselves with one
side and fight against the other.  The forest spirit did not have to die and the
boars did not have to die in a senseless war.&lt;/p&gt;
&lt;p&gt;The question is not whether there will be iron, but what kind of Irontown we
build with it.  I want our conversation about AI to make room for nuance and
genuine understanding, for us to look at it and each other with eyes unclouded
by hate.  We should take advantage of its real benefits, but also recognize the
damage it can do, and the choices we still have about what we build and what
we&amp;#8217;re willing to sacrifice for it.  I like to think there is a path for people
with differing opinions can find a way to live alongside before losing something
we can&amp;#8217;t get back.&lt;/p&gt;</description>
	<pubDate>Mon, 09 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>LernerPython blog, from Reuven Lerner: New course: Introduction to agentic coding with Claude Code</title>
	<guid>https://lernerpython.com/2026/11/08/new-course-introduction-to-agentic-coding-with-claude-code/</guid>
	<link>https://lernerpython.com/2026/11/08/new-course-introduction-to-agentic-coding-with-claude-code/</link>
	<description>&lt;p class=&quot;wp-block-paragraph&quot;&gt;I&amp;#8217;m excited to announce my newest course, Introduction to agentic coding with Claude Code (&lt;a href=&quot;https://lernerpython.com/courses/intro-agentic-coding-claude-code/&quot;&gt;https://lernerpython.com/courses/intro-agentic-coding-claude-code/&lt;/a&gt;).&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;If you want to go beyond giving simple prompts or using Claude Code in trivial ways, this is the course for you. I&amp;#8217;ve distilled what I&amp;#8217;ve learned from using Claude Code every day for about a year to run my business, create and improve open-source projects, and create new applications, and I&amp;#8217;ve put them into this four-hour course.&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Of course, it includes hands-on exercises. And it includes advice on using &amp;#8220;uv&amp;#8221; (for Python projects) and Git (for all projects).&lt;/p&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;The course is included in a LernerPython membership, which has a two-week free trial. Give it a shot at https://LernerPython.com !&lt;/p&gt;
&lt;p&gt;The post &lt;a href=&quot;https://lernerpython.com/2026/11/08/new-course-introduction-to-agentic-coding-with-claude-code/&quot;&gt;New course: Introduction to agentic coding with Claude Code&lt;/a&gt; appeared first on &lt;a href=&quot;https://lernerpython.com&quot;&gt;LernerPython&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Sun, 08 Nov 2026 18:12:02 +0000</pubDate>
</item>
<item>
	<title>LernerPython blog, from Reuven Lerner: Pandas 3.1 filter: Selecting rows with a pd.col condition</title>
	<guid>https://lernerpython.com/2026/11/08/pandas-3-1-filter-selecting-rows-with-a-pd-col-condition/</guid>
	<link>https://lernerpython.com/2026/11/08/pandas-3-1-filter-selecting-rows-with-a-pd-col-condition/</link>
	<description>&lt;img src=&quot;https://lernerpython.com/wp-content/uploads/2026/11/2026-11-08-scaled.png&quot; alt=&quot;Pandas 3.1 filter: Selecting rows with a pd.col condition&quot; class=&quot;wp-image-18800&quot; width=&quot;2560&quot; height=&quot;1746&quot; /&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;Starting with Python Pandas 3.1, use &amp;#8220;filter&amp;#8221; to apply a condition to rows. So:&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;(
  df
  .loc[ pd.col('passenger_count') &amp;gt; 1 , 'trip_distance']
  .mean()
)&lt;/code&gt;&lt;/pre&gt;



&lt;p class=&quot;wp-block-paragraph&quot;&gt;can now be (the much slower):&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;(
  df
  .filter(cond=pd.col('passenger_count') &amp;gt; 1)
  ['trip_distance']
  .mean()
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The post &lt;a href=&quot;https://lernerpython.com/2026/11/08/pandas-3-1-filter-selecting-rows-with-a-pd-col-condition/&quot;&gt;Pandas 3.1 filter: Selecting rows with a pd.col condition&lt;/a&gt; appeared first on &lt;a href=&quot;https://lernerpython.com&quot;&gt;LernerPython&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Sun, 08 Nov 2026 07:00:00 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: Muru: how it fits together</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/muru-how-it-fits-together/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/muru-how-it-fits-together/</link>
	<description>&lt;p&gt;In &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/a-first-look-at-muru/&quot;&gt;a first look at Muru&lt;/a&gt; and &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/muru-looking-inside-the-workers/&quot;&gt;looking inside the workers&lt;/a&gt; I have been showing what Muru can do in development mode, without saying much about how it is put together. Words like host, worker, edge and release have kept turning up in the output, and I have mostly waved my hands at what they mean. Before going any further, it is time to explain what those parts are and how they fit together, since a lot of what makes Muru different follows from that design.&lt;/p&gt;
&lt;h2&gt;The processes in a development run&lt;/h2&gt;
&lt;p&gt;When you run &lt;code&gt;muructl run&lt;/code&gt; with two worker processes and look at the process tree, this is what you find:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;59435     1 muructl run app:application --processes 2
59440 59435 .venv/bin/python -m muru.host --app app:application --release r1 --processes 2 --threads 4 ...
59443 59440 .venv/bin/python -m muru.host --app app:application --release r1 --processes 2 --threads 4 ...
59444 59440 .venv/bin/python -m muru.host --app app:application --release r1 --processes 2 --threads 4 ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;muructl&lt;/code&gt; process started a Python process running &lt;code&gt;muru.host&lt;/code&gt;. That host then forked the two workers, which is why they show the same command line as the host. There is no Python in the &lt;code&gt;muructl&lt;/code&gt; process at all. It is a Rust program, and in development mode it contains two of Muru's main parts, the edge and the controller.&lt;/p&gt;
&lt;img src=&quot;https://grahamdumpleton.me/assets/diagrams/mermaid-e919b27461db1f5dfdd0e86b2ce51e50.svg&quot; alt=&quot;Mermaid diagram&quot; class=&quot;mermaid-diagram&quot; /&gt;&lt;p&gt;The edge is the part which faces clients. It accepts connections, handles TLS, speaks HTTP/1.1 and HTTP/2, works out where each request should go, and passes it on to a worker. It also deals with the things that don't need Python at all, such as serving static files, compressing responses and producing error pages. Because it is written in Rust and never runs any Python code, a slow request in your application can't hold it up.&lt;/p&gt;
&lt;p&gt;The host is a Python process which imports your application once and forks workers from it, which I will look at more closely in the next post. It never serves requests itself. Its job is to make workers, and to replace them if they die.&lt;/p&gt;
&lt;p&gt;The workers are where your application runs. Each one is a Python interpreter with your code already loaded, plus a small layer written in Rust which talks to the edge and hands requests over to your code, either on a thread for WSGI or on the event loop for ASGI.&lt;/p&gt;
&lt;p&gt;The controller decides what should be running. It starts and stops hosts, tells the edge where to send traffic, and keeps track of releases. Importantly, it is never on the path a request takes.&lt;/p&gt;
&lt;h2&gt;The path a request takes&lt;/h2&gt;
&lt;p&gt;Following a single request through the system shows how the work is divided up.&lt;/p&gt;
&lt;img src=&quot;https://grahamdumpleton.me/assets/diagrams/mermaid-d1f87ffac3b07eca11f2e05140dabba3.svg&quot; alt=&quot;Mermaid diagram&quot; class=&quot;mermaid-diagram&quot; /&gt;&lt;p&gt;The edge reads the request and decides whether it is something it can answer itself, such as a static file or a redirect, or whether it needs to go to the application. If it does, the edge picks which release should handle it according to the traffic policy, then which worker within that release, choosing the one with the fewest requests already in flight. If every worker is busy, the request waits in a queue at the edge. That time spent waiting is the &lt;code&gt;queue&lt;/code&gt; figure in the access log, which I made a point of back in &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/a-first-look-at-muru/&quot;&gt;a first look at Muru&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The request then crosses to the worker over what Muru calls the worker link, a connection over a Unix socket. What travels over it isn't HTTP. The edge has already parsed the request, so the method, path, headers and details about the connection are sent as small typed messages, followed by the request body as it arrives. Nothing gets turned back into HTTP text, only to be parsed all over again on the other side.&lt;/p&gt;
&lt;p&gt;In the worker, your application sees exactly what it would under any other server. A WSGI application gets an ordinary &lt;code&gt;environ&lt;/code&gt; dictionary, an ASGI application gets an ordinary scope. The response then flows back over the same link, with the worker gathering up small pieces of a response before sending them, so an application which yields a thousand tiny strings doesn't cause a thousand separate writes. The edge writes the response to the client at whatever pace the client can take it. If the client is slow, that slowness is passed back through the edge and the worker to the application, which waits with the interpreter released, so nothing ends up being buffered without limit along the way.&lt;/p&gt;
&lt;p&gt;Request bodies are handled on demand in the same way. The edge only reads more of the body from the client as the application reads it, so an application which rejects a request without looking at the body doesn't cause a large upload to be read in for nothing. Where the application responds with a file, the edge sends it to the client straight from the file, rather than the contents being copied through the worker.&lt;/p&gt;
&lt;h2&gt;What the extra hop costs&lt;/h2&gt;
&lt;p&gt;Anyone who has looked at how mod_wsgi daemon mode works will recognise the general shape here. There, the Apache child processes accept the connection and pass the request over a Unix socket to the daemon processes where the Python application runs. Muru does much the same, with a separate front end proxying requests to Python processes. The obvious question is what that costs compared with a server where Python reads the HTTP request directly off the socket.&lt;/p&gt;
&lt;p&gt;The answer is one more copy of each byte, and some tens of microseconds of work per request, when both processes are on the same machine. Next to the time a typical Python web application spends handling a request, that is lost in the noise, and in return you get the edge doing all the client-facing work in Rust.&lt;/p&gt;
&lt;p&gt;The bigger payoff though isn't performance. It is that the edge and the Python processes can come and go independently of each other. The edge can keep accepting connections while the application behind it is replaced. Workers can be recycled or restarted without dropping connections at the edge. More than one version of an application can be running at the same time, each on a different version of Python if need be. None of that is possible if the process handling the client connection is also the one running your Python code.&lt;/p&gt;
&lt;h2&gt;A release is a unit you can swap&lt;/h2&gt;
&lt;p&gt;That brings things back to releases. A release is a fixed combination of application code, Python version and settings, together with the host and workers running it. Releases are never changed once started. If something needs to change, a new release is started.&lt;/p&gt;
&lt;p&gt;That is what is happening when development mode reloads after you save a file. The controller doesn't restart anything. It starts a new release beside the current one, and only moves traffic across once the new release is ready.&lt;/p&gt;
&lt;img src=&quot;https://grahamdumpleton.me/assets/diagrams/mermaid-ce370b785e88a3c8b434c377364c845b.svg&quot; alt=&quot;Mermaid diagram&quot; class=&quot;mermaid-diagram&quot; /&gt;&lt;p&gt;If release &lt;code&gt;r2&lt;/code&gt; never becomes ready, because the code fails to import for example, the step that changes the routing never happens, and &lt;code&gt;r1&lt;/code&gt; keeps on serving as if nothing happened. That is what was going on in the first post when a broken import left the previous release serving.&lt;/p&gt;
&lt;p&gt;Because moving traffic is just a change to the edge's routing, it doesn't have to be all or nothing either. The edge could just as easily send most requests to one release and a small share to another, or send particular clients to a preview of a new release. A reload in development mode is a straight switch, but the mechanism underneath is the same one used for blue/green deploys, canaries and rolling back.&lt;/p&gt;
&lt;h2&gt;The same parts under the daemon&lt;/h2&gt;
&lt;p&gt;In development mode, the edge and the controller both live inside the one &lt;code&gt;muructl run&lt;/code&gt; process, and there is only ever one application. Running on a server under the daemon, the same parts are arranged a bit differently.&lt;/p&gt;
&lt;img src=&quot;https://grahamdumpleton.me/assets/diagrams/mermaid-930d15b4970f44ffff0665fb5b65ab8a.svg&quot; alt=&quot;Mermaid diagram&quot; class=&quot;mermaid-diagram&quot; /&gt;&lt;p&gt;The controller now lives in a long running daemon process, along with the admin API that &lt;code&gt;muructl&lt;/code&gt; commands and the dashboard talk to, and a store which keeps track of what should be running. The edge runs as a separate process of its own. There can be many applications, and several releases of each running side by side, here with a new release of an application on a newer version of Python running beside the old one.&lt;/p&gt;
&lt;p&gt;Keeping the daemon off the request path has a useful consequence. If the daemon is restarted or upgraded, the edge carries on serving requests using the routing it last received, and the hosts keep running. Replacing the edge is also handled without refusing connections, as the new edge process takes over the listening sockets from the old one. Traffic keeps flowing while the parts managing it are swapped out.&lt;/p&gt;
&lt;p&gt;This is the part I most wanted to get right, given my history. Under Apache, the parent process owns the lifecycle of every child process, including the mod_wsgi daemon processes. Their number is fixed by the configuration, and changing anything significant means a restart of Apache, with everything that comes with it. With Muru, the thing that decides what should run is separate from the thing serving traffic, and both are separate from the Python processes. Each can be changed without disturbing the others.&lt;/p&gt;
&lt;p&gt;The same model also applies when running in a container with &lt;code&gt;muructl serve&lt;/code&gt;. There the edge, the controller, the host and its workers all run as the container's main process, much as in development mode, since a container is meant to run a single application.&lt;/p&gt;
&lt;h2&gt;What's next&lt;/h2&gt;
&lt;p&gt;That covers how the parts fit together. The daemon, and deploying an application to a server with releases and traffic moving between them, will come once I have finished with development mode. Before that, in the next post I will go back to the host, and how Muru imports an application once and forks its workers from it, along with what it does to make sure that is safe.&lt;/p&gt;</description>
	<pubDate>Sun, 08 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>Graham Dumpleton: Muru: looking inside the workers</title>
	<guid>https://grahamdumpleton.me/posts/2026/11/muru-looking-inside-the-workers/</guid>
	<link>https://grahamdumpleton.me/posts/2026/11/muru-looking-inside-the-workers/</link>
	<description>&lt;p&gt;In &lt;a href=&quot;https://grahamdumpleton.me/posts/2026/11/a-first-look-at-muru/&quot;&gt;a first look at Muru&lt;/a&gt; I made a point of the access log separating how long a request waited for a worker from how long it spent in the application. That tells you where a slow request lost its time, but not why. Sooner or later the question you end up asking is what the workers are actually doing, usually because a request is taking far longer than it should.&lt;/p&gt;
&lt;p&gt;The traditional answers to that are to guess, to sprinkle print statements and timing code through the application, or to bring in a separate monitoring product. Having spent twenty years on mod_wsgi seeing what people struggle with when hosting Python web applications, and five years at New Relic working on exactly this problem for Python, I have some fairly strong opinions about how much of this a hosting system should be able to tell you itself. Quite a lot, as it turns out, is the answer Muru gives.&lt;/p&gt;
&lt;h2&gt;A slow report&lt;/h2&gt;
&lt;p&gt;To have something to look into, here is a small WSGI application with a deliberately slow report. Fetching the rows for the report pretends to wait on a database for a tenth of a second per month, and summarising them is pure Python arithmetic over eight million rows per month.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import time
from urllib.parse import parse_qs


def fetch_rows(months):
    time.sleep(0.1 * months)  # as if waiting on a slow database query
    return range(8_000_000 * months)


def summarise(rows):
    total = 0

    for row in rows:
        total += row * row % 7

    return total


def build_report(months):
    rows = fetch_rows(months)
    return f&amp;quot;{months} months: {summarise(rows)}\n&amp;quot;


def application(environ, start_response):
    query = parse_qs(environ[&amp;quot;QUERY_STRING&amp;quot;])
    months = int(query.get(&amp;quot;months&amp;quot;, [&amp;quot;1&amp;quot;])[0])

    if environ[&amp;quot;PATH_INFO&amp;quot;] == &amp;quot;/report&amp;quot;:
        body = build_report(months)
    else:
        body = &amp;quot;ok\n&amp;quot;

    start_response(&amp;quot;200 OK&amp;quot;, [(&amp;quot;Content-Type&amp;quot;, &amp;quot;text/plain&amp;quot;)])
    return [body.encode()]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rather than giving everything as options to &lt;code&gt;muructl run&lt;/code&gt;, Muru can read its settings from a &lt;code&gt;[tool.muru]&lt;/code&gt; table in the project's &lt;code&gt;pyproject.toml&lt;/code&gt;. Here they name the entrypoint, ask for two worker processes, and also set a soft timeout of three seconds, which I will come back to shortly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.muru]
entrypoint = &amp;quot;report:application&amp;quot;
processes = 2

[tool.muru.timeouts]
soft_timeout = &amp;quot;3s&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start it with &lt;code&gt;muructl run&lt;/code&gt; and ask for reports covering one month and then three months. The access log shows what we would expect:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;200 GET /report?months=1 19B 382ms queue=0.0ms app=382ms worker=0 id=01M6T02DPVG7XRY5V2P2V9S7WG
200 GET /report?months=3 19B 1.15s queue=0.0ms app=1.15s worker=1 id=01M6T02E30G7XRY5V2P2V9S7WG
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The queue time is zero and the application time is all of it, so we know straight away the problem is in our code and not a lack of capacity. Three months taking over a second also means a year is going to be painful.&lt;/p&gt;
&lt;h2&gt;Catching the worst case&lt;/h2&gt;
&lt;p&gt;By default a request in Muru can run for as long as it likes, up to a deadline at the edge of five minutes. Even when the edge gives up and answers the client, though, the request keeps going in the worker, tying up a thread for no benefit to anyone. That is what the soft timeout is for, and why I turned it on for this example.&lt;/p&gt;
&lt;p&gt;Ask for twelve months, using JSON since that is easier to read here:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -H 'Accept: application/json' 'http://127.0.0.1:8080/report?months=12'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three seconds later the response comes back:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&amp;quot;type&amp;quot;:&amp;quot;about:blank&amp;quot;,&amp;quot;title&amp;quot;:&amp;quot;Gateway Timeout&amp;quot;,&amp;quot;status&amp;quot;:504,&amp;quot;detail&amp;quot;:&amp;quot;The request ran past the soft timeout of 3 s.&amp;quot;,&amp;quot;reason&amp;quot;:&amp;quot;soft_timeout&amp;quot;,&amp;quot;request_id&amp;quot;:&amp;quot;01M6T02F76G7XRY5V2P2V9S7WG&amp;quot;,&amp;quot;traceback&amp;quot;:&amp;quot;...&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the request passed three seconds, a watchdog in the worker noted where the request was, then raised a &lt;code&gt;muru.RequestTimeout&lt;/code&gt; exception in the thread running it. The exception unwound the report just like any other exception would, and Muru answered with a 504. The terminal says exactly where the request was when it was stopped:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[74087]: request.soft_timeout: worker 0 (pid 74088): request 01M6T02F76G7XRY5V2P2V9S7WG GET /report?months=12 ran past the soft timeout of 3 s at report.py line 14 in summarise, called from report.py line 21 in build_report, called from report.py line 29 in application
muru.worker[0]: Traceback (most recent call last):
muru.worker[0]:   File &amp;quot;/Users/graham/reports/report.py&amp;quot;, line 29, in application
muru.worker[0]:     body = build_report(months)
muru.worker[0]:   File &amp;quot;/Users/graham/reports/report.py&amp;quot;, line 21, in build_report
muru.worker[0]:     return f&amp;quot;{months} months: {summarise(rows)}\n&amp;quot;
muru.worker[0]:                                ~~~~~~~~~^^^^^^
muru.worker[0]:   File &amp;quot;/Users/graham/reports/report.py&amp;quot;, line 14, in summarise
muru.worker[0]:     total += row * row % 7
muru.worker[0]:     ^^^^^
muru.worker[0]: muru.RequestTimeout
504 GET /report?months=12 638B 3.07s queue=0.0ms app=3.07s reason=soft_timeout worker=0 id=01M6T02F76G7XRY5V2P2V9S7WG
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Only the one request was stopped, and the worker carried on serving others.&lt;/p&gt;
&lt;p&gt;One detail worth knowing is that &lt;code&gt;muru.RequestTimeout&lt;/code&gt; derives from &lt;code&gt;BaseException&lt;/code&gt; and not &lt;code&gt;Exception&lt;/code&gt;. A lot of application code catches &lt;code&gt;Exception&lt;/code&gt; to log an error and carry on, and if the timeout were an &lt;code&gt;Exception&lt;/code&gt; that code would swallow it and keep going. An application which does want to respond in its own way, such as telling the user to narrow down the date range, can still catch &lt;code&gt;muru.RequestTimeout&lt;/code&gt; by name.&lt;/p&gt;
&lt;p&gt;The soft timeout can only interrupt Python code. A thread stuck inside a C extension won't see the exception until the call returns. For that case there is also a hard timeout, which gives up on the whole worker and replaces it, but that is a topic for another time.&lt;/p&gt;
&lt;h2&gt;Which requests are in flight&lt;/h2&gt;
&lt;p&gt;A timeout tells you about a request after the fact. Often what you want is to know what is running right now, and with more than one worker, the first thing to work out is which worker a slow request is running in. A six month report takes about two seconds, so start one in the background while a few one month reports are also being requested, and ask Muru what requests are in flight:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s 'http://127.0.0.1:8080/report?months=6' &amp;amp;
muructl requests
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;RELEASE              GROUP        QUEUED  QUEUED-OLDEST  THREADS  THREAD-OLDEST
report@r1            default      0       -              5/8      1.2s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the daemon you would need to say which application to look at, but a development run only ever serves the one application. The &lt;code&gt;muructl&lt;/code&gt; commands find the run by its admin socket when you run them from the project directory, so there is nothing more to type.&lt;/p&gt;
&lt;p&gt;This first view counts the requests for each release. Nothing is waiting in the queue, five of the eight threads across the two workers are busy, and the oldest request has been running for 1.2 seconds. Adding &lt;code&gt;--list&lt;/code&gt; shows each request, oldest first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl requests --list
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;AGE      STATE    KIND            WORKER  REQUEST                                  CLIENT           REQUEST-ID
1.2s     running  wsgi            1       GET /report?months=6                     127.0.0.1        01M6T02SA1G7XRY5V2P2V9S7WH
0.5s     running  wsgi            0       GET /report?months=1                     127.0.0.1        01M6T02T18G7XRY5V2P2V9S7WJ
0.4s     running  wsgi            0       GET /report?months=1                     127.0.0.1        01M6T02T56G7XRY5V2P2V9S7WJ
0.3s     running  wsgi            0       GET /report?months=1                     127.0.0.1        01M6T02T94G7XRY5V2P2V9S7WJ
0.1s     running  wsgi            1       GET /report?months=1                     127.0.0.1        01M6T02TD2G7XRY5V2P2V9S7WJ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The six month report is the oldest, and it is running in worker 1.&lt;/p&gt;
&lt;p&gt;Strictly speaking, this isn't looking inside the workers at all. The answer comes from the edge, which knows about every request it has passed to a worker, along with how long it has been running and which worker it went to. Nothing has to be asked of the workers to get it, so it costs them nothing, and it still answers even when a worker is too wedged to respond. That is why it is the first place to look. Once you know which worker a request is in, the more expensive tools can be pointed at just that worker.&lt;/p&gt;
&lt;p&gt;There is an equivalent &lt;code&gt;muructl websockets&lt;/code&gt; command for WebSockets, which aren't being used here. It counts the WebSockets open for each endpoint, and with &lt;code&gt;--list&lt;/code&gt; shows each one, the longest open first.&lt;/p&gt;
&lt;h2&gt;Seeing a request in progress&lt;/h2&gt;
&lt;p&gt;Knowing the six month report is in worker 1, the next step is to see what it is doing. Asking for the stacks of that worker:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl debug stacks --worker 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Among the threads shown is the one serving the report:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru-app-1 in worker 1:
  serving GET /report?months=6 for 1.3 s (request 01M6T02SA1G7XRY5V2P2V9S7WH)
  File &amp;quot;/Users/graham/Library/Caches/muru/runtime/0.1.0a18/cp315t/muru/_pool.py&amp;quot;, line 30, in serve_jobs
    serve()
  File &amp;quot;/Users/graham/reports/report.py&amp;quot;, line 29, in application
    body = build_report(months)
  File &amp;quot;/Users/graham/reports/report.py&amp;quot;, line 21, in build_report
    return f&amp;quot;{months} months: {summarise(rows)}\n&amp;quot;
  File &amp;quot;/Users/graham/reports/report.py&amp;quot;, line 14, in summarise
    total += row * row % 7
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It shows which request the thread is handling, how long that request has been running, and where in the code it is at that moment. Leave off &lt;code&gt;--worker&lt;/code&gt; and you get every thread in every worker instead, all without stopping anything.&lt;/p&gt;
&lt;p&gt;How the stacks are obtained depends on the platform. On Linux with Python 3.15 or later, Muru reads the stacks from outside the worker process using the remote debugging support added by &lt;a href=&quot;https://peps.python.org/pep-0768/&quot;&gt;PEP 768&lt;/a&gt;, so it works even if a worker is wedged in C code while holding the interpreter. On macOS, where reading another process needs root, the worker is asked to report its own stacks instead, which is what happened here.&lt;/p&gt;
&lt;h2&gt;Where the time goes&lt;/h2&gt;
&lt;p&gt;A stack is a single moment in time. To know where the time goes overall, you want a profile, which samples the stacks repeatedly over a period and counts where they were. To get some load to profile, keep reports coming in another terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;while true; do curl -s 'http://127.0.0.1:8080/report?months=2' &amp;gt; /dev/null; done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and then profile the workers for ten seconds:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl debug profile --for 10s --output report.folded
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;808 samples of running Python code in 1632 rounds across 2 workers
   73.4%  summarise (report.py:10)
   26.6%  fetch_rows (report.py:5)
muru: collapsed stacks written to report.folded
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three quarters of the time is going on summarising the rows and a quarter on waiting for them, so a faster summary would be worth a lot more than a faster query. Being able to say that with evidence, rather than a hunch, is the whole point.&lt;/p&gt;
&lt;p&gt;The file written out holds the collapsed stacks with their counts, which is the format flame graph tools expect, so &lt;code&gt;flamegraph.pl&lt;/code&gt; can turn it into an SVG or you can open it in &lt;a href=&quot;https://www.speedscope.app/&quot;&gt;speedscope&lt;/a&gt; in a browser. If the application has many endpoints and only one of them is a problem, the profile can also be restricted to requests for a particular path.&lt;/p&gt;
&lt;p&gt;Sampling every so often tells you proportions. If you want exact numbers instead, &lt;code&gt;muructl debug trace&lt;/code&gt; counts every call of every Python function over a period, along with the time spent in each:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl debug trace --for 3s
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;worker 0 (pid 74088)
    total ms     calls  function
    1483.823         2  application (report.py:24)
    1483.674         2  build_report (report.py:19)
    1073.508         2  summarise (report.py:10)
     410.103         2  fetch_rows (report.py:5)
       0.095         2  parse_qs (parse.py:859)
       0.056         2  parse_qsl (parse.py:902)
       0.042         4  parse_qsl.&amp;lt;locals&amp;gt;._unquote (parse.py:937)
       0.031         4  unquote_plus (parse.py:983)
       0.014         4  unquote (parse.py:836)

worker 1 (pid 74089)
    total ms     calls  function
     736.969         2  application (report.py:24)
     736.857         2  build_report (report.py:19)
     531.764         2  summarise (report.py:10)
     410.168         2  fetch_rows (report.py:5)
       0.104         2  parse_qs (parse.py:859)
       0.064         2  parse_qsl (parse.py:902)
       0.048         4  parse_qsl.&amp;lt;locals&amp;gt;._unquote (parse.py:937)
       0.036         4  unquote_plus (parse.py:983)
       0.018         4  unquote (parse.py:836)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tracing every call is a lot more expensive than sampling, so Muru will refuse to trace a worker which already has more than half of its threads busy with requests unless forced to with &lt;code&gt;--force&lt;/code&gt;. The worker does keep serving while the trace runs though, since the requests it takes in that time are what the trace is measuring. Those requests are slowed down by the tracing, so this isn't something to leave running for long against an application people are using.&lt;/p&gt;
&lt;h2&gt;Memory and running code&lt;/h2&gt;
&lt;p&gt;Slow requests aren't the only thing that goes wrong. Python web applications also have a habit of slowly growing in memory until something gives. Running &lt;code&gt;muructl debug memory&lt;/code&gt; reports for each worker its memory use, the garbage collector's counts and the object types it holds most of. Adding &lt;code&gt;--trace&lt;/code&gt; turns on &lt;code&gt;tracemalloc&lt;/code&gt; for a period and reports the places in the code which allocated the most memory during that time, and there are options for taking a snapshot at one point and comparing against it later.&lt;/p&gt;
&lt;p&gt;When all else fails, &lt;code&gt;muructl debug exec&lt;/code&gt; will run a snippet of code inside the workers and print the result, which is handy for checking the state of some cache or other in a running process:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl debug exec -- 'import sys; sys.version'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;worker 0 (pid 74088):
'3.15.1 free-threading build (main, Oct 21 2026, 17:44:52) [Clang 23.1.3 ]'
worker 1 (pid 74089):
'3.15.1 free-threading build (main, Oct 21 2026, 17:44:52) [Clang 23.1.3 ]'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the daemon this one is turned off unless explicitly allowed in the daemon's configuration, since it lets someone run arbitrary code inside a production application. In a development run it is always on, since the person at the terminal is the one who started the workers in the first place.&lt;/p&gt;
&lt;h2&gt;Stopping at a breakpoint&lt;/h2&gt;
&lt;p&gt;All of the above is about looking at a worker from the outside. Sometimes though you really do want to stop in the middle of a request and poke around, and the usual way to do that in Python is a call to &lt;code&gt;breakpoint()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The catch is that with a normal development server, &lt;code&gt;breakpoint()&lt;/code&gt; drops you into &lt;code&gt;pdb&lt;/code&gt; on whatever terminal the server's output is going to, interleaved with log output from everything else. In Muru, a worker which hits &lt;code&gt;breakpoint()&lt;/code&gt; instead waits for a debugger to attach, and the terminal tells you how to do that. Add a &lt;code&gt;breakpoint()&lt;/code&gt; call to &lt;code&gt;build_report()&lt;/code&gt; after the rows are fetched, save the file so it reloads, make a request, and the terminal shows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[75915]: debug.breakpoint: worker 0 (pid 75918): breakpoint() at /Users/graham/reports/report.py:21 waits 60 s for muructl debug attach report --worker 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The end of that line gives the command to run, which is &lt;code&gt;muructl debug attach report --worker 0&lt;/code&gt;. Running it from another terminal opens a &lt;code&gt;pdb&lt;/code&gt; session in the worker, at the point the request stopped:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muructl debug attach report --worker 0
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;muru: attached to worker 0 at /Users/graham/reports/report.py:21; the end of input lets the code go on
&amp;gt; /Users/graham/reports/report.py(21)build_report()
-&amp;gt; breakpoint()
(Pdb) p months
1
(Pdb) continue
muru: the session in worker 0 has ended
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typing &lt;code&gt;continue&lt;/code&gt; lets the request carry on, and it completes as normal. While the session was open, the worker was taken out of routing so it wouldn't be given any new requests, leaving those to worker 1. The terminal records that, along with the worker being put back once the session ended:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;muru.host[75915]: worker 0 (pid 75918) leaves routing while it is debugged
muru.host[75915]: debug.detached: worker 0 (pid 75918): the debugger session at /Users/graham/reports/report.py:21 ended
muru.host[75915]: worker 0 (pid 75918) serves again: no debugger is attached
200 GET /report?months=1 19B 2.28s queue=0.0ms app=2.28s worker=0 id=01M6T043M4G7XRY5V2P2V9S7WJ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The request was also exempt from the soft timeout, so it wasn't killed off while I was looking at it. If nobody attaches within the timeout, the request just carries on by itself.&lt;/p&gt;
&lt;p&gt;For those who prefer to debug from an IDE, &lt;code&gt;muructl debug attach --dap&lt;/code&gt; speaks the Debug Adapter Protocol instead, so something like VS Code can attach to the worker through &lt;code&gt;muructl&lt;/code&gt;. That needs &lt;code&gt;debugpy&lt;/code&gt; installed in the project's environment, and I will leave the details of that to the Muru documentation.&lt;/p&gt;
&lt;h2&gt;The dashboard&lt;/h2&gt;
&lt;p&gt;Everything so far has been done from the command line. When you just want to keep an eye on things, the run also serves a dashboard, by default at &lt;code&gt;http://127.0.0.1:8081/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://grahamdumpleton.me/posts/2026/11/muru-looking-inside-the-workers/dashboard.png&quot; alt=&quot;The Muru development dashboard showing the last minute's requests, latency and errors, the two workers and how busy they are, charts over time, and the most recent requests&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The top of the page shows the last minute at a glance, with requests per second, the 50th and 95th percentile latency, the count of server errors, how many requests are in flight and how many WebSockets are open. Below that is a table of the workers and how many of their threads are busy, charts of the same figures over time which can be switched to cover the last hour or the last day, the most recent requests with the same queue and application times as the access log, and the latest output from the run. A link from the workers table opens a page for each worker with its status, and the same stacks, tasks and memory figures the &lt;code&gt;muructl debug&lt;/code&gt; commands give. In a development run there is no sign-in, and the dashboard is read-only, so nothing on it can change what the run is doing.&lt;/p&gt;
&lt;p&gt;The same address also serves a &lt;code&gt;/metrics&lt;/code&gt; endpoint in the Prometheus format, so if you do want to point some other monitoring system at a development run, you can.&lt;/p&gt;
&lt;h2&gt;What's next&lt;/h2&gt;
&lt;p&gt;What I like about all this is that none of it required changing the application, installing an agent, or restarting anything, and all of it works the same way against an application running under the daemon in production. Along the way I have been talking about hosts, workers, the edge and admin sockets without really explaining them, so in the next post I will step back and look at how the parts of Muru fit together.&lt;/p&gt;</description>
	<pubDate>Sat, 07 Nov 2026 00:00:00 +0000</pubDate>
</item>
<item>
	<title>Peter Hoffmann: Stable URLs for 42,000 Huts and Peaks</title>
	<guid>https://peter-hoffmann.com/2026/stable-urls-for-huts-and-peaks.html</guid>
	<link>https://peter-hoffmann.com/2026/stable-urls-for-huts-and-peaks.html</link>
	<description>&lt;p&gt;&lt;a href=&quot;https://biwak.info&quot;&gt;biwak.info&lt;/a&gt; is my map of Alpine huts, bivouacs and peaks.
For a long time the only way to link to a hut was a &lt;code&gt;#Name&lt;/code&gt; hash that did an
exact name match and fell back to the search when the name wasn't unique. I
wanted real URLs I can share and that show a proper preview card in a chat:
&lt;a href=&quot;https://biwak.info/p/matterhorn&quot;&gt;biwak.info/p/matterhorn&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The data comes from &lt;a href=&quot;https://www.openstreetmap.org&quot;&gt;OpenStreetMap&lt;/a&gt;: 9,429 huts
and bivouacs and 33,437 named peaks, 42,866 places in total. Turning a name
into a URL is easy. The hard part is that many places share a name, and that a
URL, once shared, should keep pointing to the same place, even when the data
changes underneath.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://peter-hoffmann.com/static/2026/biwak-slug-card.webp&quot; alt=&quot;The preview card for https://biwak.info/p/bivouac-des-periades&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;From a name to a slug&lt;/h2&gt;
&lt;p&gt;The first step is a plain &lt;code&gt;slugify&lt;/code&gt;: German umlauts are written out, everything
else is folded to ASCII, lowercased, and every run of other characters becomes
a single dash.&lt;/p&gt;
&lt;div class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GERMAN_TRANSLIT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;maketrans&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;ä&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ae&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ö&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;oe&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ü&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ue&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;Ä&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;Ae&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;Ö&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;Oe&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;Ü&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;Ue&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                                 &lt;span class=&quot;s2&quot;&gt;&amp;quot;ß&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ss&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ẞ&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ss&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;slugify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;sd&quot;&gt;&amp;quot;&amp;quot;&amp;quot;German transliteration for umlauts and ß, then ASCII-fold, lowercase, everything else to single dashes.&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;translate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GERMAN_TRANSLIT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;unicodedata&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;normalize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;NFKD&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;encode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;ascii&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;ignore&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;ascii&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lower&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;re&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;[^a-z0-9]+&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;-&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;-&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;So &lt;code&gt;Oberaletschhütte&lt;/code&gt; becomes &lt;code&gt;oberaletschhuette&lt;/code&gt;, &lt;code&gt;Štuhecov dom&lt;/code&gt; becomes
&lt;code&gt;stuhecov-dom&lt;/code&gt; and &lt;code&gt;Rifugio Città di Busto&lt;/code&gt; becomes &lt;code&gt;rifugio-citta-di-busto&lt;/code&gt;.
None of the 42,866 names ends up empty.&lt;/p&gt;
&lt;h2&gt;Names are not unique&lt;/h2&gt;
&lt;p&gt;Alpine names repeat a lot. Every valley has its Jagdhütte and its Rosskopf.
With the name alone, the most common slugs are:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;Slug&lt;/th&gt;
  &lt;th&gt;Places&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jagdhuette&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;schwarzkogel&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;buchberg&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;rosskopf&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;mitterberg&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Before choosing a scheme, I measured how many places would still share a slug
with different suffixes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;Slug made of&lt;/th&gt;
  &lt;th&gt;Shared slugs&lt;/th&gt;
  &lt;th&gt;Places affected&lt;/th&gt;
  &lt;th&gt;Worst&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;name&lt;/td&gt;
  &lt;td&gt;2,181&lt;/td&gt;
  &lt;td&gt;6,690 (15.6 %)&lt;/td&gt;
  &lt;td&gt;70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;name + country&lt;/td&gt;
  &lt;td&gt;1,993&lt;/td&gt;
  &lt;td&gt;5,936 (13.8 %)&lt;/td&gt;
  &lt;td&gt;64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;name + region&lt;/td&gt;
  &lt;td&gt;1,516&lt;/td&gt;
  &lt;td&gt;3,932 (9.2 %)&lt;/td&gt;
  &lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;name + region + elevation&lt;/td&gt;
  &lt;td&gt;118&lt;/td&gt;
  &lt;td&gt;394 (0.9 %)&lt;/td&gt;
  &lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Adding the country barely helps: most duplicates are in the same country. The
region roughly halves them, but there are still 27 places called Jagdhütte in
Carinthia alone. Elevation fixes most of the rest. It can't fix everything,
because 4,311 of the huts have no elevation in OpenStreetMap. The worst case
in the last row is &lt;code&gt;casella&lt;/code&gt; in Liguria: 21 small huts with the same name and
no elevation.&lt;/p&gt;
&lt;p&gt;So there is no single suffix that always works, and adding all of them to every
URL would make &lt;code&gt;/p/matterhorn&lt;/code&gt; look like &lt;code&gt;/p/matterhorn-wallis-4478&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Add a suffix only while the name is shared&lt;/h2&gt;
&lt;p&gt;The rule I settled on: a place gets the shortest slug that is unique, trying
these in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;the plain name: &lt;code&gt;/p/matterhorn&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;name and region: &lt;code&gt;/p/oberaletschhuette&lt;/code&gt; stays plain, but a shared name
becomes something like &lt;code&gt;/p/rosskopf-tirol&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;name, region and elevation: &lt;code&gt;/p/rosskopf-tirol-2257&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;name, region and OpenStreetMap id: &lt;code&gt;/p/jagdhuette-kaernten-w1202594655&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In code this is a generator that yields the candidates for one place, given all
current places with the same name slug:&lt;/p&gt;
&lt;div class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;sd&quot;&gt;&amp;quot;&amp;quot;&amp;quot;Slugs for POI p in order of preference; `group` = all current POIs with the same name slug.&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;region&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;base&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slugify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;region&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;others&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;o&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;o&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;group&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;others&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;slugify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;region&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;region&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;o&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;others&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;ele&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;slugify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;region&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;region&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;ele&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;ele&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;o&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;others&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'ele'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'id'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The OpenStreetMap id is the last resort because it's always unique, but nobody
can read it. On today's data the 42,866 places split like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;Slug&lt;/th&gt;
  &lt;th&gt;Places&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;plain name&lt;/td&gt;
  &lt;td&gt;36,176&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;+ region&lt;/td&gt;
  &lt;td&gt;2,758&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;+ elevation&lt;/td&gt;
  &lt;td&gt;3,474&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;+ OpenStreetMap id&lt;/td&gt;
  &lt;td&gt;458&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Regions from Natural Earth&lt;/h2&gt;
&lt;p&gt;The region has to be something people recognise. I cut the
&lt;a href=&quot;https://www.naturalearthdata.com&quot;&gt;Natural Earth&lt;/a&gt; admin-1 boundaries down to
the units that make sense in the Alps: the Bundesland in Austria and Germany,
the Kanton in Switzerland, the département in France (Savoie and Haute-Savoie
are better known than the big régions), the regione in Italy with South Tyrol
and Trentino split, and the statistical regions in Slovenia. Names use the
German form where that is common (Wallis, Tessin, Aostatal), otherwise the
local one. The places on the map fall into 63 of these regions.&lt;/p&gt;
&lt;p&gt;Each place gets its region by a point-in-polygon lookup with
&lt;a href=&quot;https://shapely.readthedocs.io&quot;&gt;Shapely&lt;/a&gt;. The boundaries are simplified, so a
hut right on a border or a lake shore can fall outside every polygon. Then the
nearest polygon wins:&lt;/p&gt;
&lt;div class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;lookup&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lon&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;sd&quot;&gt;&amp;quot;&amp;quot;&amp;quot;(cc, region name); points just outside every polygon (coast, simplified border) take the nearest.&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;pt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Point&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lon&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tree&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;geoms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tree&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nearest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;props&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;cc&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;props&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The region is also shown on the preview card and in the detail view, next to
the type: &amp;quot;Biwak · Haute-Savoie, Frankreich&amp;quot;.&lt;/p&gt;
&lt;h2&gt;Stable: a registry that never forgets&lt;/h2&gt;
&lt;p&gt;The scheme above depends on the data. If someone adds a second Matterhorn to
OpenStreetMap tomorrow, the plain rule would rename the real one to
&lt;code&gt;matterhorn-wallis&lt;/code&gt;, and every link shared so far would break.&lt;/p&gt;
&lt;p&gt;So the build keeps a &lt;strong&gt;slug registry&lt;/strong&gt;, &lt;code&gt;data/slug-registry.json&lt;/code&gt;, which is
committed to git. It maps every slug ever handed out to the OpenStreetMap id
and the name:&lt;/p&gt;
&lt;div class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;matterhorn&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;n26863664&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;Matterhorn&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;quot;oberaletschhuette&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;n11262553552&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;Oberaletschhütte&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The assignment then follows three rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A slug in the registry &lt;strong&gt;never moves to another place&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A place &lt;strong&gt;keeps its registry slug&lt;/strong&gt; as long as it still fits its current
name, even if the plain rule would now give it a different one. When a
second Matterhorn shows up, the old one stays &lt;code&gt;matterhorn&lt;/code&gt; and only the
newcomer gets a suffix.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;new or renamed&lt;/strong&gt; place gets the first candidate that isn't taken yet.
The old slug of a renamed place stays in the registry and redirects to the
new one.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;primary&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pois&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;fitting&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;by_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;base&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;startswith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;base&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&amp;quot;-&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fitting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;primary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fitting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;lambda&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;sorted&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;groups&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;groups&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;primary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;slug&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;groups&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;registry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;registry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;slug&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]]&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;primary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slug&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A place that disappears from OpenStreetMap keeps its slug reserved. Opening
it starts a search for the old name instead of showing someone else's hut.&lt;/p&gt;
&lt;p&gt;Because the registry is a sorted JSON file with one slug per line, a build
that changes slugs shows up as a readable git diff. That made one mistake easy
to spot: the first version folded &lt;code&gt;ü&lt;/code&gt; to &lt;code&gt;u&lt;/code&gt;, so the Oberaletschhütte was
&lt;code&gt;/p/oberaletschhutte&lt;/code&gt;. Two weeks after launch I switched to &lt;code&gt;ue&lt;/code&gt;, which changed
7,182 slugs. I rebuilt the registry from scratch, so the old slugs are gone
instead of redirecting.&lt;/p&gt;
&lt;h2&gt;Two fallbacks&lt;/h2&gt;
&lt;p&gt;A plain name that is shared, like &lt;a href=&quot;https://biwak.info/p/rosskopf&quot;&gt;/p/rosskopf&lt;/a&gt;,
doesn't return an error. It opens the search with exactly the places of that
name, with their type, elevation and region, and each of them links to its own
slug. The same works one level down for &lt;code&gt;/p/rosskopf-tirol&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A slug that ends in an OpenStreetMap id always resolves, even if the name part
is wrong or outdated. &lt;code&gt;/p/old-name-n11262553552&lt;/code&gt; answers with a 301 to
&lt;code&gt;/p/oberaletschhuette&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Limits&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Readable doesn't mean unique.&lt;/strong&gt; 458 places end with an OpenStreetMap id in
their slug. Better elevation data in OpenStreetMap would shrink that number,
and the registry would still keep the old slugs working as redirects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The registry only grows.&lt;/strong&gt; It has one line per slug ever handed out,
42,866 today.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Changing the rules touches thousands of URLs.&lt;/strong&gt; Any change to &lt;code&gt;slugify&lt;/code&gt; or
the regions renames a large part of the places, as the umlaut switch showed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same &lt;code&gt;/p/&lt;/code&gt; URLs also let the map open peaks directly, as described in
&lt;a href=&quot;https://peter-hoffmann.com/2026/loading-peaks-on-demand-with-a-z10-tile-grid.html&quot;&gt;Loading 33,000 Peaks on Demand with a z10 Tile Grid&lt;/a&gt;.&lt;/p&gt;</description>
	<pubDate>Sat, 07 Nov 2026 00:00:00 +0000</pubDate>
</item>

</channel>
</rss>
