Track failed condition in TransitionNotAllowed#98
Track failed condition in TransitionNotAllowed#98knaperek wants to merge 3 commits intodjango-commons:mainfrom
Conversation
Codecov Report❌ Patch coverage is
🚀 New features to boost your workflow:
|
|
Hi @knaperek , NB: I pushed a small commit to fix typing issues |
|
I have a sweeper that tries to transition an object from one state to another, as long as a particular condition allows it. If it does not, it's OK and I want to handle this silently. On the other hand, I'd like to be informed if the transition attempt failed for another reason (e.g. different condition failing or wrong source state etc.). Using I deliberately wanted this to be perfectly backwards compatible and not introduce any regressions, including performance. People may have multiple conditions declared on transitions, in a precise order. The current checker uses |
|
Is there anything else I can do to get this merged? |
|
First of all, thank you again for this contribution. The implementation is clean, well structured, and easy to review. I really appreciate the care you put into it. From a technical standpoint, the patch itself looks solid. My hesitation isn’t about quality, but more about validating the underlying use case. I want to make sure we’re solving a problem that is sufficiently common and well-understood before extending the public surface of the project. As you probably noticed, the community around django-fsm-2 isn’t as active as I’d like, so feedback sometimes takes a bit of time. I’d prefer to let this PR sit a little longer to see whether others chime in with real-world scenarios. Patience here is more about stewardship than doubt in your work. That said, I’m genuinely interested in your concrete use case. Would you be willing to share a small example of how your code looks today, and how it would look after merging this change? A before/after snippet would be extremely helpful. I’m currently thinking about enriching the documentation with more “best practices” and possibly a small cookbook section. Real-world examples like yours would be perfect material for that. On the concurrency side: what you’re addressing does touch the classic TOCTOU category of issues. In many cases, this can be mitigated by using a proper transaction boundary and/or row-level locking ( EAFP is indeed the more Pythonic style, but in distributed systems or concurrent workflows, it sometimes just shifts the problem. And sweepers — while pragmatic (I use them too sometimes) — are often compensating for something happening outside a proper transactional boundary. So I’d like to better understand whether your use case is:
None of this blocks the technical quality of the PR — I just want to ensure we evolve the project in a way that remains coherent and minimal. Thanks again for the thoughtful contribution 🙏 |
|
@pfouque For what it's worth, this improvement would be a debugging help to us in a few applications. There are some concurrency issues we've run into and just in solving some bugs but having a little more context in the NotAllowed exception would be helpful. |
|
Hi @dwasyl I’m a bit hesitant to add this to the core API. When a project needs detailed information about why a transition cannot proceed, my impression is that this is usually better handled in project code by moving the relevant checks into the transition function itself. That allows the project to either raise a custom exception deriving from This keeps the default exception small and stable, while still giving an escape hatch for applications with more advanced debugging/reporting requirements. It also avoids making assumptions about conditions, which are arbitrary callables and do not always map cleanly to a meaningful public error payload. More broadly, I’m about to introduce some best practices in the documentation, and one of them is to keep conditions “light”. Conditions are evaluated every time available transitions are listed, so putting non-trivial logic there can have unintended side effects (performance, unexpected behavior, etc.). In many cases, the For example: class Order(models.Model):
state = FSMField(default="new")
- @transition(field=state, source="new", target="paid", conditions=[can_be_paid])
- def pay(self):
- pass
+ @transition(field=state, source="new", target="paid")
+ def pay(self):
+ if not self.can_be_paid(): # or can_be_paid(self)
+ raise PaymentNotAllowed("Order cannot be paid due to ...")
+ # or: logger.debug("Payment blocked because ...")
+ ... |
|
Thanks for your input @pfouque. It is true that TOCTOU issues can, in general, be avoided with explicit lock or similar pessimistic locking patterns. Sometimes, it is an overkill though - from either performance or just code length and clarity point of view. I have already introduced the concept of optimistic locking, piggy-backed on the status field, a couple years ago into this library. The neat property and main selling point has been its performance and general overhead characteristics - no added fields or extra queries required, just a tiny bit of logic. And I've been using it heavily for ages with zero issues and perfect feedback from other team members as well. Similarly, the extra context I'm proposing to add to the exception here has virtually no overhead, but provides optionality. It is also conceptually native to Python or common 3rd party libraries to provide extra context in exceptions. And it makes the code shorter, more DRY, and even faster - following the EAFP principle. If we were to test all the conditions before the attempt to make the transition, we'd need to either:
I believe the arguments for this are real and not that hard to imagine in any slightly more complex FSM. We really have nothing to loose but gain with this little improvement. |
When a transition is blocked because a condition returns False, the raised TransitionNotAllowed provides no information about which condition failed. This limits both debugging and production error handling — code that catches TransitionNotAllowed cannot distinguish between different failure reasons or take condition-specific recovery action without resorting to fragile workarounds like re-evaluating conditions after the fact. This commit adds a `failed_condition` attribute to TransitionNotAllowed that carries a reference to the first condition callable that returned False. The condition's name is also included in the exception message string for immediate readability. Together, these give callers both a programmatic handle for precise error handling and a human-readable explanation for logging and debugging. The implementation preserves identical evaluation semantics to the existing `all()` check: conditions are evaluated in declaration order with short-circuit on first failure, so no additional condition evaluations occur compared to the current code. The `conditions_met()` method and all of its callers (can_proceed, get_available_FIELD_transitions, has_transition_perm) are left completely untouched. The change is fully backwards-compatible: - `failed_condition` defaults to None (matching the existing kwargs pattern used by `object` and `method`) - All existing raise sites that don't pass `failed_condition` are unaffected - The exception string is a strict superset of the previous message
3a05291 to
1ed59f4
Compare
|
Hi @knaperek — thanks again for the work on tracking failed conditions. I built a small follow‑up on top of your contribution to improve exception management while keeping compatibility:
I’d love your feedback on the hierarchy and naming — does this fit the intent of your original change? |
1ed59f4 to
10bd853
Compare
10bd853 to
383a8ff
Compare
Description
Failed condition tracking in TransitionNotAllowed for better diagnosis and fine-grained error handling.
Fully backwards compatible, this only adds advisory context to the exception.
Type of Change
Checklist
uv run pytestorpytest)uv run ruff check .)Testing
Unittests added.
Fork tested manually in an interractive Python shell.
# Commands used to test uv run pytest tests/test_xxx.py -v