python-statemachine icon indicating copy to clipboard operation
python-statemachine copied to clipboard

Add translation strings for events or transitions?

Open robinharms opened this issue 1 year ago • 8 comments

As of know, it's possible to add a translation string as state name, and then use that translation string within an application. (For instance, have an API where all state machines can be read by the frontend-part of an application)

However, there's no straight forward way (that I know of) to add translation strings for events or transitions.

How about something like this?

_ = SomeGettextThingy


class TrafficLightMachine(StateMachine):
    "A traffic light machine"

    green = State(name=_("Green"), initial=True)
    yellow = State(name=_("Yellow"))
    red = State(name=_("Red"))

    cycle = (
        green.to(yellow, name=_("Cycle"))
        | yellow.to(red, name=_("Cycle"))
        | red.to(green, name=_("Let's go!"))
    )

All the best, Robin

robinharms avatar Aug 15 '24 12:08 robinharms

Hi @robinharms , how are you? Thanks for your suggestion. I like the idea, sounds great!

We need to mature the "how" and the external API... I think that the initial suggestion does not match yet the data model... there's also an "Event" class, it's today only a thin wrapper used to trigger events, just a "syntatic sugar": calling sm.cycle() is the same as calling sm.send("cycle").

Concepts that we neet to address for a more future proof API:

  1. Events are similar to the input signals, they don't need to be assigned to the class declaration to exist. Maybe we can call them input events.
  2. Then, we have the SM know events, they are registered to the transitions, so the SM knows that a transition should occur if a known event is matched. The event names attached to transitions are conceptually "match patterns" (they are not yet implemented like patterns, but eventually will).
  3. We already have an Event class.
  4. We already have a SM class method (does not need an instance) to access all know events: StateMachine.events() that return a list of Event instances.
  5. Event "pattern matching" can be bound directly to the transition.

To make this point 5 in context, this SM is similar to the one declared in your example, even if this method is not well documented:

class TrafficLightMachine(StateMachine):
    "A traffic light machine"

    green = State(name=_("Green"), initial=True)
    yellow = State(name=_("Yellow"))
    red = State(name=_("Red"))

    green.to(yellow, event="cycle")
    yellow.to(red, event="cycle")
    red.to(green, event="cycle")

Hackish version

Example of how you can accomplish something similar today using the current version of the SM, accessing an internal API (not ideal).

Maybe we can explore more this path, to find and define a stable API:

# i18n_label.py
from gettext import gettext as _

from tests.examples.order_control_machine import OrderControl

OrderControl._events["add_to_order"].label = _("Add to order")
OrderControl._events["process_order"].label = _("Process order")
OrderControl._events["receive_payment"].label = _("Receive payment")
OrderControl._events["ship_order"].label = _("Ship order")

for e in OrderControl.events:
    print(f"{e.name}: {e.label}")

Output:

(python-statemachine-py3.12) ❯ python i18n_label.py
add_to_order: Add to order
process_order: Process order
receive_payment: Receive payment
ship_order: Ship order

POT can be extracted using pybabel or similar:

pybabel extract i18n_label.py -o i18n_label.pot

Contents:

# Translations template for PROJECT.

# ...

"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.8.0\n"

#: i18n_label.py:5
msgid "Add to order"
msgstr ""

#: i18n_label.py:6
msgid "Process order"
msgstr ""

#: i18n_label.py:7
msgid "Receive payment"
msgstr ""

#: i18n_label.py:8
msgid "Ship order"
msgstr ""

fgmacedo avatar Aug 15 '24 13:08 fgmacedo

That's great!

In that case I guess it's just about having a nicer documented way to add an attribute to the events? (Since the translation seem to belong there and not on transitions) And since events and allowed_events already exists they're already a public API.

One thing though, it might be a bit confusing with the name/value/id/label usage on SM vs Events. It took me a while to figure out that part of the code the first time I read through it. Maybe it would be a good idea to use id for event key and name as optional translation string / human readable?

That would end up being quite similar to how states are defined now:


red = State(name=_("Red"))
<etc...>

# option 1
cycle = (
        | yellow.to(red, name=_("Cycle"))
        | red.to(green, name=_("Let's go!"))
    )

