fgmacedo / python-statemachine

Python Finite State Machines made easy.
MIT License
854 stars 84 forks source link

Add translation strings for events or transitions? #472

Open robinharms opened 1 month ago

robinharms commented 1 month ago

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

fgmacedo commented 1 month ago

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 ""
robinharms commented 1 month ago

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!

fgmacedo commented 4 weeks ago

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?

robinharms commented 3 weeks ago

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!

fgmacedo commented 1 week ago

red = State(name=_("Red"))

# 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?

robinharms commented 6 days ago

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!

fgmacedo commented 6 days ago

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!

robinharms commented 6 days ago

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