Skip to content

Add a typed-context API #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open

Conversation

logan-markewich
Copy link
Contributor

This PR adds an opt-in typed-context state. This effectively replaces the old get/set API

By default, the state is effectively an untyped dict (taking advantage of the base Event class features). This means users will see no breaking change in core usage, and the existing serdes flow continues to work.

However, users can also opt-in to a typed state experience.

from pydantic import BaseModel, Field

from workflows import Context, Workflow
from workflows.decorators import step
from workflows.events import StartEvent, StopEvent

# State must have defaults for every field
class MyState(BaseModel):
    name: str = Field(default="Jane")
    age: int = Field(default=25)


class MyWorkflow(Workflow):
    @step
    async def step(self, ctx: Context[MyState], ev: StartEvent) -> StopEvent:
        # Modify state attributes
        await ctx.state.set("name", "John")
        await ctx.state.set("age", 30)

        # Get and update entire state
        state = await ctx.state.get_all()
        state.age += 1
        await ctx.state.set_all(state)

        return StopEvent()


async def main():
    wf = MyWorkflow()

    handler = wf.run()

    # Run the workflow
    _ = await handler

    # Check final state
    state = await handler.ctx.state.get_all()
    assert state.model_dump() == MyState(name="John", age=31).model_dump()

Design:

  • Async first
  • Non-breaking
  • Designed to (hopefully) be DB ready at some point in the future if we want remote context

Limitations:

  • Only one "state" type is supported per workflow (although we could easily support more than one)
  • We might want a run_id or context_id in the future to support remote-storage easily.
  • ctx.clear might need to be deprecated because its not async

Deprecations/Breaking Changes

  • Removed ctx.data, ctx._serialize_globals, and ctx._globals
  • Deprecated ctx.get() and ctx.set() in favor of ctx.state.get() and ctx.state.set()

@coveralls
Copy link

coveralls commented Jun 21, 2025

Pull Request Test Coverage Report for Build 15797215531

Details

  • 247 of 265 (93.21%) changed or added relevant lines in 9 files are covered.
  • 2 unchanged lines in 1 file lost coverage.
  • Overall coverage decreased (-0.2%) to 95.495%

Changes Missing Coverage Covered Lines Changed/Added Lines %
tests/test_state_manager.py 82 83 98.8%
src/workflows/context/context.py 29 35 82.86%
src/workflows/context/state_manager.py 97 108 89.81%
Files with Coverage Reduction New Missed Lines %
src/workflows/context/context.py 2 94.8%
Totals Coverage Status
Change from base Build 15751935861: -0.2%
Covered Lines: 2819
Relevant Lines: 2952

💛 - Coveralls

def state(self) -> InMemoryStateManager[MODEL_T]:
# Default to DictState if no state manager is initialized
if self._state_manager is None:
self._state_manager = InMemoryStateManager(DictState())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we use the init method here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the init method is async, and we can't have an async @property 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

special case since we don't need to check the existing class (there is none)

Copy link
Member

@masci masci left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment about an indirection level it would be nice to remove if possible

self._state_manager = InMemoryStateManager(state_class)

@property
def state(self) -> InMemoryStateManager[MODEL_T]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect that state returns the state I specified to be MyState but I get a manager instead. A yellow flag this might be a problem is the get_all and set_all methods, where it's not clear what all is.
I haven't tried but I think we have two options to align semantic here:

  1. We make the existence of the manager explicit: this proporty becomes state_manager and setter/getter become a writable property ctx.state_manager.state.
  2. We remove the concept of the manager, moving its code to a base State class that MyState must inherit from. This way this property could return the actual MODEL_T state

Copy link
Contributor Author

@logan-markewich logan-markewich Jun 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for 1. ctx.state_manager.state can't work unless we make it async. get_state() ?

oh interesting idea on 2 🤔 But It also removes the general idea of async. The reason I've made it async is I can imagine a future where the state for a workflow may live in some db (and the state could be so large that you might avoid loading the entire thing and use the get/set methods instead)

If that doesn't sound valuable I can just remove all async and go the 2 route

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants