Package: unicycle
UnicycleA predictable, unidirectional state store for Python. Inspired byRedux.Unicycle allows you to:Keep the state of a component or application in one or more immutable objectsTrigger state changes with action messagesSubscribe to state transitionsInstallpipinstallunicycleSimple usageLet's build a Pokédex with Unicycle!Step 1 - Define your stateStart by defining the application's state. Our Pokédex is going to keep track of which Pokémon we've seen, and which ones we've caught.The state can be anything! It can be as simple as a single boolean, but usually you'll need more complexity than that. The only rule is thatstate is immutable. You are not allowed to modify state directly nor mutate. For this reason, we recommend you use aNamedTupleor a frozendataclassas your state type.Youcandefine your state using mutable objects as long as you never actually mutate them. Using immutable type hints likeMappingandSequencecan help, if you want to go this route.fromtypingimportNamedTuple,SetclassPokedexState(NamedTuple):"""Pokedex state.Properties:seen: Names of all seen Pokemon.caught: Names of all caught Pokemon."""seen:frozenset[str]=frozenset()caught:frozenset[str]=frozenset()Step 2 - Define your actionsNext, we define ouractions. An action is an event that may
cause the state to change. The state of our Pokédex will change whenever
we see or catch a new Pokémon.An action object can be anything! Like state, though, actions areread-only. If you need to attach payloads to action, we recommend you use aNamedTupleor a frozendataclassas your action type.If you use type-hints, you must also create aUnionof actions the store may receive.fromtypingimportNamedTuple,UnionclassPokemonSeen(NamedTuple):"""A Pokemon named `name` has been spotted."""name:strclassPokemonCaught(NamedTuple):"""A Pokemon named `name` has been caught."""name:strPokedexAction=Union[PokemonSeen,PokemonCaught]Step 3 - Define how your state changesWith our state shape and actions defined, we need to define:Our initial state.How the state changes in response to those actions.For this, we'll create a subclass ofStore. We can set the initial state of the store using thestateattribute, and we can write reducers to [fold][] the action into the previous state to calculate a new state.Use the@reducerdecorator to mark a given method as handling a certain action type. We have two state changes to worry about:If we see a Pokémon, we need to ensure it is in the seen listIf we catch a Pokémon, we need to ensure it is in both the seen and caughtfromunicycleimportStore,reducerclassPokedexStore(Store[PokedexState,PokedexAction]):state:PokedexState=PokedexState()@reducer(PokemonSeen)defpokemon_seen(self,action:PokemonSeen)->PokedexState:prev_state=self.statereturnPokedexState(seen=prev_state.seen.union([action.name]),caught=prev_state.caught,)@reducer(PokemonCaught)defpokemon_caught(self,action:PokemonCaught)->PokedexState:prev_state=self.statereturnPokedexState(seen=prev_state.seen.union([action.name]),caught=prev_state.caught.union([action.name]),)Step 4 - Add it to your appLet's wire this state up to a simple HTTP API that:Can add a Pokémon to the seen listCan add a Pokémon to the caught listPushes out WebSocket notifications whenever our Pokédex state changes!To trigger state changes, useStore.dispatchto send actions into the store. From there, you can retrieve the state fromStore.state. Additionally, you can useStore.subscribeto receive to state changes notifications asynchronously.fromquartimportQuart,request,websocketapp=Quart("Pokedex")store=PokedexStore()@app.route("/seen",methods=["PUT"])asyncdefput_seen()->None:name=request.data.decode()state=store.dispatch(PokemonSeen(name=name)))returnstate.seen@app.route("/caught",methods=["PUT"])asyncdefput_caught()->None:name=request.data.decode()state=store.dispatch(PokemonCaught(name=name)))returnstate.caught@app.route("/",methods=["GET"])asyncdefget_pokedex()->Dict[str,OrderedSet[str]]:state=store.statereturn{"seen":state.seen,"caught":state.caught,}@app.websocket('/notifications')asyncdefnotifications()->None:withstore.subscribe()asnotifications:asyncforstate,actioninnotifications:awaitwebsocket.send({"seen":state.seen,"caught":state.caught,})app.run()Complicated usageCombined storesFor more complicated states, you can combine several stores into one. This is a powerful feature that allows you to separate your state into different domains while still receiving all the same actions.A combined store is just anotherStore, so you can nest combined stores in other combined stores to create whatever state tree you need. For our Pokédex, we could split our single store into two: aSeenStorefor tracking seen Pokémon and aCaughtStorefor tracking caught Pokémon.Using our same actions...fromtypingimportNamedTuple,UnionfromunicycleimportStore,combine_stores,reducerclassPokemonSeen(NamedTuple):"""A Pokemon named `name` has been spotted."""name:strclassPokemonCaught(NamedTuple):"""A Pokemon named `name` has been caught."""name:strPokedexAction=Union[PokemonSeen,PokemonCaught]We can create aSeenStateandSeenStore...classSeenState(NamedTuple):"""Seen Pokemon state.Properties:names: Names of all seen Pokemon names."""names:frozenset[str]=frozenset()classSeenStore(Store[SeenState,PokedexAction]):state:SeenState=SeenState()@reducer(PokemonSeen,PokemonCaught)defpokemon_seen(self,action:Union[PokemonSeen,PokemonCaught])->SeenState:names=self.state.namesreturnSeenState(names=names.union([action.name]))As well as aCaughtStateandCaughtStore...classCaughtState(NamedTuple):"""Caught Pokemon state.Properties:names: Names of all caught Pokemon."""names:frozenset[str]=frozenset()classCaughtStore(Store[CaughtState,PokedexAction]):state:CaughtState=CaughtState()@reducer(PokemonCaught)defpokemon_caught(self,action:PokemonCaught)->CaughtState:names=self.state.namesreturnCaughtState(names=names.union([action.name]))To create our combined store, we must create a combined state object, built up of the sub-states, as well as a combined store, using@combined_store.classPokedexState(NamedTuple):seen:SeenStatecaught:CaughtState@combined_store(PokedexState,seen=SeenStore,caught=CaughtStore)classPokedexStore(Store(PokedexState,PokedexAction)):passFrom here, we candispatchactions into the combinedPokedexStore, and the actions will be sent into theSeenStoreandCaughtStoresubstores, giving us a new combined state!>>>pokedex_store=PokedexStore()>>>pokedex_store.statePokedexState(seen=SeenState(names=frozenset()),caught=CaughtState(names=frozenset()))>>>>>>pokedex_store.dispatch(PokemonSeen("Squirtle"))PokedexState(seen=SeenState(names=frozenset({'Squirtle'})),caught=CaughtState(names=frozenset()))>>>>>>pokedex_store.dispatch(PokemonSeen("Charmander"))PokedexState(seen=SeenState(names=frozenset({'Charmander','Squirtle'})),caught=CaughtState(names=frozenset({'Charmander'})))Computed states and cachingWhen using a state store like Unicycle, it's usually a good idea to store the most fundamental state possible, and compute derived data on the fly.There's more than one way to compute derived state from your store. One easy option is to use functions:defget_all_known_pokemon(state:PokedexState)->FrozenSet[str]:returnstate.seen.names|state.caught.namesIf you're using aNamedTupleordataclassfor your states, another option is to add methods to your state objects to create these "selectors".classSeenState(NamedTuple):names:frozenset[str]=frozenset()@propertydefbulbasaur_is_seen(self)->bool:return"Bulbasaur"inself.namesSince the state objects are immutable, if you have expensive computed state, you can use tools likefunctools.cacheto cache these computed states.Caching like this can cause unintended performance issues and memory leaks(depending on the arguments a method may receive), so be careful, and only add caching after you've confirmed it'll actually improve performance.classSeenState(NamedTuple):names:frozenset[str]=frozenset()@property@functools.cachedefshasum(self)->str:# expensive calculation...Need to compute state from several substates? No problem! Add methods to a common ancestor of substate.classPokedexState(NamedTuple):seen:SeenStatecaught:CaughtStatedefpokemon_is_known(self,name:str)->bool:returnnameinself.seen.namesornameinself.caught.names