skip to navigation
skip to content

Planet Python

Last update: September 01, 2026 09:48 PM UTC

September 01, 2026


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.

September 01, 2026 09:38 PM UTC

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.

September 01, 2026 09:38 PM UTC


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 PyCoder’s Weekly Logo


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

Quiz: Python 3.15 Preview: Sampling Profiler

REAL PYTHON

RISC-V Is Now Officially Supported by CPython

PYTHON.ORG

PEP 843: Export Statement for DRY Re-Exports (Draft)

PYTHON.ORG

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

alt

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

Quiz: The Python print() Function: Go Beyond the Basics

REAL PYTHON

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

Quiz: How to Write an AGENTS.md File for a Python Project

REAL PYTHON

Projects & Code

Arid: Fast Python Duplicate-Code Detection

GITHUB.COM/SPONGE-B0B • Shared by Bob Taylor

pydantic-pint: Pydantic Pint Quantities

GITHUB.COM/TYLERH111

django-fastmig: Experimental Drop-in That Makes Django migrate Faster

GITHUB.COM/VIKTOR2097

django-danceschool: Django CMS for Running a Dance School

GITHUB.COM/DJANGO-DANCESCHOOL

vcrpy: Automatically Mock HTTP Interactions

GITHUB.COM/KEVIN1024

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 »

alt

[ 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 ]

September 01, 2026 07:30 PM UTC


Django Weblog

Djangonaut Space - Session 7 Accepting Applications

We are thrilled to announce that Djangonaut Space, a mentorship program for contributing to Django, is open for applicants for our next cohort! 🚀

Session 7 launches October 12th, 2026. We are currently accepting applications until September 6th, 2026 Anywhere on Earth. More details can be found in the website.

Djangonaut Space is a free, 8-week group mentoring program where individuals will work self-paced in a semi-structured learning environment. It seeks to help members of the community who wish to level up their current Django code contributions and potentially take on leadership roles in Django in the future.

Rodrigo Vieira, Djangonaut Session 6

“Participating as a mentee in the Djangonaut Space was a highly valuable experience. The program provided clear, step-by-step guidance on how to navigate the core framework, find the right issues to tackle, and confidently send my PRs.

Beyond the code, the community on Discord was very active, sharing experiences, wins and support. We had access to regular talks, Django Fellows and long-time maintainers who generously shared their time and expertise.

It was a welcoming environment to connect with other newcomers from all over the world, allowing us to share our journeys and learn together. The highlight of the program for me was seeing my first pull request finally merged and mentioned in Django News. I'm looking forward to keep involved as an alumni and help others now.“

Eliana, Djangonaut Session 1

“I'm so grateful to have been a part of the Djangonaut Space program. It's a wonderfully warm, diverse, and welcoming space, and the perfect place to get started with Django contributions. The community is full of bright, talented individuals who are making time to help and guide others, which is truly a joy to experience. Before Djangonaut Space, I felt as though I wasn't the kind of person who could become a Django contributor; now I feel like I found a place where I belong.”

Enthusiastic about contributing to Django but wondering what we have in store for you? No worries, we have got you covered! 🤝

✏️ Mission Briefing

📷 AMA Recap

📡 Have questions? Email sessions@djangonaut.space

September 01, 2026 05:13 PM UTC


James Bennett

Boring Python: dependency management

This post has been significantly updated since it was originally written. The most recent update was in September 2026; a summary of what changed is at the end.

This is the first in hopefully a series of posts I intend to write about how to build/manage/deploy/etc. Python applications in as boring a way as possible.

So before I go any further, I want to be absolutely clear on what I mean by “boring”: I don’t mean “reliable” or “bug-free” or “no incidents”. While there is some overlap, and some of the things I’ll be recommending can help to reduce bugs, I also want to be clear: there will be bugs. There will be incidents where a feature or maybe an entire service is down. “Boring”, to me, is about the sources of those incidents. It’s difficult enough to manage your own code and the bugs and other problems that will inevitably pop up in it from time to time; you don’t want to compound that by having bugs or other surprises coming from the tools and processes you use to build, manage, and deploy it. So when I call something “boring”, I mean it’s unlikely to add another source of bugs and nasty surprises that cause a pager to go off at 2AM; the pager will, of course, eventually go off at 2AM, but when it does you’ll be able to feel reasonably confident (if that’s the right word for such a situation) that the source of it was something in your own code that you can diagnose and fix.

And so I’m planning several posts exploring different aspects of making Python development boring (in this sense). But for this first installment, I’ll be talking about one of the internet’s favorite topics: managing dependencies.

If you’re just interested in the end result, the process I recommend is pretty simple and can be found in the section titled “Putting it all together”. The rest of this post exists to explain, in as much detail as I can manage, all the things going on behind those simple-looking recommendations, and why I make those specific recommendations.

Python packaging: a brief overview

I’ve written about this before, but to make sure it’s clear I want to do a quick refresher.

As I see it, there are three basic aspects to “packaging”, and one of the difficult things about “Python packaging” discussions is lack of clarity around which are being discussed. They are:

  1. Given some working code, define and produce from it a distributable artifact.
  2. Given that someone has produced a distributable artifact, use it to cause the corresponding code to appear, in working form, somewhere else.
  3. Given the existence of potentially many separate projects or versions of projects each with their own independent and potentially-conflicting sets of dependencies, make it possible to work on and run more than one at a time, on the same system.

Most of the actual remaining complexity in “Python packaging” today is concentrated in item (1), and even then only really kicks in if you’re packaging extensions written in non-Python languages.

A lot of the perceived complexity of “Python packaging” is due to its long history, evolving expectations over the course of that history, and the resulting pile of strata of different things people have come up with to try to suit the expectations of different eras.

Much of the public grumbling and complaining about “Python packaging” these days is due to the lack of a single, standard first-party, all-in-one high-level tool to handle all aspects of packaging, and the proliferation of third-party tools to try to fill that niche.

Finally, most of the meaningful work in Python packaging, for quite a while now, has been the unglamorous, underappreciated slog of people on mailing lists and discussion forums patiently working out what Python packaging really should look like and thoroughly specifying/standardizing it, but with the added thrill of all that having to occur in a way that doesn’t break things for the vast number of people relying on Python and its huge package ecosystem for their day-to-day work.

To the extent that we now have a bunch of shiny fancy flashy third-party all-in-one packaging tools, it’s because of that heroic unsung work that’s been quietly going on for years. The fancy new tools get to be so fancy and flashy in large part because modern Python packaging has such strong foundations (and also because, unlike the pre-existing tools, the new ones don’t have to maintain compatibility with literal decades of historical features and configuration options, all of which have been relied on, at some point, by someone).

If I went into detail on everything I’d wind up with several posts’ worth of material, so let me just pick out a few things that I think are the most crucial underpinnings of modern Python packaging:

Boring Python packaging: a philosophy

When I wrote the first version of this post, four years ago, I focused on defining a workflow that relied as much as possible on standard first-party (as in, included with Python itself) tooling. I recommended exactly one third-party tool, and only as an optional convenience; the functionality it provided could also be obtained from chaining together the right invocations of first-party tooling.

That focus on first-party tooling was deliberate. As I wrote at the time:

[The] core default tools I’ve mentioned have all been around and stable for a long time, in software terms, at least — they’re all over a decade old, and their end-user interfaces evolve incredibly slowly, when they evolve at all. They’re reliable. They’re well-understood. That’s exactly what I want and exactly what I recommend.

This is in line with what I said at the start of this post about wanting things to be boring. And my stance has not changed, but advances in the foundational standards of Python packaging do let me be a bit more flexible. So although I’m still going to be focused on first-party tools I’ll now point out a couple of places where I think it’s safe to use some of the hip and trendy new third-party tools if you want the added conveniences they offer.

Also, I should note here that my use case for Python is deploying networked (primarily web) services, on servers. If you primarily use Python for, say, machine learning or data science, you are likely to already be a happy user of a completely different world of tools optimized for those use cases (in particular, for the much greater amount of non-Python code wrapped by/accessed through Python in those fields and for their different patterns of code reuse and sharing). Please continue using them! They suit your use case really well, and replacing them with my preferred tools and workflow would probably be a regression for you.

Now, let’s get started.

Lock it up

The standard Python package manager is pip. And my eventual goal is to invoke pip install to, well, do what it says: install a bunch of Python packages. But how to tell pip which packages to install? There are a couple of options:

Historically, the second option was referred to as a “requirements file”, and by convention was generally named requirements.txt. Now that PEP 751 has defined a standard Python lock file format, and now that pip supports producing and consuming that standard lock file format, that’s the format I recommend.

This prompts a question, though: since dependencies could already be specified as part of the metadata in pyproject.toml, why did we need another file format to record them? By way of an answer, consider a web application written using the Django web framework. You might think it’s simple enough to just declare Django as a dependency in your pyproject.toml file, like so:

[project]
dependencies = ["Django"]

But if I create a new virtual environment and run pip install Django inside it, three packages get installed:

$ pip install Django
Collecting Django
  Using cached django-6.1-py3-none-any.whl.metadata (3.9 kB)
Collecting asgiref>=3.9.1 (from Django)
  Using cached asgiref-3.12.1-py3-none-any.whl.metadata (9.4 kB)
Collecting sqlparse>=0.5.0 (from Django)
  Using cached sqlparse-0.6.0-py3-none-any.whl.metadata (6.0 kB)
Using cached django-6.1-py3-none-any.whl (8.4 MB)
Using cached asgiref-3.12.1-py3-none-any.whl (25 kB)
Using cached sqlparse-0.6.0-py3-none-any.whl (50 kB)
Installing collected packages: sqlparse, asgiref, Django
Successfully installed Django-6.1 asgiref-3.12.1 sqlparse-0.6.0

And which versions of those packages I get will depend on when I run pip install. Right now I get Django 6.1, but Django does frequent bug fix releases, so if I re-run that pip install in the future I’ll get Django 6.1.1, and further in the future I’ll get Django 6.1.2, and so on. If I test my application locally with the set of packages I installed today, and then deploy it to a server later using pip install to fetch dependencies, the deployment will very likely get a different set of packages than I used during development, which is a potential source of problems.

So the key difference is that the pyproject.toml file typically specifies a list of direct dependencies, often with broad version constraints or none at all, while a lock file specifies an entire environment, with all direct and transitive dependencies resolved and pinned to exact versions along with the expected checksums of the packages, to be reproduced as exactly as possible.

There are two main approaches to generating a lock file:

Managing your local environment and producing your lock file is one of the areas where I think it’s generally OK to use a third-party tool. If something goes wrong with it, it might temporarily mess with your ability to get work done on your own computer, but that won’t break your actual deployed application.

The hip and trendy third-party all-in-one tool these days is uv, which has its own tool-specific lock file format (uv.lock), but also has a uv export command which can export to the standard pylock.toml format.

My own personal preference, and the tool I use on my own projects, is PDM, which has a similar feature set to uv and can export to pylock.toml from its own lock format, but also can just manage a project directly in a pylock.toml file.

As long as the tool you choose is one you and your team are all happy with, I think you can pick anything that can produce the standard lock file format and be OK. Also, you should probably automate the process of exporting the lock file(s), to ensure developers don’t have to remember how to manually do it. I personally like to include a Makefile in most projects, with a target specifically to refresh the lock file(s) (make lock, for example), but the idea is portable to a lot of task-automation tools, so it’s easy to make it work with whatever your team likes to use.

I also want to point out here that if you’re deploying services written in Python, you can build them as distributable packages and then install those packages to automatically pull their dependencies, rather than using a lock file. I just don’t recommend this, for a few reasons:

Getting testy

In addition to direct dependencies to run an application, it’s fairly common to have additional dependencies needed to run a test suite, linters, or other development and quality-control tasks. But there’s no need for a production deployment to have all those extra dependencies installed, and in general the less stuff you put in your production environment the fewer things you have that can go wrong, so I generally like to install those only when the test suite (or other task requiring extra dependencies) is actually going to be run.

The ideal solution for this is dependency groups, which were first defined for library-style package dependencies but are also part of the lock file specification. Unfortunately, as far as I can tell the current release of pip (26.2.1 as I’m writing this) does not support selecting dependency groups when installing from a lock file.

So for the moment (at least until pip gains support for selecting dependency groups from a lock file), my recommendation for handling different sets of dependencies is just to have multiple lock files. The exact syntax for this will vary depending on what packaging frontend tool you decide to use. But suppose you have a FastAPI application and want to use pytest to test it. Here’s an example of how that would look with uv:

uv add fastapi       # Regular dependency
uv add --dev pytest  # Used only for tests
uv export --no-dev --format pylock.toml -o pylock.deploy.toml
uv export --format pylock.toml -o pylock.tests.toml

Or with PDM:

pdm add fastapi
pdm add --dev pytest
pdm export --prod --format pylock -o pylock.deploy.toml
pdm export --dev --format pylock -o pylock.tests.toml

In either case, the pylock.deploy.toml file will contain only the dependencies for FastAPI, and is what you’d use for deployment. Meanwhile, pylock.tests.toml would also contain pytest, and is what you’d use for a development or test/CI environment. You can split this up further if you want—I’ve done finer-grained splits of dependencies before—but at the very least I think you should be keeping test-only dependencies separate from the rest.

Using the right invocations

Before putting all this together, it’s worth covering one more detail: how to invoke the tools. This may seem a bit silly, since they all provide executable entry points. Just run pip install, right?

But there’s a potential issue here: in a moment I’m going to recommend creating a Python virtual environment, which opens up the possibility of multiple Python environments coexisting on the same machine. This is certainly a useful feature, but it comes with a new concern, which is how to ensure you’re using and running things in the environment you expect to be using.

For example, one way to run into trouble with multiple Python environments is to have one environment’s package directory be first on your $PYTHONPATH (which controls Python import locations) while a different one’s bin/ directory is first on your general $PATH (which is where executable scripts will be found). If you just run pip install you’ll get the second one’s instance of pip, which may not be at all what you want.

This is why the official Python packaging guides, and official documentation for tools like pip, always use a different approach: they tell you to run python -m pip instead of pip, and python -m venv instead of a standalone script like virtualenv. The -m flag allows a Python module to be run directly like a script (as long as it’s been written to provide an entry point for this, which pip and venv both have), and can prevent a lot of potential hard-to-debug issues that can accidentally result from things like manually hacking around with paths, by ensuring you’re getting the version of pip or venv that actually goes with the Python environment you invoked.

And to guarantee that you get the Python environment you actually want, you can specify the full path. For example, many base Python container images will put the Python interpreter in /usr/local/bin, so invoking /usr/local/bin/python instead of just python ensures you get that interpreter.

There’s also one more thing I like to do that isn’t (currently) in some of the popular guides, and that’s invoking Python with the -I flag. This runs Python in “isolated” mode, which removes some of the automatic implicit directories from the import path and also ignores environment variables like PYTHONPATH. Once again, this reduces the number of things that can go wrong (for example, with -I the current working directory won’t be implicitly added to the import path, so you can’t accidentally depend on it being importable). So whenever you can invoke Python in isolated mode, I generally recommend that you do.

Putting it all together