# option 2
green.to(yellow, event="cycle", name=_("Cycle"))

And hopefully only require a bit of internal renaming :)

What do you think?

All the best!

robinharms avatar Aug 16 '24 09:08 robinharms

I agree with using name as metadata instead of the event identification.

I only didn't get how the name on transition will be used. As you demonstrated, we could possibly have distinct names, one for each transition, with multiple transitions related to the same event.

Can you elaborate a bit more on the transition's name usage?

fgmacedo avatar Aug 17 '24 13:08 fgmacedo

If I understod the code correctly, when you create a transition via a SM as in the example above, the transition also creates an event right? So the name arg in the above example would basically transfer to the event. But since event already has an (internal?) attribute called name it might be good to use something else, or rename event.name to event.id

Hope that was a bit clearer :)

All the best!

robinharms avatar Aug 19 '24 12:08 robinharms

red = State(name=_("Red")) <etc...>

option 1

cycle = ( | yellow.to(red, name=("Cycle")) | red.to(green, name=("Let's go!")) )

option 2

green.to(yellow, event="cycle", name=_("Cycle"))

I still have a concern: Given that many transitions can point to the same event, don't seem like the correct place to insert the event name. The example above exposes this issue: Both name=_("Cycle") and name=_("Let's go!") pointing to the same event cycle, if we read cycle.name, that is the expected output?

fgmacedo avatar Sep 08 '24 19:09 fgmacedo

Yeah that certainly throws a wrench in it. Ugh, and the other way around, there might be several events that point to the same transition. (Something like "customer_finish_order" vs."silently_process_internal_order" should probably end up in the same state)

So I think that points in the direction of avoiding writing to event instances while defining transitions. Might be a bit more verbose (or annoying), but this is something I expect quite few developers will use right?

Here are some ideas:

Idea 1 - appending to class

class TrafficLightMachine(StateMachine):

   <...>

    cycle = (
            | yellow.to(red)
            | red.to(green)
        )


TrafficLightMachine.add_event_meta('cycle', name=_('Cycle'))

TrafficLightMachine.add_event_meta('404', name=_('404'))
(raise EventNotFound)

What's the expected behavior with subclasses here though?

Idea 2: Only allow event modification when passing event instance


emergency_stop = Event('emergency_stop', name=_('Emergency stop'))

class TrafficLightMachine(StateMachine):

   <...>

# Default event here
    cycle = yellow.to(red) | red.to(green)

# This must be set per row in this case
    yellow.to(red, event=emergency_stop)
    green.to(red, event=emergency_stop)

Idea 3: Operators?

reusable_event = Event('reusable', name=_('Reusable'))

class TrafficLightMachine(StateMachine):

   <...>

# Id set from attr, event for both transitions
    cycle = (yellow.to(red) | red.to(green)) & Event(name='Cycle')

# Two events for a single transition (as list or combined with &...?)
    green.to(red) & Event('emergency_override', name=_('Emergency')) & reusable_event)

# But these two would cause exceptions due to 
    bad = yellow.to(red) & Event('other') # Ambiguous, bad or other?
    yellow.to(red) & Event('emergency_override') # A new instance of event with the same id as an event that already exists

Any thoughts on this? All the best!

robinharms avatar Sep 09 '24 07:09 robinharms

I liked the idea of having an explicit Event instance when customizing the name. Options 2 & 3 seem the best.

The operators option looks like syntactic sugar on top of the Event instance, which we may implement but the main component is still the possibility to pass an Event where we use a string now.


# But these two would cause exceptions due to 
    bad = yellow.to(red) & Event('other') # Ambiguous, bad or other?
    yellow.to(red) & Event('emergency_override') # A new instance of event with the same id as an event that already exists

Instead of an exception, this should be possible, it's like adding two events to the same set of transitions. Currently, it's supported: bad = yellow.to(red, event='other') works by adding both events. Even repeating the same event is possible and de-duplicated: cycle = (yellow.to(red, event='cycle') | red.to(green, event='cycle')).

Another option could be:

Idea 4: Explicit event creation from a transition list.

cycle = Event(green.to(yellow) | yellow.to(red) | red.to(green), name=_("Cycle"))

