tomata


Nametomata JSON
Version 1.1.0 PyPI version JSON
download
home_pagehttps://github.com/evtn/tomata
SummaryNone
upload_time2024-04-26 03:52:52
maintainerNone
docs_urlNone
authorDmitry Gritsenko
requires_python<4.0,>=3.8
licenseMIT
keywords
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage
            # Welcome to `tomata` readme!

This is a generic state automata-ish module allowing you to build an typed event-driven stateful flow with minimal setup.

The basic flow is:

1. You define your own emitter class, inheriting from `tomata.Emitter`. There you define `get_state` and `set_state` methods to provide the state storage.
2. You create an instance of emitter
3. You register handlers for the state changes and events with `emitter.on(state_key, handler_type = "event")`
4. In your main loop, you call `emitter.emit(event, identity)` with arbitrary event and identity values (identity is passed to `get_state`, `set_state` and handler, event is passed to handler)

If you want to dive into examples, go to [examples](https://github.com/evtn/tomata/tree/lord/examples) folder.


## Concepts

tomata works around several concepts (in *italic* in text):

### Event

**Event** is an arbitrary value to process. It is passed to all the *handlers*

### State

**State** is some key to denote the current state. *Handlers* are called depending on which state is currently active

### Identity

**Identity** is a key that is used to differentiate between states of different entites. An identity could be, for example, a user id or a string hash.

### Handler

**Handler** is a function called on any event. There are three types of handlers: `enter`, `event` and `leave`.

- `enter` handlers are called when *state* switches **to** the one tied to handler
- `event` handlers are called when *emitter* emits an event. Handlers of this type can change the *state* by returning the next *state* key
- `leave` handlers are called when *state* switches **from** the one tied to handler

### Emitter

**Emitter** is the main object of the module. It consumes *events* and calls *handlers* as a result. *Handlers* can request a *state* change, *emitter* also handles that.

To work with events, you need your own `Emitter` class (and an instance). It manages calling your handlers and changing the state.

A minimal `Emitter` class has to define `get_state` and `set_state`:


```python
from tomata import Emitter

class MyEmitter(Emitter):
    def set_state(self, identity, state):
        ...

    def get_state(self, identity):
        ...
``` 

Those methods should handle how to store the state (for example, get_state could retrieve the state from database and set_state could write it into the database).

You can also define `__init__` for your emitter class:

```python
...
class MyEmitter(Emitter):
    def __init__(self):
        default_state = "start"
        super().__init__(default_state)
        ...
    
    ...
```

If you love type hints, we've got you covered:

```python
from tomata import Emitter
from typing import Literal

StateKey = Literal["start", "age", "name", "finish"]
Event = str
Identity = int

class MyEmitter(Emitter[Event, StateKey, Identity]):
    def __init__(self):
        super().__init__("start")
    
    def get_state(self, identity: Identity) -> StateKey:
        ...
    
    def set_state(self, identity: Identity, state: StateKey) -> None:
        ...

```

...or, if you are on Python 3.12:

```python
from tomata import Emitter
from typing import Literal

# those types are arbitrary, you can actually use anything as your event / state / identity
type Event = str
type StateKey = Literal["start", "age", "name", "finish"]
type Identity = int

class MyEmitter(Emitter[Event, StateKey, Identity]):
    def __init__(self):
        super().__init__("start")
    
    def get_state(self, identity: Identity) -> StateKey:
        ...
    
    def set_state(self, identity: Identity, state: StateKey) -> None:
        ...

```

## Defining handlers

Let's say, you want to run some code when state `cake_shooting` is active and some event comes by:

```python

em = MyEmitter()

...


@em.on("cake_shooting")
def cake_shooting(event: Event, identity: Identity, state: StateKey):
    # event and identity are provided through emit, state is the current state.
    if not event:
        return "no_cake" # new state
    
    cake = catch_cake(event)
    eat_cake(cake)
```

Now, if you want to run some code when state was switched to `cake_shooting`, use `enter` handler:

```python

em = MyEmitter()

...


@em.on.enter("cake_shooting")
def cake_shooting(event: Event, identity: Identity, state: StateKey):
    # event and identity are provided through emit, state is the current state.
    ...
```

Same goes for when state was switched from state, just use `.leave` handler


## Emitting events

After you've set up your emitter and handlers, it's time to emit some events:

```python
emitter = MyEmitter()

from json import load
from typing import TypedDict

class EventData(TypedDict):
    data: Event
    identity: Identity
    

with open("events.json") as file:
    events: list[EventData] = load(file)


for event in events:
    emitter.emit(event["data"], event["identity"])

```

## Default handler

You can set a default (fallback) handler to handle events when no other handler is found:

```python

@em.on.default
def default_handler(event, identity, state):
    ...

```

At the moment, you can't define a default `enter` and `leave` handlers, because it doesn't really seem useful (but feel free to open an issue if you find a good use-case)

## Async 

To use async, replace Emitter with AsyncEmitter:


```python
from tomata import AsyncEmitter
from typing import Literal


type StateKey = Literal["start", "age", "name", "finish"]
type Event = str
type Identity = int


class MyEmitter(AsyncEmitter[Event, StateKey, Identity]):
    def __init__(self):
        super().__init__("start")
    
    async def get_state(self, identity: Identity) -> StateKey:
        ...
    
    async def set_state(self, identity: Identity, state: StateKey) -> None:
        ...


em = MyEmitter()

# AsyncEmitter can call both async and sync handlers

@em.on("funny_pineapple")
async def funny_pineapple(event: Event, identity: Identity, state: StateKey):
    ...

@em.on("sad_pineapple")
def sad_pineapple(event: Event, identity: Identity, state: StateKey):
    ...

```

## Advanced behaviour

Let's say, you want to redefine handler storage.
You can easily do that by defining your own `get_handler` and `set_handler` methods.

Let's make an Emitter where state would be a dictionary, and the handler would be called on state["type"]

```python
from tomata import Emitter
from tomata.base import make_handlers_dict

Event = dict[str, str]
State = dict[str, str]
Identity = int

class AdvancedEmitter(Emitter[Event, State, Identity])
    handlers: dict[HandlerType, SyncHandlerStore[str, Event, Identity]]  
        
    def get_state(self, identity: Identity) -> State:
        ...
    
    def set_state(self, identity: Identity, state: State) -> None:
        ...
    
    def get_handler(
        self, state: SK, handler_type: HandlerType
    ) -> SyncHandler[Ev, Id, SK] | None:
        store = self.handlers[handler_type]
        
        key = state.get("type", self.default_state) 
        
        return store.get(key)

    def set_handler(
        self,
        state: SK,
        handler_type: HandlerType,
        handler: SyncHandler[Ev, Id, SK],
    ):
        key = state.get("type", self.default_state)
        
        store = self.handlers[handler_type]
        
        store[key] = handler


em = AdvancedEmitter({"type": "start"})

@em.on({"type": "start"})
def start_event(event: Event, identity: Identity, state: State):
    new_state = {**state, "type": "ongoing"}
    new_state["count"] = new_state.get("count", 0)
    
    return new_state
```

To redefine setting fallback handler logic, define your own `set_default(handler)` and `get_default()` methods.


### AsyncEmitter methods

To extend AsyncEmitter, you have to remember two things:

- Some methods (`[get|set]_state`, `get_handler`, `get_default`, `reroute`) are async. This means you have to define them with `async def` even if you don't plan to use await in them. 
    *`emit` is obviously async, but if you don't need an async `emit`, take a look at synchronous `Emitter`*

- In `set_handler` and `set_default`, you have to async-ify the handlers. To do that, use `tomata.async_emitter.make_async` function, which takes any handler and makes it async

Otherwise, the extension process should be just as with `Emitter`.


## Reroute 

*new in 1.1.0*

Sometimes you receive an event within one state and want to handle it within another instead. Use `*Emitter.reroute()` for this:

```python
from time import time

HOUR = 3600
DAY = 24 * HOUR
NOON = 12 * HOUR

@handler.on("state1")
def handle1(event: Event, identity: Identity, state: State):
    if (time() % DAY) > NOON:
        return handler.reroute("state2", event, identity)
        
    print("it is morning")


@handler.on("state2")
def handle2(event: Event, identity: Identity, state: State):
    print("it is state2 or afternoon")

```

`.reroute` basically does this:

```python
handler.set_state(identity, state)
handler.emit(event, identity)
return None
```
            

Raw data

            {
    "_id": null,
    "home_page": "https://github.com/evtn/tomata",
    "name": "tomata",
    "maintainer": null,
    "docs_url": null,
    "requires_python": "<4.0,>=3.8",
    "maintainer_email": null,
    "keywords": null,
    "author": "Dmitry Gritsenko",
    "author_email": "tomata@evtn.me",
    "download_url": "https://files.pythonhosted.org/packages/80/f3/d216ac3f7de3cb65b1da71eaee9c9271db6ed23d423c7bcaad715e3b683b/tomata-1.1.0.tar.gz",
    "platform": null,
    "description": "# Welcome to `tomata` readme!\n\nThis is a generic state automata-ish module allowing you to build an typed event-driven stateful flow with minimal setup.\n\nThe basic flow is:\n\n1. You define your own emitter class, inheriting from `tomata.Emitter`. There you define `get_state` and `set_state` methods to provide the state storage.\n2. You create an instance of emitter\n3. You register handlers for the state changes and events with `emitter.on(state_key, handler_type = \"event\")`\n4. In your main loop, you call `emitter.emit(event, identity)` with arbitrary event and identity values (identity is passed to `get_state`, `set_state` and handler, event is passed to handler)\n\nIf you want to dive into examples, go to [examples](https://github.com/evtn/tomata/tree/lord/examples) folder.\n\n\n## Concepts\n\ntomata works around several concepts (in *italic* in text):\n\n### Event\n\n**Event** is an arbitrary value to process. It is passed to all the *handlers*\n\n### State\n\n**State** is some key to denote the current state. *Handlers* are called depending on which state is currently active\n\n### Identity\n\n**Identity** is a key that is used to differentiate between states of different entites. An identity could be, for example, a user id or a string hash.\n\n### Handler\n\n**Handler** is a function called on any event. There are three types of handlers: `enter`, `event` and `leave`.\n\n- `enter` handlers are called when *state* switches **to** the one tied to handler\n- `event` handlers are called when *emitter* emits an event. Handlers of this type can change the *state* by returning the next *state* key\n- `leave` handlers are called when *state* switches **from** the one tied to handler\n\n### Emitter\n\n**Emitter** is the main object of the module. It consumes *events* and calls *handlers* as a result. *Handlers* can request a *state* change, *emitter* also handles that.\n\nTo work with events, you need your own `Emitter` class (and an instance). It manages calling your handlers and changing the state.\n\nA minimal `Emitter` class has to define `get_state` and `set_state`:\n\n\n```python\nfrom tomata import Emitter\n\nclass MyEmitter(Emitter):\n    def set_state(self, identity, state):\n        ...\n\n    def get_state(self, identity):\n        ...\n``` \n\nThose methods should handle how to store the state (for example, get_state could retrieve the state from database and set_state could write it into the database).\n\nYou can also define `__init__` for your emitter class:\n\n```python\n...\nclass MyEmitter(Emitter):\n    def __init__(self):\n        default_state = \"start\"\n        super().__init__(default_state)\n        ...\n    \n    ...\n```\n\nIf you love type hints, we've got you covered:\n\n```python\nfrom tomata import Emitter\nfrom typing import Literal\n\nStateKey = Literal[\"start\", \"age\", \"name\", \"finish\"]\nEvent = str\nIdentity = int\n\nclass MyEmitter(Emitter[Event, StateKey, Identity]):\n    def __init__(self):\n        super().__init__(\"start\")\n    \n    def get_state(self, identity: Identity) -> StateKey:\n        ...\n    \n    def set_state(self, identity: Identity, state: StateKey) -> None:\n        ...\n\n```\n\n...or, if you are on Python 3.12:\n\n```python\nfrom tomata import Emitter\nfrom typing import Literal\n\n# those types are arbitrary, you can actually use anything as your event / state / identity\ntype Event = str\ntype StateKey = Literal[\"start\", \"age\", \"name\", \"finish\"]\ntype Identity = int\n\nclass MyEmitter(Emitter[Event, StateKey, Identity]):\n    def __init__(self):\n        super().__init__(\"start\")\n    \n    def get_state(self, identity: Identity) -> StateKey:\n        ...\n    \n    def set_state(self, identity: Identity, state: StateKey) -> None:\n        ...\n\n```\n\n## Defining handlers\n\nLet's say, you want to run some code when state `cake_shooting` is active and some event comes by:\n\n```python\n\nem = MyEmitter()\n\n...\n\n\n@em.on(\"cake_shooting\")\ndef cake_shooting(event: Event, identity: Identity, state: StateKey):\n    # event and identity are provided through emit, state is the current state.\n    if not event:\n        return \"no_cake\" # new state\n    \n    cake = catch_cake(event)\n    eat_cake(cake)\n```\n\nNow, if you want to run some code when state was switched to `cake_shooting`, use `enter` handler:\n\n```python\n\nem = MyEmitter()\n\n...\n\n\n@em.on.enter(\"cake_shooting\")\ndef cake_shooting(event: Event, identity: Identity, state: StateKey):\n    # event and identity are provided through emit, state is the current state.\n    ...\n```\n\nSame goes for when state was switched from state, just use `.leave` handler\n\n\n## Emitting events\n\nAfter you've set up your emitter and handlers, it's time to emit some events:\n\n```python\nemitter = MyEmitter()\n\nfrom json import load\nfrom typing import TypedDict\n\nclass EventData(TypedDict):\n    data: Event\n    identity: Identity\n    \n\nwith open(\"events.json\") as file:\n    events: list[EventData] = load(file)\n\n\nfor event in events:\n    emitter.emit(event[\"data\"], event[\"identity\"])\n\n```\n\n## Default handler\n\nYou can set a default (fallback) handler to handle events when no other handler is found:\n\n```python\n\n@em.on.default\ndef default_handler(event, identity, state):\n    ...\n\n```\n\nAt the moment, you can't define a default `enter` and `leave` handlers, because it doesn't really seem useful (but feel free to open an issue if you find a good use-case)\n\n## Async \n\nTo use async, replace Emitter with AsyncEmitter:\n\n\n```python\nfrom tomata import AsyncEmitter\nfrom typing import Literal\n\n\ntype StateKey = Literal[\"start\", \"age\", \"name\", \"finish\"]\ntype Event = str\ntype Identity = int\n\n\nclass MyEmitter(AsyncEmitter[Event, StateKey, Identity]):\n    def __init__(self):\n        super().__init__(\"start\")\n    \n    async def get_state(self, identity: Identity) -> StateKey:\n        ...\n    \n    async def set_state(self, identity: Identity, state: StateKey) -> None:\n        ...\n\n\nem = MyEmitter()\n\n# AsyncEmitter can call both async and sync handlers\n\n@em.on(\"funny_pineapple\")\nasync def funny_pineapple(event: Event, identity: Identity, state: StateKey):\n    ...\n\n@em.on(\"sad_pineapple\")\ndef sad_pineapple(event: Event, identity: Identity, state: StateKey):\n    ...\n\n```\n\n## Advanced behaviour\n\nLet's say, you want to redefine handler storage.\nYou can easily do that by defining your own `get_handler` and `set_handler` methods.\n\nLet's make an Emitter where state would be a dictionary, and the handler would be called on state[\"type\"]\n\n```python\nfrom tomata import Emitter\nfrom tomata.base import make_handlers_dict\n\nEvent = dict[str, str]\nState = dict[str, str]\nIdentity = int\n\nclass AdvancedEmitter(Emitter[Event, State, Identity])\n    handlers: dict[HandlerType, SyncHandlerStore[str, Event, Identity]]  \n        \n    def get_state(self, identity: Identity) -> State:\n        ...\n    \n    def set_state(self, identity: Identity, state: State) -> None:\n        ...\n    \n    def get_handler(\n        self, state: SK, handler_type: HandlerType\n    ) -> SyncHandler[Ev, Id, SK] | None:\n        store = self.handlers[handler_type]\n        \n        key = state.get(\"type\", self.default_state) \n        \n        return store.get(key)\n\n    def set_handler(\n        self,\n        state: SK,\n        handler_type: HandlerType,\n        handler: SyncHandler[Ev, Id, SK],\n    ):\n        key = state.get(\"type\", self.default_state)\n        \n        store = self.handlers[handler_type]\n        \n        store[key] = handler\n\n\nem = AdvancedEmitter({\"type\": \"start\"})\n\n@em.on({\"type\": \"start\"})\ndef start_event(event: Event, identity: Identity, state: State):\n    new_state = {**state, \"type\": \"ongoing\"}\n    new_state[\"count\"] = new_state.get(\"count\", 0)\n    \n    return new_state\n```\n\nTo redefine setting fallback handler logic, define your own `set_default(handler)` and `get_default()` methods.\n\n\n### AsyncEmitter methods\n\nTo extend AsyncEmitter, you have to remember two things:\n\n- Some methods (`[get|set]_state`, `get_handler`, `get_default`, `reroute`) are async. This means you have to define them with `async def` even if you don't plan to use await in them. \n    *`emit` is obviously async, but if you don't need an async `emit`, take a look at synchronous `Emitter`*\n\n- In `set_handler` and `set_default`, you have to async-ify the handlers. To do that, use `tomata.async_emitter.make_async` function, which takes any handler and makes it async\n\nOtherwise, the extension process should be just as with `Emitter`.\n\n\n## Reroute \n\n*new in 1.1.0*\n\nSometimes you receive an event within one state and want to handle it within another instead. Use `*Emitter.reroute()` for this:\n\n```python\nfrom time import time\n\nHOUR = 3600\nDAY = 24 * HOUR\nNOON = 12 * HOUR\n\n@handler.on(\"state1\")\ndef handle1(event: Event, identity: Identity, state: State):\n    if (time() % DAY) > NOON:\n        return handler.reroute(\"state2\", event, identity)\n        \n    print(\"it is morning\")\n\n\n@handler.on(\"state2\")\ndef handle2(event: Event, identity: Identity, state: State):\n    print(\"it is state2 or afternoon\")\n\n```\n\n`.reroute` basically does this:\n\n```python\nhandler.set_state(identity, state)\nhandler.emit(event, identity)\nreturn None\n```",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": null,
    "version": "1.1.0",
    "project_urls": {
        "Homepage": "https://github.com/evtn/tomata",
        "Repository": "https://github.com/evtn/tomata"
    },
    "split_keywords": [],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "6e8ebc740152b536e6e1e47e5f2a2f64006cfe5b4f5ce66513120c2b6599befd",
                "md5": "883108588599863e3c6656228c139cfd",
                "sha256": "06068396a4b99643a1526a6b5e90c9d68acbc0c6e55ae89dd873fce9d0ab79c1"
            },
            "downloads": -1,
            "filename": "tomata-1.1.0-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "883108588599863e3c6656228c139cfd",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": "<4.0,>=3.8",
            "size": 7579,
            "upload_time": "2024-04-26T03:52:51",
            "upload_time_iso_8601": "2024-04-26T03:52:51.370902Z",
            "url": "https://files.pythonhosted.org/packages/6e/8e/bc740152b536e6e1e47e5f2a2f64006cfe5b4f5ce66513120c2b6599befd/tomata-1.1.0-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "80f3d216ac3f7de3cb65b1da71eaee9c9271db6ed23d423c7bcaad715e3b683b",
                "md5": "8295434392477dd41f3634117dd575e6",
                "sha256": "e071435ce25acd45d1550e5d15d65d57bd435b3ed4d822f60009667ebaa29aca"
            },
            "downloads": -1,
            "filename": "tomata-1.1.0.tar.gz",
            "has_sig": false,
            "md5_digest": "8295434392477dd41f3634117dd575e6",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": "<4.0,>=3.8",
            "size": 5989,
            "upload_time": "2024-04-26T03:52:52",
            "upload_time_iso_8601": "2024-04-26T03:52:52.993064Z",
            "url": "https://files.pythonhosted.org/packages/80/f3/d216ac3f7de3cb65b1da71eaee9c9271db6ed23d423c7bcaad715e3b683b/tomata-1.1.0.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2024-04-26 03:52:52",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "evtn",
    "github_project": "tomata",
    "travis_ci": false,
    "coveralls": true,
    "github_actions": true,
    "lcname": "tomata"
}
        
Elapsed time: 0.21945s