Package: pyservice
pyserviceAlight-serviceinfluenced project in Python.IntroAre you tired of 500 lines long Python code with conditionals, iterators, and function calls? Testing this logic is close to impossible, and with the lack of testing, you don't dare to touch it.All complex logic can be decomposed into small functions, invoked sequentially. The functions should protect themselves from execution if a previous failure occurs, a more elegant solution exists:Railway-Oriented Programming.Let's see how that looks withpyservice. There are two functions, one that adds 2 to the initial number, and one that adds 3. The data is carried over between the functions in an extended dictionary we callContext, just like how a conveyor belt would be used in an assembly line:frompyserviceimportaction,Context,Organizer@action()defadd_two(ctx:Context)->Context:number=ctx.get("n",0)ctx["result"]=number+2returnctx@action()defadd_three(ctx:Context)->Context:result=ctx["result"]ctx["result"]=result+3returnctxdeftest_can_run_functions():ctx=Context.make({"n":4})organizer=Organizer([add_two,add_three])result_ctx=organizer.run(ctx)assertctx.is_successassertresult_ctx["result"]==9TheContextis an extended dictionary, it stores failure and success states in it besides its key-value pairs. This is the "state" that is carried between the actions by theOrganizer. All Organizers expose arunfunction that is responsible for executing the provided actions in order.This is the happy path, but what happens when there is a failure between the two functions? I add afail_contextfunction that will fail the context with a message:@action()deffail_context(ctx:Context)->Context:ctx.fail("I don't like what I see here")returnctxThe context will be in a failure state and only the first action will be executed as processing stops after the second action (4+2=6):deftest_can_run_functions_with_failure():ctx=Context.make({"n":4})organizer=Organizer([add_two,fail_context,add_three])result_ctx=organizer.run(ctx)assertctx.is_failureassertresult_ctx["result"]==6Look at the actions, no conditional logic was added to them, the function wrapper protects the action from execution once it's in a failure state.You can find these exampleshere.But there is more to it!Expects and PromisesYou can define contracts for the actions with theexpectsandpromiseslist of keys like this:@action(expects=["n"],promises=["result"])defadd_two(ctx:Context)->Context:number=ctx.get("n",0)ctx["result"]=number+2returnctx@action(expects=["result"])defadd_three(ctx:Context)->Context:result=ctx["result"]ctx["result"]=result+3returnctxTheactionwill verify - before it's invoked - that the expected keys are in theContexthash. If there are any missing,ExpectedKeyNotFoundErrorwill be thrown and all of the missing keys will be listed in the exception message. Similarly,PromisedKeyNotFoundErroris raised when the action fails to provide a value with the defined promised keys.You can find the relevant exampleshere.RollbackOne of your actions might fail while they have logic that permanently changes state in a data store or in an API resource. A trivial example is charging your customer while you can't complete the order. When that happens, you can leveragepyservice'srollbackfunctionality like this:defadd_two_rollback(ctx:Context)->Context:ctx["result"]-=2returnctx@action(expects=["n"],promises=["result"],rollback=add_two_rollback)defadd_two(ctx:Context)->Context:number=ctx.get("n",0)ctx["result"]=number+2returnctx@action()deffail_context(ctx:Context)->Context:ctx.fail("I don't like what I see here")raiseOrganizer.ContextFailed(fail_context)The action accepts a function reference to roll back its state changes when aContextfails. The rollback field is optional, nothing happens when you don't provide one.Take a look atthisbasic example.