<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Rafael de Carvalho]]></title><description><![CDATA[Rafael de Carvalho]]></description><link>https://eurafaeldecarvalho.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 04:50:26 GMT</lastBuildDate><atom:link href="https://eurafaeldecarvalho.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I built floorup: bumping pyproject.toml floors without touching anything else]]></title><description><![CDATA[A small Rust CLI that raises the version floors in pyproject.toml to the latest PyPI release, and keeps every other byte of the file the same.]]></description><link>https://eurafaeldecarvalho.hashnode.dev/how-i-built-floorup-bumping-pyproject-toml-floors-without-touching-anything-else</link><guid isPermaLink="true">https://eurafaeldecarvalho.hashnode.dev/how-i-built-floorup-bumping-pyproject-toml-floors-without-touching-anything-else</guid><category><![CDATA[Python]]></category><category><![CDATA[Rust]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Rafael de Carvalho]]></dc:creator><pubDate>Thu, 29 Oct 2026 17:39:50 GMT</pubDate><content:encoded><![CDATA[<p><em>I'm Rafael, the author of floorup. This post is about how it works and where it falls short. Disclosure: this article was written by an AI assistant that publishes for me, working from floorup's README and source code.</em></p>
<h2>The problem</h2>
<p>If you use uv, you probably know this. You run <code>uv lock --upgrade</code>, the lock file moves forward, and your <code>pyproject.toml</code> stays where it was. So the project still says <code>httpx&gt;=0.23</code> while it really runs 0.28. After a year or two the floors in <code>pyproject.toml</code> say very little about what the project actually needs.</p>
<p>uv has no command for this yet. The issue is <a href="https://github.com/astral-sh/uv/issues/6794">astral-sh/uv#6794</a>, "Upgrade dependencies in <code>pyproject.toml</code> (<code>uv upgrade</code>)". It was opened in August 2024, and on 2026-10-28 it had 537 👍. The right long-term place for this is uv itself. I wanted something I could use until then, so I built a small separate tool: <a href="https://github.com/eurafaeldecarvalho/floorup">floorup</a>. It is not affiliated with Astral.</p>
<p>What it does, in one line: raise every version floor in <code>pyproject.toml</code> to the latest PyPI release, in one command, without touching anything else.</p>
<pre><code class="language-console">$ floorup --dry-run
bumped 5 dependencies in 0.195s; 1 exact pins unchanged
httpx&gt;=0.23  -&gt;  httpx&gt;=0.28.1  (project.dependencies)
pydantic&gt;=2.0,&lt;3  -&gt;  pydantic&gt;=2.14.1,&lt;3  (project.dependencies)
rich~=13.0  -&gt;  rich~=13.9  (project.dependencies)
pytest&gt;=7  -&gt;  pytest&gt;=9.1.1  (dependency-groups.dev)
ruff&gt;=0.1.0  -&gt;  ruff&gt;=0.17.3  (dependency-groups.dev)
</code></pre>
<p>(That is real output from 2026-10-27. Your versions will be newer.)</p>
<h2>The hard part: do not touch anything else</h2>
<p>The first idea is easy: load the TOML, change the strings, write it back. The problem is the "write it back" part. People put comments next to dependencies, line up columns, choose single or double quotes, and leave blank lines on purpose. A tool that re-serializes the file will change some of that, and then the diff in your pull request is full of noise. I did not want a diff with even one unrelated line.</p>
<p>So floorup never re-serializes the file. It is written in Rust and uses <code>toml_edit</code>, but only to <em>find</em> things. It parses the file with <code>ImDocument</code>, which keeps the byte span of every value in the original text. (<code>DocumentMut</code> turns those into owned strings, which would force a full rewrite.) For each dependency string, floorup knows exactly where it starts and ends in the file.</p>
<p>Then there is a second, smaller parser for the requirement string itself (PEP 508). It also keeps spans: where the operator is and where the version is. Names, extras, markers and spaces are not touched. The edit at the end is a byte splice: take the original text, replace only the operator and version bytes, and keep the rest. That code is in <code>src/edit.rs</code>.</p>
<p>To check this, there is a function called <code>preserves_unrelated_bytes</code>. It removes the edited spans from the old text and from the new text, and checks that what is left is identical. The tests call it all over the place. If any byte outside an edited specifier changes, the test fails.</p>
<p>The tables it edits are <code>[project] dependencies</code>, <code>[project.optional-dependencies]</code>, <code>[dependency-groups]</code>, <code>[tool.uv] dev-dependencies</code> and <code>[build-system] requires</code>.</p>
<h2>Version policy</h2>
<p>Finding the newest version is the easy part. Deciding what to write is where most of the logic lives (<code>src/policy.rs</code>). Version parsing and comparison use the <code>pep440_rs</code> crate. The rules are:</p>
<ul>
<li><code>&gt;=2.28</code> becomes <code>&gt;=</code> the newest release.</li>
<li>Caps (<code>&lt;</code>, <code>&lt;=</code>) are never widened. With <code>&gt;=2.0,&lt;3</code>, the floor moves to the newest release under the cap.</li>
<li><code>~=13.0</code> becomes <code>~=13.9</code>. It stays in its series with the same number of components. <code>--expand-compatible</code> lets it leave.</li>
<li><code>!=</code> is respected. The chosen release must satisfy every exclusion.</li>
<li><code>&gt;2.28</code> is rewritten to <code>&gt;=</code> only when the new release is strictly newer and still satisfies the old <code>&gt;</code>.</li>
<li><code>==1.2.3</code> stays unless you pass <code>--bump-pins</code>. <code>==1.*</code> and <code>===</code> are never rewritten.</li>
<li>A dependency with no bound stays without a bound. floorup never invents a bound you did not write.</li>
<li>URLs, paths and VCS references are skipped. Extras and markers are carried through.</li>
<li>Pre-releases and dev releases are ignored unless you pass <code>--pre</code> (or the specifier already names one). Fully yanked releases, releases with no files and local versions are ignored.</li>
</ul>
<h2>Speed</h2>
<p>floorup reads versions straight from the PyPI JSON API with <code>reqwest</code> (blocking client, rustls). Lookups run in parallel with <code>std::thread::scope</code> and 16 workers by default (<code>--jobs</code>). Responses are cached on disk for one hour, and <code>--offline</code> uses only the cache.</p>
<p>The number I measured: on FastAPI's real <code>pyproject.toml</code>, 90 dependencies bumped in 0.165 s, with 0 unrelated diff lines. That is the median of 7 cold runs (empty cache, PyPI lookups included) on 2026-10-27, using <code>scripts/measure.py</code> from the repo. The full diff is in <code>docs/fastapi-dry-run.diff</code>, so you can check it yourself.</p>
<h2>Tests</h2>
<p>Since the whole point is "don't break my file", most of the work went into tests:</p>
<ul>
<li><strong>Golden files.</strong> Real <code>pyproject.toml</code> files from FastAPI, Flask, httpx, Pydantic, Rich and Ruff, bumped against a fixed version grid so the expected output does not change when PyPI does.</li>
<li><strong>Property tests</strong> with <code>proptest</code>. Random but valid pyproject files, and the check is always that only specifier bytes changed.</li>
<li><strong>Adversarial tests</strong> for edge cases the golden files do not cover.</li>
<li><strong>A mocked PyPI server</strong>, so CI never talks to the network.</li>
<li><strong>An optional live test</strong> against real PyPI (<code>FLOORUP_PYPI_LIVE=1 cargo test --test live</code>).</li>
</ul>
<p>CI runs <code>cargo fmt --check</code>, <code>cargo clippy -D warnings</code> and <code>cargo test</code>.</p>
<p>To be honest about how it was made: the initial implementation was written with an AI coding agent, and it is covered by the test suite above. The tests are the part I trust.</p>
<h2>--check and --lock</h2>
<p>Two flags made it useful for me beyond running it by hand.</p>
<p><code>--check</code> writes nothing and exits 1 if any bound would move. You can run it on a schedule in CI, and a stale <code>pyproject.toml</code> fails a job the same way unformatted code does. <code>--dry-run</code> prints a unified diff (made with the <code>similar</code> crate) and writes nothing.</p>
<p><code>--lock</code> writes the file and then runs <code>uv lock</code>. If locking fails, <code>pyproject.toml</code> is restored. This matters because of the main design choice: floorup reads the index, not a resolver.</p>
<h2>Limitations</h2>
<p>Reading the index directly is why it is fast and does not need a venv or a lock file. It is also the biggest limitation: each package gets its newest eligible release on its own, and nobody checks that all of them resolve together. Run <code>uv lock</code> or use <code>--lock</code> for that. Note that <code>--lock</code> restores <code>pyproject.toml</code> on failure, but not <code>uv.lock</code> if uv rewrote it before failing.</p>
<p>Other things it does not do:</p>
<ul>
<li>No uv workspace walk. Run it once per member (<code>floorup packages/foo</code>).</li>
<li><code>requires-python</code> is not edited.</li>
<li>No Poetry tables.</li>
<li>uv's <code>constraint-dependencies</code>, <code>override-dependencies</code> and <code>build-constraint-dependencies</code> are not edited.</li>
<li>Only the PyPI JSON API. You can point <code>--index-url</code> at a mirror, but indexes that only serve the simple API do not work.</li>
</ul>
<h2>Other tools for the same gap</h2>
<p>Several people have built tools for this, with different trade-offs, and one of them may suit you better. <a href="https://github.com/zundertj/uv-bump">zundertj/uv-bump</a> runs <code>uv sync</code> and sets the floors to what uv actually resolved, and it supports workspaces. <a href="https://github.com/Rezarys/uvbump">Rezarys/uvbump</a> also preserves formatting, has <code>--check</code>, runs <code>uv lock</code> by default and also edits uv's constraint and override tables. <a href="https://github.com/Alirex/uv_upgrade">Alirex/uv_upgrade</a> works from <code>uv.lock</code> with workspace support and rollback. <a href="https://github.com/duriantaco/fyn">duriantaco/fyn</a> is a uv fork with its own resolver. <a href="https://github.com/rafaelsq/uvu">rafaelsq/uvu</a> is interactive, one package at a time. <a href="https://github.com/janusheide/uppd">janusheide/uppd</a> is <code>pur</code>-style, and <a href="https://github.com/MousaZeidBaker/poetry-plugin-up">poetry-plugin-up</a> is the one to use for Poetry projects. The README has a table comparing them.</p>
<p>And when uv ships a native command for #6794, use that.</p>
<h2>Try it</h2>
<p>It needs Rust 1.88 or newer. There is no <a href="http://crates.io">crates.io</a>, PyPI or Homebrew release yet, so you install it from git:</p>
<pre><code class="language-sh">cargo install --locked --git https://github.com/eurafaeldecarvalho/floorup
floorup --dry-run
</code></pre>
<p>Repo (MIT): <a href="https://github.com/eurafaeldecarvalho/floorup">https://github.com/eurafaeldecarvalho/floorup</a></p>
<p>The most useful thing you can send me is a <code>pyproject.toml</code> that floorup mangles, or a specifier where it gets the wrong answer. There are issue templates for that.</p>
]]></content:encoded></item></channel></rss>