{"id":33196622,"url":"https://github.com/paysure/orinoco","last_synced_at":"2025-11-21T02:01:10.841Z","repository":{"id":44870922,"uuid":"345920159","full_name":"paysure/orinoco","owner":"paysure","description":"Functional composable pipelines allowing clean separation of the business logic and its implementation","archived":false,"fork":false,"pushed_at":"2024-05-28T12:52:17.000Z","size":2225,"stargazers_count":11,"open_issues_count":2,"forks_count":3,"subscribers_count":2,"default_branch":"master","last_synced_at":"2024-07-22T01:33:19.074Z","etag":null,"topics":["business-logic","business-process","fp","functional-programming","mypy","pipelines","python3","railway-oriented-programming","type-safety"],"latest_commit_sha":null,"homepage":"https://orinoco.rtfd.io","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/paysure.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2021-03-09T07:31:45.000Z","updated_at":"2024-05-24T13:20:47.000Z","dependencies_parsed_at":"2024-01-18T15:56:58.343Z","dependency_job_id":"1f255c0d-9ba0-41cb-b9b8-550e05b399c6","html_url":"https://github.com/paysure/orinoco","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/paysure/orinoco","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paysure%2Forinoco","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paysure%2Forinoco/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paysure%2Forinoco/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paysure%2Forinoco/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/paysure","download_url":"https://codeload.github.com/paysure/orinoco/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paysure%2Forinoco/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":285543732,"owners_count":27189594,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-11-21T02:00:06.175Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["business-logic","business-process","fp","functional-programming","mypy","pipelines","python3","railway-oriented-programming","type-safety"],"created_at":"2025-11-16T08:00:27.637Z","updated_at":"2025-11-21T02:01:10.826Z","avatar_url":"https://github.com/paysure.png","language":"Python","readme":"# orinoco\n\nFunctional composable pipelines allowing clean separation of the business logic and its implementation.\n\nFeatures\n\n* powerful chaining capabilities\n* separation of business logic from the implementation\n* functional approach\n* async support\n* PEP 561 compliant (checked with strict `mypy`)\n* complete set of logic operations\n* built-in execution measurement via observer pattern\n* typed data container with powerful lookup capabilities\n* easy to extend by user defined actions\n\nConsider pipeline like this:\n\n```\nParseUserName() \u003e\u003e GetUserDataFromDb() \u003e\u003e GetEmailTemplate() \u003e\u003e SendEmail()\n```\n\nEven without knowing how the implementation looks like or even what this library does, it's quite obvious what will \nhappen when the pipeline is executed. Main idea of this library is to operate with simple composable blocks \n(\"actions\") to build more complex pipelines.\n\n`orinoco` provides many action bases to simply define new actions. An example implementation of the first action from \nthe above example could look like this:\n\n```\nclass ParseUserName(TypedAction):\n    def __call__(self, payload: str) -\u003e str\n        return json.loads(payload)[\"user_name\"]\n```\n\nThe action above is using annotations to get the signature of the input and output data. However, we can be more \nexplicit:\n\n```\n\nclass ParseUserName(TypedAction):\n    CONFIG = ActionConfig(\n          INPUT=({\"payload\": Signature(key=\"payload\")}), OUTPUT=Signature(key=\"user_name\", type_=str)\n        )\n    def __call__(self, payload: str) -\u003e str\n        return json.loads(payload)[\"user_name\"]\n```\n\nWe don't have to limit ourselves to simple \"straightforward\" pipelines as the one above. The execution flow can be \ncontrolled or modified via several predefined actions. These actions allow to perform conditional execution, branching, \nloops, context managers, error handling etc. \n\n\n```\nSwitch()\n    .case(\n        If(ClaimInValidState(\"PAID\")),\n        Then(\n            ~ClaimHasPaymentAuthorizationAssigned(\n                fail_message=\"You cannot reset a claim {claim} that has payment authorizations assigned!\"\n            ),\n            StateToAuthorize(),\n            ResetEligibleAmount(),\n        ),\n    )\n    .case(\n        If(\n            ClaimInValidState(\"DECLINED\", \"CANCELLED\"),\n            ClaimHasPreAuth(),\n            ~ClaimHasValidNotification(),\n            ~ClaimIsRetroactive(),\n        ),\n        Then(UserCanResetClaim(), StateToPendingAuthorization()),\n    )\n\u003e\u003e GetClaimChangedNotificationMessage()\n\u003e\u003e NotifyUser()\n```\n\nSee the [docs](https://orinoco.rtfd.io) for more info.\n\n## Installation\n\nUse pypi to install the package:\n\n```\npip install orinoco\n```\n\n## Motivation\n\nPython is a very powerful programming language allowing developers to quickly transform their ideas into the code.\nAs you can imagine, this could be a double-edged sword. On one hand, it renders Python easy to use, on the other hand,\nlarger projects can get messy if the team is not well-disciplined. Moreover, if the problem domain is complex enough,\neven seasoned developers can struggle with producing maintainable and easily readable code.\n\n`orinoco` aims to help developers to express complex business rules in a more readable, understandable\nand maintainable fashion. Usual approach of implementing routines as a sequence of commands (e.g. querying a database,\ncommunicating with an external API) is replaced with pipelines composed from individual actions.\n\n### Example scenario\n\nLet's imagine an application authorizing payments, for instance the ones send by a payment terminal in a shop. The \nauthorisation logic is, understandably, based on various business rules. \n\nSuppose the card holder is also an insurance policy holder. Their card could be then used to cover\ntheir insurance claims. Ideally, we would like to authorise payments based not only on the details of the current \ntransaction, but also based on their insurance policy. An example implementation could look like this:\n\n```\nclass Api:\n    def __init__(self, parser, fraud_service, db_service, policies_matcher):\n        self.parser = parser\n        self.fraud_service = fraud_service\n        self.db_service = db_service\n        self.policies_matcher = policies_matcher\n\n    def payment_auth_endpoint(self.request):\n        payment_data = self.parser.parser(request)\n        \n        self.db_service.store_payment(payment_data)\n        \n        if self.fraud_service.is_it_fraud(payment_data):\n            return Response(json={\"authorization_decision\": False, \"reason\": \"Fraud payment\"})\n        \n        policy = self.policies_matcher.get_policy_for_payment(card_data)\n        \n        if not policy:\n            return Response(json={\"authorization_decision\": False, \"reason\": \"Not matching policy\"})\n            \n        funding_account = self.db_service.get_funding_account(policy[\"funding_account_id\"])\n        \n        if funding_account.amount \u003c payment_data[\"amount\"]:\n            return Response(json={\"authorization_decision\": False, \"reason\": \"Not enough money\"})\n            \n            \n        self.db_service.update_policy(policy, payment_data)\n        self.db_service.update_funding_account(funding_account, payment_data)\n        self.db_service.link_policy_to_payment(policy, payment_data)\n        \n        return Response(json={\"authorization_decision\": True})\n```\n\nIn this example we abstracted all we could into services and simple methods, we leveraged design patterns (such as \nexplicit dependency injection), but it still feels there is a lot going on in this method. In order to \nunderstand the ins and outs of the method, it's rather necessary to go through the code line by line.\n\nLet's look at an alternative version implemented using `orinoco`:\n\n```\nclass Api:\n    AUTH_ACTION = (\n        ParseData()\n        \u003e\u003e StorePayment()\n        \u003e\u003e IsFraud().if_then(GetDeniedFraudResponse().finish())\n        \u003e\u003e GetUserPolicy()\n        \u003e\u003e GetFundingAccount()\n        \u003e\u003e (~EnoughMoney()).if_then(GetNoMoneyResponse().finish())\n        \u003e\u003e UpdatePolicy()\n        \u003e\u003e UpdateFundingAccount()\n        \u003e\u003e UpdatePolicy()\n        \u003e\u003e GetAuthorizedResponse()\n    )\n    def payment_auth_endpoint(self, request):\n        return self.AUTH_ACTION.run_with_data(request=request).get_by_type(Response)\n```\n\nWe moved from the actual implementation of the process as a series of commands into an actual description of the \nbusiness process. This makes it readable even for people without any programming knowledge. \nWe can go even further and separate the pipeline into another file which will serve \nas a source of truth for our business processes.\n\n## Building blocks\n\n### Actions\nActions are main building blocks carrying the business logic and can be chained together. There are many predefined \nactions that can be used to build more complex pipelines such as actions for boolean logic and loops.\n\nActions can be created directly by inheriting from specialized bases (see subsections below). If there is no\nsuitable base for your use case, you can inherit from `orinoco.action.Action` too, but it's generally discouraged. In\nthe latter can you would proceed by overriding `run(action_data: ActionData) -\u003e ActionData` method.\n\nPipelines can be then executed by providing `ActionData` container directly to `run` method or \nby `run_with_data(**kwargs: Any)` method which will basically create the `ActionData` and pass it to the `run` method. \nFind more info about `ActionData` below.\n\n\n#### TypedAction\n\nThis is an enhanced `Action` that uses `ActionConfig` as the way to configure the input and the output.\n\nBusiness logic is defined in the call method with \"normal\" parameters as the input, this means that no raw\n`ActionData` is required. The propagation of the data from the `ActionData` to the method is done automatically\nbased on the `ActionConfig` which can be either passed directly to the initializer (see `config`) or as a class variable\n(see `CONFIG`) or it could be implicitly derived from annotations of the `__call__` method.\n\nThe result of the method is propagated to the `ActionData` with a matching signature. Note that implicit\nconfig will use only annotated return type for the signature, unless it's annotated by `typing.Annotated`, where \nthe arguments are: type, key, *tags. For more control, please define the `ActionConfig` manually.\n\nThe implicit approach:\n\n```\nclass SumValuesAndRound(TypedAction):\n    def __call__(self, x: float, y: float) -\u003e str:\n        return int(x + y)\n\nclass SumValuesAndRoundAnnotated(TypedAction):\n    def __call__(self, x: float, y: float) -\u003e Annotated[str, \"my_sum\", \"optional_tag1\", \"optional_tag2\"]:\n        return int(x + y)\n        \nassert 3 == SumValuesAndRound().run_with_data(x=1.2, y=1.8).get_by_type(int)\nassert 3 == SumValuesAndRoundAnnotated().run_with_data(x=1.2, y=1.8).get(\"my_sum\")\nassert 3 == SumValuesAndRoundAnnotated().run_with_data(x=1.2, y=1.8).get_by_tag(\"optional_tag1\")\nassert 3 == SumValuesAndRoundAnnotated().run_with_data(x=1.2, y=1.8).get_by_tag(\"optional_tag1\", \"optional_tag1\")\n```\n\nExplicit approach:\n\n```\nclass SumValuesAndRound(TypedAction):\n    CONFIG = ActionConfig(\n          INPUT=({\"x\": Signature(key=\"x\"), \"y\": Signature(key=\"y\")}), OUTPUT=Signature(key=\"sum_result\", type_=int)\n        )\n    def __call__(self, x: float, y: float) -\u003e int:\n        return int(x + y)\nresult: ActionData = SumValuesAndRound().run_with_data(x=1.2, y=1.8)\nassert 3 == result.get_by_type(int) == result.get(\"sum_result\")\n```\n\nNotice there are more possibilities how to retrieve the values from the `ActionData` since it's explicitly \nannotated. See the next section for more information about `ActionData` container. In this \"mode\" the type annotations \nare optional.\n\n```\nassert 5 == SumValuesAndRound()(x=1.9, y=3.1)\n```\n\n##### Default values\n\n```\nclass SumValuesAndRound(TypedAction):\n    def __call__(self, x: float, y: float = 1.0) -\u003e str:\n        return int(x + y)\n\nassert 2.2 == SumValuesAndRound().run_with_data(x=1.2)\n```\n\n##### Retry\n`TypedAction` and `TypedCondition` can be configured to retry the execution of the action if they fail.\n\n```\nmy_typed_condition.retry_until_not_fails(max_retries=3, retry_delay=0.5) \u003e\u003e other_actions\nmy_typed_condition.retry_until_not_fails(exception_cls_to_catch=ValueError, max_retries=3, retry_delay=0.5) \u003e\u003e other_actions\nmy_typed_condition.retry_until(retry_delay=0.001, max_retries=20) \u003e\u003e other_actions\n\nmy_typed_action.retry_until_equals(5, retry_delay=0.001, max_retries=5)\nmy_typed_action.retry_until_contains(\"some_sub_string\", retry_delay=0.001, max_retries=10)\nmy_typed_action.retry_until_contains(\"some_item_in_list\", retry_delay=0.001, max_retries=10)\n```\n\n##### Changing output signature\nSometimes it's necessary to change the output signature of the actions \"on the fly\". This can be done by using \n`output_as` method implemented on `TypedAction` and `TypedCondition`.\n\n```\nmy_typed_action.output_as(key=\"x_doubled\", type_=MyNumber)\nmy_typed_action.output_as(key=\"different_key\")\n```\n\n##### Changing input signature\n\n```\nclass MyAction(TypedAction):\n    def __call__(self, x: int) -\u003e int:\n        ...\n        \nMyAction().input_as(x=\"different_input\").run_with_data(different_input=3)\n```\n\n#### OnActionDataSubField\nUtility action that executes an action on values of a nested item. Consider this example -- we got his nested data:\n\n```\naction_data = ActionData.create(user_info={\"address\": {\"city\": \"London\"}})\n```\nAction executed on the \"city\" field:\n\n```\nclass GetPopulation(TypedAction):\n    ...\n    def __call__(self, city: str) -\u003e int:\n        ...\n```\n\nYou might be wondering - how can we run this action with the data above, since we expect `city` to be present in the \n`ActionData` container (not hidden in the `user_info.address`)? We can either define a new action that would retrieve \ndata manually, or we can use `OnActionDataSubField` to do the trick for us:\n\n```\nOnActionDataSubField(GetPopulation(), \"user_info.address\").run(action_data).get(\"user_info.address.population\")\n```\n\nAll actions have `on_subfield` method which is a shortcut for the `OnActionDataSubField`:\n\n```\nGetPopulation().on_subfield(\"user_info.address\").run(action_data).get(\"user_info.address.population\")\n```\n\n#### Return\n\nAction that flags the end of the execution path. In other words if the execution \npath reaches this action, none of the following actions will be executed. \n\n```\nCondition1().if_then(Return(Action1())) \u003e\u003e Action2()\n```\n\n```\nCondition1().if_then(Action1() \u003e\u003e Return()) \u003e\u003e Action2()\n```\n\n```\nCondition1().if_then(Action1().finish()) \u003e\u003e Action2()\n```\nIn all of these 3 examples the `Action2` is not executed.\n\n#### HandledExceptions\n\nIt's not uncommon that during the execution various actions at various places might cause failures. \n`HandledExceptions` action brings control of these actions (that can fail) and allow us to specify how the failure \nwould be handled.\n\n```\nclass FailingForNonAdmin(Event):\n    def run_side_effect(self, action_data: ActionData) -\u003e None:\n        if action_data.get(\"user\") != \"admin\":\n            raise ValueError()\n\naction = HandledExceptions(\n   FailingForNonAdmin(),\n   catch_exceptions=ValueError,\n   handle_method=lambda error, action_data: send_alert(action_data.get(\"user\")),\n   fail_on_error=False,\n    )\n```\n\n#### AddActionValue\n\nAction that adds static data into the `ActionData` container.\n\n```\nassert 1 == AddActionValue(key=\"my_number\", value=lambda: 1).run_with_data().get(\"my_number\")\n```\n\n`value` can also be callable `Callable[[], Any]`.\n\n\n#### AddVirtualKeyShortcut\n\nSometimes it can be hard to plug more pipelines together, because they use different keys for same inputs. The \nrecommended way how to handle this situation is to use `AddVirtualKeyShortcut` to basically create a \"symbolic link\" \nbetween a key and a data already registered in `ActionData`. This way you can access the same original data\nwith both old and new keys.\n\n```\naction_data = AddVirtualKeyShortcut(key=\"color\", source_key=\"my_favorite_color\").run_with_data(my_favorite_color=\"pink\")\nassert action_data.get(\"my_favorite_color\") == action_data.get(\"color\") == \"pink\n```\n\n#### RenameActionField\n\nThis changes the key of an item in the `ActionData`.\n\n```\naction_data = RenameActionField(key=\"old\", new_key=\"new\").run_with_data(old=1)\nassert action_data.is_in(\"new\")\nassert not action_data.is_in(\"old\")\n```\n\n#### WithoutFields\n\nThis deletes items with matching keys.\n\n```\naction_data == WithoutFields(\"a\", \"b\").run_with_data(a=1, c=3)\nassert not action_data.is_in(\"a\")\nassert not action_data.is_in(\"b\")\nassert action_data.is_in(\"c\")\n```\n\n#### ActionSet\n\nCollection of actions. This action is used internally when actions are chained.\n\n```\nAction1() \u003e\u003e Action2() \u003e\u003e Action3()\n```\n\nis equal to\n\n```\nActionSet([Action1(), Action2(), Action3()])\n```\n\n##### GuardedActionSet\nIt can be generated from the `ActionSet` by using `as_guarded` method. This is useful when you want to ensure that \nthe action set will use only specified fields and \"return\" (=propagate further) only desired fields.\n\n```\nActionSet([Action1(), Action2(), Action3()]).as_guarded()\n    .with_inputs(\"a\", \"b\", new_c_name=\"old_c_name\"),\n    .with_outputs(\"c\", \"d\", new_e_name=\"old_e_name\", new_f_name=\"old_f_name\"),\n).run_with_data(a=1, b=2, old_c_name=3, will_be_ignored=4)\n```\n\nResult action data will contain only \"c\", \"d\", \"new_e_name\" and \"new_f_name\" keys.\n\n\n##### EventSet\n\nSpecial type of isolated action set -- none of the `ActionData` modification is propagated further to the pipeline. \nThe motivation of this class was adopting more functional approach to the parts of the execution chain.\n\n```\naction = IncrementNumber() \u003e\u003e EventSet([DoubleNumber() \u003e\u003e PrintNumber()] \u003e\u003e IncrementNumber())\n\nassert 12 == action.run_with_data(number=10).get(\"number\")\n# But `22` (`11 * 2`) is printed by `PrintNumber`\n```\n\n\n#### AtomicActionSet and AsyncAtomicActionSet\n\nAction set which run its actions in a context manager.\n\nWe could, for instance, wrap `ActionSet` within a DB transaction:\n\n```\nAtomicActionSet(actions=[GetModelData(), CreateModel()], atomic_context_manager=transaction.atomic)\n```\n\n#### Generic actions\n\nGeneric actions are used to dynamically define actions, usually via lambda functions. There 4 types of them:\n\n##### GenericDataSource\n\nThis adds a value (`provides`) resolved by `method`.\n\n```\n GenericDataSource(provides=\"next_week\", method=lambda ad: 7 + ad.get(\"today\"))\n```\n\n##### GenericTransformation\n\nThis creates a new version of `ActionData`.\n\n```\nGenericTransformation(lambda ad: ad.evolve(counter=ad.get(\"counter\") + 1))\n```\n\n##### GenericEvent\n\nThis action runs a side effect while nothing is returned to the `ActionData`. \n\n```\nGenericEvent(lambda ad: event_handler.report(ad.get(\"alert\")))\n```\n\n##### GenericCondition\n\nThis is a condition (see `Condition` section below) that returns a boolean value.\n\n```\nGenericCondition(lambda ad: ad.get(\"user_name\") == \"Alfred\")\n```\n\n### Conditions\n\nThis class allows us to check pre-conditions during the pipeline execution. Also, it can be used to support branching \nlogic.\n\nThe result of the `Condition` action is not propagated further into the data container, hence it's used only for the \nvalidation purposes. Essentially, you should provide a validation routine (via `_is_valid()`) that returns a boolean \nvalue - if the value returned is `True`, the pipeline continues uninterrupted, whereas when the value is `False` \nan exception is raised.\n\n\nThis is how we would check an `x` in the `ActionData` container is non-negative:\n\n```\nclass IsPositive(Condition):\n    ERROR_CLS = ValueError\n    \n    def _is_valid(self, x: float) -\u003e bool:\n        return x \u003e= 0\n```\n\nIf the condition holds, nothing happens. If the condition is not fulfilled, an exception `ValueError` is raised.\n\nAs mentioned above, `Condition` can be used for branching logic too - \nsee conditional actions such `Switch`, `If` or `ConditionalAction` below.\n\n### Custom fail message\nFail messages can be customized  or by setting the `fail_message` attribute, specifying `FAIL_MESSAGE` class attribute \nor by overriding the `fail` method.\n\nFail messages support formatted keyword strings where the values are injected from the `ActionData` container.\n\n```\nclass IsPositive(Condition):\n    FAIL_MESSAGE = \"{x} is not positive\"\n    \n    def _is_valid(self, x: float) -\u003e bool:\n        return x \u003e= 0\n```\n\n\n#### Operators\n\n* You can use `|` as `OR` operator between two `Condition` instances.\n* Analogically you can use `\u0026` as `AND` operator.\n* Negation could be achieved with the use of tilde (`~`).\n\n```\n~((always_true \u0026 always_true) | always_true)\n```\n\n#### Branching\n\nClass `Switch` provides a branching support. It can be used in three ways:\n\n1) Provide all `Condition` classes via the initializer (i.e. `__init__` method)\n2) Use `if_then` convenience method: `Switch().if_then(cond1, action1).if_then(cond2, action2).otherwise(action3)`\n3) Use `Switch.case` with `If` and `Then` actions:\n\n```\n Switch()\n    .case(\n        If(ClaimInValidState(\"PAID\")),\n        Then(\n            ~ClaimHasPaymentAuthorizationAssigned(\n                fail_message=\"You cannot reset a claim that has payment authorizations assigned!\"\n            ),\n            StateToAuthorize(),\n            ResetEligibleAmount(),\n        ),\n    )\n    .case(\n        If(\n            ClaimInValidState(\"DECLINED\", \"CANCELLED\"),\n            ClaimHasPreAuth(),\n            ~ClaimHasValidNotification(),\n            ~ClaimIsRetroactive(),\n        ),\n        Then(UserCanResetClaim(), StateToPendingAuthorization()),\n    ).otherwise(SendNotificationAboutInvalidClaim())\n```\n\n\n#### Conditional actions\n\nA convenience method `if_then` is also present on all `Condition` instances, therefore you can use it to chain \nconditions:\n\n```\nis_positive.if_then(DoSomething()) \u003e\u003e DoThisAlways()\n```\n\n\n#### TypedCondition\n\nTyped conditions are defined in the same fashion as the `TypedAction`, but they should return `bool` value which is \nused for validation. Please note that you should implement `__call__` instead of `_is_valid` \n(as is case with `Condition`).\n\n```\nclass IsPositive(TypedCondition):\n    def __call__(self, value: float) -\u003e bool:\n        return value \u003e= 0\n```\n\nNote that explicit definition can be used as well (see `TypedAction`).\n\n#### Convenience classes\n\n* Use `NonNoneDataValues` to check for the existence/non-null values within `ActionData`.\n* Use `PropertyCondition` to check properties of objects.\n\n### ActionData\n\nThis is the main data container passed between actions. It also holds metadata about the pipeline execution.\n\n#### Pattern matching\n\n`ActionData` can be pattern-matched by various ways to retrieve data.\n\nData in `ActionData` are described by the `Signature` which represents \"metadata\" of the data. Basically it says:\n- `type_` - Type of the object\n- `key` - Key of the object\n- `tags` - Custom tags of the object\n\nFor example:\n\n```\nActionData(\n    [\n        (Signature(type_=Card, tags={\"new\"}), card1),\n        (Signature(type_=Card, tags={\"old\", \"to-remove\"}), card2),\n        (Signature(key=\"my_value\", type_str), \"Hello\"),\n    ]\n)\n```\n\n#### Get by name\n\nGet data by name:\n\n```\ndata = action_data.get(\"claim\")\n```\n\nIt's the same as `action_data.get_by_signature(Signature(name=\"claim\"))`\n\n##### Nested dictionary lookup\n\nValues from dictionary data can be retrieved by \"dot lookups\":\n\n```\nassert \"chrome\" == ActionData.create(request={\"payload\": {\"meta\": {\"browser\": \"chrome\"}}}).get(\n        \"request.payload.meta.browser\"\n    )\n```\n\n#### Get by type\n\nGet data by data type:\n\n```\ndata = action_data.get_by_type(Claim)\n```\n\nIt's the same as `action_data.get_by_signature(Signature(type_=Claim))`\n\n#### Get by signature\n\nGet data with an exact signature:\n\n```\ndata = action_data.get_by_signature(Signature(type_=Card))\n```\n\nIt raises `SearchError` if there is not exactly one matching entry. Moreover `get_with_signature` can \nbe used to retrieve matched signature along with the data:\n\n```\nsignature, data = action_data.get_with_signature(Signature(type_=Card))\n```\n\n#### Find by signature\n\nGet all data which match the signature:\n\n```\ndata_list = action_data.find(Signature(type_=Card))\n```\n\nAlternatively `find_one` can be used to retrieve single entry (otherwise `SearchError` will be raised). \nDifference from `get_by_signature` is that the signature matching is not exact. For example sets aren't compared, \nbut rather subsets:\n\n```\nActionData(\n    [\n        (Signature(type_=str, tags={\"tag1\", \"tag2\"}), \"a\"),\n    ]\n).get_by_signature(Signature(type_=str, tags={\"tag1\"}))\n```\n\nExpression above would raise `SearchError`, because the tag is not exactly the same. In order to find this particular \nentry the search signature would have to be `Signature(type_=str, tags={\"tag1\", \"tag2\"})`.\n\n```\nActionData(\n        [\n            (Signature(type_=str, tags={\"tag1\", \"tag2\"}), \"a\"),\n        ]\n    ).find_one(Signature(type_=str, tags={\"tag1\"}))\n```\n\nThis expression would find the data as expected.\n\n#### Observers\n\nThere is a mechanism that allows developers to run a code every time an individual action is started and to run\na (possibly different) code when each action is finished. This follow the _Observer_ design pattern.\n\nIn order to implement such observers, inherit from `Observer` class. Such classes should be then passed to\nthe initializer of `ActionData` container. The container is then responsible for passing data to your observer\nduring the execution.\n\nNote: If you don't provide any custom observers, two default observers are used instead.\n\nObservers can be accessed directly by looking them up in `ActionData.observers` list or via \n`ActionData.get_observer(observer_cls: Type[ObserverT]) -\u003e ObserverT`. Note that if more observers of on type exists \nthis method will raise an `FoundMoreThanOne` exception.\n\n##### ActionsLog observer\n\nIt records starts and ends of all actions into the log.\n\n```\nassert action_data.get_observer(ActionsLog).actions_log == [\n        \"ActionSet_start\",\n        \"DoSomething_start\",\n        \"DoSomethingElse_start\",\n        \"DoSomethingElse_end\",\n        \"DoSomething_end\",\n        \"ActionSet_end\",\n    ]\n```\n\n##### ExecutionTimeObserver observer\n\nIt records the execution times.\n\n```\nassert action_data.get_observer(ExecutionTimeObserver).measurements == [\n    (\"DoSomething\", 0.012)\n    (\"DoSomethingElse\", 0.028)\n   ]\n```\n\n##### Custom observers\n\nNew observers can be implemented by inheriting `orinoco.observers.Observer`. Simple logger for condition actions can look \nlike this:\n\n```\nclass ConditionsLogger(Observer):\n\n    def __init__(self, logger: BoundLogger):\n        self.logger = logger\n        \n    # This method decides whether an action should be recorded via `record_start` and `record_end`\n    def should_record_action(self, action) -\u003e bool:\n        return isinstance(action, Condition)\n\n    def record_start(self, action):\n        self.logger.info(\"Condition evaluation started\", action=action)\n\n    def record_end(self, action):\n        self.logger.info(\"Condition evaluation ended\", action=action)\n        \naction_data = ActionData(observers=[ConditionsLogger()])\n```\n\n#### Exporting to a dict\n\n`ActionData` can be exported to a dictionary `Dict[str, Any]` via `action_data.as_keyed_dict()`. Note that's done only \nfor signatures which have `key` set, since they are used as dictionary keys.\n\n\n#### Reusing action data\n\nResult `ActionData` returned by the actions pipeline after the execution can be used again as an input to a pipeline. \nThe only issues is that it also carries information about the execution from the previous run, so the new records \nwould be appended to the old ones. However, this doesn't have to be desirable in all cases. In this case, use \n`ActionData.with_new_execution_meta` method to create a new version of action data without the history of previous \nexecutions.\n\n#### Immutability\n\nAll methods that change action data always return the copy of such data instead of just modifying it. This is, \nfor example, done after the execution of each action. One of the many implications of this fact is that the input of the\naction won't be changed in any way.\n\n### Chaining actions\n\nAll actions implement `then` method for chaining. You can also use `rshift` operator shortcut:\n\n```\nParsePayload() \u003e\u003e ExtractUserEmail() \u003e\u003e SendEmail() \n```\n\nThe other way to build pipelines is to use `orinoco.action.ActionSet`:\n\n```\nActionSet([ParsePayload(), ExtractUserEmail(), SendEmail()])\n```\n\n### Loops\n\n#### For\n\nIf you need to loop over any `Iterable` within `ActionData` and you want to apply any `Actions` to every single element\nof such iterable, use `For` class.\n\n\n```\nclass DoubleValue(ActionType):\n    def __call__(self, x: int) -\u003e Annotated[int, \"doubled\"]:\n        return x * 2\n\nassert For(\n    \"x\", lambda ad: ad.get(\"values\"), aggregated_field=\"doubled\", aggregated_field_new_name=\"doubled_list\"\n).do(DoubleValue()).run_with_data(values=[10, 40, 60]).get(\"doubled_list\") == [20, 80, 120]\n```\n\n`For` parameters :\n- iterating_key: Key which will be propagated into the `ActionData` with the new value\n- method: Method which returns the iterable to iterate over\n- aggregated_field: Name of the field which will be extracted from the `ActionData` and aggregated\n        (appended to the list)\n- aggregated_field_new_name: Name of the field which will be used for the aggregated field\n- skip_none_for_aggregated_field: If `True` then `None` values won't be added to the aggregated field\n\n#### LoopCondition\n\nImplementation of any (`AnyCondition`) and all (`AllCondition`) conditions.\n\n```\nany_action = AnyCondition(\n    iterable_key=\"numbers\", as_key=\"number\", condition=GenericCondition(lambda ad: ad.get(\"number\") \u003e= 0)\n)\nany_action.run_with_data(numbers=[-1, -2, 10])\n```\n\n\n### Async support\n\nAll `Action` pipelines can be executed synchronously or asynchronously by invoking dedicatted methods. Main async \nexecution methods are `async_run(action_data: ActionData)` and `async_run_with_data(**kwargs: Any)`. \n\n```\nclass SendEmail(Action)\n    sending_service = ...\n    \n    async def async_run(self, action_data: ActionData) -\u003e ActionData:\n        await self.sending_service.send(action_data.get(\"email\")) \n        return action_data\n        \ncoroutine = SendEmail().async_run_with_data(email=...)\n\n# Or you can use any other async framework\nasyncio.get_event_loop().run_until_complete(coroutine)\n```\n\n#### AsyncTypedAction\n\nThis is a specialized variation of `TypedAction` that uses `ActionConfig` for managing data in `ActionData`.\n\n```\nclass AsyncIntSum(AsyncTypedAction):\n    async def __call__(self, x: float, y: float) -\u003e int:\n        return int(x + y)\n```\n\n#### Combining sync and async actions\n\nExecuting sync actions asynchronously is supported by default. On the other hand, execution of async actions \nsynchronously has to be implemented explicitly by implementing both `async_run` and `run` methods:\n\n```\nclass SleepOneSecondAction(Action):\n    async def async_run(self, action_data: ActionData) -\u003e ActionData:\n        await asyncio.sleep(1)\n        return action_data\n        \n    def run(self, action_data: ActionData) -\u003e ActionData:\n        time.sleep(1)\n        return action_data\n```\n\n`AsyncTypedAction` has `SYNC_ACTION: Optional[Type[TypedAction]]` attribute to provide sync version of the action:\n\n```\nclass AsyncIntSum(AsyncTypedAction):\n    SYNC_ACTION = SyncIntSum\n\n    async def __call__(self, x: float, y: float) -\u003e int:\n        return int(x + y)\n```\n\n### Other action bases\n\n`TypedAction` and `AsyncTypedAction` are recommended action bases which should do just fine for most cases. \nHowever, sometimes it's necessary to do more low-level operations and work at `ActionData -\u003e ActionData` level. \nIt's not generally recommended to inherit from `Action` directly (see the section below for more details), use one of\nthe following bases instead.\n\n#### Transformation\n\nThis is an `Action` subtype. Interestingly, its functionality does not differ from its parent class, but it\nserves the purpose of clearly describing the nature of the action (i.e. transforming data), thus the only difference \nis that the method to implement is called `transform`.\n\n```\nclass RunTwoActions(Transformation):\n\n    def __init__(self, action1: ActionT, action2: ActionT):\n        super().__init__()\n        self.action1 = action1\n        self.action2 = action2\n\n    def transform(self, action_data: ActionDataT) -\u003e ActionDataT:\n        resutl1 = self.action1.run(action_data)\n        # do something meaningful\n        ...\n        \n        result2 = self.action1.run(resutl1)\n        \n        # do something meaningful\n        ...\n        \n        return result2\n```\n\nValues in `ActionData` should be \"modified\" (well it's an immutable container, so it returns a copy of itself with \nthe new values) via `ActionData.evolve` to add a value without a full signature (using a key only) or \n`ActionData.register` to add a value with a full signature.\n\n```\nclass IncrementByRandomNumbers(Transformation):\n    def transform(self, action_data: ActionDataT) -\u003e ActionDataT:\n        my_number = action_data.get(\"my_number\")\n        return action_data.evolve(random_number1=my_number + random()).register(\n            Signature(key=\"random_number2\", type_=float, tags={\"random\", \"zero-to-one\"}),\n            my_number + random(),\n        )\n        \nresult_action_data = AddRandomNumbers().run_with_data()\nresult_action_data.get(\"random_number1)\nresult_action_data.get(\"random_number2)\nresult_action_data.find_one(Signature(tags={\"random\"}))\n```\n\nIn order to create an async version, just implement `async_transform` method.\n\n#### DataSource\n\nThis is an action that adds a value to the `ActionData`. You should just return the plain value (no fiddling around\nwith the container). The action will then add the return value to the container automatically. Nevertheless,\nwe need to specify what should be the _key_ used when adding the value to the container. That's why this class\nhas to specify `PROVIDES: str` class attribute. \n\n```\nclass IncrementByRandomNumber(DataSource):\n    PROVIDES = \"random_number\"\n   \n    def get_data(self, action_data: ActionDataT) -\u003e Any:\n        my_number = action_data.get(\"my_number\")\n        return my_number + random()\n```\n\nIn order to create an async version, just implement `async_get_data` method.\n\n#### Event\n\nThis is an action that does not return anything (nothing from this action is propagated to the action data). It's usually \nused to isolate side effects from the rest of the pipeline. Since nothing is returned back to the `ActionData`, events \ncan be executed in parallel in a separate thread when running the async version of the pipeline (see \"Async support\" \nsection above). This is done by default and can be turned off via `async_blocking` parameter of the `Event`.\n\n\n```\nclass SendEmail(Event):\n    \n    def __init__(self, email_service: EmailService):\n        super().__init__()\n        self.email_service = email_service\n        \n    def run_side_effect(self, action_data: ActionDataT) -\u003e None:\n         self.email_service.send(recepient=action_data.get_by_type(User), message=action_data.get_by_type(EmailMessage))\n        \n```\n\nIn order to create an async version, just implement `async_run_side_effect` method.\n\n#### Inheriting Action directly\n\nThis is not recommended and using other action bases should be just fine -- basically the same capabilities has \n`Transformation` class since it's also operating on `ActionData -\u003e ActionData` level. However, if for some reason it's \nnecessary to do that, there are a few things needed to keep in mind.\n\nThe most important one is to implement \"skipping logic\". Current implementation is using `ActionData.skip_processing` \nflag to determine whether the action data should be processed (see `Return` action). So don't forget to add \nsomething like this in your code (especially when using `Return` action in the pipeline):\n\n```\n@record_action\n@verbose_action_exception\ndef run(self, action_data: ActionDataT) -\u003e ActionDataT:\n    if action_data.skip_processing:\n        return action_data\n    \n    # normal implementation of the action\n    ...\n```\n\nSecond thing is to add `record_action` (for `run`) or `async_record_action` (for `async_run`) decorators for the run \nmethod. These decorators are important for recording the executed actions to observers. \n\nLastly, `verbose_action_exception` (for `run`) or `async_verbose_action_exception` (for `async_run`) should be added \nas well to prettify errors.","funding_links":[],"categories":["Python","Awesome Functional Python"],"sub_categories":["Libraries"],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaysure%2Forinoco","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpaysure%2Forinoco","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaysure%2Forinoco/lists"}