That was a lot of explanation for what ends up being, ultimately, a pretty simple process to actually use. So now let’s finally take a look at it.

First things first: always work in, and always deploy in, a virtual environment. Even if you think you don’t need one. In fact, especially if you don’t think you need one. This may seem like strange advice if you’re already using a container or other virtual machine, since you’re probably thinking that provides all the isolation you’ll need. But virtual environments don’t cost you anything to create, and if you ever do end up with multiple Python interpreters—which is easy to accidentally do, if you use a base system with a purpose-built Python and then install a system package that turns out to depend on the distro’s own Python, for example—using one from the start will help to save you from potentially having a pager go off one night when suddenly the wrong Python is being invoked.

Virtual environments also provide a useful “Python environment” artifact that can be copied between stages of a container build, and many tools automatically recognize and can work with them. And ever since PEP 668 started to be adopted by operating-system vendors, many “system” Python installations will require you to use a virtual environment in order to install packages with pip (and only allow you to interact with the “system” Python environment through the system’s own package manager). So use a virtual environment, even if you’re working in a container or other VM.

When you’re working in a local directory on your own computer, you can invoke the correct Python version with -m venv to create a virtual environment and install things into it, but again this is an area where third-party package tools can be useful, because they’ll manage this for you automatically. If you do create your local virtual environment manually, you should almost certainly put it inside your project in a subdirectory named .venv, because that’s already the common unofficial convention supported by a lot of IDEs and other tools, and likely to become the official standard convention once PEP 832 finalizes.

In a container, I think the choice that’s most in line with Linux filesystem hierarchy standards is to put a Python virtual environment somewhere under /opt. Generally I like to create an /opt/venvs in a base “build” stage, which can populate multiple virtual environments with different package sets to be copied into later stages for tasks like CI or deployment.

So, taking as an example current Debian stable (as I write this, Debian 13 “trixie”) and the most recent Python (as I write this, Python 3.14), let’s see what this actually looks like. This example assumes you’re producing standard Python lock files as described above, and naming them according to their purposes as I did (otherwise, adjust the names before using this snippet):

# syntax=docker/dockerfile:1

ARG DISTRO="slim-trixie"
ARG PYTHON_VERSION="3.14"

FROM python:${PYTHON_VERSION}-${DISTRO} AS dependencies

RUN <<END
mkdir -p /opt/venvs/deploy
mkdir -p /opt/venvs/tests
# This is a cache directory for package downloads, to speed up
# rebuilds when the package set doesn't change (or doesn't
# change very much).
mkdir -p /var/cache/pip
/usr/local/bin/python -Im venv /opt/venvs/deploy
/usr/local/bin/python -Im venv /opt/venvs/tests
END

COPY pylock.deploy.toml  /opt/venvs/deploy/
COPY pylock.tests.toml /opt/venvs/tests/

The next step is to ensure pip is present at the latest version in each virtual environment, and invoke it to install packages.

At this point you might be wondering: if I was willing to recommend using a third-party packaging frontend earlier for local development use and producing lock files, why am I insisting on pip here? And the answer is that this is one of the places where I think using boring standard default tools really matters. As I said above, if something goes wrong with your fancy third-party package tool in a local development setup, it just causes a problem for that local development setup. If it goes wrong in your production build/deploy process, it breaks all your builds and deployments until you resolve the issue. I want to avoid that, so I stick to pip here, because even if it’s not as fancy as some of the newer third-party tools, in my experience it’s much less likely to be a source of unexpected issues than the newer third-party alternatives.

Also, I’m not going to go into a full explanation of an ideal Python application Dockerfile here, but the example below does at least use a Docker cache mount to store the downloaded packages, so that subsequent rebuilds only have to download new or changed packages, and can pull everything else from cache.

The pip invocation adds a few flags, mostly to ensure maximum safety and reproducibility:

If you’re using GitHub Actions as your CI/CD, Python core developer Brett Cannon has written a reusable action which automatically invokes pip with these flags.

But continuing with the example of a Dockerfile containing a “dependencies” stage, here’s what it looks like:

# Turn off pip upgrade reminders, since we're about to upgrade
# it anyway, and also specify the directory pip should use as
# its package cache location.
ENV PIP_DISABLE_VERSION_CHECK=1 \
    XDG_CACHE_HOME=/var/cache/pip

# Install test dependencies first since they're likely to be a
# superset of the deployment dependencies and will populate the
# package cache.
RUN --mount=type=cache,sharing=locked,target=/var/cache/pip,id=pip <<END
/opt/venvs/tests/bin/python -Im pip install --upgrade pip
/opt/venvs/tests/bin/python -Im pip install \
    --no-deps \
    --only-binary :all: \
    --require-hashes \
    -r /opt/venvs/tests/pylock.tests.toml
END

RUN --mount=type=cache,sharing=locked,target=/var/cache/pip,id=pip <<END
/opt/venvs/deploy/bin/python -Im pip install --upgrade pip
/opt/venvs/deploy/bin/python -Im pip install \
    --no-deps \
    --only-binary :all: \
    --require-hashes \
    -r /opt/venvs/deploy/pylock.deploy.toml
END

Now you can pull the installed packages into later stages of your build by copying the virtual environment. For example, in a deployment stage:

FROM python:${PYTHON_VERSION}-${DISTRO} AS deploy

COPY --from=dependencies /opt/venvs/deploy /opt/venvs/deploy

# Do the rest of your deployment stage setup here: copying in
# your application source code, setting the entry point, etc.

Once again, I’m not going to go into a ton of detail here on other things that make a good Python Dockerfile. If you want to learn more about that, I’d recommend reading Itamar Turner-Trauring’s articles on Python and Docker or Hynek Schlawack’s guide to Python and Docker. Both are regularly updated, and they’ll both give you a solid education in how to containerize Python.

Be up-to-date, but stay cool

The only thing still missing here is how to handle updates as new versions of your dependencies are released. For security updates this is crucial, but it’s also important as a general practice. If you make dependency updates a regular, routine part of your development process that’s easy for developers to do, then it’ll also be routine and easy to apply critical updates when they appear. And by applying updates as they come, a couple dependencies at a time, you avoid building up a huge backlog of deferred updates that will make a critical issue even more difficult to address when one inevitably occurs.

And luckily, all of the popular third-party package management tools have straightforward commands you can run to identify and update outdated packages. But there is one wrinkle: you probably don’t want to always eagerly accept most routine dependency updates as soon as they’re released, since that can expose you to potential security issues if a dependency has been compromised but nobody’s noticed it yet. The solution to this is dependency cooldowns: when upgrading, only consider packages which have had at least a bit of time for security researchers and scanners to take a look. The typical recommended window is three days; anything newer than that should be excluded from your package updates unless it’s a critical security patch.

At this point, most popular tools in the Python ecosystem support dependency cooldowns. For example:

You also can configure third-party dependency update bots to apply updates for you as they appear. GitHub’s Dependabot automatically applies a three-day cooldown for everything except critical security updates, and so does the Renovate update bot.

Whether to have a dependency update be a periodic manual task for a developer or something to automate via a tool like Dependabot is up to you (though I personally prefer automation). The important thing is you do it and commit to it, on a cadence that lets you take in updates only one or a few at a time, rather than falling behind and needing to update dozens of packages all in one go.

And that’s a wrap

As promised, that was a lot of words for what’s really a pretty simple set of recommendations. Unfortunately there’s a lot of complexity—necessary complexity, of a sort that pops up in any software packaging ecosystem—lurking in this topic, and explaining all that is what drives up the word count.

But hopefully you now understand how to do “boring” Python dependency management, relying primarily on standard first-party tooling. Even if you don’t want to adopt my recommendations, I’d like to think that learning what they are and why I make them is helpful to you. For me, these recommendations are the result of over a decade of work, across multiple employers, to develop a dependency management workflow that keeps things up-to-date with minimal risk of causing pagers to go off.

Meanwhile I’ve got some ideas for further “boring Python” articles, but those will have to wait for another day.

Changelog

The version of this post you’re looking at right now was written mostly during late August 2026, and published in early September 2026. It’s a major revision of the original, which was written and published in May 2022.

The key changes between the two versions are:

September 01, 2026 04:37 PM UTC


Python Bytes

#494 Python Wrapture

<strong>Topics covered in this episode:</strong><br> <ul> <li><strong><a href="https://github.com/openai/openai-python/blob/main/httpx2.md?featured_on=pythonbytes">OpenAI's Python SDK has migrated to HTTPX2</a></strong></li> <li><strong><a href="https://tmog.org?featured_on=pythonbytes">TMOG - Native Task Manager for macOS, Windows, and Linux</a></strong></li> <li><strong><a href="http://grahamdumpleton.me/posts/2026/08/introducing-wrapture/?featured_on=pythonbytes">wrapture - one wrapper for mocking, tracing, and observability</a></strong></li> <li><strong><a href="https://github.com/juanmanueldaza/linkedin2md?featured_on=pythonbytes">linkedin2md</a>: turn your LinkedIn export into 40+ Markdown files</strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=4AUE4ewgN38' style='font-weight: bold;'data-umami-event="Livestream-Past" data-umami-event-episode="494">Watch on YouTube</a><br> <p><strong>About the show</strong></p> <p>Sponsored by us!</p> <p>Support our work through:</p> <ul> <li>Our <strong><a href="https://training.talkpython.fm/?featured_on=pythonbytes">courses at Talk Python</a></strong></li> <li>Consulting from <strong><a href="https://sixfeetup.com/?featured_on=pythonbytes">Six Feet Up</a></strong> </li> </ul> <p><strong>Connect with the hosts</strong></p> <ul> <li>Michael: <a href="https://fosstodon.org/@mkennedy">Mastodon</a> / <a href="https://bsky.app/profile/mkennedy.codes?featured_on=pythonbytes">BlueSky</a> / <a href="https://x.com/mkennedy?featured_on=pythonbytes">X</a> / <a href="https://www.linkedin.com/in/mkennedy/?featured_on=pythonbytes">LinkedIn</a></li> <li>Calvin: <a href="https://sixfeetup.social/@calvin?featured_on=pythonbytes">Mastodon</a> / <a href="https://bsky.app/profile/calvinhp.com?featured_on=pythonbytes">BlueSky</a> / <a href="https://x.com/calvinhp?featured_on=pythonbytes">X</a> / <a href="https://www.linkedin.com/in/calvinhp/?featured_on=pythonbytes">LinkedIn</a></li> <li>Show: <a href="https://fosstodon.org/@pythonbytes">Mastodon</a> / <a href="https://bsky.app/profile/pythonbytes.fm">BlueSky</a> / <a href="https://x.com/PythonBytes?featured_on=pythonbytes">X</a> </li> </ul> <p>Join us on YouTube at <strong><a href="https://pythonbytes.fm/stream/live">pythonbytes.fm/live</a></strong> to be part of the audience. Usually <strong>Tuesday at 7am PT</strong>. Older video versions available there too.</p> <p>Finally, if you want an artisanal digest of every week of the show notes in email form? Add your name and email to <a href="https://pythonbytes.fm/friends-of-the-show">our friends of the show list</a>, we'll never share it.</p> <p><strong>Calvin #1: <a href="https://github.com/openai/openai-python/blob/main/httpx2.md?featured_on=pythonbytes">OpenAI's Python SDK has migrated to HTTPX2</a></strong></p> <ul> <li>The OpenAI Python SDK has migrated to HTTPX2, the Pydantic-stewarded fork of httpx. Pydantic picked it up citing "limited activity recently" in the original project, promising "a reliably maintained path forward."</li> <li>If you just use the default client, nothing to do. No code changes.</li> <li>The catch is TLS. Quoting the guide: HTTPX "previously verified certificates against the CA bundle provided by certifi. HTTPX2 instead uses the operating-system trust store, and the SDK no longer installs certifi."</li> <li>That "can break certificate verification in minimal container images without system CA certificates, environments using corporate TLS-inspecting proxies, and deployments that relied on a custom or modified certifi bundle."</li> <li>The fix is SSL_CERT_FILE or SSL_CERT_DIR, or pass your own ssl.SSLContext via verify.</li> <li>Deeper integrations need real edits: custom clients, auth handlers, hooks, and request mocking all take HTTPX2 objects now, and plain httpx is no longer pulled in transitively. So import httpx in your own code means declaring it yourself or moving over.</li> <li><strong>Temporary escape hatch: a legacy HTTPX client</strong></li> </ul> <p><strong>Michael #2: <a href="https://tmog.org?featured_on=pythonbytes">TMOG - Native Task Manager for macOS, Windows, and Linux</a></strong></p> <p>A native, deeply instrumented system monitor for macOS, Windows, and Linux, now in public beta - from Plummers' Software, i.e. Dave Plummer, who wrote the original Windows Task Manager and donated it to Microsoft in 1995. <a href="https://en.wikipedia.org/wiki/Dave_Plummer?featured_on=pythonbytes">Wikipedia</a></p> <ul> <li><strong>Three real native apps</strong>: Swift/AppKit on macOS, Win32 on Windows, C++/Qt 6 on Linux, with a shared C++ core keeping metric semantics aligned - no browser shell anywhere.</li> <li><strong>One dense summary</strong>: CPU, clocks, thermals, GPU, memory, storage, network, energy, and the processes responsible for the load, all click-through.</li> <li><strong>Per-core honesty</strong>: logical processor and NUMA views, P and E cores color-coded, optional kernel time, 60 FPS live meters.</li> <li><strong>Memory with context</strong>: pressure, wired, compressed, cached, committed, available, and swap, plus configurable scrolling history.</li> <li><strong>Processes that act like processes</strong>: tree view, filtering, sorting, follow mode, and native verbs including service and launchd control.</li> <li><strong>Phosphor themes</strong>: light, dark, green, amber, blue, or mono, with color and saturation you tune yourself.</li> </ul> <p><strong>Calvin #3: <a href="http://grahamdumpleton.me/posts/2026/08/introducing-wrapture/?featured_on=pythonbytes">wrapture - one wrapper for mocking, tracing, and observability</a></strong></p> <ul> <li>Graham Dumpleton, author of wrapt and the original New Relic Python agent, has released wrapture. The name is wrapt plus capture. The core idea: wrap real code instead of replacing it, so the real code still runs while you watch every call.</li> <li>Name a method with wrapture.binding(Class, "method"), open a timeline(), and you get a tape of what actually happened. Real return values, real nesting, arguments normalised against real signatures. tape.tree() prints the call graph as it ran.</li> <li>One mechanism, three jobs: monkey patching with a real lifecycle (apply, remove, suspend, plus returns, raises, transforms_args), unit testing that asserts on real call flow instead of a flat MagicMock call list, and ad-hoc tracing of a running app.</li> <li>The testing pitch is error paths. Inject TimeoutError at the payment gateway, then assert the ledger was never written. Stubs and mocks are strict and spec-required, and there is deliberately no bare Mock().</li> <li>Tracing needs no code at all. A wrapture.toml naming targets and a sink, run with python -m wrapture <a href="http://main.py/?featured_on=pythonbytes">main.py</a>, and you get a live call tree with timings. It captures ordinary logging calls as nested events, and with the otel extra it exports spans, metrics and correlated logs with W3C trace ids that join across services.</li> <li>Every line of code and docs was AI-written under their direction, and they say so up front. Two weeks from first commit, eleventh alpha, over 1000 tests, 150+ pages of docs. Alpha on PyPI, needs Python 3.12+ and wrapt 2.4.0+.</li> </ul> <p><strong>Michael #4: <a href="https://github.com/juanmanueldaza/linkedin2md?featured_on=pythonbytes">linkedin2md</a>: turn your LinkedIn export into 40+ Markdown files</strong></p> <p>Via Juan Manuel Daza - a Python CLI that unpacks LinkedIn's data-export ZIP into clean, per-category Markdown you can drop straight into an LLM.</p> <ul> <li><strong>One command</strong>: <code>linkedin2md Complete_LinkedInDataExport.zip</code>, plus <code>o</code> for output dir, <code>-lang en|es</code>, and <code>-pdf</code>.</li> <li><strong>40+ output files</strong>: profile, experience, education, skills, connections, posts, comments, reactions, recommendations, endorsements, job applications, even ad targeting and LinkedIn's inferences about you.</li> <li><strong>Built for LLM analysis</strong>: the README pitches NotebookLM, Claude Projects, Obsidian, and Ollama, with example prompts like "what patterns do you see in my career transitions?"</li> <li><strong>PDF resume mode</strong>: <code>-pdf</code> renders an A4 CV via weasyprint, and degrades gracefully to Markdown-only if it isn't installed.</li> <li><strong>Dependency note</strong>: "pure Python / zero-dep" holds for the Markdown path only - the PDF path needs weasyprint and markdown installed.</li> <li><strong>Install</strong>: <code>pipx install linkedin2md</code> recommended, <code>pip</code> in a venv otherwise - 86% Python, 10 releases, v0.3.1 in May.</li> <li><strong>Agentic dev angle</strong>: repo ships opencode config and an N3RV subagent pipeline, including a "judgment day" dual-model adversarial PR review.</li> </ul> <p><strong>Extras</strong></p> <p>Calvin:</p> <ul> <li><a href="https://www.eveonline.com/news/view/the-move-to-python-3-begins?featured_on=pythonbytes">EVE Online Migrates to Python 3</a></li> </ul> <p>Michael:</p> <ul> <li><a href="https://dinkus.textualize.io?featured_on=pythonbytes">Dinkus</a> by Will McGugan</li> </ul> <p><strong>Joke: <a href="https://www.talisman.org/tao/?featured_on=pythonbytes">Tao of Programming: Book 5 Maintenance</a></strong></p>