So we come up with implementing both 2 and 4.

cycle = Event(
    green.to(yellow, event=Event("slowdown", name=_("Slowdown")) | yellow.to(red) | red.to(green), 
    name=_("Cycle")
)

Similar construction of passing events as parameters

cycle = Event(name=_("Cycle"))  # customized name
slowdown = Event()  # name derived from event identifier
green.to(yellow, event=[cycle, slowdown]) | yellow.to(red, event=cycle) | red.to(green, event=cycle)

What do you think?

Best!

fgmacedo avatar Sep 09 '24 13:09 fgmacedo

Much better, yay idea 4! Easier to read + not enforced + easy to follow!

I'm not a fan of duplicate events with the same id though, it will make it harder to pass along the correct id. (And harder to understand when developing other things based on this) But if that's another issue and a bit off topic for this thread :)

All the best

robinharms avatar Sep 09 '24 13:09 robinharms

Hi @robinharms , can you please review the work at https://github.com/fgmacedo/python-statemachine/pull/488?

Specially if the docs are ok? https://python-statemachine--488.org.readthedocs.build/en/488/transitions.html#event

fgmacedo avatar Nov 04 '24 20:11 fgmacedo

Hi! I've read through the changes and the docs, and it seems reasonable and easy enough to understand. (Especially since it's optional) Great!

Let me try to build something a bit more concrete too and try it out!

All the best

robinharms avatar Nov 06 '24 12:11 robinharms

It works great and does exactly what's expected from the docs.

There are some API-changes around events that may break peoples code, but it should be easy enough to fix. Maybe it needs a bigger version bump? :)

Great change and it works as expected with translation strings too. Thanks a lot!

robinharms avatar Nov 06 '24 13:11 robinharms

Nice! Glad to know it worked!

Can you elaborate more about the API changes you think may break? I just want to be sure I'm on the same page.

I'm planning a major bump to turn some new behavior default, like strict_states=True.

fgmacedo avatar Nov 06 '24 13:11 fgmacedo

Well essentially that name turned into something else. I used that as an ID before. Like this: [x.name for x in <some SM class>.events]

But on the other hand, if I wouldn't have used the Event class with name, name would still be the same, so perhaps it doesn't really matter...?

robinharms avatar Nov 06 '24 13:11 robinharms

Ah, got it. Yes... I've preserved the same behavior as before If you don't change to explicit create an Event by yourself... so for every previous use case, the event == event.id == event.name. But if you do change the code to create Event class, then the name will have the new behavior.

I wrote just a warning at the end of the Events section that says:

An Event declared as string will have its name set equal to its id. This is for backward compatibility when migrating from previous versions. In the next major release, Event.name will default to a capitalized version of id (i.e., Event.id.replace("_", " ").capitalize()). Starting from version 2.4.0, use Event.id to check for event identifiers instead of Event.name.

So for now... these cases are on the automated tests:

Preserving old behaviour

class StartMachine(StateMachine):
    created = State(initial=True)
    started = State(final=True)

    launch_the_machine = created.to(started)

assert list(StartMachine.events) == ["launch_the_machine"]
assert [e.id for e in StartMachine.events] == ["launch_the_machine"]
assert [e.name for e in StartMachine.events] == ["launch_the_machine"]
assert StartMachine.launch_the_machine.name == "launch_the_machine"
assert str(StartMachine.launch_the_machine) == "launch_the_machine"
assert StartMachine.launch_the_machine == StartMachine.launch_the_machine.id

New behaviour

And if you use the Event explicitly, even without providing a name, it will derive the name from ID...

class StartMachine(StateMachine):
    created = State(initial=True)
    started = State(final=True)

    launch_the_machine = Event(created.to(started))

assert list(StartMachine.events) == ["launch_the_machine"]
assert [e.id for e in StartMachine.events] == ["launch_the_machine"]
assert [e.name for e in StartMachine.events] == ["Launch the machine"]
assert StartMachine.launch_the_machine.name == "Launch the machine"
assert str(StartMachine.launch_the_machine) == "launch_the_machine"
assert StartMachine.launch_the_machine == StartMachine.launch_the_machine.id

fgmacedo avatar Nov 06 '24 14:11 fgmacedo