Agent Rules / Chimeras
Why does my Django signal break after a refactor?
An order's status field decides which handler runs: a post_save receiver builds the function name from the status and calls it through getattr. Tests that call the handlers directly keep passing when a handler's signature changes, because the call that went wrong is a string-built name that no test and no type checker connects to the handler. Replace the lookup with an explicit match and the call is an ordinary call again.
The code below is from a small example project we wrote for this page. The break below is read from the files, by line.
1. The setup
Django does the heavy lifting well here. post_save fires after every Order.save(), the signal machinery is decoupled from the view that saved the order, and putting side effects in handlers keeps views thin. The shape is common and reasonable.
orders/models.py has an Order with a status field (draft, placed, paid, shipped, refunded). orders/handlers.py has one function per status:
# before/orders/handlers.py
def on_placed(order: Order) -> None: ...
def on_paid(order: Order) -> None: ...
def on_shipped(order: Order) -> None: ...
def on_refunded(order: Order, reason: str) -> None: ... # line 37orders/signals.py picks one:
# before/orders/signals.py
@receiver(post_save)
def dispatch_order_status(sender, instance, **kwargs):
if not isinstance(instance, Order):
return
handler = getattr(handlers, f"on_{instance.status}", None) # line 12
if handler is not None:
handler(instance) # line 14Two test files cover it. orders/tests/test_handlers.py calls each handler directly. orders/tests/test_signals.py saves orders in the placed and shipped states and checks the mail outbox.
2. Where the call path hides
Three things hide the path from a coding agent or a static tool:
- The function name is a string. Line 12 builds
"on_refunded"at runtime from a database value. Searching for callers ofon_refundedfinds the definition (handlers.py:37) and the direct test call (test_handlers.py:27). Nothing in the source sayssignals.pyreaches it. - The call is through a variable. Line 14 calls
handler(...).handlercame fromgetattrwith a default ofNone, so a type checker sees an object of unknown type orNone, not a function with a signature to compare against. - **The receiver has no
sender.**@receiver(post_save)connects to every model's save. Theisinstancecheck on line 10 filters at runtime, so a reader can't tell from the decorator which model this serves. The module is also loaded as a side effect of the import at the bottom ofmodels.py, not fromAppConfig.ready(), so a tool that followsINSTALLED_APPStoapps.pyfinds no mention of it.
The None default turns a second kind of edit into a silent one: rename on_shipped to on_dispatched and the dispatcher skips shipped orders without an error.
3. The edit that breaks it, and which call is left wrong
The edit adds a required argument to one handler, so the refund email can say why:
# handlers.py:37, after the edit
def on_refunded(order: Order, reason: str) -> None:The direct test was updated with it (test_handlers.py:27 passes "damaged in transit"), so the handler tests pass. Every other call to on_refunded that a search can find is correct.
The call that is still wrong is before/orders/signals.py:14:
handler(instance)When an order's status is refunded, line 12 resolves on_refunded, and line 14 calls it with one argument. Python raises TypeError for the missing reason, inside the receiver, during Order.save(). The refund request fails at the save call. Django's docs say send() does not catch exceptions raised by receivers and lets them propagate; the same page says "All built-in signals, except those in the async request-response cycle, are dispatched using Signal.send()", and the signals reference says all built-in signals are sent using send(), so post_save goes through send() (https://docs.djangoproject.com/en/stable/topics/signals/ and https://docs.djangoproject.com/en/stable/ref/signals/).
Why no existing test reaches line 14 with a refunded order: test_signals.py saves placed and shipped orders only. The refund test (test_refunding_an_order_sends_refund_notice) is written the way it would be written, and shipped marked @unittest.expectedFailure with the reason in a comment, so the suite shows the gap instead of passing around it.
4. The fix
Replace the string-built name with an explicit match in which each branch is an ordinary call, connect the receiver with its sender, and load signals.py from AppConfig.ready().
--- before/orders/signals.py+++ after/orders/signals.py+from typing import Any+ from django.db.models.signals import post_save from django.dispatch import receiver from orders import handlers from orders.models import Order -@receiver(post_save)-def dispatch_order_status(sender, instance, **kwargs):- if not isinstance(instance, Order):- return- handler = getattr(handlers, f"on_{instance.status}", None)- if handler is not None:- handler(instance)+@receiver(post_save, sender=Order)+def dispatch_order_status(+ sender: type[Order], instance: Order, **kwargs: Any+) -> None:+ match instance.status:+ case Order.Status.PLACED:+ handlers.on_placed(instance)+ case Order.Status.PAID:+ handlers.on_paid(instance)+ case Order.Status.SHIPPED:+ handlers.on_shipped(instance)+ case Order.Status.REFUNDED:+ handlers.on_refunded(instance, instance.refund_reason)+ case _:+ pass # draft: nothing to send--- before/orders/apps.py+++ after/orders/apps.py class OrdersConfig(AppConfig): name = "orders"++ def ready(self) -> None:+ # Importing the module connects the @receiver functions in it.+ from orders import signals # noqa: F401--- before/orders/models.py+++ after/orders/models.py---# Connect the receivers defined in orders/signals.py.-from orders import signals # noqa: E402,F4015. Why the fix works
- Each handler call is a call a tool can see.
handlers.on_refunded(instance, instance.refund_reason)atafter/orders/signals.py:22names the function. A search for callers lists it. Had the edit been made against this file, leavinghandlers.on_refunded(instance)in place, a type checker compares the call todef on_refunded(order: Order, reason: str)in the plainhandlers.pyand reports the missing argument at that line. That holds without Django stubs, because the mismatch is between two ordinary Python functions. - A rename can't skip an order silently. The old
Nonedefault is gone. Renamingon_shippednow leaveshandlers.on_shipped(instance)pointing at a name that no longer exists, which a language server rename updates and a type checker flags. - **
sender=Ordersays which model the receiver serves.** The signals topic in the Django docs shows@receiver(pre_save, sender=MyModel)and states the handler is called only when an instance of that model is saved. Thepost_saveform works the same way: the signals reference describespost_saveas "Likepre_save, but sent at the end of thesave()method", withsenderas the model class; the topic page prints nopost_saveexample. Theisinstanceguard is no longer needed. - Loading is in one named place. Django's docs say signal receivers are usually defined in a
signalssubmodule and connected in theready()method of the app configuration, and that with thereceiver()decorator you import thesignalssubmodule insideready().AppConfig.ready()is called once the app registry is fully populated. - The test now covers the refund path.
after/orders/tests/test_signals.pysaves a refunded order and checks that the reason is in the email.
The ready() docs add one caution that applies here: ready() can run more than once in tests that change installed apps, so registration should be safe to repeat. A @receiver function connected by import is, because it is connected on first import of the module.
6. Rules that apply
- AR-07: don't build method names as strings.
- AR-08: dispatch over a closed set with a literal-keyed mapping or an explicit
match. The order status is a closed set. - AR-31: Django signals get an explicit
senderand live in onesignals.pyper app, imported inAppConfig.ready(). - AR-25: before changing a signature, list every caller and every dynamic site that might reach it. The
getattrsite is the one a plain search won't list.
7. References
Django (fetched 2026-10-30 from the stable docs):
- Signals topic (where to put receivers, connecting in
ready(),@receiver(..., sender=...),dispatch_uid,send()vssend_robust()): https://docs.djangoproject.com/en/stable/topics/signals/ - Applications reference (
AppConfig.ready(), when it runs, registering signals): https://docs.djangoproject.com/en/stable/ref/applications/
Changelog
- 2026-10-30: verify items resolved (docs check)
Last verified