September 01, 2026 03:39 PM UTC


Hugo van Kemenade

Help test Python 3.15!

Calling all Python library maintainers! 🐍

The second and final Python 3.15 release candidate is out! 🎉

It’s your last chance to join the cool testers’ club and say that YOU helped test 3.15 before the big day on 1st October! 😎

PEP 790 defines the release schedule for Python 3.15.0:

In his announcement, Hugo van Kemenade (👋 hi, that’s me!), release manager for Python 3.14 and 3.15, gave a call to action:

We strongly encourage maintainers of third-party Python projects to prepare their projects for 3.15 during this phase, and publish Python 3.15 wheels on PyPI to be ready for the final release of 3.15.0, and to help other projects do their own testing. Any binary wheels built against Python 3.15.0 release candidates will work with future versions of Python 3.15. As always, report any issues to the Python bug tracker.

Test with 3.15 #

It’s now time for us library maintainers to start testing our projects with 3.15. There’s two big benefits:

  1. There have been removals and changes in Python 3.15. Testing now helps us make our code compatible and avoid any big surprises (for us and our users) at the big launch in October.

  2. We might find bugs in Python itself! Reporting those will help get them fixed and help everyone.

How #

To test the latest alpha, beta or release candidate on GitHub Actions with actions/setup-python, add 3.15 and allow-prereleases: true to your workflow matrix.

For example:

jobs:
 test:
 runs-on: ubuntu-latest
 strategy:
 fail-fast: false
 matrix:
 python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]

 steps:
 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

 - name: Set up Python ${{ matrix.python-version }}
 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
 with:
 python-version: ${{ matrix.python-version }}
 allow-prereleases: true

(We can instead use 3.15-dev and omit allow-prereleases: true, but I find the above a bit neater, and when 3.15.0 final is released in October, it will continue testing with full release versions.)

When to support 3.15? #

Now is also a good time to declare support and add the Programming Language :: Python :: 3.15 Trove classifier. Many projects already have!

Especially if you have extension modules and other projects depend on yours, a release with binary wheels will help them test and prepare.

ABI breaks? #

From the announcement:

There will be no ABI changes from this point forward in the 3.15 series, and the goal is that there will be as few code changes as possible.

Let’s start testing 3.15 now! 🚀

See also #


Header photo: A woodcut of a crowned snake in the Museo di Palazzo Poggi, Bologna, used for Ulisse Aldrovandi’s Serpentum, et draconum historiæ libri duo (1640).

September 01, 2026 02:33 PM UTC


Python Software Foundation

The 2026 PSF Board Election is Open!

It’s time to cast your vote! Voting for the 2026 PSF Board Election is open starting today Tuesday, September 1st, 2:00 pm UTC, through Tuesday, September 15th, 2:00 pm UTC. 

How to Vote

If you are a voting member of the PSF who affirmed your intention to participate in this year’s election or you voted in last year’s election, you will receive an email today from “OpaVote Voting Link <noreply@opavote.com>” with a link to your ballot. The subject line will read “Python Software Foundation Board of Directors Election 2026”. If you haven’t seen your ballot by Wednesday, please first check your spam folder for a message from “noreply@opavote.com”. If you can’t find the ballot email from OpaVote please get in touch by emailing psf-elections@pyfound.org so we can look into your account and make sure we have the most up-to-date email for you.

Four seats on the board are open, but you can vote to approve as many of the 17 candidates as you like. We’re delighted by how many of you are willing to contribute to the Python community by serving on the PSF Board! Make sure you take some time to look at all the nominee statements and choose your candidates carefully. 

ATTN: Choose carefully before you press the big green vote button. Once your vote is cast, it cannot be changed.

An Informed Vote is a Great Vote

Nomination and supporting statements are the clearest way to see how each candidate actually thinks: their priorities, experience, and vision for the PSF. Rather than just voting on name recognition, we encourage you to read each candidate's nomination and supporting statements. PSF Board members shape real decisions about budget, programs, and community direction. Reading these statements helps ensure your vote reflects what the candidates would actually do in the role. 

This year, the PSF election team has taken a few more steps to ensure informed voting. We invited this year’s candidates to participate in written interviews. You can find the candidate interviews on the PSF blog. We also encouraged this year’s candidates to open AMA (ask me anything) threads on discuss.python.org. You can find those threads under the PSF Board Election category on Discuss. Some great Q&A has already been taking place, so make sure to check them out!

Who can vote? 

You need to be a Contributing, Supporting, or Fellow member and have affirmed your voting intention by August 25th, 2:00 pm UTC, to vote in this election. Per last year’s Bylaw change that allows for simplifying the voter affirmation process by treating past voting activity as intent to continue voting, if you voted last year, you have been automatically added to the 2026 voter roll. Please note: If you removed or changed your email on psfmember.org, you may not automatically be added to this year's voter roll. 

Reminder that if you were formerly a Managing member, your membership has been updated to Contributing as of June 25th, 2025, per a recent Bylaw change that merged Managing and Contributing memberships

If you’d like to learn more or sign up as a PSF Member, check out our membership types. You can check your membership status on your User Information page on psfmember.org (you will need to be logged in). If you have questions about your membership or the election please email psf-elections@pyfound.org

September 01, 2026 10:02 AM UTC

Inaugural Python Packaging Council Election: Voting is now open!

It’s time to cast your vote! Voting for the inaugural 2026 Python Packaging Council (PPC) Election is open now through September 15 at 2:00 pm UTC.

Find Your Ballot

If you are a voting member of the PSF who affirmed your intention to participate in this year’s election, you will receive an email today from “OpaVote Voting Link <noreply@opavote.com>” containing a link to your ballot. The subject line will be “Python Packaging Council Election 2026”.

If you haven’t received your ballot by September 2, first check your spam folder for a message from noreply@opavote.com. If you still can’t find the OpaVote email, please contact pc-elections@python.org. We can look into your account and confirm that we have the most up-to-date email address for you.

Review the Nominees

Please take some time to read all of the nominee statements before casting your ballot. Consider what each nominee brings to the Council, the priorities and perspectives they describe, and how you would like to see Python packaging evolve. You can also ask candidates questions directly in the PPC Nominations AMA threads on discuss.python.org.

Important: Review your selections carefully before pressing the big green vote button. Once your ballot has been cast, your vote cannot be changed.

Who Can Vote?

To vote in this election, you must be a Contributing, Supporting, or Fellow member of the PSF and have affirmed your intention to vote by August 25 at 2:00 pm UTC.

If you’d like to learn more about PSF membership or become a member, see the PSF Membership page. You can check your current membership status on your User Information page at psfmember.org while logged in.

If you have questions about your membership or the election, please contact pc-elections@python.org.

September 01, 2026 09:59 AM UTC


Python Insider

Python 3.15.0 candidate 2 is here!

Last chance to test before the big day!

September 01, 2026 12:00 AM UTC


Graham Dumpleton

Introducing wrapture

For the best part of two decades, through wrapt, I have been dealing with the mechanics of monkey patching in Python. Anyone who has followed wrapt will know I am quite pedantic about correctness, to the point of caring whether a wrapper preserves every last introspectable detail of the thing it wraps. For much of that time I have wanted the same standard from the tools I use when testing code, and unittest.mock, which does what it does well enough, was never designed to give it. A fabricated Mock answers every method call and verifies nothing. A patched call records a flat list of calls, with no return values and no sense of what was called from what. The calls an object makes to itself are invisible, because the substitute never runs the real code at all. What I want a test to be sure of, that the right calls happened, in the right order, with the real code actually running, sits just outside what substitution can express.

Seeing the real calls as they happen, with the real code still doing the work, was also something I had already spent years on for a quite different purpose. I was the original author of the New Relic Python agent, written while I worked there, and that left me with a lasting interest in instrumenting Python programs. Attaching observation to code you do not control, recording what flows through it, and doing so without disturbing the program being watched, is a problem I have never really stopped thinking about.

Testing and tracing look like different problems, but from where I sat they wanted the same thing, and I had long believed that wrapt's approach of wrapping real code rather than replacing it could serve both. wrapture (the name being wrapt plus capture) is me finally getting around to finding out whether that belief held up.

Wrap anything, capture everything

The one idea everything in wrapture sits on is to wrap rather than replace. A binding names a location in code, a method of a class or a function in a module, and when applied installs a wrapt wrapper around the real callable. Unless you tell it otherwise the wrapper is transparent. The real code runs, with wrapture in a position to watch the call, change it, or answer it instead.

Take a small call graph where an order service charges a payment gateway and then records the result in a ledger:

import wrapture

class Gateway:
    def charge(self, amount, currency="USD"):
        return {"id": f"ch_{amount}", "amount": amount}

class Ledger:
    def record(self, entry):
        return f"led_{entry['id']}"

class OrderService:
    def __init__(self):
        self.gateway = Gateway()
        self.ledger = Ledger()

    def place(self, amount):
        result = self.gateway.charge(amount)
        self.ledger.record(result)
        return result

None of these classes import wrapture or know they are about to be observed. Bindings are created by naming the methods, and a timeline opens a recording scope in which every call through them lands on a tape:

place = wrapture.binding(OrderService, "place")
charge = wrapture.binding(Gateway, "charge")
record = wrapture.binding(Ledger, "record")

with wrapture.timeline(place, charge, record) as tape:
    OrderService().place(500)

print(tape.tree())

Running this the output is:

__main__:OrderService.place(amount=500)  -> {'id': 'ch_500', 'amount': 500}
  __main__:Gateway.charge(amount=500, currency='USD')  -> {'id': 'ch_500', 'amount': 500}
  __main__:Ledger.record(entry={'id': 'ch_500', 'amount': 500})  -> 'led_ch_500'

That is the call graph as it actually ran. The arguments are normalised against the real signatures (so charge(500) and charge(amount=500) look the same), the return values are the real ones, and the nesting comes from what really called what. The tape.tree() call is a convenience for debugging and for demonstrations like this one; in a test you would query the tape instead, and outside of a test the events would be going to a sink, which I will come to.

The same bindings intervene as well as observe. The real method can be stubbed out, made to fail, or left running while one thing about the call is changed on the way in or out:

gateway = Gateway()

with wrapture.binding(Gateway, "charge").on_call.raises(TimeoutError("down")):
    gateway.charge(500)

Inside the block the call raises TimeoutError, and after the block exits the original method is back exactly as it was.

Three uses of one mechanism

That is the whole mechanism. What makes it interesting is that it serves three purposes which are usually handled by three different tools.

The first is plain monkey patching. wrapt's wrap_object() has always been able to patch a target, but it leaves the bookkeeping to you. wrapture adds a lifecycle and a vocabulary over the top of it. A binding declares a target without touching it, apply() installs the patch, remove() restores the original, suspend() makes it inert in place, and a group of bindings applies and removes as one unit. Behaviour is configured on the binding, with returns(), raises(), transforms_args(), transforms_result() and a few others, and can be scripted to change over time, so "succeed twice, then time out" is three lines rather than a hand-written counter. This layer is useful on its own with nothing else switched on.

The second is unit testing, which is where the recording comes in. Because the real code runs, a test can assert on how calls actually flowed through it, and the interesting cases are the error paths. Inject a failure at the gateway, then check that the ledger was never written:

with wrapture.timeline(place, charge, record) as tape:
    charge.on_call.raises(TimeoutError("down"))

    try:
        OrderService().place(500)
    except TimeoutError:
        pass

    record.events.assert_never()

    print(tape.tree())

With the assertion passing, the tree shows where the failure was injected and how it propagated:

__main__:OrderService.place(amount=500)  !! TimeoutError
  __main__:Gateway.charge(amount=500, currency='USD')  !! TimeoutError (injected)

When a test must supply a stand-in, because the code under test receives a collaborator rather than importing it, wrapture provides stub() for a callable and mock(Spec) for a whole object, and these record onto the same tape as everything else. Both are strict: signatures are checked and nothing is invented on first touch. There is deliberately no spec-less Mock() equivalent, and the comparison with unittest.mock in the documentation explains why, alongside a mapping of each mock idiom to its wrapture counterpart. An opt-in pytest plugin sweeps each test for patches left applied and attaches recordings to failure reports.

The third use is ad-hoc tracing of a running application, including one you cannot modify or redeploy. Take the bindings, drop the test around them, and the only remaining question is where the events go. A sink answers that. In practice this is done with a wrapture.toml file naming the targets and the sink, and no code at all. Here is one for a slightly bigger version of the shop above, where the gateway declines some cards and the order service logs a warning when it does:

[[observe]]
target = "shop:OrderService"
name = "place"

[[observe]]
target = "shop:PaymentGateway"
match = "*"
exclude = "_*"

[[observe]]
target = "shop:Ledger"
name = "record"

[[log]]
name = "shop.*"

[[sink]]
type = "printer"

Running the program as python -m wrapture main.py applies the config before the program starts, so the patches are in place before the application imports anything, and the printer sink writes the trace to stderr as it happens:

shop:OrderService.place(order_id='order-1', amount=30, card='5100-0010')
  shop:PaymentGateway.charge(amount=30, card='5100-0010')
  shop:PaymentGateway.charge -> 'ch_30' [7us]
  shop:Ledger.record(order_id='order-1', amount=30)
  shop:Ledger.record -> 'ledger:order-1:30' [5us]
shop:OrderService.place -> 'ch_30' [234us]
shop:OrderService.place(order_id='order-2', amount=240, card='5100-0020')
  shop:PaymentGateway.charge(amount=240, card='5100-0020')
  shop:PaymentGateway.charge -> 'ch_240' [4us]
  shop:Ledger.record(order_id='order-2', amount=240)
  shop:Ledger.record -> 'ledger:order-2:240' [3us]
shop:OrderService.place -> 'ch_240' [105us]
shop:OrderService.place(order_id='order-3', amount=75, card='4000-0030')
  shop:PaymentGateway.charge(amount=75, card='4000-0030')
  shop:PaymentGateway.charge !! PaymentDeclinedError [4us]
  log shop.orders WARNING 'order order-3 declined'
shop:OrderService.place !! PaymentDeclinedError [260us]

Unlike the tidy reconstruction from tape.tree(), this is the live view, with an opening line as each call begins and a closing line with the outcome and how long it took. The [[log]] entry captures the application's ordinary logging calls as events too, so the warning appears nested inside the call that logged it rather than somewhere in a separate log file. With autowrapt installed, even the launcher is unnecessary: AUTOWRAPT_BOOTSTRAP=wrapture in the environment applies the same config at interpreter startup, so the program runs with plain python. That covers the case where something else owns the command line, like a container entry point or a WSGI server.

The printer is the simplest sink. Others stream events to disk as JSON lines, count without retaining, and compose with fan-out, sampling and filtering. Sitting on top of the tracing layer is OpenTelemetry export: with the wrapture[otel] extra installed, one [otel] table in the config sends the same events to any OTLP backend as spans, metrics and correlated logs. Every tree of events carries a W3C trace id, and the id arrives and leaves in traceparent headers, so two services both observed by wrapture join up as one distributed trace without either of them calling an OpenTelemetry API.

The point I want to land is that these are layers of one mechanism and not separate products. The binding vocabulary that stubs a method in a test is the same one that traces it in production, and the config that names methods for a printed call tree is the config that exports spans. What starts as a monkey patch or a test assertion can grow into observability without the code being rewritten along the way.

Where it sits beside what already exists

Part of why I built this is that nothing I could find did all of it. unittest.mock records a flat call list with no nesting and no return values, and a patched call returns a fabricated MagicMock rather than running the real code. Span assertion tools such as OpenTelemetry's InMemorySpanExporter require the code to already be instrumented. Tools built on sys.settrace give you a firehose with no assertion API. APM agents are all-or-nothing products rather than toolkits, and their auto-instrumentation only covers the frameworks they already know about. wrapture needs none of that. You point at your own methods by name and a trace appears, and the same pointing is how it lands in a test, a terminal, or a backend.

Just as important is what it is not. It is not a fabrication tool, and unittest.mock remains the right thing for invented objects. It is not a production APM, although it is a toolkit that APM-like things could be built on. And it is not an OpenTelemetry competitor; it emits to OpenTelemetry rather than trying to replace it.

Pre-built instrumentation

Pointing at your own methods is the core of wrapture, but for common third-party packages the pointing has already been done. The companion wrapture-instrumentation package provides ready-made instrumentation, with Flask and Jinja2 covered so far. Each records a request or a template render as one structured tree, and enabling one is an [[instrument]] entry in wrapture.toml naming the target. Installing the package brings in wrapture and nothing else; the instrumentation for a package you do not have is inert. It is being built target by target, and the instrumentation packages guide describes how to write one for a package not yet covered.

Built with AI, on purpose

Every line of code and documentation in wrapture was written by an AI assistant working under my direction. I want to be upfront about that, and equally upfront about what it was not. This was not vibe coding, where a one-shot prompt produces a pile of generated code and the person driving hopes for the best because they lack the knowledge to judge what came back. Vibe coding has earned its bad reputation. I engineered wrapture carefully from the start. I have spent a long time in this particular corner of Python and knew exactly what the result needed to be, and the AI was the means of producing it rather than the source of the design.

The experiment had two halves. The first was whether an idea I had carried around for years actually held up once built. The second, and just as much the point, was whether a library of this kind could be produced this way, with an AI doing the writing and me doing the directing, to a standard I would be happy to put my name to.

The process is what makes the result worth trusting or not, so it deserves describing. The work started well before any code, with days spent on design documents setting out the goals, the scope, the shape of the API and the layers it would be built in, which the AI and I argued over before implementation began. From there it proceeded in layers, each one specified, discussed, implemented, tested and documented before the next began. Documentation grew with the code rather than after it, and every example in the docs runs as a doctest, so the docs are continually proven against the implementation. Writing them repeatedly exposed designs that read worse than they demoed. The test suite runs against every supported Python version, including the free-threaded builds, on every change. The overhead of Python instrumentation is the usual objection to it, so the recording path was also put through a performance pass, with the cost of a call observed and exported through wrapture measured against the same call instrumented directly with the OpenTelemetry SDK and in the style of its instrumentation packages. The result was comparable per call, with the figures in the OpenTelemetry export guide.

The step I would most recommend to anyone attempting something similar came late. I took the unit test suites of well-known Python packages that lean heavily on unittest.mock and had the AI replicate their tests using wrapture instead, side by side with the originals. Every point of friction became a decision: sometimes a documented position on why wrapture deliberately differs, and sometimes a missing feature that got specified, built and documented like everything else. Several pieces of wrapture exist only because a real test suite could not be expressed cleanly without them.

Throughout, the division of labour was consistent. The AI wrote the code, the tests and the prose. I set the direction, made the design calls, reviewed what came back, and sent plenty of it back. My experience with wrapt and with Python's darker corners is all through the result, in what was asked for as much as in what was refused.

The first commit was in the middle of August and the current release is the eleventh alpha, so this all happened in a bit over two weeks. In that time it accumulated over 1000 tests and over 150 pages of documentation. The documentation is admittedly quite dense in places and needs some work still, but it is complete in the sense that every part of the package is covered. One thing a brand new library has over an old one is coherence. Mature packages accrete features one release at a time, and there is never a moment when the whole API can be redesigned to match what was learned along the way. Because wrapture arrived in a compressed period with the whole design still in view, when validation showed a design could be better it was redesigned rather than worked around.

I know some people are firmly opposed to using AI-written software. If that is you, I understand, and I am not going to argue with your position. It is a reasonable one to hold, and this post exists so you can make the call with the facts in hand rather than discover them later. If AI involvement rules wrapture out for you then wrapture is not for you, and that does not worry me one bit. This has been about finding out whether the process works, and I now have my answer to that. The longer version of all this is on the how wrapture was built page in the documentation.

What's next

wrapture is in alpha, with pre-releases on PyPI. Until 1.0.0 is final a plain pip install wrapture picks up the latest pre-release, so there is no need to pin a version. It requires Python 3.12 or later and wrapt 2.4.0 or later. The API is complete for the three uses described above and I am not expecting it to break, so code written against it today should carry forward to 1.0.0.

What it needs now is use. unittest.mock and OpenTelemetry's own instrumentation are the established tools for the two halves of what wrapture does, and the open question is whether an alternative that does both from one mechanism is something people want. Reports of it working, or not, on real code, and of what confused or was missing, are what will decide whether anything changes before a beta. They go to the issue tracker.

To be clear, wrapture was never premised on anyone else using it. I built it because I wanted to see it exist, not because I had identified a gap in the market. If people find it useful and pick it up, that is great, and I will aim to support it. If there is no interest, I will keep treating it as an experiment and work on it for my own purposes. Either way the questions got answered, and getting them answered was the point.

There is a lot more in wrapture than fits in an introduction, and I expect to write about specific parts of it in follow-up posts, starting with how it can be used for unit testing, and then tracing a Flask application through to an OpenTelemetry backend without touching the application code. For now the getting started page is the place to begin.

September 01, 2026 12:00 AM UTC


Seth Michael Larson

Buying GameCube games from other regions

This blog post was inspired by discovering that Pokémon Box: Ruby & Sapphire, a legendarily rare and expensive GameCube game in the United States (>$2,500 USD), could be purchased complete-in-box (CIB) in Japan for $50 USD. That's a savings of $2450, or ~98% of the US price. This is a somewhat extreme example: in the US market Pokémon Box was only sold online and at a single store: the Pokémon Center in New York City.

This big price difference between regions got me thinking: what other games are cheaper in other regions and by how much? Please note that the below data was taken from Pricecharting on August 30th, 2026. Shipping costs aren't included in the price, shipping from Japan to the US is typically $15-$20 USD on eBay, but many games are already imported to the US.

GameDiff.Price (US)Price (PAL)Price (JP)
Pokémon BoxCIB-$2348$2400$204$52
Loose-$1553$1574$137$21
CubivoreCIB-$612$640$28
Loose-$318$341$23
Gotcha ForceCIB-$405$474$137$69
Loose-$204$249$121$45
Resident Evil: Code Veronica XCIB-$239$299$80$60
Loose-$151$213$62
Chibi RoboCIB-$189$210$223$34
Loose-$136$144$161$25
Pokémon XD: Gale of DarknessCIB-$170$209$232$62
Loose-$129$150$183$54
Fire Emblem: Path of RadianceCIB-$166$214$187$48
Loose-$101$145$145$44
Resident Evil 2CIB-$90$161$78$71
Loose-$77$106$29$44
Pokémon ColosseumCIB-$98$142$64$44
Loose-$83$109$49$26
Legend of Zelda: Twilight PrincessCIB-$108$196$115$88
Loose-$66$146$107$80
Resident Evil 3: NemesisCIB-$97$156$59$74
Loose-$52$99$47$69
Legend of Zelda: Four Swords AdventuresCIB-$92$77$130$38
Loose-$58$56$79$21
Eternal DarknessCIB-$82$114$32$34
Loose-$60$85$25$33
Billy Hatcher and the Giant EggCIB-$66$89$23$34
Loose-$55$65$10$34
Wario WorldCIB-$55$80$60$25
Loose-$44$62$45$18

Even more excitingly, there is a mod patch for Pokémon Box enabling the Japanese version to communicate and trade Pokémon between US, JP, and PAL Game Boy Advance games. This means if you wanted to use Pokémon Box functionality you could buy a Japanese copy for $50, patch the software, and use the software as if it were from any region.

This fact carries beyond GameCube games, as you'd imagine. Legend of Zelda: Minish Cap is $90 for a loose US copy and only $40 for a Japanese copy. Surprisingly it's not always the case that the US release is cheaper than the Japanese release. For example, the Sonic Gems Collection for GameCube costs $55 CIB for a US release compared to $180 CIB for JP.

I suspect this is because the soundtrack for Sonic CD is different for each region, JP Sonic Gems Collection carrying the far-superior and sought after JP Sonic CD soundtrack. The US Sonic CD soundtrack was included in the US and PAL Sonic Gems Collection releases. The Japanese Sonic CD soundtrack was released for the first time outside Japan in Sonic Origins.

Why shouldn't you buy other regions' games?

The primary reason is if you don't speak the language. Many PAL GameCube titles include English as one of the many supported languages, but this can't be said for NTSC-J. Most Japanese titles are exclusively in Japanese, and I don't speak the language. For some games this is fine, not knowing Japanese won't hamper games that aren't text-heavy, but RPGs or story-based adventures will impact the fun-factor. There are some modifications you can apply to translate the Japanese text back into English.

Many consoles are region-locked, and thus require either purchasing a matching region console, modifying a console to support all regions, or dumping ROMs and using emulators (usually with a dedicated device or console modification). So you can't use any region's software without at least some extra work or hardware. The Nintendo GameCube has three main regions: NTSC-J (Japan), NTSC-M (Americas), and PAL (and maybe NTSC-K (Korea)? I cannot tell if this is actually its own region).

I've gone the way of modifying my GameCube hardware with a solderless mod-chip called the FlippyDrive, but other open source options for a similar device are now available. This modification allows running any regions games on any region hardware.

If you are using unmodified hardware you may run into issues with frame rate if you're mixing software and hardware from an NTSC region (US, JP, KR) and the PAL region. I am not super knowledgeable in this area, look around for solutions to this issue before buying.



Thanks for reading ♥ I would love to hear your thoughts! Contact me via Mastodon, Bluesky, or email. Browse the blog archive. Check out my blogroll.



September 01, 2026 12:00 AM UTC

August 31, 2026


PyCharm

Fine-Tuning SOTA Object Detection Models on Real-World Datasets

In our previous blog post in this series, we discussed state-of-the-art models for object detection: the architectures, the theory, and what makes YOLO12, YOLO26, and RF-DETR tick. If you want the theoretical background on these models, start there.

This post is the practical follow-up: how to actually use these models, how to fine-tune them on diverse, specialized datasets that look nothing like their training data, and how to evaluate the results – all within PyCharm.

Why fine-tune at all?

Every pretrained detector you download was trained on some distribution of images, almost always COCO, which is ~118k training images of everyday scenes containing 80 common object categories (people, cars, dogs, chairs, etc.).

Real-world deployment data rarely looks like COCO. Things that object detection might actually be applied to, such as damaged industrial cables, bone fractures on X-rays, or densely stacked soda bottles on a shelf, are:

Deploying a detector on off-distribution data therefore requires fine-tuning. But before we break the models, let’s establish that we get similar results on our hardware to the ones reported by developers.

The models

For the purposes of this experiment, we’ll focus on three current SOTA object detection families and examine two sizes of each model:

FamilyVariantsImplementation
YOLO12yolov12n, yolov12mOriginal authors’ repo
YOLO26yolo26n, yolo26mUltralytics PyPI package
RF-DETRRFDETRNano, RFDETRBaseRoboflow PyPI package

Sanity check: Reproducing COCO val2017 baselines

We’re going to be working with six pretrained checkpoints: Two different sizes of each of the three models. To check that these models are behaving as expected, we evaluated all of them on the full 5,000-image COCO validation dataset (val2017) to verify the numbers reported in the previous post:

ModelParams (M)mAP50mAP50-95Latency (ms)
YOLOv12-N2.550.55480.402123.9
YOLO26-N2.570.54980.395212.3
YOLOv12-M19.670.69530.525972.4
YOLO26-M21.900.69060.518113.9
RF-DETR Nano30.470.67500.483512.4
RF-DETR Base32.170.72100.532512.9

Three things stand out even before we leave COCO behind:

  1. Larger models (mostly) have better performance. RF-DETR Base leads (0.5325 mAP50-95), but the medium YOLOs get remarkably close (0.5259 / 0.5181) with ~10M fewer parameters.
  2. YOLO26’s NMS-free design pays off in throughput. YOLO26-N is the fastest model in the lineup (12.3 ms latency) at essentially the same mAP50-95 as YOLOv12-N, which, despite being the smallest model here, is considerably slower (23.9 ms latency). Attention is expensive. (If you want more detail on the model architectures and how they affect performance, see the previous blog post in this series.)
  3. RF-DETR Nano is not “nano” by parameter count (~30M – more than YOLO26-M), but it is well-optimized: With 12.4 ms latency, it is the second-fastest overall.

Published papers report optimized inference latency: That is, they measure the model’s forward pass in isolation, stripped of the surrounding stages of the object detection pipeline. We deliberately skipped that aggressive optimization so our numbers reflect what you’d actually see when deploying these models.

As a result, our latency figures don’t line up with the benchmarks in the models’ white papers. There are two main reasons for this:

Accuracy is a different story: While our latencies diverge from the published ones, our mAP50-95 results fall within reasonable noise bounds of the reported figures.

Now that we’ve seen what our pretrained models can do on COCO, the dataset they were trained on, let’s see what happens when they’re tested off distribution.

The datasets

For evaluation, we used RF100-VL, a large-scale collection of 100 multimodal datasets covering concepts deliberately chosen to be rare in object detection models’ pretraining data. These datasets contain exactly the off-distribution targets we care about. These targets also mirror common real-life applications for object detection, giving us a realistic test of these models’ capabilities out in the wild.

We picked three datasets that stress test the models in different ways:

DatasetDomainWhy it’s hardClasses
cable-damageTechnical/industrialFine-grained damage types on visually similar backgroundsbreak, thunderbolt
bone-fractureMedical (X-ray)Entirely different imaging modality; subtle featuresangle, fracture, line, messed_up_angle
soda-bottlesRetailHeavy occlusion, many near-identical instances per imagecoca-cola, fanta, sprite

Tutorial: Fine-tuning all three models in PyCharm 🙂

Step 1: Setting up the project

One of the first challenges we had to overcome in this project was that the three implementations do not share a compatible set of dependencies. In particular, the two different generations of YOLO require different versions of the ultralytics package. PyCharm offers a clean solution for this: one PyCharm project with three isolated uv environments – one per model family.

We’ll run our computations on a remote GPU. Configuring a remote interpreter in PyCharm follows the same workflow as a local one: the same dialog and the same dropdown as in the local case. Note that remote interpreters require PyCharm Professional; Community Edition supports local environments only.

Firstly, we need to instantiate our three uv virtual environments via:

cd yolov12 && uv venv .venv --python 3.11 

cd yolov26 && uv venv .venv --python 3.11 

cd rf-detr && uv venv .venv --python 3.11

Once your uv virtual environments exist, register each one as an existing interpreter. Go to Settings | Python | Interpreter, click Add Interpreter → Add Local Interpreter, choose Environment as Select existing, and point the interpreter field at that environment’s bin/python. PyCharm doesn’t create anything here, it just picks up the environment uv already built.

Repeat for each environment. From then on, switching is a matter of picking one from the Settings | Python | Interpreter dropdown, or from the interpreter widget in the bottom-right-hand status bar.

You can find the full list of dependencies required for each model in their respective project repositories. You can either install all the projects’ dependencies in PyCharm’s built-in Terminal tool window or install individual packages using the Python Packages tool window (including selecting specific versions of packages). You can access both of these tool windows by clicking the relevant icons in the lower left-hand corner of the PyCharm toolbar. 

For a step-by-step guide on setting up the environments for all three models, see our GitHub implementation of this tutorial.

Step 2: Getting the datasets

To obtain the out-of-COCO-distribution datasets, we can install our datasets via the rf-detr virtual environment, since it has roboflow as one of its core dependencies. We then set the Roboflow API key as an environment variable so that it is available to the API when downloading the datasets.

pip install roboflow

export ROBOFLOW_API_KEY="your_key_here" # you can get API key here: https://docs.roboflow.com/reference/authentication/authentication/find-your-roboflow-api-key 

After setting everything up, now you can run the Python script below to get the three datasets we’re going to use in our tutorial:

import os
from roboflow import Roboflow

api_key = os.environ.get("ROBOFLOW_API_KEY")

if not api_key:
    raise RuntimeError("ROBOFLOW_API_KEY is not set")

DATASETS = [
    "bone-fracture-7fylg",
    "cable-damage",
    "soda-bottles",
]

VERSION = 2          # RF100 projects are generally published at version 2
FORMAT = "yolov8"    # or "coco", "voc", "yolov5"
rf = Roboflow(api_key=api_key)
workspace = rf.workspace("rf100")

for slug in DATASETS:
    print(f"Downloading {slug} ...")

    try:
        project = workspace.project(slug)
        dataset = project.version(VERSION).download(FORMAT)
        print(f"  -> {dataset.location}")

    except Exception as e:
        print(f"  !! failed: {e}")

This script connects to the Roboflow cloud service via its Python API client and downloads three specified RF100 datasets in YOLOv8 format. It loops through each dataset, reports where successful downloads are saved, and prints an error if any download fails.

Step 3: Getting a zero-shot baseline by using pretrained models on custom data

Before fine-tuning, we’re going to evaluate the COCO-pretrained checkpoints directly on our three datasets, to see whether the fine-tuning is actually necessary. The result was unambiguous: The models predicted essentially nothing.

Zero-shot mAP50-95 on the test splits of our three datasets:

Modelcable-damagebone-fracturesoda-bottles
RF-DETR Nano0.00040.00000.0027
RF-DETR Base0.00050.00000.0004
YOLOv12-N0.00070.00000.0266
YOLO26-N0.00000.00000.0033
YOLOv12-M0.00000.00000.0160
YOLO26-M0.00000.00000.0012

This is to be expected; it’s not a bug! As the models are closed-vocabulary detectors, that is, they have a finite number of predefined target classes, they physically cannot output a class like fracture that isn’t in their 80-class COCO head. 

This is the punchline of this whole post: A model scoring 0.72 mAP50 on COCO scores 0.00 on bone fractures. Pretrained ≠ deployable, even when the model is state of the art. Basic machine learning principles still apply, even in the age of AI!

Step 4: Fine-tuning

All models were fine-tuned on a single A100 GPU for 10 epochs. We used standard Ultralytics/RF-DETR fine-tuning pipelines in order to fine-tune the models on our three datasets. We fine-tuned a model for each dataset. The full fine-tuning pipeline can be found in finetune_rf100.py scripts in the project repo, under the folders for each model.

You can see the core of the training setup below. Both YOLO and RF-DETR are built on PyTorch under the hood, but the training loops are abstracted behind higher-level library APIs: Ultralytics’ YOLO.train() for the YOLO models, and RF-DETR’s own train() functionality.

YOLO12 and YOLO26

train_model = YOLO(args.model)

train_res = train_model.train(
                data=str(yaml_path),
                epochs=args.epochs,
                imgsz=args.imgsz,
                batch=args.batch,
                device=args.device,
                project=args.project,
                name=run_name,
                exist_ok=True,
                verbose=False,
            )

RF-DETR

ModelClass().train(
                dataset_dir=str(coco_dir),
                output_dir=str(output_dir),
                epochs=args.epochs,
                batch_size=args.batch_size,
                grad_accum_steps=args.grad_accum,
                lr=args.lr,
                resolution=resolution,
                early_stopping=True,
                checkpoint_interval=1,
            )

Step 5: Results

Fine-tuning transforms the picture. You can see the results on the test set after training:

On the left, we have the pretrained models’ results for the COCO validation dataset. As we showed earlier, accuracy (mAP50-95) fell between 0.39 and 0.53, and all models except for YOLOv12-M showed low latency. The fine-tuned models on the right showed a similar range of accuracy for the cable-damage and soda-bottle detection tasks, only falling lower for the bone-fracture task. Moreover, the fine-tuned models were comparable in latency to the pretrained models for their intended tasks, and for YOLOv12-M, they were even faster. This suggests that, after fine-tuning to the target domain, the models achieve performance that’s broadly comparable to the pretrained performance on their original training domain.

Let’s now have a closer look at the fine-tuned models’ performance, breaking it down by mAP50 and mAP50-95 for the three separate RF-100 datasets:

Modelcable-damagebone-fracturesoda-bottles
RF-DETR Nano0.9195 (0.4391)0.2317 (0.1136)0.9617 (0.6223)
RF-DETR Base0.9281 (0.4456)0.4474 (0.1915)0.9688 (0.6332)
YOLOv12-N0.9236 (0.4378)0.0911 (0.0532)0.9677 (0.6343)
YOLO26-N0.8165 (0.3681)0.0193 (0.0064)0.9148 (0.5896)
YOLOv12-M0.8266 (0.3649)0.1500 (0.0635)0.9706 (0.6422)
YOLO26-M0.8707 (0.3896)0.2194 (0.1038)0.9596 (0.6304)

What the numbers say:

Qualitative results

To visually assess how these models perform, we can overlay the predicted bounding boxes on the images. Let’s look at the objects our models detected in six random images per class:

We can see this confirms the accuracy values we saw above: The noisy images of soda bottles in fridges are labeled accurately, with tight bounding boxes for each object. The cable damage is identified less consistently, with some models failing to find the damage altogether, and others creating unnecessarily large bounding boxes. Finally, the images of broken bones contrast sharply with the other two, with less than half of the images having any break identified, and different models identifying different potential breakage points.

Conclusions 

Pretrained object detectors are powerful, based on advancements in model architecture over the past five years, but as we’ve seen here, pretrained does not necessarily mean deployable. All six models performed well on COCO, yet when we applied those same checkpoints directly to our specialized datasets, their performance fell close to zero. However, fine-tuning completely changed that picture.

After only 10 epochs of fine-tuning, all three model families were able to adapt well to both the cable-damage and soda-bottle datasets. As we noted, the soda-bottle task was particularly transferable, likely because it contained objects similar to those contained in COCO. cable-damage was also detected relatively reliably, although the larger gap between mAP50 and mAP50-95 showed that precisely locating these tiny defects was still challenging for all of the models. However, bone-fracture was a completely different story, likely because moving from the sort of natural images contained in COCO to X-rays is a much larger domain shift. While RF-DETR handled this jump best, even its performance shows the limits of fine-tuning, and there are times when you might need to consider more data, longer training, or even domain-specific pretraining.

The broader takeaway is that there is no single “best” detector: It is dependent on the task. Model size, latency requirements, licensing restrictions, and most importantly, the similarity between the model’s pretraining data and your target domain all affect the outcome. It is important to refrain from unquestioningly trusting the numbers reported by model providers and explore the fit of a specific model for your own particular task.

Get started with PyCharm today

In this post, we’ve gone from validating pretrained YOLO12, YOLO26, and RF-DETR checkpoints on COCO to testing them zero-shot on specialized data, to fine-tuning them on three very different object detection tasks, and then finally, comparing the resulting accuracy and latency. Along the way, we’ve seen how PyCharm can help manage the practical side of a project like this, where multiple model families require different dependency sets and training environments.

PyCharm helps you keep these workflows together in a single project while using isolated Python environments for each model family. Its interpreter management, built-in terminal, Python Packages tool window, and support for remote development make it easier to move between environments and run training on remote GPU hardware without having to manage each part of this workflow separately.

If you’d like to try these experiments yourself, maybe look into fine-tuning these models for your own specific object detection use case! PyCharm is available to download and try. You can use the accompanying project code to reproduce our COCO baselines, download the RF100 datasets, fine-tune the models, and evaluate them using the held-out test splits.

You can find the full code for this project on GitHub. And if you’d like to learn more about object detection, including the architectures behind the models we used in this post, check out the previous post in this series.

August 31, 2026 01:50 PM UTC


Python Software Foundation

Kojo Idrissa: 2026 PSF Board Election Candidate Interview

Who are you? 

I'm a software engineer at REVSYS. Before that I was an accountant. I was also a university instructor in the US and China.  I taught English, Western Culture, Problem-Solving, Accounting and MIS.

What would you bring to the PSF Board of Directors?

I bring business acumen from my accounting degree, MBA, and prior career as an accountant. I've also been on a non-profit board, when I was a board member for DEFNA, the non-profit that puts on DjangoCon US. I bring a global perspective of the Python community. I'm American, but I've spoken at conferences in Canada, Mexico, the Dominican Republic, Europe, Africa, and Australia. I used those opportunities to build ties with those local communities and increase my awareness of how their needs differ from the communities in the US, where the PSF is based.

What motivated you to run for the PSF Board of Directors?

I hadn't thought about running for the board until someone suggested I consider it, based on my prior community activity. As I thought about it, I realized I had some expertise and experience that I could use to benefit the broader Python community. In addition to my business background, I've made lots of connections with people from different parts of the global community.

What problem or challenge do you want to address if you are on the board?

The decline in the community's confidence in the board.
What I see (and hear, from other community members) is more a set of related challenges, instead of a single problem. The word "consistency" keeps coming up when discussing the PSF, both in action and communication. It comes up when discussing the Fiscal Sponsorees program and the Grants program. When do these programs open/close? Who are they available to and why/how? What type of support do they offer?

There's also an issue of "focus". The PSF's core activities are, "Infrastructure", "Community", and "Investing in Python & Tools". But at what rates are PSF resources allocated to those different activities. And do those allocation rates need to change?

Where do you see the PSF in 5 years from now?

If we handle things properly, I see the PSF thriving. I see a PSF more focused on the the parts of the core mission that only the PSF can best perform ("Infrastructure" and "Investing in Python & Tools"). I also see a PSF with better internal infrastructure in place to provide Community support in a clear, consistent, sustainable fashion.

However, if the board doesn't improve its internal infrastructure to provide Community support in a reliable fashion, I can see the global community "moving on" from the PSF and building its own mechanisms for support.

What areas of the Python community are you involved with?

I've been a DjangoCon US organizer since 2016 and the DEFNA North American Ambassador since 2017. I'm also a former DEFNA board member. I'm the Orientation Chair for DjangoCon US and I've helped lead the Newcomer's Orientation for PyCon US since 2022. I'm also the co-founder of Black Python Devs. I've spoken at multiple Python and Django conferences, been a Django Girls coach multiple times.

------

Note from the election administrators: 

Want to learn more about this candidate or ask them a question? 

Check out their nomination statement.

Check out their AMA thread on discuss.python.org.

August 31, 2026 10:45 AM UTC


Ed Crewe

From Routing Checks to Trajectory Testing: Evaluating an Agentic Chatbot

Which Agentic Chatbot?

I have been working on a Python based AI test framework for a chatbot interface for my company's product, Postgres AI Hybrid Manager. The manager allows the setup of Postgres clusters across cloud or on-prem and attaching various AI tools such as Langflow.  So a combination of more traditional Postgres backup, migration, telemetry and analytics features along with LLM workflows leveraging the data it holds.

The product already has a control plane UI for managing Postgres estates. It also has full help for the product, all Postgres versions, analytics, AI and add ons. The chatbot brings all these things together: ask a question, get the relevant help, or ask it to do something such as migrate a cluster, or evaluate telemetry that would otherwise require clicking through the UI.

