diff --git a/AGENTS.md b/AGENTS.md index dc7ba202..49062228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/releases/3.2.1.md b/docs/releases/3.2.1.md index 0369a9b1..a05e422e 100644 --- a/docs/releases/3.2.1.md +++ b/docs/releases/3.2.1.md @@ -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). diff --git a/docs/states.md b/docs/states.md index 1694a2ba..dba8633c 100644 --- a/docs/states.md +++ b/docs/states.md @@ -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. | diff --git a/statemachine/contrib/diagram/extract.py b/statemachine/contrib/diagram/extract.py index 863ed0fa..ca5986f9 100644 --- a/statemachine/contrib/diagram/extract.py +++ b/statemachine/contrib/diagram/extract.py @@ -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, @@ -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) @@ -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, diff --git a/statemachine/exceptions.py b/statemachine/exceptions.py index 08185d24..f91b06fc 100644 --- a/statemachine/exceptions.py +++ b/statemachine/exceptions.py @@ -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 ) diff --git a/statemachine/state.py b/statemachine/state.py index 8327aa92..065cc52a 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -296,7 +296,7 @@ def __repr__(self): ) def __str__(self): - return self.name + return str(self.name) @property def id(self) -> str: @@ -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 diff --git a/tests/test_translation_names.py b/tests/test_translation_names.py new file mode 100644 index 00000000..023b0d20 --- /dev/null +++ b/tests/test_translation_names.py @@ -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)