01 Sep 2026
Planet Python
Graham Dumpleton: Unit testing with wrapture
In introducing wrapture I said the one idea everything sits on is to wrap rather than replace. That is easy to say and harder to see the point of, so this post takes a small piece of code and writes tests for it twice, once with unittest.mock and once with wrapture. I am not going to walk through every mock idiom and show its wrapture spelling, since a good part of the time the two are doing the same thing with different syntax, and the comparison page in the documentation already maps one onto the other. What I want to show is the handful of cases where the difference is structural, where wrapping the real code lets a test say something that substitution cannot.
The code under test
An order service which takes a payment through a gateway, records it in a ledger, and sends a notification. If the ledger write fails, the payment is refunded and the error propagates. The collaborators can be injected through the constructor, so there is a seam for a mock to sit behind, and the payment step goes through a private method on the service itself.
class Gateway:
def charge(self, amount, currency="USD"):
return {"id": f"ch_{amount}", "amount": amount}
def refund(self, charge_id):
return {"id": f"re_{charge_id}"}
class Ledger:
def record(self, entry):
return f"led_{entry['id']}"
class Notifier:
def send(self, message):
return True
class OrderService:
def __init__(self, gateway=None, ledger=None, notifier=None):
self.gateway = Gateway() if gateway is None else gateway
self.ledger = Ledger() if ledger is None else ledger
self.notifier = Notifier() if notifier is None else notifier
def place(self, amount):
charge = self._take_payment(amount)
try:
self.ledger.record(charge)
except Exception:
self.gateway.refund(charge["id"])
raise
self.notifier.send(f"order {charge['id']} placed")
return charge
def _take_payment(self, amount):
return self.gateway.charge(amount)
Where the two look the same
Stubbing a return value is the bread and butter of both tools, and on the surface there is nothing to choose between them:
from unittest.mock import patch
def test_stub_with_mock():
with patch.object(Gateway, "charge", return_value={"id": "stub", "amount": 0}):
assert OrderService().place(500)["id"] == "stub"
import wrapture
def test_stub_with_wrapture():
with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub", "amount": 0}):
assert OrderService().place(500)["id"] == "stub"
Even here there is a difference under the surface. A mock only checks a stubbed call against the real signature if you asked for autospec=True, so without it a call that has drifted from the method's signature returns the stub happily:
def test_drifted_call_with_mock():
with patch.object(Gateway, "charge", return_value={"id": "stub"}):
assert Gateway().charge(500, bogus=True) == {"id": "stub"}
That test passes. A wrapture binding is strict by default, so a call the real method would have rejected is rejected by the stub too:
def test_drifted_call_with_wrapture():
with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub"}):
with pytest.raises(TypeError):
Gateway().charge(500, bogus=True)
The error names the site and the problem:
TypeError: orders:Gateway.charge (stubbed): got an unexpected keyword argument 'bogus'
There is a strict=False option for the rare patch which genuinely means to accept a different shape, but the default is the direction I care about. A test which passes because the stub was more forgiving than the real code is a test that will be wrong in production.
Calls an object makes to itself
The mock approach to testing OrderService is to inject a MagicMock as the gateway and assert on what it recorded:
from unittest.mock import MagicMock
def test_self_call_with_mock():
gateway = MagicMock()
service = OrderService(gateway=gateway)
service.place(500)
gateway.charge.assert_called_once_with(500)
Look at what the mock actually saw, by printing gateway.mock_calls after the call:
[call.charge(500),
call.charge().__getitem__('id'),
call.charge().__getitem__().__str__(),
call.charge().__getitem__('id'),
call.charge().__getitem__().__str__()]
The charge() call is there, followed by a trail of fabricated chains as the service reached into a return value that was never a real dictionary. What is not there, and cannot be, is _take_payment(). The call from place() to _take_payment() never crosses the seam the mock sits behind, so as far as the test can tell the private method does not exist. If you instead reach for patch.object(OrderService, "_take_payment") to get at it, you have replaced it, and now the real payment logic does not run and the gateway is never charged. Either the method is invisible or it is gone.
With wrapture the binding is on the class, so a call the object makes to itself passes through the wrapper like any other:
def test_self_call_with_wrapture():
take_payment = wrapture.binding(OrderService, "_take_payment")
charge = wrapture.binding(Gateway, "charge")
with wrapture.timeline(take_payment, charge) as tape:
OrderService().place(500)
take_payment.events.with_args(amount=500).assert_once()
assert tape.parent_of(charge.events.first) is take_payment.events.first
The second assertion says the charge happened inside the payment step, and tape.tree() shows the same thing:
orders:OrderService._take_payment(amount=500) -> {'id': 'ch_500', 'amount': 500}
orders:Gateway.charge(amount=500, currency='USD') -> {'id': 'ch_500', 'amount': 500}
Real arguments, normalised against the real signature so the currency default appears even though the caller never passed it, and real return values, nested the way the calls actually nested.
Running the real code while changing one thing
This is where substitution runs out of road entirely. Mock(wraps=real) will forward calls to the real method, but it cannot change the arguments the real method receives, and it cannot touch the result on the way back. The standard library has no way to say "run the real method, but change one thing about it".
For wrapture that is the ordinary case. Here the real charge() runs and only the id in its result is rewritten, which is useful when a real id would be unstable across runs but everything else about the result matters:
def test_pinned_result_with_wrapture():
charge = wrapture.binding(Gateway, "charge")
charge.on_call.transforms_result(lambda r: {**r, "id": "ch_TEST"})
with charge:
assert OrderService().place(500) == {"id": "ch_TEST", "amount": 500}
transforms_args() does the same on the way in, and validates_args() and validates_result() check without changing. These are stages, and they compose, so a binding can rewrite one argument and check the result at the same time while the real code does the actual work in between.
Asserting on what did not happen
The tests that matter most are usually on the error paths, and the interesting fact on an error path is often an absence. When the ledger write fails, the refund must be issued and the notification must not be sent. Here it is with mock, and it takes three doubles to write:
def test_error_path_with_mock():
gateway = MagicMock()
ledger = MagicMock()
ledger.record.side_effect = OSError("disk full")
notifier = MagicMock()
service = OrderService(gateway, ledger, notifier)
with pytest.raises(OSError):
service.place(500)
gateway.refund.assert_called_once_with(gateway.charge.return_value["id"])
notifier.send.assert_not_called()
The refund assertion is the awkward part. Because the gateway is a mock, the charge id is a fabricated MagicMock rather than "ch_500", so the only way to assert on it is to ask the mock what it invented. The test cannot say "the refund was for the charge that was taken", only "the refund was passed whatever charge() returned", which is the same thing only if you trust the code you are testing. (With a plain Mock rather than MagicMock the test does not even get that far, since charge["id"] fails with a TypeError about subscripting.)
With wrapture the failure is injected at the ledger and nothing else is touched:
def test_error_path_with_wrapture():
charge = wrapture.binding(Gateway, "charge")
refund = wrapture.binding(Gateway, "refund")
record = wrapture.binding(Ledger, "record")
send = wrapture.binding(Notifier, "send")
record.on_call.raises(OSError("disk full"))
with wrapture.timeline(charge, refund, record, send) as tape:
with pytest.raises(OSError):
OrderService().place(500)
refund.events.with_args(charge_id="ch_500").assert_once()
send.events.assert_never()
tape.assert_order(charge, record, refund)
The real gateway was charged, so the refund is asserted against the real charge id. The notifier is real and was never called. And assert_order() says the refund came after the failed ledger write, across three different bindings. The tape shows exactly that:
orders:Gateway.charge(amount=500, currency='USD') -> {'id': 'ch_500', 'amount': 500}
orders:Ledger.record(entry={'id': 'ch_500', 'amount': 500}) !! OSError (injected)
orders:Gateway.refund(charge_id='ch_500') -> {'id': 're_ch_500'}
When an assertion fails, the message shows what was recorded rather than just the count that was wrong. Asserting on a refund for the wrong id gives:
AssertionError: expected exactly 1 event(s), got 0
<EventLog orders:Gateway.refund[charge_id='ch_999']: 0 event(s)>
(no events)
filtered from:
<EventLog orders:Gateway.refund: 1 event(s)>
orders:Gateway.refund(charge_id='ch_500')
The "filtered from" section is there because an over-narrowed filter producing an empty log is the easiest way to get a wrong assertion, and showing what the filter discarded is the fastest way to see it.
How the tests are shaped
Beyond what the assertions can say, the shape of the test code changes in a few ways that are worth pointing out.
A binding declares a target without touching it. binding() never patches, so bindings can be created at module scope, given behaviour, and shared, with each test applying and removing them. Nothing happens until apply(), a with block, or a timeline. That separation of declaration from effect is what lets the four bindings in the error path test be declared up front and read like a cast list.
The with block is the primitive, and for a test that binds one or two targets around its whole body there is a decorator form that says the same thing without the nesting. The bindings arrive as keyword arguments, and expectations can be declared on the decorator and are verified when the test finishes:
@wrapture.taped()
@wrapture.bound(Ledger, "record").on_call.raises(OSError("disk full"))
@wrapture.bound(Gateway, "refund").expect_once()
@wrapture.bound(Notifier, "send").expect_never()
def test_error_path_with_decorators(tape, record, refund, send):
with pytest.raises(OSError):
OrderService().place(500)
That is the same test as before with the assertions moved to the top as a contract, and a body that only performs the action. An expectation with nothing recording is an error rather than a silent pass.
Fixtures work the way you would expect, and because a fixture yields the binding a test can reconfigure it mid-flight, which is how a test walks a collaborator through failing and then recovering:
@pytest.fixture
def stub_charge():
with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub", "amount": 0}) as charge:
yield charge
def test_gateway_recovers(stub_charge):
stub_charge.on_call.raises(TimeoutError("down"))
with pytest.raises(TimeoutError):
OrderService().place(500)
stub_charge.on_call.returns({"id": "retry", "amount": 0})
assert OrderService().place(500)["id"] == "retry"
Finally there is an opt-in pytest plugin, enabled with one line in conftest.py, which fails any test that leaves a binding applied and attaches the tape's tree to the failure report of any test that recorded one. The leak sweep is the thing I would turn on first. A patch that leaks changes the behaviour of every test that runs after it, and I suspect plenty of people have lost hours to that without ever knowing which test was the culprit.
Where mock still fits
Everything above follows one rule, which is to wrap the real code and record what actually flowed, with one deliberate opt-out. When the test itself must supply the thing being called, because the code under test receives a callback or a collaborator rather than importing one, stub() supplies a single callable and mock(Spec) a whole object. Both are strict, in that signatures are checked and a name the spec does not have raises, and both record onto the same tape as everything else.
What wrapture does not provide is a spec-less MagicMock() whose attributes exist on first touch and whose call chains all answer. That is the thing which would let a misspelled gateway.chargee.assert_not_called() pass silently in the tests above, and it is left out on purpose. If a test wants an object invented as it is touched, unittest.mock is the tool for that, and it has the other advantage of being in the standard library, on every team's common ground. The two coexist in one suite without difficulty, and the comparison page is there for translating between them.
What's next
The error path test leaned on the timeline and the tape without much explanation of what they are or what an event holds. That is the subject of the next post, where the example is a resource leak, which is a bug nothing in a return value will ever tell you about.
01 Sep 2026 9:38pm GMT
Graham Dumpleton: Recording calls with wrapture
In unit testing with wrapture the tests leaned on a timeline and a tape to assert on what happened, and I skipped over what those actually are. This post is about the recording side of wrapture: what gets recorded, what one event holds, how a test reads the record back, and the whole-tape views that answer questions about the flow between calls rather than about any one of them.
The example is a resource leak, because it is the kind of bug the recording model was made for. Code that acquires a connection has to release it on every path out: the normal return, the early return, and the exception. The path that forgets is the one nobody looks at, and it does not fail. Nothing raises, nothing returns the wrong value, the test passes, and the pool runs dry a week later in production. The failure is an absence, and asserting on an absence needs a record of what did happen, on the real objects, including objects minted mid-call that the test never held.
The code under test
A stand-in for any pooled resource. Database.connect() mints a Connection, and a connection answers queries until close() sets its closed flag:
class Connection:
def __init__(self, number):
self.number = number
self.closed = False
def execute(self, sql):
if self.closed:
raise RuntimeError("connection is closed")
return [(1, "widget")] if "id = 1" in sql else []
def close(self):
self.closed = True
def __repr__(self):
return f"<Connection {self.number}>"
class Database:
def __init__(self):
self.issued = 0
def connect(self):
self.issued += 1
return Connection(self.issued)
The repository is where the bug lives. count() releases in a finally, so it is safe on every path. find() releases only when a row was found; the not-found early return leaks its connection:
class Repository:
def __init__(self, database):
self.database = database
def count(self, table):
connection = self.database.connect()
try:
return len(connection.execute(f"SELECT * FROM {table}"))
finally:
connection.close()
def find(self, table, key):
connection = self.database.connect()
rows = connection.execute(f"SELECT * FROM {table} WHERE id = {key}")
if not rows:
return None
connection.close()
return rows[0]
def report(repository, keys):
found = [repository.find("products", key) for key in keys]
return repository.count("products"), [row for row in found if row]
Running report(Repository(Database()), [1, 2]) returns (0, [(1, 'widget')]), which is correct. Nothing about that result says a connection was left open.
The usual way to test this is a hand-written fake Database whose connect() appends to a list, with connections that flip a flag, and a test that walks the list. It works, but it tests a substitute. The real classes never run, the fake has to be kept in step with them, and every acquiring class in the codebase needs its own. The record you want is of the real calls.
The timeline and the tape
Bind connect on Database and close on Connection, and record both onto one tape. Neither binding has any behaviour configured, so they observe and nothing else:
connect = wrapture.binding(Database, "connect")
close = wrapture.binding(Connection, "close")
with wrapture.timeline(connect, close) as tape:
report(Repository(Database()), [1, 2])
print(tape.tree())
__main__:Database.connect() -> <Connection 1>
__main__:Connection.close() -> None
__main__:Database.connect() -> <Connection 2>
__main__:Database.connect() -> <Connection 3>
__main__:Connection.close() -> None
Three acquisitions, two releases, and reading down the tape you can already see which one has no partner.
The two words are two views of one thing. The timeline is the scope: with wrapture.timeline(...) opens it, the bindings handed to it are applied on entry and removed on exit, and while it is open every call through every applied binding records an event. The tape is what the scope holds. Bindings applied by other means, a fixture or an outer with, record onto an open tape as well, and a binding applied with no timeline open records nothing and costs almost nothing beyond wrapt's own dispatch, so leaving bindings applied and only occasionally recording is a supported pattern rather than a mistake.
Notice that close is bound on the Connection class, not on any connection object. The connections do not exist when the test starts; connect() mints them mid-call. A binding on the class wraps the method for every instance, present and future, which is exactly what covers objects a factory hands out. A mock injected through a seam cannot see those objects at all.
What one event holds
Each call through a binding inside the scope records one event, and an event is a good deal richer than a mock's call record. The fields a test typically reads are path, the fully qualified location in module:qualname form; instance, the object the method was called on; arguments, the call normalised against the real signature with defaults applied, so charge(500) and charge(amount=500) record identically; result, the real return value, or exception when the call raised instead; and seq, parent_id and depth, which place the event in the call tree. There are timings too, started and duration, with recording's own bookkeeping excluded from the figure.
Because the values are real, they can be compared across events. A connect event's result is the connection it minted, and a close event's instance is the connection it was called on, so the leaked connections are the difference between the two sets:
with wrapture.timeline(connect, close):
report(Repository(Database()), [1, 2])
acquired = {event.result for event in connect.events}
released = {event.instance for event in close.events}
print(acquired - released)
{<Connection 2>}
That is the whole question answered, and it needed nothing from the repository. Events record what actually flowed, behaviour included: a call stubbed with returns() records the stubbed result, a failure injected with raises() records that exception, and when transforms_args() rewrote the arguments the event keeps both the arguments as the caller sent them and the ones the real method received, which no substitution-based tool can record because replacing a function discards what it would have been called with.
Filters narrow, assertions conclude
A binding's events property is a filterable view over the tape for that one binding, and it works inside the with block after the code under test has run. One naming rule holds across the whole package: a method whose name starts with assert_ raises immediately, one starting with expect_ declares and is checked when the scope closes, and everything else returns data. A mistyped assertion name is therefore an AttributeError rather than the silent pass mock's assert_calld_once was famous for.
Filters chain and never raise. with_args(amount=500) keeps calls whose normalised arguments include the given values, with_instance(obj) keeps calls made on exactly that object by identity, raising(TimeoutError) keeps calls that raised, returning(value) keeps calls that returned it, and matching(predicate) is the escape hatch. Assertions then conclude: assert_never(), assert_once(), assert_times(n), assert_at_least(n) and assert_at_most(n). Each returns the log on success so a passing assertion can keep chaining, and each prints the events it looked at on failure. Asserting three closes when there were two gives:
AssertionError: expected exactly 3 event(s), got 2
<EventLog __main__:Connection.close: 2 event(s)>
__main__:Connection.close()
__main__:Connection.close()
An assertion is written where it runs. An expectation is the same claim declared on the binding up front, before the run, and verified when the timeline exits:
close = wrapture.binding(Connection, "close").expect_times(3)
with wrapture.timeline(connect, close):
report(Repository(Database()), [1, 2])
ExpectationNotMetError: declared expectation on __main__:Connection.close not met: expected exactly 3 event(s), got 2
<EventLog __main__:Connection.close: 2 event(s)>
__main__:Connection.close()
__main__:Connection.close()
ExpectationNotMetError derives from AssertionError, so test frameworks report it as a failure. Expectations read as a contract at the top of the test with the body free of bookkeeping, and an expectation with nothing recording is an error rather than a pass. Verification is skipped when the block itself raised, since the in-flight failure is the real cause and a verification error on top would bury it.
The tree names the culprit
Counting says something leaked, and pairing says what. To say who, add the repository methods to the timeline. The tape then nests each acquire and release under the method that made it, and tape.children_of() walks the tree, so a root whose children include a connect but no close names itself:
find = wrapture.binding(Repository, "find")
count = wrapture.binding(Repository, "count")
with wrapture.timeline(find, count, connect, close) as tape:
report(Repository(Database()), [1, 2])
print(tape.tree())
for caller in tape.roots():
paths = [child.path for child in tape.children_of(caller)]
if "__main__:Connection.close" not in paths:
print("leaked by", caller)
__main__:Repository.find(table='products', key=1) -> (1, 'widget')
__main__:Database.connect() -> <Connection 1>
__main__:Connection.close() -> None
__main__:Repository.find(table='products', key=2) -> None
__main__:Database.connect() -> <Connection 2>
__main__:Repository.count(table='products') -> 0
__main__:Database.connect() -> <Connection 3>
__main__:Connection.close() -> None
leaked by __main__:Repository.find(table='products', key=2)
The tree shows the bug as it happened. find() with a key that matched released its connection, find() with a key that did not match never called close(), and count() released on the way out of its finally.
When the method is long, or acquires in several places, you want the line rather than the method. Stack capture on the acquire binding records the calling frame with each event, priced per binding so only the acquire pays for it:
connect = wrapture.binding(Database, "connect", stack="caller")
with wrapture.timeline(connect, close):
report(Repository(Database()), [1, 2])
released = {event.instance for event in close.events}
for event in connect.events:
if event.result not in released:
frame = wrapture.stack_frames(event.stack)[0]
print(f"{event.result} acquired at line {frame.lineno} in {frame.function}, never released")
<Connection 2> acquired at line 40 in Repository.find, never released
Order across bindings
Per-binding logs answer questions about one call site; the tape answers questions about the flow between them. tape.assert_order(connect, close) is a subsequence check across any bindings: other events may appear before, between and after, and only the relative order of the named bindings' events matters. A step can also be a filtered log, which is how to say which call, so tape.assert_order(charge.events.raising(TimeoutError), refund) reads as "the refund came after the charge that timed out". consecutive=True requires the steps to match a consecutive run with nothing of those bindings' in between, and exact=True requires those bindings' events to be exactly the steps, which are mock's assert_has_calls and mock_calls == respectively, except that they work across bindings instead of within one mock.
On failure the message names where the walk stalled and prints the actual timeline, which reads far better than a list diff. Asserting a close before a connect on a run that only leaked:
AssertionError: expected order not satisfied; stalled waiting for __main__:Database.connect (position 2 of 2)
actual timeline:
__main__:Repository.find(table='products', key=2)
__main__:Database.connect()
__main__:Repository.count(table='products')
__main__:Database.connect()
__main__:Connection.close()
Scoping instead of resetting
A tape is never cleared. Where a mock suite reaches for reset_mock() to discard setup calls before the act step, wrapture opens the timeline around the part that counts. Timelines nest, and an inner timeline() with no arguments records only what happens inside it while the outer one keeps the whole run:
with wrapture.timeline(connect, close) as whole:
repository = Repository(Database())
repository.count("products") # lands on `whole` only
with wrapture.timeline() as act:
repository.find("products", 1)
connect.events.assert_once() # the act step alone
Inside the inner block connect.events reads the innermost tape, so the count is one even though the outer tape holds four events. The same scoping is how a phased test keeps each phase's counts separate, one timeline per phase, with the same bindings applied on entry and removed on exit each time. The second phase can then state assert_never() outright, where one cumulative tape could only say the count is still one.
Messages and phases as events
Calls are not the only thing that records. An attribute binding records reads and writes of an attribute as get and set events on the same tape, which for this example means the closed flag can be watched directly rather than inferred from close() being called. That is a subject for a later post. Two other event producers are worth knowing about now, because they change what a test can pin an assertion to.
The first is log capture. capture_logs() records standard library logging onto the tape as events of kind "log", selected by logger name pattern and level, and it applies like a binding so timeline() accepts it alongside them. Give the repository a warning when nothing is found, and the message lands inside the call that logged it:
logs = wrapture.capture_logs("myapp.*")
with wrapture.timeline(find, connect, close, logs) as tape:
report(Repository(Database()), [1, 2])
print(tape.tree())
warning = logs.events.at_level("WARNING").with_message("*no row*").assert_once().first
assert tape.parent_of(warning) is find.events.with_args(key=2).first
__main__:Repository.find(table='products', key=1) -> (1, 'widget')
__main__:Database.connect() -> <Connection 1>
__main__:Connection.close() -> None
__main__:Repository.find(table='products', key=2) -> None
__main__:Database.connect() -> <Connection 2>
log myapp.repo WARNING 'no row in products with id 2'
__main__:Database.connect() -> <Connection 3>
__main__:Connection.close() -> None
That last assertion is the one pytest's caplog has no words for: the warning was logged by this call, not merely somewhere during the test. Capture sits at Logger.handle, so it hears each record once on the logger that emitted it, before propagation and regardless of handler configuration, and nothing the application configured is touched.
The second is a block. wrapture.block(name) is a context manager the code, or the test, uses to declare a stretch of code as one event, with everything recorded inside it nested underneath. In a test body it names the phases of an integration test so that "the events during the second request" stops being an exercise in parent-chasing:
with wrapture.timeline(connect, close) as tape:
repository = Repository(Database())
with wrapture.block("lookups"):
repository.find("products", 1)
repository.find("products", 2)
with wrapture.block("summary"):
repository.count("products")
lookups = tape.blocks("lookups").assert_once().first
tape.within(lookups).for_binding(close).assert_once()
block: lookups
__main__:Database.connect() -> <Connection 1>
__main__:Connection.close() -> None
__main__:Database.connect() -> <Connection 2>
block: summary
__main__:Database.connect() -> <Connection 3>
__main__:Connection.close() -> None
tape.within(event) scopes the whole query surface to one block's contents, so an ordering assertion on the view never sees an event outside it. In application code the same marker is inert when nothing is listening, so it can stay in production code permanently, which is what makes the same block a span when the events are going to a tracing backend rather than a test.
As a pytest test
In a test the pairing becomes the assertion, and the failure message carries the leaked connections and where each was acquired. close is given a declared expectation of at least one call, so a path that acquires nothing at all cannot pass by accident:
def test_find_releases_its_connection():
connect = wrapture.binding(Database, "connect", stack="caller")
close = wrapture.binding(Connection, "close").expect_at_least(1)
with wrapture.timeline(connect, close):
report(Repository(Database()), [1, 2])
released = {event.instance for event in close.events}
leaked = [
(event.result, wrapture.stack_frames(event.stack)[0])
for event in connect.events
if event.result not in released
]
assert not leaked, f"connections left open: {leaked}"
The test fails today, naming <Connection 2> and the frame inside find(). Fix the early return with a finally and it passes. With the pytest plugin enabled the tape's tree is attached to the failure report as well, so the output shows what ran rather than only the assertion that tripped.
What's next
Everything in this post recorded real calls with the bindings doing nothing but watch. The next post is about the other direction, changing what a call does, and specifically about behaviour that changes over time as the code under test keeps calling, which is what retry logic and circuit breakers need from a test.
01 Sep 2026 9:38pm GMT
PyCoder’s Weekly: Issue #750: State of Django, PSF Elections, Python 3.15, and More (2026-09-01)
#750 - SEPTEMBER 1, 2026
View in Browser »
The State of Django 2026: Boring Is So Back
A summary of this year's State of Django report which draws on responses from nearly 3,500 developers across more than 40 countries: from students in their first year to veterans with decades of experience.
WILL VINCENT
2026 PSF Board Election Interviews
This is a collection of interviews of the various candidates running for the Python Software Foundation Board. Many of the posts also include links to AMA sessions.
PYTHON SOFTWARE FOUNDATION
Python 3.15 Preview: Sampling Profiler
Explore Python 3.15's new sampling profiler and learn low-overhead profiling of scripts, threads, and live production processes.
REAL PYTHON
Articles & Tutorials
The Python Community's Institutional Response to the Astral Acquisition Has Begun
Brett Cannon posted on discuss.python.org (March 23): a PEP is coming, the python/prebuilt-cpython repo already exists, and the PSF has been building an official prebuilt relocatable CPython distribution since October 2025. Covers what's actually being built, what it means for uv/ruff/python-build-standalone, and why the Astral upstream patches and PSF alternative aren't in conflict.
DEV.TO • Shared by Anonymous
Unsubscribe Links Without a Login: Django Signing
Django has a signing module that makes it easy to build an unsubscribe link that works with no login and no session: token = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT). The token itself is the credential, and it ships with Django out of the box.
BOB BELDERBOS • Shared by Bob
AI Coding Tools for Python Developers: What's Actually Worth Using Right Now
Join the one-day live course this Saturday, September 12. Get live demos of every category of AI coding tool, a verdict on each, and a 60-second test you can run on whatever launches next. Examples are in Python, and no prior AI experience is required. Reserve your seat here →
REAL PYTHON
CMS With AI, Not AI CMS: Wagtail 8.0's New API
Wagtail 8 includes a new API built on Django Ninja and Pydantic, to automate common admin tasks with and without agents. 50+ operations derived from projects' existing Python/Django code, mimicking the admin panel but via endpoints and an official CLI.
WAGTAIL.ORG • Shared by Thibaud Colas
Fuzzy String Matching in Django and PostgreSQL
Fuzzy string matching allows you to find values that are similar but not exact. This is particularly useful with the spelling of names (Smith, Smyth, Smythe). This post shows you how to use fuzzy matching in Postgres with Django.
MCNULTY & CARLTON
Learn Vectorized Thinking in Python Through Examples
Vectorization allows you to perform a mathematical operation on multiple values at the same time. NumPy supports this and it is one of the reasons it is far faster than equivalent looping operations.
JASON BROWNLEE
Moving Into the Future: Upgrading to Python 3
A dev blog about the challenges and rewards of upgrading Carbon, the engine upon which EVE Frontier is built, from Stackless Python to Python 3.
EVEFRONTIER.COM
The Python print() Function: Go Beyond the Basics
Learn about Python's print() function, discover its lesser-known features, avoid common mistakes, and know when to use a better alternative.
REAL PYTHON course
12 Things You Should (And Shouldn't) Do in AWS
Talk Python interviews Matt Lea and they discuss all sorts of things that can go wrong in your infrastructure and what to do about it.
TALK PYTHON podcast
How to Write an AGENTS.md File for a Python Project
Learn how to write an AGENTS.md file so your AI coding agent produces idiomatic Python code that fits your project on the first try.
REAL PYTHON
Projects & Code
Events
Weekly Real Python Office Hours Q&A (Virtual)
September 2, 2026
REALPYTHON.COM
Canberra Python Meetup
September 3, 2026
MEETUP.COM
Sydney Python User Group (SyPy)
September 3, 2026
SYPY.ORG
PyDelhi User Group Meetup
September 5, 2026
MEETUP.COM
Melbourne Python Users Group, Australia
September 7, 2026
J.MP
PyBodensee Monthly Meetup
September 7, 2026
PYBODENSEE.COM
Happy Pythoning!
This was PyCoder's Weekly Issue #750.
View in Browser »
[ Subscribe to 🐍 PyCoder's Weekly 💌 - Get the best Python news, articles, and tutorials delivered to your inbox once a week >> Click here to learn more ]
01 Sep 2026 7:30pm GMT
Django community aggregator: Community blog posts
"Premature" optimization
"Premature optimization is the root of all evil" - our field's favorite half-sentence, quoted far more often than the sentence it was cut from. Usually it serves as permission: build it fast, profile later, fix a thing or two, done. Let's put the sentence back together and ask what it licenses: when is optimization premature, and when is "premature" the excuse?

01 Sep 2026 10:00am GMT
28 Aug 2026
Django community aggregator: Community blog posts
Issue 352: PyCharm & Django Fall Fundraiser
News
PyCharm & Django Fall Fundraiser

Buy or renew an annual PyCharm Professional license through the campaign link and you get 30% off while JetBrains donates a matching amount to the DSF, which is working to close the gap between the roughly $300,000 it raises each year and the $500,000 that would make a full-time Executive Director sustainable. The campaign runs through September 10, 2026, so buy or renew before then. A renewal adds 12 months to your existing subscription.
Django Software Foundation
DEP 0020: Annual Release Cycle
Django moves to one feature release each January under YYYY.N calendar versioning, starting with 2028.0 in place of what would have been 7.0. Every release now gets a year of mainstream support and two years of security fixes, which retires the LTS label.
The Block and Tackle of Django's Code of Conduct Working Group
The machinery behind Django's move to Contributor Covenant 3.0: a 30-day public comment period enforced by a GitHub workflow, CODEOWNERS gating changes to the CoC text, and an automated decision changelog. The working group is releasing its case-tracking templates under CC BY 3.0 for other projects to adopt.
Python Software Foundation
RISC-V is now officially supported by CPython!
CPython now supports RISC-V at tier 3, the entry level for new platforms, which means the open instruction set architecture gets ongoing testing on real hardware through buildbots donated by the RISE Project. Stan Ulbrych led the work with Ludovic Henry, Furkan Onder, and Emma Smith, backed by a Sovereign Tech Agency fellowship.
Wagtail CMS News
Wagtail 8.0
A read-and-write v3 REST API, custom base page models, a global permission policy registry, and formalized Django 6.1 support. Two smaller wins: StreamField block IDs are now available as template context variables, and AVIF and WebP images are no longer converted to PNG by default.
Wagtail security releases: 7.0.9, 7.3.4, and 7.4.3
7.0.9, 7.3.4, and 7.4.3 carry the same five fixes as 8.0: permission handling in the Pages, Documents, Images, and translation APIs, document identification by SHA1, and snippet copying. Upgrade to the patch release matching your version.
Streamlining content ops with LLMs: Wagtail user guide
Google Summer of Code contributor Raghad Dahi rebuilt the user guide site, retiring a versioning scheme that made editors duplicate the whole site per release in favor of blocks readers can filter by version. For translations, an evaluation suite scored LLM providers on cost and quality before settling on DeepSeek V4 Flash, now covering 52 languages plus right-to-left support.
CMS with AI, not AI CMS: Wagtail 8.0's new API
The thinking behind that API: rather than bolting AI buttons onto the admin, Wagtail exposes 50+ admin operations with OpenAPI docs and Markdown rich text, drivable from curl, a script, or an MCP implementation. Worked examples include fixing SEO descriptions with an LLM and a generic content importer in about 100 lines.
Django Fellow Reports
Django Fellow Report - Jacob
Jacob Tyler Walls filed an early report before heading to DjangoCon US, with his usual prolificacy triaging three tickets, reviewing nine, and authoring seven. In addition, engaged with regular security reports and provided 1-1 mentoring to his GSoC mentee, Pravin.
Editors Note
All 3 Fellows are at DjangoCon US this week, so no formal report from Sarah or Natalia. All 3 also gave excellent talks that we will link to when the videos are available later this year.
Articles
Fuzzy String Matching in Django and PostgreSQL
Four ways to match misspelled and variant names, with the trade-offs spelled out. It expands on the author's DjangoCon US 2026 talk on search-as-you-type across 54 million names.
Modern Django Deployments in 2026: My DjangoCon US 2026 Conference Talk
The slides and notes from Will Vincent's recent talk on deployments.
Nifty Django Feature: Third-Party Packages
A helpful overview of Django's third-party package ecosystem, where to look, and how to apply it in your Django projects.
When Python is Too Slow
An opinionated guide on where to turn when Python feels too slow in your application (the answer isn't always switch to Rust).
The Move to Python 3 Begins!
CCP is moving EVE Online's 2.4 million lines of Python off Stackless Python 2.7, in place since 2010, with the first changes deployed on August 25. Stage one leans on automated tools to make the code compile under both versions: 95.9% of roughly 20,000 files already do, leaving about 3,300 blocking lines (1,500 print statements, 800 long literals like 123L, 600 old-style except clauses) and another 20,000 lines that compile but behave differently and need a human to look at each one.
Core Dispatch #10
A roundup of CPython development from August 5 to 27: Python 3.12.14, 3.11.16, and 3.10.21 shipped on the 12th, 3.15.0 release candidate 2 is due September 1, and six PEPs moved, including PEP 805 on safe parallel execution and PEP 833 reaching Final for the simple repository API. Most of the discussion energy went to the competing module export proposals in PEPs 842, 843, and 844.
Events
DjangoCon Europe 2027 in Austria!
Five days of Django, Python, and community in Innsbruck, Austria, February 17-21, 2027.
DjangoCon US 2027 in Riverside, California
Join us for five days of inspiration, education, and networking at the Riverside Convention Center in beautiful Riverside, California, September 13-17, 2027.
Call for Organizers: DjangoCon US 2027
That Riverside conference needs people to run it, and more than 15 committees are recruiting, from program and sponsorship to Code of Conduct, A/V, website, and sprints. Leadership roles carry the heaviest load, with weekly check-ins and monthly board reports, but most positions do not require previous organizing experience: email hello@djangocon.us to volunteer.
Django Forum
Public thanks to our 3 Fellows
A short forum thread appreciating Django's Fellows.
Podcasts
Django Chat #205: Django Developers Survey 2026
A special summer episode on the just-released 2026 Django Developers Survey, working through what it says about Django 6.1, HTMX, async, AI, deployment, testing, and Python tooling.
Django Job Board
Two foundation roles anchor the board this week, with the DSF hiring its first Executive Director and the PSF looking for a Security Developer, alongside two full stack engineering openings.
Full Stack Software Engineer (Hybrid) at Provision
Executive Director at Django Software Foundation
AI-Assisted Software Engineer, Web Applications at Logical Media Group
Security Developer at Python Software Foundation
Projects
15r10nk/matchify
Converts eligible if/elif/else chains into Python 3.10+ match statements, preserving runtime behavior and source formatting, including isinstance checks that become class patterns with attributes.
matiasb/django-tasks-fennel
A Django Tasks backend which uses Celery as its underlying queue. Mentioned as part of the author's talk at DjangoCon US this week: Teach Django Tasks to speak Celery: Building a Celery backend for Django Tasks.
28 Aug 2026 3:00pm GMT
27 Aug 2026
Django community aggregator: Community blog posts
Building large features for Django
It's been another month of slow writing and not for want, but in between holidays and the same amount of work, the blog post just got squeezed out each week. Additionally the GSoC project that I have been mentoring took a slight turn into more of a research based thing rather than trying to push anything into Django right now.
However with the GSoC research itself and the recent features of tasks, the email updates, my own prodserver package, I'm beginning to solidify in my head what a modern Django feature looks like. One thing to clarify here is when I use the word feature, I'm referencing a concept that Django can represent, such as Databases, Tasks, Emails & Storage.
These features while different in what they achieve have a very similar architecture within Django, marked by some common characteristics:
- A common API for the rest of Django to use
- A single settings configuration, typically a dictionary
- Pluggable backends that do the actual implementation and specified in the above settings
- Minimal backend implementations inside Django (except Databases), with extra implementations provided as community packages
This architecture leans into Django being an API layer for various concepts that all tie together to become a website or web app. I'm taking this route with prodserver and this approach also taken by by mentee for GSoC when producing django-experimental and django-featurevault.
Django Experimental is a proof of concept around how an experimental features may be added to Django at some point. Django Feature Vault is a similar package specifically targeting an API for feature flags native to Django. Please do give them a read and a spin on a project if you like and raise issues. Both also have associated draft DEPs (Experimental, Feature Flags) available to review and comment.
I wonder if we can more formally codify this architecture (perhaps a copier package template?) to continue to smooth the on-ramp for those that want to contribute new features and ideas to Django and the community.
27 Aug 2026 5:00am GMT
06 Aug 2026
Planet Twisted
Hynek Schlawack: Production-ready Python Docker Containers with uv
Starting with 0.3.0, Astral's uv brought many great features, including support for cross-platform lock files uv.lock. Together with subsequent fixes, it has become Python's finest workflow tool for my (non-scientific) use cases. Here's how I build production-ready containers, as fast as possible.
06 Aug 2026 12:00am GMT
23 Jun 2026
Planet Twisted
Glyph Lefkowitz: Adversarial Communication
As I have discussed in previous posts, "AIs" can make mistakes. In fact, they do make mistakes, and their mistake-making patterns are such that where and how they will make mistakes is both uncertain and constantly changing.
Thus, in any scenario where you want to attempt to make "productive" use of "AI", you must have a system in place for checking every result. Not checking some results; checking every result. If each result might have a consequence for you (and if it didn't have a consequence, why bother automating it?) and you cannot predict in advance which kinds of results will need verification, then verification is always required.
The verification often ends up being just as expensive as doing the work in the first place, which means that if you want your usage of "AI" to be personally profitable, you have to find someone else to externalize the cost of verification onto. This person becomes your adversary, and, if you are successful, your "AI's" victim.
The Ladder-Climber And Their Reverse-Centaur Rungs
One way that this constellation of facts can straightforwardly assemble themselves into a dystopian nightmare is the phenomenon, described by Cory Doctorow, of the reverse centaur. This is when your employer non-consensually turns you into the verification system. The "AI" does the fun part of initially performing the work, and then you do the boring part where you check if the robot is right and clean up its messes, even if everyone already knows that it would, in aggregate, be cheaper for you to do the work in the first place.
Reverse centaurs can be made from any automation, not only "AI" automation. I think that there is a reason that this term happens to have emerged in the "age of AI", though, and not with earlier automation technologies (even those which were considerably more viscerally horrific). That reason is: the wrongness of "AI" output is not merely a technical feature that must be compensated for, it is a generalized externality.
As I mentioned above, if you are responsible for the entirety of the work, both extruding the "AI" output and checking it, it's usually cheaper to have humans do the entirety of the work to begin with. When humans do the writing directly, we can check as we go, and thus verification doesn't need to be as comprehensive.
When "AI" coding advocates say "code review is the bottleneck", what they are observing is that the LLM is still rolling the dice for each PR, and a human is still necessary to verify that each of those rolls is a winner. But calling this process "code review" is a bit of a misnomer; it's not really "code review" in the traditional sense, it's human understanding.
Before the advent of "AI", the human understanding was implicit in the process of writing the code in the first place1, and the code review was a way of diffusing and extending that understanding. Now that the code can be authored with no initial understanding taking place, that cost has not gone away, it has moved.
Human understanding was always the bottleneck.
However, this is taking a collaborative view of a software project, where satisfying the needs and solving the problems of your customers are the goals. We can see that "AI" is a bad tool to satisfy those goals, because all it's doing is converting the first half of the work, that of understanding the code as you write it, to understanding the agent's output as you read it.
What if, instead, we were to take the view that every software company is a Hobbesian nightmare, red in tooth and claw? In this view, the only goal of a software project is for the individual developers to make their promo cycles and get their bonuses. Given that there is only a certain amount of money to go around, this is a zero-sum game where each programmer wants to look more productive than their colleagues.
Pretty much every organization finds it easy to reward "productivity" as expressed by lines of code emitted, but the benefits of doing thorough and thoughtful design, analysis, and code review very difficult to reward. In this world, an LLM is an invaluable tool for the sociopathic ladder-climber, particularly if your legacy organization is still structuring their workflows as if the person prompting the bot is "writing" the code, and then they get to foist off the act of "reviewing" the code onto someone else.
Here, the prompter effectively externalizes the cost of the LLM's failures but internalizes any benefits. The prompter will vibe-code a big feature, so large that the assigned reviewer can't possibly comprehend it all effectively. When this happens, the reviewer will, eventually, be pressured to approve it, even if they can try to spot a few problems along the way. The reviewer has their own work to get back to, after all, the obligation to review the prompter's (read: the bot's) code is a drain on their time that they are not going to get rewarded for.
If this feature is a big success, the prompter gets a promotion. If it causes a big issue, well, the reviewer must not have been careful enough.
This is why LLMs are "good for coding", and also why their biggest promoters keep having outages.
The Generative Gish Galloper
Coding is the biggest "success story" of this type of adversarial communication, but it is by far not the only instance of such a thing. LLMs create a new form of leverage that can turn Brandolini's law from a linear advantage into an exponential one. If you are engaged in a political debate where you want to overwhelm the other side in nonsense, an LLM can generate bullshit faster than it is physically possible for a human being to type, let alone respond thoughtfully. There is an asymmetry to the utility of this weapon as well: only one side of the political spectrum wants to flood the zone and destroy trust in institutions and the concept of truth. There's a good reason that the fascists love it.
Straightforward Spam and Fraud
This is kind of obvious, but LLMs can generate lightly-customized, plausible-looking text much more quickly than any human being. This facilitates their use in fraud, spam, and scams. In a spamming or fraudulent interaction, once again, the costs are externalized onto the victim: the recipient of a spam message has to do all the work of "checking" the LLM's output. Spammers already expect very low hit rates from boilerplate, and if the LLM can increase those percentages from 1% to 5% the technology will pay for itself; they don't need anything like reliable accuracy.
Customer "Support"
If you have any kind of commercial relationship with a company, I probably don't even need to mention this: customer "support" bots are a misery. Everybody knows it at this point. But customer support is usually conceptualized by businesses as an adversarial interaction, because it is a cost center. They maintain internal metrics on time-to-resolution and try to optimize them. Implicitly, this creates a dynamic where the goal of the customer service agent's job is not to solve your problem, but to emit noise that will cause you to think your problem is resolved, or to give up, as fast as possible. Unsurprisingly, LLMs can emit this noise faster than humans can, getting those customers off the phone. But those customers will remember those interactions, and the story outside the TTR metrics is horrible.
Similarly to the situation in software development, LLMs can look very good on paper for customer support, but mostly what they are doing is illuminating the problems with the industry's existing metrics, by turning "winning the metrics battle against the customer" into a more obvious and immediate defeat for the company's long term reputation.
"Education"
In 2026 it is sadly a fact of life that students cheat all the time using "AI", and that this cheating is very successful, in that the teachers find it very hard to detect.
LLMs are great for cheating on schoolwork because the student is externalizing the work of the checking onto the teachers, who are often starting at a disadvantage to begin with, at least in the US.
My view is that this is happening because of a divergence in the way that students vs. teachers (or, more accurately, "the broader educational system") view grading.
When a student is asked to write an essay, the teachers see the effort as both intrinsically worthwhile for the student, as well as useful as a pedagogical tool to evaluate and react to the student's progress. The student, by contrast, sees a stumbling block designed to knock them off the path to success and into a permanent underclass. It is no wonder that the student sees "AI" as useful to their own goals and has no compunction about deploying it.
There is a bitter irony that the ability to understand the inherent value of actually writing the essay on their own is the sort of thing that students can really only learn by writing a bunch of essays. There's no way that I can think of which makes the benefit legible as long as a shortcut is available.
The net effect here is a downward spiral, where the already-wobbling educational system is sustaining an attack that it doesn't have the resources to recover from. The individual students' attacks against their teachers and their schools' grading systems might appear to momentarily succeed, but they will win the battle and lose the war.
Spamming "For Good"?
Usually when we talk about someone unilaterally choosing to enter into an adversarial relationship, that's an "attack" and for good reasons we have a negative impression of the attacker. However, I would be remiss if I did not point out that there are some cases where the relationship was already adversarial; just because you're the attacker doesn't mean that you are evil.
For example we might imagine use-cases like automatically filing appeals for prior authorizations against health insurance. It's relatively well-known at this point that the main way for-profit insurers maintain their margins is by denying claims right up to the line of the policies themselves being fraud, so using a spamming tool to fight them might be entirely justifiable2 in that case.
Similarly, using an LLM could be justified in a fight against a company refusing to honor a warranty. One could imagine using an LLM to immediately generate replies and escalations.
However, even in imagined cases like these, the underlying problem is that the insurers and the vendors already have a tremendous amount of structural power, so it is more likely that they will have the advantage in deploying a communications weapon like an LLM, as well as enacting policies to simply ignore any LLM-based communication that you might submit. Worse, if these strategies were to become widespread, they might provide an excuse to reject any communications by feeding them into an unreliable "LLM detector" and issuing an automated "computer says no" even to hand-written correspondence.
It is also worth stressing that these cases are imagined, as compared to the very real coworker-abuse, spam, scam, fraud, and disinformation campaigns being waged in real life today.
Therefore, while legitimate uses might exist, it's hard to imagine that there's anywhere they would be genuinely valuable and sustainable. In the best case "AI" will provide a temporary advantage for underdogs that will provoke an arms race which the resource-advantaged adversaries will win in the long run, in the worst case the arms race itself will cement permanent structural change that will make things worse.
"Search" By Stealing
Most of the adversarial utility of "AI" is on the "write" side, since write-amplification is more obviously aggressive than reading. But the "read" side of LLMs - summarization and question-answering - can be a form of attack as well.
To begin with, the act of reading itself is currently enormously destructive, but that's arguably not a fundamental aspect of this technology. They could set reasonable rate-limits and respect things like robots.txt, as search engines have for decades now. They could also refrain from committing criminal levels of copyright infringement. But, today, using "AI" tools does suborn this sort of out-of-control crawling.
More insidiously, consider the scenario described in this YouTube video. The LTT Bros decided to try Linux again, and in the course of so doing, they had problems. When trying to solve these problems, they were faced with a choice: they could consult Reddit, or they could ask an LLM. Asking an LLM would "gaslight the heck out of" them, but they still found it preferable, because they would at least get an answer without getting yelled at.
Initially this sounds great. But it also means that you want to extract knowledge from a community, while mechanically eliding any values or norms that the community may want to impart as part of offering that knowledge. As someone who spent many years in a community tech support role, this is worrying. Many requests for support are people asking how to do things that will momentarily solve a superficial problem but create a long-term reliability problem or even an immediate security risk, that the question-asker doesn't want to hear about. Consider the question "I'm tired of entering my password so much, how do I make it so my laptop unlocks automatically". An obsequious chatbot will helpfully tell you how to do this without pushback.
But, this is also a sort of ethically murky area. The Linux community is somewhat famously, for many years now, a toxic cesspool of general hostility, misogyny, etc. It is certainly a good thing that people can get access to this knowledge without subjecting themselves to abuse. But it also means that the people with the power and the privilege to change the community for the better can just quietly withdraw, rather than fixing the problems. It also means that the positive elements of culture cannot be transmitted, and people will have no opportunity to learn about unknown unknowns.
In this case, the "adversarial" communication is with society. The thing that using an LLM for search lets you do is withdraw from society and avoid forming any personal connections. There are some personal connections which are painful and annoying, and so that can feel like a momentary balm. But the need to make connections in general is, like, the concept of society itself.
Who Am I Hurting?
LLMs are good at adversarial communication. They are so good at it, relative to their other benefits, that they will tend to make communications adversarial if you are not remaining vigilant about the possibility that it might do so. My request to you, dear reader, if you are going to use such tools, is to always ask yourself, "who might I be hurting, if I use an LLM for this?"
If you're using an "AI", who is its adversary? If you haven't given it one yet, who might the "AI" turn into an adversary? Who might you overwhelm with an asymmetric amount of output, or, if you're receiving information and not sending it, who are you taking that information from without consulting?
Figure out the answers to these questions and conduct yourself accordingly; the answer might be "yourself".
Acknowledgments
Thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor!
-
One of the reasons that software developers tend to prefer greenfield development is that when you are given a blank page, you can project your own specific understanding onto it. You can structure the codebase in a way that works for your brain, down to the variable naming conventions and the module layouts. LLM-assisted development makes everything into instant brownfield work, which makes developers instantly miserable; even those who are excited about the technology will frequently complain about how it feels like their agency has been stolen and their joy in the work has been diminished. But I digress. ↩
-
Modulo the massive amount of other externalities involved in using LLMs, of course, but I don't have the time or energy to get into those here. ↩
23 Jun 2026 8:06pm GMT
09 Jun 2026
Planet Twisted
Hynek Schlawack: How to Ditch Codecov for Python Projects
Codecov's unreliability breaking CI on my open source projects has been a constant source of frustration for me for years. I have found a way to enforce coverage over a whole GitHub Actions build matrix that doesn't rely on third-party services.
09 Jun 2026 12:00am GMT