That makes it a pretty handy interface, especially for the less technical. However it is not simple to test and ensure good quality responses.

A normal deterministic API test is simple. Send a request, check the status code, check the JSON body, perhaps check the database state. An LLM-backed agent does not pass or fail so clearly. It can route to the wrong capability and still return fluent text. It can pick a plausible but wrong tool. It can miss half the task and still sound confident. It can complete the first turn of a conversation and lose the plot on the second. It could get malformed or missing data from tooling that leads it to deliver a misleading conclusion. It might only provide help to something that should be from tool data or was a request for an action such as create a cluster.

So the testing problem was not “does the chatbot return a reasonable response?” It was “how do we test the whole chat path is doing the right thing?”

This is the story of how our agent-eval test framework evolved as we worked to see that our chatbot was not only getting the right answer,  42 , but whether it was asking all the right questions of the right tools to get that answer. Known as trajectory testing ...


 You're Golden 

Before we can tell our story we need to define some terms.

A Golden is an example of a perfect desired output from a test input. They often refer to more complex outputs that may need saving as separate files, but a simple assertable output such as 42, is a golden too!
Whilst complex goldens may be used and marked for semantic similarity against the test output. It is more common for complex outputs to be described by a rubric. A rubric is a checklist of qualitative properties a good answer must exhibit, written in plain English as opposed to a golden example of an answer.

For AI testing the tests are termed evals, ie they evaluate the tool, but not by strict assertions, because one thing you can be sure of with an LLM is that given the same input, you usually get subtly different output, ie they are non-deterministic. Which means for LLM outputs the only way to test them is to use an LLM-as-judge,  ie give that LLM the test output and a rubric or golden and let it mark it against that. Then you set a pass threshold for that mark, to translate your complex output into a pass or fail.

You can also total up all the passes to give you a Task Completion Rate, TCR. So with complex AI agentic LLM interactions a 100% pass of all evals is often not realistic. Hence you set a TCR below 100% for the whole test suite of evals to pass. Start with the smallest useful test. The core principle of evals is not complicated, you want the input to give you the expected output.

But for an Agentic application this may require a sequence of LLM calls and tools: Making the final output dependent on the route that should be chosen, the tool(s) that should be called, the actions to be taken, further LLM calls that may be necessary and finally the core data that the response to the user should contain.

Our first version did not try to solve every part of that. It started with routing, simple and deterministic.

Routing is the starting point

The chatbot originally had an agent per tool. The tool being the code and API calls that performed actions or returned data or help.

Different specialist agents owned different parts of the product surface: Control-plane actions, Postgres database operations, schema design, roles and permissions, cluster reporting, migration, and so on.

Before any specialist can help, something has to choose the right specialist.

So the first eval suite asked a narrow question:

Given this user prompt, did the chatbot route to the expected tool?

That gave us a fast health check. We could keep a corpus of prompts, map each one to an expected destination, run them through either a direct model path or against the real deployment and its tools, and score whether the selected destination tool matched the golden.

 A golden here is just the name of the tool:

- id: "core-iam-001"
    prompt: "List all my projects"
    expected_tool: "control-plane"
    tags: ["core", "control-plane", "project"]

And the check on the other end is deliberately dumb — an equality test, not a semantic one:

self.success = tool_match(predicted_tool, expected_tool)

Agents became skills, but routing remained 

The design moved away from “one agent per tool family” toward a more consolidated orchestrating agent with skills that could better compose different tool use for common tasks.

That is a better fit for how modern agent systems are evolving. A skill = instructions, constraints, and a subset of tools that are relevant for a task. It is a form of progressive disclosure. Give the model the minium it needs at each step to save tokens.

But this did not make routing irrelevant.

Instead of asking “did we transfer to the right sub-agent?”, the eval asks “was the right skill made visible and selected for this task?” The labels changed but a skill could still use the wrong tool.

Routing evals stayed valuable because they were fast, explainable, and easy to run in CI. But they are limited, routing should always be correct but it doesn't mean that the final agent response is too.

TCR jumps to the endpoint, the response

Task Completion Rate, or TCR, was the next step.

The user asked for a cluster comparison, or a schema recommendation, or help diagnosing a database issue. We need to know whether the full response actually completed these tasks.

Responses are complex goldens so they need the LLM-as-a-judge pattern: run the chatbot, take the actual response, and ask a judge model to score it against expected sections.

The eval has a rubric here for judging the output:

- id: "tcr-core-014"
  prompt: "Compare CPU usage between these two clusters"
  expected_sections:
    - "identifies which cluster has higher CPU usage"
    - "cites at least one supporting metric"
    - "suggests a plausible next step"

The judge gets one simple instruction: score each expected_sections between 0.0–1.0 A metric class then just thresholds it for pass / fail:

self.success = score >= 0.7

The judge must be calibrated and a consistent model used for comparing runs over time. Consistent judging enables skill and/or prompt tuning from metric trends. The rubric must be specific enough to avoid marking waffle as success. But it turns a non-deterministic complex output into a simple pass and fail. It also separated two different levels of QA:

That led to two execution modes.

Direct mode calls the model with simulated context. It is faster and useful for prompt and rubric development.

Proxy mode calls the real chatbot. It is slower, but it exercises the production path: routing, skill selection, tool calls, guardrails, streaming responses, conversation state, and the actual service wiring.

Both matter. Direct mode tells you whether the model is capable of the answer. Proxy mode tells you whether your product is capable of really delivering it via agents running your deployment's tools. 

This is the major difference from standard AI LLM testing, the model is only a small pluggable engine for the full agentic skill set that requires the actual deployment domain of data, actions and tools. Direct mode testing of only the model, is occasionally useful but E2E testing of the Chatbot deployment is required for agentic AI Chatbot QA, tuning and validation.   

Multi-step conversations changed the unit of testing

Single-turn TCR is still too small for many real chatbot tasks.

Users do not always provide all required information in one message. They ask to create a cluster, then pick a project, then choose a size, then confirm. They ask for a schema review, then refine the problem, then ask for a migration path. They troubleshoot by adding information over time.

So the framework has to exercise test cases that are conversations, not just single prompts.

That sounds like a minor data-model change. It was not. Once a test has steps, the eval runner has to preserve conversation state. In proxy mode, that means carrying the real conversation_id returned by the chatbot and sending each follow-up as part of the same server-side conversation. In direct mode, it means building a synthetic conversation history so the model sees the prior turns.

In code that split is about as literal as it sounds. Proxy mode threads a real id through each call:

response = client.send_message(prompt=msg, conversation_id=conversation_id)
conversation_id = response.conversation_id  # captured on turn 1, reused after

Direct mode has no server-side conversation to lean on, so it fakes one by re-rendering the transcript into the prompt itself, every turn:

full_prompt = f"## Conversation History\n{render(history)}\n\n{next_prompt}"

Same test case, same expected outcome, but a different code path depending on which half of the system is actually holding the conversation state. That's impacts multi-turn evals because conversation memory is part of the harness code for the actual deployment not just a model issue.

The scoring also becomes more interesting. You want per-step checks, because the assistant should ask the right clarifying question at the right time. You also want an overall score, because a conversation can have reasonable individual turns and still fail to complete the user's goal.

Coding it yourself: deepeval underneath

Everything above sits on top of deepeval, the open-source LLM eval library. We add a Synthesize → Execute → Evaluate pipeline, a plugin system, YAML goldens, CI wiring, and Langfuse push on top of it But the core library underneath is plain deepeval, and you do not need any of the surrounding machinery we used. Here are routing, TCR and multi-step just built directly on deepeval (simplified deepeval 3.6.9)

A test case is just an input/output pair. LLMTestCase is the base unit everything else scores:

from deepeval.test_case import LLMTestCase

test_case = LLMTestCase(
    input="List all my projects",
    actual_output=chatbot_response_text,       # what the system under test said
    expected_output="control-plane",                  # the golden - a skill label here, not prose
    additional_metadata={"predicted_skill": predicted_skill},
)

Routing is a custom metric, not a built-in one. deepeval ships plenty of semantic metrics, but “did it route to the right skill” is an exact-match business rule, so you write your own BaseMetric. This is a simplified version of the same shape our real AgentMatch metric takes:

from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase

class AgentMatch(BaseMetric):
    def __init__(self, threshold: float = 1.0):
        self.threshold = threshold
        self.async_mode = False  # routing checks are cheap; no need for async here

    def measure(self, test_case: LLMTestCase) -> float:
        predicted = test_case.additional_metadata["predicted_skill"]
        expected = test_case.expected_output
        self.score = 1.0 if tool_match(predicted, expected) else 0.0
        self.success = self.score >= self.threshold
        return self.score

    async def a_measure(self, test_case: LLMTestCase) -> float:
        return self.measure(test_case)

    def is_successful(self) -> bool:
        return bool(self.success)

    @property
    def __name__(self):
        return "Agent Match"

tool_match is the check from earlier. Run it with deepeval's own runner rather than hand-rolled assertions, and you get retries, pretty output, and a result object for free:

from deepeval import evaluate

evaluate(test_cases=[test_case], metrics=[AgentMatch()])

TCR is where deepeval's built-in GEval earns its keep. GEval is deepeval's off-the-shelf LLM-as-judge metric, you give it criteria (or explicit evaluation steps) and it handles the judge prompt, the JSON parsing, and the scoring for you. Our rubric-per-line expected_sections maps onto evaluation_steps almost directly:

from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, LLMTestCaseParams

task_completion = GEval(
    name="TaskCompletion",
    evaluation_steps=[
        "Check whether the response identifies which cluster has higher CPU usage",
        "Check whether the response cites at least one supporting metric",
        "Check whether the response suggests a plausible next step",
    ],
    evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
    threshold=0.7,
)

test_case = LLMTestCase(
    input="Compare CPU usage between these two clusters",
    actual_output=chatbot_response_text,
)

evaluate(test_cases=[test_case], metrics=[task_completion])

Multi-step conversations get their own test case type. ConversationalTestCase takes a list of Turns instead of a single input/output pair, and pairs with a BaseConversationalMetric instead of BaseMetric:

from deepeval.test_case import ConversationalTestCase, Turn

convo = ConversationalTestCase(
    turns=[
        Turn(role="user", content="Create a new cluster"),
        Turn(role="assistant", content="Sure - which project should it go in?"),
        Turn(role="user", content="acme-prod"),
        Turn(role="assistant", content=final_response_text),
    ],
    expected_outcome="A cluster is created in acme-prod after resolving the missing project name",
)

deepeval has a conversational counterpart to GEval too (ConversationalGEval), scored against the whole turn sequence rather than a single response which is the natural fit for “did the assistant ask the right clarifying question at the right time”, the per-step-plus-overall shape TCR needed once prompts became conversations.

Put together, that is the whole starting kit: LLMTestCase plus a hand-written BaseMetric for hard business rules like routing, GEval for rubric-style task completion, ConversationalTestCase plus ConversationalGEval once a prompt becomes a conversation, and evaluate() to run the lot and get a result object back.

Everything else we built, the YAML goldens, the plugin architecture, the CI wiring, the Langfuse push exists to run more of these at scale and make the failures easy to find. But none of it is required to get started. If you are testing your own agentic chatbot, this is how to begin.

This is where instrumentation started to matter much more.

For a single-turn answer, a markdown report with pass/fail rows is often enough to start debugging. For multi-step conversations, that is thin. You need to know which turn failed, whether the route changed, whether the wrong tool was called, whether the tool call used correct arguments, whether the model forgot earlier context, or whether the final answer simply missed a required section.

That is why we added span-level telemetry and pushed eval traces into Langfuse.

Langfuse made the failures inspectable

The useful thing about Langfuse is not just having another pretty dashboard. Although that is important for spotting quality regressions over time via regular CI/CD automated runs.

The vital thing was being able to treat an eval run as a set of traces. A run becomes a session. Each test case becomes a trace. The trace carries the prompt, response, scores, tags, model, mode, scenario, and the spans emitted by the proxy.

For a chatbot path, those spans are where the debugging starts. You can see routing, tool execution, LLM calls, latency, and token usage where it is available. You can filter by scenario and model. You can compare runs. You can look at a failing conversation and see whether the problem began at route selection, tool selection, tool arguments, or final synthesis.

That changes the tuning loop.



Without traces, an eval failure says “this case failed”. With traces, it can say why it failed.

That distinction matters because the fix lands in different places...
Is it a routing rule?
Is it a skill description?
Is it a tool schema?
Is it the judge rubric?
Is it that the eval has has an expectation that the product has never actually promised?

 Trajectory testing ->  knitted the pieces together

Routing and TCR started as separate signals.

Routing asked whether the right capability was selected. TCR asked whether the final task was completed. Multi-step testing asked whether that held across a conversation. Instrumentation showed what happened between those points.

Trajectory testing is the next natural step: score the path itself.

For an agentic product, the fully correct path is essential to response quality.
So trajectory tests add expectations about intermediate actions:

The label-based routing tests are still useful as fast canaries. They tell us whether the classifier shape has drifted and distinguish tiers - see the next section.
But full trajectory tests judge the route by consequence: did the system actually follow the tool path that would satisfy the user?

So retain the fast determisitc routing tests, but move more user-visible behavioural coverage into trajectory and TCR.

Sovereign AI makes the eval problem tiered S/M/L/XL

There is one more constraint that makes this more than a generic chatbot-testing story.

Our chatbot has to work for sovereign and air-gapped deployments. In those environments, prompts, tool results, schema details, and operational data cannot be sent to a hosted frontier model outside the customer's trust boundary. The inference model may run inside the customer's environment.

That usually means a smaller model.

Smaller models are not just cheaper versions of larger ones. They have different context limits, weaker tool-selection behaviour, and less tolerance for an over-wide capability surface. If you show a smaller model every possible tool and skill, you have increased the chance that it chooses a bad one.

So the architecture becomes tiered. Models are effectively T-shirt sized. A small self-hosted model sees a curated subset of reliable skills. A larger model can be allowed to see more. Some experimental or complex skills only make sense for the highest tiers.

That changes the meaning of a routing eval again.

The correct visible skill set is no longer universal. It depends on the model tier. A prompt that should route to an advanced skill for an XL model may need to be dropped, refused, or handled differently for a smaller model that should not see that skill at all.

This is why trajectory testing and routing need to be tier-aware. We are not only asking whether the chatbot can complete a task. We are asking whether it can complete the task through the capability surface that a deployment's LLM size allows.

What I would keep from the journey

The final shape was not obvious at the start.

We began with routing because it was the first integration failure point and the cheapest one to isolate. We added TCR because correct routing did not prove task completion. We added multi-step cases because real users have conversations, not isolated prompts. We added telemetry because multi-step failures are otherwise too hard to debug. We moved toward trajectory testing because the route, tools, arguments, and answer need to be judged as one path.

If I were starting another agentic product eval framework, I would keep that order.

Do not start by trying to build a grand universal benchmark. Start with the smallest failure point that would embarrass the product if it regressed. Then move the signal closer to the user's actual goal.

