├── .github └── CODEOWNERS ├── .gitignore ├── LICENSE ├── README.md ├── activities.py ├── banking_service.py ├── run_worker.py ├── run_workflow.py ├── shared.py ├── tests ├── __init__.py └── test_run_worker.py ├── workflows.py └── yarn.lock /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This repository is maintained by the Temporal Education team 2 | @temporalio/education 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | env 3 | __pycache__ 4 | .mypy_cache 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 temporal.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Temporal Money Transfer Python Project Template 2 | 3 | This is the code to support the tutorial at https://learn.temporal.io/getting_started/python/first_program_in_python/ 4 | 5 | To use this code, make sure you have a [Temporal Cluster running](https://docs.temporal.io/docs/server/quick-install/) first. 6 | 7 | 8 | Clone this repo and run this application. 9 | 10 | ```bash 11 | git clone https://github.com/temporalio/money-transfer-project-template-python 12 | cd money-transfer-project-template-python 13 | ``` 14 | 15 | Create a virtual environment and activate it. On macOS and Linux, run these commands: 16 | 17 | ``` 18 | python3 -m venv env 19 | source env/bin/activate 20 | ``` 21 | 22 | On Windows, run these commands: 23 | 24 | ``` 25 | python -m venv env 26 | env\Scripts\activate 27 | ``` 28 | 29 | With the virtual environment configured, install the Temporal SDK: 30 | 31 | ``` 32 | python -m pip install temporalio 33 | ``` 34 | 35 | 36 | Run the workflow: 37 | 38 | ```bash 39 | python run_workflow.py 40 | ``` 41 | 42 | In another window, activate the virtual environment: 43 | 44 | On macOS or Linux, run this command: 45 | 46 | ``` 47 | source env/bin/activate 48 | ``` 49 | 50 | On Windows, run this command: 51 | 52 | ``` 53 | env\Scripts\activate 54 | ``` 55 | 56 | 57 | Then run the worker: 58 | 59 | 60 | ```bash 61 | python run_worker.py 62 | ``` 63 | 64 | Please [read the tutorial](https://learn.temporal.io/getting_started/python/first_program_in_python/) for more details. 65 | -------------------------------------------------------------------------------- /activities.py: -------------------------------------------------------------------------------- 1 | # @@@SNIPSTART python-money-transfer-project-template-withdraw 2 | import asyncio 3 | 4 | from temporalio import activity 5 | 6 | from banking_service import BankingService, InvalidAccountError 7 | from shared import PaymentDetails 8 | 9 | 10 | class BankingActivities: 11 | def __init__(self): 12 | self.bank = BankingService("bank-api.example.com") 13 | 14 | @activity.defn 15 | async def withdraw(self, data: PaymentDetails) -> str: 16 | reference_id = f"{data.reference_id}-withdrawal" 17 | try: 18 | confirmation = await asyncio.to_thread( 19 | self.bank.withdraw, data.source_account, data.amount, reference_id 20 | ) 21 | return confirmation 22 | except InvalidAccountError: 23 | raise 24 | except Exception: 25 | activity.logger.exception("Withdrawal failed") 26 | raise 27 | 28 | # @@@SNIPEND 29 | # @@@SNIPSTART python-money-transfer-project-template-deposit 30 | @activity.defn 31 | async def deposit(self, data: PaymentDetails) -> str: 32 | reference_id = f"{data.reference_id}-deposit" 33 | try: 34 | confirmation = await asyncio.to_thread( 35 | self.bank.deposit, data.target_account, data.amount, reference_id 36 | ) 37 | """ 38 | confirmation = await asyncio.to_thread( 39 | self.bank.deposit_that_fails, 40 | data.target_account, 41 | data.amount, 42 | reference_id, 43 | ) 44 | """ 45 | return confirmation 46 | except InvalidAccountError: 47 | raise 48 | except Exception: 49 | activity.logger.exception("Deposit failed") 50 | raise 51 | 52 | # @@@SNIPEND 53 | 54 | # @@@SNIPSTART python-money-transfer-project-template-refund 55 | @activity.defn 56 | async def refund(self, data: PaymentDetails) -> str: 57 | reference_id = f"{data.reference_id}-refund" 58 | try: 59 | confirmation = await asyncio.to_thread( 60 | self.bank.deposit, data.source_account, data.amount, reference_id 61 | ) 62 | return confirmation 63 | except InvalidAccountError: 64 | raise 65 | except Exception: 66 | activity.logger.exception("Refund failed") 67 | raise 68 | 69 | # @@@SNIPEND 70 | -------------------------------------------------------------------------------- /banking_service.py: -------------------------------------------------------------------------------- 1 | """ This code simulates a client for a hypothetical banking service. 2 | It supports both withdrawals and deposits, and generates a random transaction ID for each request. 3 | 4 | Tip: You can modify these functions to introduce delays or errors, allowing 5 | you to experiment with failures and timeouts. 6 | """ 7 | import uuid 8 | from dataclasses import dataclass 9 | from typing import NoReturn 10 | 11 | 12 | @dataclass 13 | class InsufficientFundsError(Exception): 14 | """Exception for handling insufficient funds. 15 | 16 | Attributes: 17 | message: The message to display. 18 | 19 | Args: 20 | message: The message to display. 21 | 22 | """ 23 | 24 | def __init__(self, message) -> None: 25 | self.message: str = message 26 | super().__init__(self.message) 27 | 28 | 29 | @dataclass 30 | class InvalidAccountError(Exception): 31 | """Exception for invalid account numbers. 32 | 33 | Attributes: 34 | message: The message to display. 35 | 36 | Args: 37 | message: The message to display. 38 | 39 | """ 40 | 41 | def __init__(self, message) -> None: 42 | self.message: str = message 43 | super().__init__(self.message) 44 | 45 | 46 | @dataclass 47 | class Account: 48 | """A class representing a bank account. 49 | 50 | Attributes: 51 | account_number: The account number for the account. 52 | balance: The balance of the account. 53 | 54 | Args: 55 | account_number: The account number for the account. 56 | balance: The balance of the account. 57 | """ 58 | 59 | def __init__(self, account_number: str, balance: int) -> None: 60 | self.account_number: str = account_number 61 | self.balance: int = balance 62 | 63 | 64 | @dataclass 65 | class Bank: 66 | """ 67 | A Bank with a list of accounts. 68 | 69 | The Bank class provides methods for finding an account with a given account number. 70 | 71 | Attributes: 72 | accounts: A list of Account objects representing the bank's accounts. 73 | """ 74 | 75 | def __init__(self, accounts: list[Account]) -> None: 76 | self.accounts: list[Account] = accounts 77 | 78 | def find_account(self, account_number: str) -> Account: 79 | """ 80 | Finds and returns the Account object with the given account number. 81 | 82 | Args: 83 | account_number: The account number to search for. 84 | 85 | Returns: 86 | The Account object with the given account number. 87 | 88 | Raises: 89 | ValueError: If no account with the given account number is 90 | found in the bank's accounts list. 91 | """ 92 | for account in self.accounts: 93 | if account.account_number == account_number: 94 | return account 95 | raise InvalidAccountError(f"The account number {account_number} is invalid.") 96 | 97 | 98 | @dataclass 99 | class BankingService: 100 | """ 101 | A mock implementation of a banking API. 102 | 103 | The BankingService class provides methods for simulating deposits and withdrawals 104 | from bank accounts, as well as a method for simulating a deposit that always fails. 105 | 106 | Attributes: 107 | hostname: The hostname of the banking API service. 108 | """ 109 | 110 | def __init__(self, hostname: str) -> None: 111 | """ 112 | Constructs a new BankingService object with the given hostname. 113 | 114 | Args: 115 | hostname: The hostname of the banking API service. 116 | """ 117 | self.hostname: str = hostname 118 | 119 | self.mock_bank: Bank = Bank( 120 | [ 121 | Account("85-150", 2000), 122 | Account("43-812", 0), 123 | ] 124 | ) 125 | 126 | def withdraw(self, account_number: str, amount: int, reference_id: str) -> str: 127 | """ 128 | Simulates a withdrawal from a bank account. 129 | 130 | Args: 131 | account_number: The account number to deposit to. 132 | amount: The amount to deposit to the account. 133 | reference_id: An identifier for the transaction, used for idempotency. 134 | 135 | Returns: 136 | A transaction ID 137 | 138 | Raises: 139 | InvalidAccountError: If the account number is invalid. 140 | InsufficientFundsError: If the account does not have enough funds 141 | to complete the withdrawal. 142 | """ 143 | 144 | account = self.mock_bank.find_account(account_number) 145 | 146 | if amount > account.balance: 147 | raise InsufficientFundsError( 148 | f"The account {account_number} has insufficient funds to complete this transaction." 149 | ) 150 | 151 | return self.generate_transaction_id("W") 152 | 153 | def deposit(self, account_number: str, amount: int, reference_id: str) -> str: 154 | """ 155 | Simulates a deposit to a bank account. 156 | 157 | Args: 158 | account_number: The account number to deposit to. 159 | amount: The amount to deposit to the account. 160 | reference_id: An identifier for the transaction, used for idempotency. 161 | 162 | Returns: 163 | A transaction ID. 164 | 165 | Raises: 166 | InvalidAccountError: If the account number is invalid. 167 | """ 168 | try: 169 | self.mock_bank.find_account(account_number) 170 | except InvalidAccountError: 171 | raise 172 | 173 | return self.generate_transaction_id("D") 174 | 175 | def deposit_that_fails( 176 | self, account_number: str, amount: int, reference_id: str 177 | ) -> NoReturn: 178 | """ 179 | Simulates a deposit to a bank account that always fails with an 180 | unknown error. 181 | 182 | Args: 183 | account_number: The account number to deposit to. 184 | amount: The amount to deposit to the account. 185 | reference_id: An identifier for the transaction, used for idempotency. 186 | 187 | Returns: 188 | An empty string. 189 | 190 | Raises: 191 | A ValueError exception object. 192 | """ 193 | raise ValueError("This deposit has failed.") 194 | 195 | def generate_transaction_id(self, prefix: str) -> str: 196 | """ 197 | Generates a transaction ID we can send back. 198 | 199 | Args: 200 | prefix: A prefix so you can identify the type of transaction. 201 | Returns: 202 | The transaction id. 203 | """ 204 | return f"{prefix}-{uuid.uuid4()}" 205 | -------------------------------------------------------------------------------- /run_worker.py: -------------------------------------------------------------------------------- 1 | # @@@SNIPSTART python-money-transfer-project-template-run-worker 2 | import asyncio 3 | 4 | from temporalio.client import Client 5 | from temporalio.worker import Worker 6 | 7 | from activities import BankingActivities 8 | from shared import MONEY_TRANSFER_TASK_QUEUE_NAME 9 | from workflows import MoneyTransfer 10 | 11 | 12 | async def main() -> None: 13 | client: Client = await Client.connect("localhost:7233", namespace="default") 14 | # Run the worker 15 | activities = BankingActivities() 16 | worker: Worker = Worker( 17 | client, 18 | task_queue=MONEY_TRANSFER_TASK_QUEUE_NAME, 19 | workflows=[MoneyTransfer], 20 | activities=[activities.withdraw, activities.deposit, activities.refund], 21 | ) 22 | await worker.run() 23 | 24 | 25 | if __name__ == "__main__": 26 | asyncio.run(main()) 27 | # @@@SNIPEND 28 | -------------------------------------------------------------------------------- /run_workflow.py: -------------------------------------------------------------------------------- 1 | # @@@SNIPSTART python-project-template-run-workflow 2 | import asyncio 3 | import traceback 4 | 5 | from temporalio.client import Client, WorkflowFailureError 6 | 7 | from shared import MONEY_TRANSFER_TASK_QUEUE_NAME, PaymentDetails 8 | from workflows import MoneyTransfer 9 | 10 | 11 | async def main() -> None: 12 | # Create client connected to server at the given address 13 | client: Client = await Client.connect("localhost:7233") 14 | 15 | data: PaymentDetails = PaymentDetails( 16 | source_account="85-150", 17 | target_account="43-812", 18 | amount=250, 19 | reference_id="12345", 20 | ) 21 | 22 | try: 23 | result = await client.execute_workflow( 24 | MoneyTransfer.run, 25 | data, 26 | id="pay-invoice-701", 27 | task_queue=MONEY_TRANSFER_TASK_QUEUE_NAME, 28 | ) 29 | 30 | print(f"Result: {result}") 31 | 32 | except WorkflowFailureError: 33 | print("Got expected exception: ", traceback.format_exc()) 34 | 35 | 36 | if __name__ == "__main__": 37 | asyncio.run(main()) 38 | # @@@SNIPEND 39 | -------------------------------------------------------------------------------- /shared.py: -------------------------------------------------------------------------------- 1 | # @@@SNIPSTART python-money-transfer-project-template-shared 2 | from dataclasses import dataclass 3 | 4 | MONEY_TRANSFER_TASK_QUEUE_NAME = "TRANSFER_MONEY_TASK_QUEUE" 5 | 6 | 7 | @dataclass 8 | class PaymentDetails: 9 | source_account: str 10 | target_account: str 11 | amount: int 12 | reference_id: str 13 | 14 | 15 | # @@@SNIPEND 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/temporalio/money-transfer-project-template-python/ad83ae611da7528d312ff2186299654c2c2e5585/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_run_worker.py: -------------------------------------------------------------------------------- 1 | # @@@SNIPSTART money-transfer-project-template-python-tests 2 | import uuid 3 | 4 | import pytest 5 | from temporalio.client import WorkflowFailureError 6 | from temporalio.testing import WorkflowEnvironment 7 | from temporalio.worker import Worker 8 | 9 | from activities import BankingActivities 10 | from shared import PaymentDetails 11 | from workflows import MoneyTransfer 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_money_transfer() -> None: 16 | task_queue_name: str = str(uuid.uuid4()) 17 | async with await WorkflowEnvironment.start_time_skipping() as env: 18 | data: PaymentDetails = PaymentDetails( 19 | source_account="85-150", 20 | target_account="43-812", 21 | amount=250, 22 | reference_id="12345", 23 | ) 24 | activities = BankingActivities() 25 | async with Worker( 26 | env.client, 27 | task_queue=task_queue_name, 28 | workflows=[MoneyTransfer], 29 | activities=[activities.withdraw, activities.deposit, activities.refund], 30 | ): 31 | result: str = await env.client.execute_workflow( 32 | MoneyTransfer.run, 33 | data, 34 | id=str(uuid.uuid4()), 35 | task_queue=task_queue_name, 36 | ) 37 | assert result.startswith("Transfer complete") 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_money_transfer_withdraw_insufficient_funds() -> None: 42 | task_queue_name: str = str(uuid.uuid4()) 43 | async with await WorkflowEnvironment.start_time_skipping() as env: 44 | data: PaymentDetails = PaymentDetails( 45 | source_account="85-150", 46 | target_account="43-812", 47 | amount=25000, 48 | reference_id="12345", 49 | ) 50 | 51 | activities = BankingActivities() 52 | async with Worker( 53 | env.client, 54 | task_queue=task_queue_name, 55 | workflows=[MoneyTransfer], 56 | activities=[activities.withdraw, activities.deposit, activities.refund], 57 | ): 58 | with pytest.raises(WorkflowFailureError) as excinfo: 59 | await env.client.execute_workflow( 60 | MoneyTransfer.run, 61 | data, 62 | id=str(uuid.uuid4()), 63 | task_queue=task_queue_name, 64 | ) 65 | 66 | assert excinfo.value.__cause__.__cause__.type == "InsufficientFundsError" 67 | 68 | 69 | # @@@SNIPEND 70 | @pytest.mark.asyncio 71 | async def test_money_transfer_withdraw_invalid_account() -> None: 72 | task_queue_name: str = str(uuid.uuid4()) 73 | async with await WorkflowEnvironment.start_time_skipping() as env: 74 | data: PaymentDetails = PaymentDetails( 75 | source_account="85-151", 76 | target_account="43-812", 77 | amount=250, 78 | reference_id="12345", 79 | ) 80 | 81 | activities = BankingActivities() 82 | async with Worker( 83 | env.client, 84 | task_queue=task_queue_name, 85 | workflows=[MoneyTransfer], 86 | activities=[activities.withdraw, activities.deposit, activities.refund], 87 | ): 88 | with pytest.raises(WorkflowFailureError) as excinfo: 89 | await env.client.execute_workflow( 90 | MoneyTransfer.run, 91 | data, 92 | id=str(uuid.uuid4()), 93 | task_queue=task_queue_name, 94 | ) 95 | 96 | assert excinfo.value.__cause__.__cause__.type == "InvalidAccountError" 97 | -------------------------------------------------------------------------------- /workflows.py: -------------------------------------------------------------------------------- 1 | # @@@SNIPSTART python-money-transfer-project-template-workflows 2 | from datetime import timedelta 3 | 4 | from temporalio import workflow 5 | from temporalio.common import RetryPolicy 6 | from temporalio.exceptions import ActivityError 7 | 8 | with workflow.unsafe.imports_passed_through(): 9 | from activities import BankingActivities 10 | from shared import PaymentDetails 11 | 12 | 13 | @workflow.defn 14 | class MoneyTransfer: 15 | @workflow.run 16 | async def run(self, payment_details: PaymentDetails) -> str: 17 | retry_policy = RetryPolicy( 18 | maximum_attempts=3, 19 | maximum_interval=timedelta(seconds=2), 20 | non_retryable_error_types=["InvalidAccountError", "InsufficientFundsError"], 21 | ) 22 | 23 | # Withdraw money 24 | withdraw_output = await workflow.execute_activity_method( 25 | BankingActivities.withdraw, 26 | payment_details, 27 | start_to_close_timeout=timedelta(seconds=5), 28 | retry_policy=retry_policy, 29 | ) 30 | 31 | # Deposit money 32 | try: 33 | deposit_output = await workflow.execute_activity_method( 34 | BankingActivities.deposit, 35 | payment_details, 36 | start_to_close_timeout=timedelta(seconds=5), 37 | retry_policy=retry_policy, 38 | ) 39 | 40 | result = f"Transfer complete (transaction IDs: {withdraw_output}, {deposit_output})" 41 | return result 42 | except ActivityError as deposit_err: 43 | # Handle deposit error 44 | workflow.logger.error(f"Deposit failed: {deposit_err}") 45 | # Attempt to refund 46 | try: 47 | refund_output = await workflow.execute_activity_method( 48 | BankingActivities.refund, 49 | payment_details, 50 | start_to_close_timeout=timedelta(seconds=5), 51 | retry_policy=retry_policy, 52 | ) 53 | workflow.logger.info( 54 | f"Refund successful. Confirmation ID: {refund_output}" 55 | ) 56 | raise deposit_err 57 | except ActivityError as refund_error: 58 | workflow.logger.error(f"Refund failed: {refund_error}") 59 | raise refund_error 60 | 61 | 62 | # @@@SNIPEND 63 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | --------------------------------------------------------------------------------