Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ enforced, so when you add or change native vocabulary:
- Signed commits preferred (`git commit -s`)
- Use [Conventional Commits](https://www.conventionalcommits.org/) messages
(e.g., `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `perf:`)
- **NEVER bypass pre-commit hooks** (`--no-verify`, `-n`, or disabling hooks). The hooks
inspect every commit to keep regressions out; only the maintainer may decide to skip them.
If a hook modifies files (e.g. `generate-images` regenerating a diagram PNG), stage the
generated changes and commit again until the hooks pass — do not skip them.

## Security

Expand Down
31 changes: 31 additions & 0 deletions docs/releases/3.2.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,34 @@ IndexError: index -4 out of range
```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).

### Lazy / translation proxy objects as `name`

A `State` (or `Event`) `name` can now be any object castable to `str`, including
**lazy translation proxies** (e.g. `django.utils.translation.gettext_lazy`).
Earlier 3.x releases stored the value as-is and later assumed it was a real
`str`, so a proxy broke message formatting (notably the `TransitionNotAllowed`
message) and `str(state)`. The proxy is now kept untouched and only resolved via
`str()` at the point of display, so the active locale is honored at render time
instead of at class-definition time.

```py
>>> from statemachine import State, StateMachine

>>> class Lazy: # stand-in for a translation proxy (resolved on str())
... def __init__(self, value):
... self.value = value
... def __str__(self):
... return self.value

>>> class SM(StateMachine):
... draft = State(Lazy("Rascunho"), initial=True)
... published = State(Lazy("Publicado"), final=True)
... publish = draft.to(published)

>>> str(SM.draft)
'Rascunho'

```

[#632](https://github.com/fgmacedo/python-statemachine/issues/632).
2 changes: 1 addition & 1 deletion docs/states.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ True

| Parameter | Default | Description |
|---|---|---|
| `name` | `""` | Human-readable display name. Defaults to the attribute name, capitalized. |
| `name` | `""` | Human-readable display name. Defaults to the attribute name, capitalized. Accepts any object castable to `str` (e.g. a lazy translation proxy), resolved via `str()` at display time. |
| `value` | `None` | Custom value for this state, accessible via `configuration_values`. |
| `initial` | `False` | Marks this as the initial state. Exactly one per machine (or per compound). |
| `final` | `False` | Marks this as a final (accepting) state. No outgoing transitions allowed. |
Expand Down
6 changes: 3 additions & 3 deletions statemachine/contrib/diagram/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _extract_state(

return DiagramState(
id=state.id,
name=state.name,
name=str(state.name),
type=state_type,
actions=actions,
children=children,
Expand Down Expand Up @@ -125,7 +125,7 @@ def _format_event_names(transition: "Transition") -> str:
continue
if eid not in seen_ids: # pragma: no branch
seen_ids.add(eid)
display.append(event.name if event.name else eid)
display.append(str(event.name) if event.name else eid)

return " ".join(display)

Expand Down Expand Up @@ -279,7 +279,7 @@ class itself thanks to the metaclass. Active-state highlighting is only
_resolve_initial_states(states)

return DiagramGraph(
name=machine.name,
name=str(machine.name),
states=states,
transitions=transitions,
compound_state_ids=compound_ids,
Expand Down
2 changes: 1 addition & 1 deletion statemachine/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class TransitionNotAllowed(StateMachineError):
def __init__(self, event: "Event | None", configuration: MutableSet["State"]):
self.event = event
self.configuration = configuration
name = ", ".join([s.name for s in configuration])
name = ", ".join(str(s.name) for s in configuration)
msg = _("Can't {} when in {}.").format(
self.event and self.event.name or "transition", name
)
Expand Down
4 changes: 2 additions & 2 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ def __repr__(self):
)

def __str__(self):
return self.name
return str(self.name)

@property
def id(self) -> str:
Expand All @@ -308,7 +308,7 @@ def _set_id(self, id: str) -> "State":
self.value = id
if not self.name:
self.name = humanize_id(self._id)
self._hash = hash((self.name, self._id))
self._hash = hash((str(self.name), self._id))

return self

Expand Down
121 changes: 121 additions & 0 deletions tests/test_translation_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Regression tests for issue #632.

Lazy translation strings (e.g. ``django.utils.translation.gettext_lazy``) are
proxy objects that are *not* real ``str`` instances but are castable via
``str()``. They must be accepted as a ``State`` / ``Event`` ``name`` and only
resolved (via ``str()``) at the point of display, so the active locale is honored
at render time instead of at class-definition time.
"""

import pytest
from statemachine.contrib.diagram.extract import extract
from statemachine.contrib.diagram.renderers.dot import DotRenderer
from statemachine.contrib.diagram.renderers.mermaid import MermaidRenderer
from statemachine.contrib.diagram.renderers.table import TransitionTableRenderer
from statemachine.exceptions import TransitionNotAllowed

from statemachine import State
from statemachine import StateMachine


class LazyString:
"""Minimal stand-in for a lazy translation proxy.

It is intentionally **not** a ``str`` subclass: it only resolves to text
when ``str()`` is called, evaluating ``factory`` each time so a change in the
active "locale" is reflected at the moment of use.
"""

def __init__(self, factory):
self._factory = factory

def __str__(self):
return str(self._factory())


# A mutable "locale" the lazy strings below resolve against at call time.
_locale = {"current": "en"}

_TRANSLATIONS = {
"en": {"start": "Start", "middle": "Middle", "end": "End"},
"pt": {"start": "Início", "middle": "Meio", "end": "Fim"},
}


def _t(key):
return LazyString(lambda: _TRANSLATIONS[_locale["current"]][key])


@pytest.fixture(autouse=True)
def reset_locale():
_locale["current"] = "en"
yield
_locale["current"] = "en"


class TranslatedSM(StateMachine):
start = State(_t("start"), initial=True)
middle = State(_t("middle"))
end = State(_t("end"), final=True)

go = start.to(middle)
finish = middle.to(end)


def test_transition_not_allowed_message_resolves_lazy_name():
sm = TranslatedSM()
with pytest.raises(TransitionNotAllowed) as exc_info:
sm.finish() # not allowed from `start`

assert "Start" in str(exc_info.value)


def test_str_returns_real_str_instance():
result = str(TranslatedSM.start)
assert type(result) is str
assert result == "Start"


def test_state_is_hashable_with_lazy_name():
state = TranslatedSM.start
# usable as set member / dict key without raising
assert state in {state}
assert {state: 1}[state] == 1


def test_laziness_is_preserved_resolved_at_call_time():
state = TranslatedSM.start
assert str(state) == "Start"

_locale["current"] = "pt"
# The same state object now resolves to the active locale, proving the
# lazy object was stored as-is (not coerced at definition time).
assert str(state) == "Início"


def test_diagram_extract_coerces_lazy_names_to_str():
graph = extract(TranslatedSM)
names = {s.id: s.name for s in graph.states}
assert all(type(n) is str for n in names.values())
assert names["start"] == "Start"
assert names["middle"] == "Middle"


def test_mermaid_renderer_with_lazy_names():
graph = extract(TranslatedSM)
output = MermaidRenderer().render(graph)
assert "Start" in output
assert "Middle" in output


def test_table_renderer_with_lazy_names():
graph = extract(TranslatedSM)
output = TransitionTableRenderer().render(graph)
assert "Start" in output
assert "Middle" in output


def test_dot_renderer_with_lazy_names():
graph = extract(TranslatedSM)
dot = DotRenderer().render(graph)
assert "Start" in str(dot)
Loading