For a chatbot wired into a real control plane, that means testing more than the output text. It means testing the route, the skill, the tool call, the arguments, the conversation state, the final answer, and the model tier that made those options visible in the first place.

That is the difference between checking that an AI system said something vaguely relevant and checking that it actually did all the things the user asked of it.

August 31, 2026 09:28 AM UTC

August 30, 2026


Paolo Melchiorre

My DjangoCon US 2026

My DjangoCon US 2026 experience in Chicago, captured through Mastodon posts about talks, my UUID talk, Django community activities, people, and moments along the way.

August 30, 2026 10:00 PM UTC

August 28, 2026


Django Weblog

Django Developers Survey 2026 results

The results from the 2026 Django Developers Survey are now available. This is the fifth annual report conducted from May to July 2026 by the Django Software Foundation in collaboration with JetBrains PyCharm.

The full report includes infographics, quotes, and dedicated sections so you can easily navigate the results. There is also a The State of Django 2026: Boring is so back blog post highlighting key Django trends in 2026 and actionable ideas for your own Django development.

PyCharm Django - Get PyCharm at 30% off, and support Django!

The Django Chat podcast also covers the survey in a special summer episode, from Django 6.1 and HTMX to async, AI, deployment, testing, and Python tooling.

August 28, 2026 02:55 PM UTC


PyCharm

The State of Django 2026: Boring is so back

Welcome to the highlights from the fifth annual Django Developers Survey, a collaboration between the Django Software Foundation and PyCharm. This year’s report draws on responses from nearly 3,500 Django developers across more than 40 countries — from students in their first year to veterans with decades of experience.

In software, “boring” is a compliment. It means a technology works so reliably you can stop thinking about it. By that standard, Django in 2026 is thriving: PostgreSQL has been the database of choice for 76–79% of respondents for five consecutive years, Django’s template engine has held steady at around 80%, and nearly half of developers upgrade with every stable release — 43% are already on Django 6.0, months after it shipped. The core is boring in the best possible way. Mature, but not static.

Everything around that core, though, is moving fast. AI is now an everyday tool for most developers, and agents are beginning to move beyond answering questions to editing files, running commands, and completing larger tasks. Newer tools are consolidating workflows that once required several separate utilities, typing is becoming standard practice, and the boundaries between editors, terminals, automation, and AI are getting blurrier.

That may be Django’s particular advantage in 2026: it is mature enough to be dependable, active enough to keep moving, and stable enough to give developers room to change almost everything else.

As an open-source framework, Django depends on its community and needs funding to remain healthy and secure. The PyCharm team runs an annual fundraiser to support Django.

Until September 10, get PyCharm at 30% off, and JetBrains will donate 100% of your purchase amount to the Django Software Foundation.

1. AI is mainstream, but no workflow has won

AI has become part of the normal Django development workflow. Only 10% of respondents said they regularly use no AI tools for coding, while 58% of AI users use them every day and another 27% several times a week.

What remains unsettled is how developers use them. The interfaces are split almost evenly across the browser, the IDE, and the command line, and no tool dominates: Claude Code leads at 35%, with ChatGPT just behind at 33% and GitHub Copilot at 23%. And for all the attention on agents, a majority of AI users — 56% — still use it purely for chat and advice.

2. Dominating AI workflow is still supervised

Nearly half of AI users already work with AI through an IDE integration. But despite the growth of coding agents, the dominant workflow is still supervised: 59% have AI generate code and then apply the changes themselves, 44% let it edit files or run commands when instructed, and only 27% use it to autonomously complete multi-step tasks.

The emerging pattern is therefore less “replace the IDE with an agent” and more “bring the agent into the development environment.” Developers are adopting AI quickly, but the editor remains their home base for understanding the codebase, reviewing changes, and deciding what makes it into the project.

Nowhere is the shift clearer than in how developers learn. Django’s official documentation remains the top resource at 67%, but AI tools are now second at 51% — ahead of YouTube, reading source code, and Stack Overflow.

AI is already routine for writing code, debugging, research, and learning. But developers are still experimenting with where it belongs: in the browser, inside the IDE, at the terminal, or increasingly, acting directly on the codebase.

AI is changing the IDE faster than it is replacing it.

3. Python tooling is consolidating fast

Two tools that barely existed a few years ago are already near the top of the survey.

uv, released in February 2024, is already used by 43% of respondents for managing Python environments—second only to venv at 63% and ahead of Docker at 31%.

Ruff tells a similar story. At 43%, it is now the most widely used code-quality and formatting tool in the survey, ahead of IDE inspections at 27%, Black at 25%, pre-commit at 20%, and Flake8 at 17%.

The shift is not that Python’s older tools have disappeared. It is that newer tools increasingly cover jobs that once required several separate utilities. The result is a Python toolchain beginning to consolidate around fewer, faster, more capable tools.

4. Type hints are winning. The type checker race is wide open.

Type hints are becoming the norm in Django development: 57% already use them, and another 26% plan to. What is much less settled is how developers check those types.

Among developers who use type hints, the most popular option isn’t a standalone type checker at all: 40% rely on the checker built into their IDE. Mypy follows at 32%, Ruff at 29%, and Pyright/Pylance at 22%. Newer entrants are already appearing too, with Astral’s ty reaching 12% and Meta’s Pyrefly at 4%.

The practice, in other words, is converging faster than the tooling. Django developers increasingly agree that types are useful, but there is still no consensus on which tool should enforce them—or whether a separate tool is necessary at all.

That makes type checking an interesting space to watch in 2027: will one of the newer standalone tools break away from the pack, or will type checking increasingly become something developers simply expect their IDE to provide?

5. As AI writes more code, verification matters more

AI is moving beyond suggestions and into the codebase. That makes automated verification more important, not less.

The survey can’t tell us whether AI is driving greater adoption of tests or CI. What it does show is that most Django developers already have the infrastructure agents need: pytest is used by 45% of respondents and unittest by 43%, with pytest-django at 34%.

That testing culture sits alongside widespread CI/CD adoption — GitHub Actions is now used by a majority of respondents at 51%, with GitLab CI/CD at another 26%. Together, these create a natural feedback loop for agentic development: an agent can make a change, run the test suite, respond to failures, and hand the developer a result that has already passed the project’s checks.

Not everyone has that loop in place: 19% of respondents write no automated tests at all. As more code is delegated to agents, that fifth of developers is working without the safety net that makes delegation trustworthy.

The emerging agentic workflow may depend as much on verification as generation. The more code we delegate, the more valuable it becomes to have tests and pipelines that can quickly tell both developers and agents whether a change actually works.

Agents can generate code. Tests and pipelines tell them whether it works.

6. One framework, two ways to build

Two distinct ways of building with Django are now firmly mainstream: letting Django render the interface, or using it as the backend for a separate frontend. 72% of respondents use server-rendered templates, while 53% use Django for API-only applications and 46% use it as the backend for a single-page application or dedicated JavaScript frontend.

The balance is even clearer when developers are asked for their primary approach. Half primarily build server-rendered applications, while 44% primarily use Django for either APIs or dedicated JavaScript frontends.

The JavaScript numbers tell the sharper story. React has barely moved in five years — 37% in 2021, 38% today. What’s changed is everything around it: jQuery has fallen from 37% to 23%, Vue from 28% to 17%, while htmx has climbed from just 5% to 34%. htmx isn’t taking share from React — it’s modernizing the server-rendered side of the divide that jQuery used to own.

That flexibility is one of Django’s strengths. The same framework can sit at the center of a hypermedia application or behind an API consumed by React, mobile apps, or other clients.

Django remains unusually comfortable on both sides of the frontend divide.

Conclusion

Across the survey, the pattern is consistent: developers are changing their tools and workflows far faster than they are changing the framework underneath them. The parts of Django they value most remain familiar — models, the admin, authentication — and even deployment stays defiantly unfashionable, with 54% shipping monoliths and 44% self-hosting.

Even where Django itself is evolving, it does so deliberately: 33% of respondents use its async features and another 40% plan to — change offered as an opt-in, not a rewrite.

That stability is increasingly valuable. Developers can experiment with a new agent, replace several tools with Ruff or uv, add htmx to a template, or adopt a new type checker without having to rethink the framework underneath their application.

Django’s advantage in 2026 is not that it is the newest thing. It is that it gives developers a solid, dependable — yes, boring — place from which to try the newest things. Boring is so back.

PyCharm for Django Fundraiser

Get a new PyCharm Pro license or renew your existing one at 30% off, with 100% of your purchase amount going to the Django Software Foundation.

Explore the complete 2026 Django Developers Survey Results.

August 28, 2026 02:17 PM UTC

Security Incident Affecting JetBrains Cadence

We are investigating a security incident affecting JetBrains Cadence. Cadence is a JetBrains-hosted service that integrates with PyCharm through an optional plugin, and lets you run your projects on cloud compute resources. Our investigation has confirmed unauthorized access to the service and the exposure of customer data associated with its use.

We have contacted affected users directly and have taken steps to contain the incident.

This post provides the latest information about the incident, its potential impact, and the actions we recommend Cadence users take. We will update it as our investigation progresses and additional information becomes available.

Last updated: September 1, 2026, 12:05 CEST

September 1, 2026, 12:05 CEST

Our investigation is nearing completion, and most mitigation and response actions are now finalized. We have not identified any additional compromised resources or data since our last update, and we are wrapping up a small number of remaining verification activities. The recommended actions for affected users remain unchanged.

August 31, 2026, 12:56 CEST

Our investigation remains ongoing. At this time, we have not identified any additional compromised resources or data.

We have confirmed that the threat actor accessed data contained in the Cadence server backup from 2024. At this time, there is no evidence to suggest that the threat actor extracted data, including secrets, from the current Cadence environment.

The recommended actions described below remain unchanged.

August 28, 2026, 11:50 CEST

Cadence is a JetBrains-hosted service integrated with PyCharm through an optional plugin, that lets you run your projects on cloud compute resources. Cadence uses JetBrains TeamCity to orchestrate this work. We recently disclosed CVE-2026-63077, a critical vulnerability in TeamCity that can allow an unauthenticated attacker to execute arbitrary commands on a vulnerable server.

We have since confirmed the Cadence environment was vulnerable to CVE-2026-63077 and was exploited through this vulnerability.

Cadence users should immediately revoke or rotate all credentials and secrets that may have been used to run their Cadence executions. They should also treat all executions, including their inputs and outputs in your Cadence project, as potentially untrusted.

Actions required immediately

We strongly recommend that Cadence users:

Cadence users can contact us to request an inventory of the credentials and secrets associated with their Cadence usage. This may help users identify which credentials need to be revoked or rotated, but the inventory should not be considered exhaustive.

We have collated a list of Indicators of Compromise (IoCs) below. These indicators are not exhaustive, and the absence of these indicators does not confirm that an account or system was unaffected:

Affected server

We have confirmed that the following Cadence server was successfully exploited: api.cadence.jetbrains.com.

Affected period

August 8, 2026, to August 24, 2026.

What happened

The Cadence server used TeamCity to orchestrate workloads and was vulnerable to CVE-2026-63077. Threat actors exploited the vulnerability and gained unauthorized access to the affected Cadence environments, with activity identified from August 8, 2026. We discovered the exploitation on August 23, 2026, and took the affected server offline on August 24, 2026, while we continued our investigation.

What we know

Our investigation is ongoing, but we have confirmed that the threat actors:

The likely consequences of the personal data exposure include an increased risk of targeted phishing, social engineering, impersonation, and other unsolicited or malicious communications using the affected names and email addresses.

As the threat actors gained access to the Cadence server, any credentials or secrets stored in Cadence, contained in the compromised backup, or made available to executions on the affected server should be considered compromised and must be revoked or rotated.

This includes but is not limited to:

Actions JetBrains has taken

We took the Cadence server offline on August 24, 2026, while we continue to investigate the incident. At present, we have confirmed that the incident is limited to data associated with the Cadence host mentioned above.

The server should have been patched as part of our response to the vulnerability, but it was not. We sincerely apologize for this failure and the impact it may have on you.

We have invalidated all access tokens used by the JetBrains Cadence plugin in PyCharm to connect to Cadence, and took the server offline on August 24, 2026.

We are also notifying the relevant authorities and taking the necessary steps to protect the data of Cadence users.

Further updates

We will publish further findings and guidance here as our investigation progresses. We recommend checking this page frequently for the latest information. We will also contact affected users directly if we identify any important new information that may require action on their part.

For more information about the underlying vulnerability, please see our original security advisory to TeamCity customers and users.

If you previously used Cadence and need assistance identifying which credentials may have been exposed or have any questions regarding this incident, contact the JetBrains Security team at security@jetbrains.com.

We recognize the seriousness of this incident and apologize again for the impact.

August 28, 2026 09:50 AM UTC


Python Software Foundation

Georgi Ker: 2026 PSF Board Election Candidate Interview

Who are you?

Hi, I’m Georgi, an independent entrepreneur, open source community organiser, and leader. I’m also a PSF Fellow and a recipient of the PSF Community Service Award.

Although I currently live in Amsterdam, I am proud to represent communities across Asia. I have served on the PSF Board since 2023. I also helped design PyCon US branding and websites since 2022 and for other open source community events and projects as well.

My contributions to the Python community span over many years, beginning mainly in Asia and becoming more international over time. Much of my work sits where people, governance, and community meet.

I care especially about the people doing the quiet work that keeps open source communities alive. They may not always be on the main stage, but the community would not exist without them.

What would you bring to the PSF Board of Directors?

Three years on the Board have taught me where the PSF is strong, where it is fragile, and where good intentions are not becoming results. 

As a former Treasurer, I raised concerns about the lack of a clear financial runway and pushed for stronger budgeting and regular financial planning. Through the Executive Committee, I have also participated in staff discussions, document reviews, difficult organisational decisions, and helped finalize the PSF’s long-delayed strategic planning process with the board.

My perspective is also strongly shaped by communities around the world. The work began from Asia and now includes PyLadiesCon, EuroPython, and regular discussions with global community leaders through the PSF Diversity and Inclusion Workgroup. Global representation at the PSF has improved, but it still remains incomplete.

I would bring institutional knowledge, experience in financial oversight and a global community perspective. And yes, I am willing to do the unglamorous work. 

I know more now than when I joined. I know what I am signing up for and how much work remains.

What motivated you to run for the PSF Board of Directors?

I am running because the past three years have shown me much more clearly what the PSF needs next. I’ve seen where the Foundation works well. I’ve also seen outdated financial assumptions, limited organisational capacity, unclear responsibilities, and decisions that could not be implemented because the necessary systems or that expertise were missing.

These are not exciting campaign topics but unfortunately, they matter.

Python is now part of the world’s infrastructure. Companies, governments, researchers, schools, and millions of developers depend on it. The organisation responsible for protecting Python must be able to plan beyond the next conference or sponsorship cycle.

I would like to continue helping the PSF build a more realistic base, improve its financial planning, strengthen its internal systems, and communicate more honestly with the community.

The next few years matter. That is why I am running again.

What problem or challenge do you want to address if you are on the Board?

The problem I most want to address is the PSF’s financial sustainability.

For years, PyCon US was a reliable source of income for the Foundation. After COVID, the economics and risks of large conferences changed. PyCon US should remain an important community event, but one conference should not be expected to carry the financial future of the organisation protecting Python.

The PSF needs realistic budgets and financial projections. The Board needs to understand the Foundation’s runway, commitments, risks, and future operating costs. Hope is useful in open source but less useful in accounting.

We also need to build sponsorship around Python itself. Companies benefit from Python every day, not only when their logo appears at a conference. Their support should help fund Python’s infrastructure, security, legal protection, trademarks, and global community.

This requires a serious review of the PSF’s business model. It also requires the right staff capacity and expertise to turn Board decisions into results.

I would like the PSF to professionalise the support around the community without professionalising the community out of Python. That distinction really matters. Volunteers and local organisers are not a cheap workforce. They are the reason Python has a community in the first place. 

Where do you see the PSF 5 years from now?

In five years, I would like to see the PSF be an organisation that plans with evidence instead of habit.

The PSF should be better equipped to protect Python’s trademarks, copyright, infrastructure, and identity. As Python becomes more important, this will require greater legal, financial, security, and organisational expertise. Goodwill alone cannot do all that work.

It should have a clear financial runway, realistic budgets, and dependable sources of income. PyCon US should continue bringing people together, but its financial performance should not decide whether the Foundation can support Python properly.

I would also like a more transparent PSF. The community should understand what the Board is working on, what the Foundation can afford, why major decisions were made, and what progress followed. 

Finally, the PSF should remain global and human. It should develop new leaders, support communities outside North America and Europe, and recognise capable people who may not be famous conference speakers. In fact, the board should consider reserving a few board seats for appointed directors with expertise that the Foundation needs.

The PSF should become more professional in how it operates without becoming corporate in how it treats people. Python’s community is not a side project. It is one of its greatest strengths and part of its unique model. 

What areas of the Python community are you involved with?

I have served on the PSF Board since 2023, including as Treasurer and currently as Vice Chair.

I currently chair the PSF Diversity and Inclusion Workgroup. I am also one of the organisers of PyLadiesCon and a member of the EuroPython Code of Conduct team. My earlier involvement included PyCon Thailand, PyCon APAC, founding PyLadies Bangkok, and other regional communities. I am also involved with the podcast PyPodcats featuring underrepresented Pythonistas.

Apart from that, I am developing Open Community Leadership through the fellowship with the Sovereign Tech Agency. The project focuses on mentorship, succession planning, sustainability, and helping communities prepare their next generation of leaders.

 ------

Note from the election administrators: 

Want to learn more about this candidate or ask them a question? 

 Check out their nomination statement.

 Check out their AMA thread on discuss.python.org

August 28, 2026 08:44 AM UTC

August 27, 2026


Python Morsels

When to use NotImplemented

When should you return NotImplemented from a dunder method? Why not return False or raise an exception instead?

Table of contents

  1. Dunder methods return NotImplemented
  2. NotImplemented means "I don't know"
  3. Using NotImplemented with your objects
  4. Why not return False instead of NotImplemented?
  5. Return NotImplemented from your dunder methods

Dunder methods return NotImplemented

Integers and floating point numbers can be compared with equality:

>>> a = 3
>>> b = 3.0
>>> a == b
True

This is powered by the __eq__ method.

But strangely, if we call the __eq__ method on an integer, giving it a floating point number, we'll get back NotImplemented:

>>> a = 3
>>> b = 3.0
>>> a.__eq__(b)
NotImplemented

But if we do the same thing on a floating point number, giving it an integer, we get the answer we're expecting:

>>> a = 3
>>> b = 3.0
>>> b.__eq__(a)
True

The same thing happens with many other operations in Python.

For example, the + operator is powered by the __add__ method:

>>> a = 3
>>> b = 3.0
>>> b + a
6.0

But just as with equality, floating point numbers can be added to integers, but integers cannot be added to floating point numbers:

>>> a = 3
>>> b = 3.0
>>> b.__add__(a)
6.0
>>> a.__add__(b)
NotImplemented

What is the NotImplemented value and why do we get it?

NotImplemented means "I don't know"

Dunder methods that perform an …

Read the full article: https://www.pythonmorsels.com/when-to-use-notimplemented/

August 27, 2026 09:45 PM UTC


Robin Wilson

How to fix a weird pandas and pyarrow issue with BirdNetPi

Summary: If you get Python crashing immediately in BirdNetPi, try uninstalling the pyarrow Python package.

I’ve got a BirdNetPi set up at home. This is a bit of software that runs on a Raspberry Pi and listens on a microphone (I’ve mounted mine on the outside of an upstairs window, using a 3D printed mount/cover to protect it from the rain) for bird noises. It then classifies them using a machine learning model and tells you what birds it’s heard. It has a nice web interface which shows graphs like this:

file

as well as allowing you to listen to the recordings, correct errors, do more detailed statistics etc.

When I first installed it, I had some problems. One of the big ones, in fact, was that the graph above wasn’t present on the homepage (I didn’t actually know there should be a graph there, so it took me a while to realise it was missing) and when loading the detailed Species Stats page I just got a message from Streamlit saying "Connecting…".

Investigating this in more depth, I found that a lot (but not all) of the Python scripts involved in creating the visualisations crashed as soon as you started them, giving an ‘Illegal instruction’ error. This is a remarkably low-level error to get from Python, and suggested something was going wrong with a package that had some sort of native (C/C++) extension.

My first guess was that it was something to do with pandas, so I tried uninstalling and reinstalling pandas – but that didn’t fix it. I installed pandas in a new virtual environment and I could import it fine – so it wasn’t pandas itself that was causing the problem.

Then I realised I could use part of Python’s profiling tools to help narrow down what import was the problem. I ran birdnet/bin/python -X importtime -c 'import pandas', which uses the BirdNet Python interpreter to run import pandas while printing out timing information about how long each import took. This should allow me to see what package was imported just before the crash – and it did. The output looked like this:

import time:      1623 |      10550 |     pandas.compat.numpy
import time:       309 |        309 |         gc
import time:      1121 |       1121 |         pyarrow._generated_version

The most recently imported package at the time of the crash was pyarrow – a Python interface for the arrow file format. I believe it was originally installed as an optional dependency of pandas. I confirmed this was the problem by running import pyarrow in the BirdNet Python interpreter, and it immediately crashed.

This narrowed down the problem, but I wasn’t sure how I’d get the correct version of pyarrow installed. However, before doing that, I thought I’d try and see whether BirdNetPi actually used pyarrow anywhere. Searching through the git repo for arrow found no uses of it, so I thought it was worth trying uninstalling the package entirely.

This solved the problem straight away – and I haven’t found anything in BirdNetPi that has broken as a result of it.

August 27, 2026 05:57 PM UTC


The Python Show

57 - Python Developer Tooling Handbook with Tim Hopper

In this episode of the Python Show Podcast, I am pleased to have Tim Hopper as my guest. Tim is the author of the Python Developer Tooling Handbook, a free online resource for everyone.

We chat about Python tooling, writing, Tim’s background, AI, and much more!

You can check out Tim’s free handbook and some of his other work here:

August 27, 2026 05:03 PM UTC


Python Software Foundation

Jeremy Tanner: 2026 PSF Board Election Candidate Interview

Who are you? 

I'm Jeremy Tanner, an organizer, speaker, sponsor, Python developer, and community member. I'm based in North America, though many of my favorite people, places, and events are not. I've spent much of my career in the open Python ecosystem; packaging infrastructure, developer tooling, and the supply chains that get Python into the hands of pythonistas building everything imaginable. I believe the Python community should look like the world, not just the English-speaking, North American corner of it, and that sustainable finances are what make that vision achievable. I'm running for the PSF Board to do both: raise sustainable funds and resources, and make sure they reach everywhere.

What would you bring to the PSF Board of Directors?

Sponsorship for Python Foundations, Events, Organizations: I have direct experience securing corporate partnerships and sponsorships for the Python ecosystem, including navigating the internal processes of large companies to provide support to open source projects, infrastructure, and community gatherings. I’ve been on the organizing side as well, responsible for fundraising, programming, and attendee experience.

What motivated you to run for the PSF Board of Directors?

I wouldn't be here if it weren't for Python.

The PSF is the organizational backbone of the most important programming language in the world, and it is perpetually underfunded relative to its mission. Companies and the community benefit enormously from the PSF's work sustaining PyPI, funding sprints, and keeping the ecosystem healthy. Many are interested and capable of supporting further, but unsure how. I want to change that, and I want the resources generated to flow toward a Python community that genuinely reflects the mission's diverse and global users.

What problem or challenge do you want to address if you are on the board?

Build a sustainable corporate partnership program

The PSF has sponsorship tiers, but lacks a structured program for engaging companies at the scale their Python dependency warrants. I’m interested in working with PSF staff to design and execute a partnership program that makes it easier, and compelling, for companies, and others to contribute meaningfully. I've done this work both from the corporate side, and the position of organizer as well. I know what makes it succeed.

Invest in regional and international events

PyCon US has been the largest gathering, but is no longer running profitably. Python is spoken in Lagos, São Paulo, Jakarta, Warsaw, and the events, meetups, and communities in those places are where most of the world's Python developers live. The PSF grants program is the primary lever for supporting these communities, and it is chronically under-resourced. I’ll advocate for dedicated, predictable funding for regional and international events, not one-off grants that organizers have to re-apply for every year, but structural support that lets community leaders plan, grow, and mentor the next generation of organizers in their own languages and time zones. A Python community that looks like the world requires the PSF to invest across the globe.

Connect packaging infrastructure investment to the PSF's mission

PyPI is critical infrastructure, and its funding has historically been precarious. My background in packaging and distribution gives me the context to make the case, both to the board and to corporate partners, for why sustained investment in Python's packaging ecosystem is a strategic priority, not an afterthought. I want the PSF to be a more confident and articulate advocate for the infrastructure that millions of developers depend on daily.

Where do you see the PSF 5 years from now?

Celebrating Python's 40th anniversary. Strong, sustainable, with a growing staff & membership. Not a member yet? Please consider joining the PSF as a Supporting Member

What areas of the Python community are you involved with?

I've spoken at, sponsored, or participated in PyCon US, as well as PyLadies, PyCarribean, PyTexas, PyGotham, SciPy, PyData (Texas, London, New York, Seattle), North Bay Python, and meetups, have kept me grounded in what pythonistas across the community are building, struggling with, and asking for.

Packaging: Working with package maintainers and partners in order to see that all pythonistas are able to get the software they need. 

 ------

Note from the election administrators: 

Want to learn more about this candidate? 

Check out their nomination statement.

Check out their AMA thread on discuss.python.org

August 27, 2026 02:57 PM UTC


PyCharm

OpenTelemetry Comes to IntelliJ IDEA, GoLand, PyCharm, and WebStorm.

The OpenTelemetry plugin has broken out of the confines of JetBrains Rider. No sandbox exploit was involved – this escape was planned by our developers. With the 2026.2 release, the OpenTelemetry plugin is now available in IntelliJ IDEA, GoLand, PyCharm and WebStorm. Rider users needn’t worry – it still works there too.

The plugin brings logs, metrics, traces, and the service map from local applications into the IDE. You can inspect them without setting up a separate local observability backend.

What is the OpenTelemetry plugin?

While developing with your IDE, you can use the plugin to:

The plugin’s functionality complements what the IDE offers out of the box. Use a debugger to step through code, a profiler to analyze performance, and a production monitoring platform to watch deployed systems. Use the OpenTelemetry plugin to inspect runtime behavior while you build or test the application.

Imagine this

Say you are testing a feature that calls several services and a database before publishing a message to a queue. The request fails, and the console shows the exception, but not the path that led to it.

In the OpenTelemetry tool window, you can search the logs for the error. Open the relevant trace to see how the request moved through the system and where it failed. The Service Map shows the services and infrastructure involved.

Explore runtime data in the IDE

Search and inspect logs

Console output is manageable until several services start writing at once. The Logs view puts OpenTelemetry log records in a searchable table. You can filter by severity or content, then open a record to inspect its attributes.

Detailed log view showing timestamps, levels, and messages for different log types

Check metrics

Select a metric from the metric tree to plot its values while you use the application. This view is not a replacement for your production dashboards, but it lets you inspect what the application will export before you send the data to a production observability platform. If a new library adds noisy or unnecessary metrics, you can catch them locally and adjust the instrumentation before your DevOps and SRE colleagues have to handle them.

The metrics viewer displays CPU utilization data with real-time charts.

Follow a request through its trace

Open a trace to see its spans across services. Each span includes timing and attributes, so you can follow the request from start to finish and zero in on the operation that failed or slowed things down. Development is also a good time to look at the trace itself. Does it contain the spans and details you’d need during a real incident? It’s much easier to fix those gaps now than to discover them in production.

The trace table view with filtering capabilities and detailed information for the selected trace. The trace viewer showing details of the POST request involving multiple internal DB and HTTP calls.

See observed relationships in the Service Map

Architecture diagrams go stale. The Service Map builds its relationships from collected traces, so it reflects the traffic the plugin has seen between services, endpoints, databases, and message queues.

Expected one HTTP call or database query, but the diagram shows several? Finding that during development gives you time to fix it before release.

Automatically generated service map showing the relationship between API endpoints, internal services, and PostgreSQL based on actual runtime traces.

Connect an instrumented application

The plugin does not add OpenTelemetry libraries or agents to your application. It configures where an already instrumented application sends its data.

Start an instrumented Java, Python, Go, or .NET application from a supported IDE run configuration, and the plugin passes OpenTelemetry Protocol (OTLP) environment variables that point to its built-in receiver. It passes the same variables to new integrated terminal sessions.

You can also point the application’s OTLP exporter at the endpoint shown by the plugin. If you already use a local OpenTelemetry Collector, add the plugin as an OTLP destination and keep the rest of your pipeline.

Runtime context for coding agents

The plugin has experimental MCP support through the JetBrains MCP server. A compatible coding agent can query the telemetry collected in the IDE with these tools:

These tools give the agent evidence from a particular run, so that it can inspect the logs and spans or query the observed service relationships.

Getting started

  1. Install the OpenTelemetry plugin from JetBrains Marketplace for your favorite IDE.
  2. Instrument your application with OpenTelemetry libraries or agents.
  3. Start it from a supported IDE run configuration or a new integrated terminal session. The plugin will pass the OTLP environment variables. Alternatively, point the application’s exporter or your local collector at the endpoint shown by the plugin.
  4. Open the OpenTelemetry tool window and exercise the part of the application you want to inspect.

The plugin shows the signals it receives. If your application exports only traces, the Logs and Metrics views stay empty.

Full setup instructions are available in the OpenTelemetry plugin documentation.

Install it and tell us what you find

Try it on a real project, then tell us in the comments or through the issue tracker what worked and what still sent you to another tool.

Some final notes

This plugin is the product of a collaborative effort between the Rider Execution team and the Dynamic Program Analysis Research team at JetBrains Research. This launch is the first step in exploring how telemetry analysis can help us build development tools that better adapt to real-world developer workflows.

August 27, 2026 10:41 AM UTC