Python Deep Drive IV 第 3 節 – Project 1(編輯後)

Project Description

  • 這節其實是 Class 單元的實作練習專案~

專案目的:

  • 設計一個銀行帳戶(Bank Account)的類別物,以下是這個類別物件所需的屬性及功能需求:

    • account number: 銀行帳號: 假設帳號只能通過帳戶類別的初始化(建構式)才能賦值

    • account holder 帳號所有人: 由first namlast nam 所組成

    • preferred time zone offse: 帳戶具有關聯的"偏好時區偏移量"設定(例如 -7 代表 MST)

    • balances 帳戶餘額: 必須是大於等於零的正數值,且具備唯讀屬性(不能直接設定)

    • deposits 存款 和 withdrawals 提款: 存款的操作沒有限制,而提款則必須在提款數值不會導致餘額變為負值的情況下才能操作

    • monthly interest rate 月利率: 對所有(銀行)帳戶都是統一的(利率)

      • 需有一個方法可以調用當前月利率計算當下餘額的利息,並將其新增加總到餘額中
    • transaction type 交易類型:

      • 存款為"D",提款為"W",利息儲存為"I",拒絕交易(當提款數額高於存款餘額時)為"X"(這種情況下餘額不受影響 → 因為提款操作會被系統拒絕)
    • transaction time 交易時間: 使用 UTC 記錄當下進行交易操作的時間

    • transaction id 交易代碼: 由一個隨所有帳戶的交易操作進行而遞增的數值與其他參數結合所衍生

      • 為了簡化專案,此遞增數值由程式開始執行時,由 0 開始隨所有帳戶每一筆交易操作(返回交易代碼)而遞增
    • confirmation code 交易(完成)確認碼:

      • 需創建一個方法,隨著所有帳戶的每一次交易操作,包含操作發生當下的帳號、交易類型、交易時間,與此遞增數值而返回一個由以上這些參數相結合而產生的一個確認數值來作為交易代碼

        • 老師故意這樣設計,以便讓所需的時區偏移量也能作為參數傳遞來作為交易代碼的產生依據

        • 例如我們可以有這樣的一個帳戶:

        • 帳號: 140568

        • 偏好的時區偏移量設定: -7 (MST)

        • 當前的帳戶餘額: 100.00

      • 假設先前的交易代碼已到 123,並且在之後這帳戶會有一筆存款 50.00 發生在 2019-03-15T14:59:00 (UTC)

      • 因此經過帳戶的重新計算,新的餘額為 150.00,因此代表此筆存款交易的交易確認碼,可表示如下: D-140568-20190315145900-124

      • 而我們尚須創建一個方法,可以讓交易確認碼能透過該方法反解回去使得:

        • result.account_number → ‘140568’

        • result.transaction_code → ‘D’

        • result.transaction_id → ‘124’

        • result.time → ‘2019-03-15 07:59:00 (MST)’

        • result.time_utc → ‘2019-03-15T14:59:00’

      • 此外,如當下的月利率是 0.5%,而帳戶餘額為 1000.00,則會調用一個 deposit_interest (儲存利息) method,而此 method 執行的結果,會返回使得帳戶餘額變成 1050.00

  • 為簡化專案起見,所有存提款及利息的數值皆以符點數(floated)來計算,但實際狀況,可能你更希望以 Decimal 來計算而不是 Floats

  • 你可以使用第二章節 Class 所學到的概念來進行這個專案的銀行帳戶類別物件設計

  • 老師的提示是他的話,會產生兩個類別物件,一個是用來儲存時區及時區偏移量的 TimeZone Class,另一個則是 Account 銀行帳戶的 Class

    • TimeZone Class 包含兩個 property: TimeZone Name and TimeZone Offset

    • Account Class 包含:

      • first name (property)

      • last name (property)

      • full name (computed, read only)

      • balance

      • interest rate

      • deposit, withdraw, pay_interest methods

      • parse confirmation code


解題的觀念:

  • 靜態的參數(如:Account Number、Transaction ID…)設成 Class 的 Property:

    • @property (getter): 取值

    • @property_name.setter: 設定 (如果是唯讀或推導屬性可不設定 setter)

  • 動態的功能(如:Deposit、Withdraw…)設定成 Class 的 Method:

    • Instance Method 物件方法:Scope 僅止於 Instance Object,適用於個別帳戶各自關聯的功能,比方說 Deposit 或 Withdraw

    • Class Method 類別方法: Scope 可包含所有衍生的 Instance Object,適用於所有帳戶必須共同擁有的統一功能,例如: Interest Rate 月利率

    • Static Method 靜態方法: 獨立於 Class 與 Instance 之外,適用於必須超然於這兩者之外的功能,例如: TimeZone Offset。


Project Solution – TimeZone

import numbers
from datetime import timedelta

class TimeZone:
    '''
    ### TimeZone Class ###
    '''
    def __init__(self, name='Asia/Taipei', offset_hours=8, offset_minutes=0):
        if name is None or len(str(name).strip()) == 0:
            raise ValueError('Timezone name cannot be empty.')
            
        self._name = str(name).strip()
        
        if not isinstance(offset_hours, numbers.Number):
            raise ValueError('Hour offset must be an integer.')
        
        if not isinstance(offset_minutes, numbers.Number):
            raise ValueError('Minutes offset must be an integer.')
            
        if offset_minutes < -59 or offset_minutes > 59:
            raise ValueError('Minutes offset must between -59 and 59 (inclusive).')
            
        # for time delta sign of minutes will be set to sign of hours
        offset = timedelta(hours=offset_hours, minutes=offset_minutes)

        # offsets are technically bounded between -12:00 and 14:00
        # see: https://en.wikipedia.org/wiki/List_of_UTC_time_offsets
        if offset < timedelta(hours=-12, minutes=0) or offset > timedelta(hours=14, minutes=0):
            raise ValueError('Offset must be between -12:00 and +14:00.')
            
        self._offset_hours = offset_hours
        self._offset_minutes = offset_minutes
        self._offset = offset
        
    @property
    def offset(self):
        return self._offset
    
    @property
    def name(self):
        return self._name
    
    def __eq__(self, other):
        return (isinstance(other, TimeZone) and 
                self.name == other.name and 
                self._offset_hours == other._offset_hours and
                self._offset_minutes == other._offset_minutes)
        
    def __repr__(self):
        return (f"TimeZone(name='{self.name}', "
                f"offset_hours={self._offset_hours}, "
                f"offset_minutes={self._offset_minutes})")

Project Solution – Transaction Number

  • 首先是創建一個 TransactionID 的 Class: 必須是一個遞增的整數值
import numbers

class TransactionID:
    def __init__(self, start_id):
        if not isinstance(start_id, numbers.Number) or start_id < 0:
            raise ValueError('start_id must be the positive integer.')
        self._start_id = start_id
        
    def next(self):
        self._start_id += 1
        return self._start_id
  • 接著我們就可以用 Account Class ::
class Account:
    transaction_counter = TransactionID(100)
    
    def make_transaction(self):
        new_trans_id = Account.transaction_counter.next()
        return new_trans_id

# 驗證
a1 = Account()
a2 = Account()

print(f'{a1.make_transaction()}')
print(f'{a2.make_transaction()}')
print(f'{a1.make_transaction()}')   
101
102
103
  • 但其實細想之下,其實所謂 TransactionID 不就是一個 Iterator Function 嗎? 那是否我們就可以用一個 Iterator Function 來取代 TransactionID Class 的創建,就能達到我們的需求,讓我們往下看:
def transaction_ids(start_id):
    while True:
        start_id += 1
        yield start_id
        

class Account:
    transaction_counter = transaction_ids(100)
    
    def make_transaction(self):
        new_trans_id = next(Account.transaction_counter)
        return new_trans_id
    
    
a1 = Account()
a2 = Account()

print(a1.make_transaction())
print(a2.make_transaction())
print(a1.make_transaction())
101
102
103
  • 還記 Python Deep Dive III 講過 Counter ? 實際上,這 TransactionID 最適合 Counter 來創建,這樣連剛剛的 transaction_id Function Create 都能省下來:
import itertools

class Account:
    transaction_counter = itertools.count(100)
    
    def make_transaction(self):
        new_trans_id = next(Account.transaction_counter)
        return new_trans_id
    
    
a1 = Account()
a2 = Account()

print(a1.make_transaction())
print(a2.make_transaction())
print(a1.make_transaction())    
100
101
102

Project Solution – Account Numbers, Names

class Account:
    transaction_counter = itertools.count(100)
    
    def __init__(self, account_number, first_name, last_name):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
    @property
    def account_number(self): #read only, 不需 setter
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError('First name cannot be empty.')
        self._first_name = value
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError('Last name cannot be empty.')
        self._last_name = value
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self): # 由 first name, last name computed 而來,故也是 read only
        return f'{self.first_name} {self.last_name}'

Project Solution – Preferred TimeZone

class Account:
    transaction_counter = itertools.count(100)
    
    def __init__(self, account_number, first_name, last_name, timezone=None):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone        
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'

    @property
    def timezone(self):
        return self._timezone
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
    
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)
        
        
try:
    a = Account('123', 'John', 'Smith', '-7:00')
except ValueError as ex:
    print(ex)
    
a = Account('123', 'John', 'Smith')
print(a.timezone)            
Time zone must be a valid TimeZone object.
TimeZone(name='Asia/Taipei', offset_hours=8, offset_minutes=0)

Project Solution – Account Balance

class Account:
    transaction_counter = itertools.count(100)
    
    def __init__(self, account_number, first_name, last_name, 
                 timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        self._balance = float(initial_balance)  # force use of floats here, but maybe Decimal would be better
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
            
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)
        
        
a = Account('1234', 'John', 'Cleese', initial_balance=100)
print(a.balance)

try:
    a.balance = 200
except AttributeError as ex:
    print(ex)
100.0
property 'balance' of 'Account' object has no setter

Project Solution – Interest Rate

  • 由於 Interest Rate 月利率必須是所有帳戶都使用相同的利率,-而我們也尚未講 Data Descriptors,因此這裡我們暫時先使用類別屬性來設定
class Account:
    transaction_counter = itertools.count(100)
    interest_rate = 0.5  # percentage
    
    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        self._balance = float(initial_balance)  # force use of floats here, but maybe Decimal would be better
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
            
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)
        
        
a1 = Account(1234, 'Monty', 'Python', initial_balance=0)
a2 = Account(2345, 'John', 'Cheese', initial_balance=0)
print(a1.interest_rate, a2.interest_rate)

Account.interest_rate = 0.025
print(a1.interest_rate, a2.interest_rate)
0.5 0.5
0.025 0.025

Project Solution – Transaction Codes

  • 雖然交易代碼 ‘D’、‘W’、‘I’、‘X’ 可以使用 hardcode,但老師的建議是使用字典集合來對應,而且這個字典 key 由於是交易代碼,並不能修改,因此老師傾向設定這個字典作為一個 private class attribute,更好的做法是使用 enumeration type
class Account:
    transaction_counter = itertools.count(100)
    _interest_rate = 0.5  # percentage
    
    _transaction_codes = {
        'deposit': 'D',
        'withdraw': 'W',
        'interest': 'I',
        'rejected': 'X'
    }
    
    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        self._balance = float(initial_balance)  # force use of floats here, but maybe Decimal would be better
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
          
    @classmethod
    def get_interest_rate(cls): # 設定成類別方法來取值月利率
        return cls._interest_rate
    
    @classmethod
    def set_interest_rate(cls, value): # 設定成類別方法來設定月利率
        if not isinstance(value, numbers.Real):
            raise ValueError('Interest rate must be a real number')
        if value < 0:
            raise ValueError('Interest rate cannot be negative.')
        cls._interest_rate = value
        
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)

Project Solution – Confirmation Code

from datetime import datetime

def generate_confirmation_code(account_number, transaction_id, transaction_code):
    # main difficulty here is to generate the current time in UTC using this formatting:
    # YYYYMMDDHHMMSS
    dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')
    return f'{transaction_code}-{account_number}-{dt_str}-{transaction_id}'

generate_confirmation_code(123, 1000, 'X')
'X-123-20230404063912-1000'
class Account:
    transaction_counter = itertools.count(100)
    _interest_rate = 0.5  # percentage
    
    _transaction_codes = {
        'deposit': 'D',
        'withdraw': 'W',
        'interest': 'I',
        'rejected': 'X'
    }
    
    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        self._balance = float(initial_balance)  # force use of floats here, but maybe Decimal would be better
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
          
    @classmethod
    def get_interest_rate(cls):
        return cls._interest_rate
    
    @classmethod
    def set_interest_rate(cls, value):
        if not isinstance(value, numbers.Real):
            raise ValueError('Interest rate must be a real number')
        if value < 0:
            raise ValueError('Interest rate cannot be negative.')
        cls._interest_rate = value
        
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)
        
    def generate_confirmation_code(self, transaction_code):
        # main difficulty here is to generate the current time in UTC using this formatting:
        # YYYYMMDDHHMMSS
        dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')
        return f'{transaction_code}-{self.account_number}-{dt_str}-{next(Account.transaction_counter)}'
    
    def make_transaction(self):
        return self.generate_confirmation_code('dummy')
    
    
a = Account('A100', 'John', 'Cheese', initial_balance=100)
print(a.make_transaction())
print(a.make_transaction())
dummy-A100-20230404063912-100
dummy-A100-20230404063912-101
# 創建一個 Confirmation Code Parser
from collections import namedtuple

Confirmation = namedtuple('Confirmation', 'account_number, transaction_code, transaction_id, time_utc, time')


class Account:
    transaction_counter = itertools.count(100)
    _interest_rate = 0.5  # percentage
    
    _transaction_codes = {
        'deposit': 'D',
        'withdraw': 'W',
        'interest': 'I',
        'rejected': 'X'
    }
    
    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        self._balance = float(initial_balance)  # force use of floats here, but maybe Decimal would be better
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
          
    @classmethod
    def get_interest_rate(cls):
        return cls._interest_rate
    
    @classmethod
    def set_interest_rate(cls, value):
        if not isinstance(value, numbers.Real):
            raise ValueError('Interest rate must be a real number')
        if value < 0:
            raise ValueError('Interest rate cannot be negative.')
        cls._interest_rate = value
        
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)
        
    def generate_confirmation_code(self, transaction_code):
        # main difficulty here is to generate the current time in UTC using this formatting:
        # YYYYMMDDHHMMSS
        dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')
        return f'{transaction_code}-{self.account_number}-{dt_str}-{next(Account.transaction_counter)}'
    
    @staticmethod
    def parse_confirmation_code(confirmation_code, preferred_time_zone=None):
        # dummy-A100-20190325224918-101
        parts = confirmation_code.split('-')
        if len(parts) != 4:
            # really simplistic validation here - would need something better
            raise ValueError('Invalid confirmation code')
        
        # unpack into separate variables
        transaction_code, account_number, raw_dt_utc, transaction_id = parts
        
        # need to convert raw_dt_utc into a proper datetime object
        try:
            dt_utc = datetime.strptime(raw_dt_utc, '%Y%m%d%H%M%S')
        except ValueError as ex:
            # again, probably need better error handling here
            raise ValueError('Invalid transaction datetime') from ex
          
        if preferred_time_zone is None:
            preferred_time_zone = TimeZone('Asia/Taipei', 8, 0)
            
        if not isinstance(preferred_time_zone, TimeZone):
            raise ValueError('Invalid TimeZone specified.')
            
        dt_preferred = dt_utc + preferred_time_zone.offset
        dt_preferred_str = f"{dt_preferred.strftime('%Y-%m-%d %H:%M:%S')} ({preferred_time_zone.name})"
        
        return Confirmation(account_number, transaction_code, transaction_id, dt_utc.isoformat(), dt_preferred_str)
    
    def make_transaction(self):
        return self.generate_confirmation_code('dummy')


# 驗證
a = Account('A100', 'John', 'Cheese', initial_balance=100)
conf_code = a.make_transaction()
print(conf_code)
Account.parse_confirmation_code(conf_code)
dummy-A100-20230404063912-100
Confirmation(account_number='A100', transaction_code='dummy', transaction_id='100', time_utc='2023-04-04T06:39:12', time='2023-04-04 14:39:12 (Asia/Taipei)')

Project Solution – Transactions

  • 接著我們要來加入各種交易方法到 Account Class 上: Deposition (存款)、Withdraw (提款)、Interest(配息)、Rejected(無效交易)

    • 由於存款最單純,因此老師先實作的是 Deposit Method,由於存款不限定得整數,因此但凡輸入(金額)是正實數即可(最後會再被轉 Float or Decimal),因此會需要兩道檢查來檢驗是否為實數以及是否輸入的值是否大於零,確認輸入是有效數值後,最後加總到 Balance 及 產生交易代碼及確認碼即可~

    • 提款部分是有條件的,當提款金額大於帳戶餘額時,必須拒絕交易,再來是當帳戶餘額已不足狀態,也是拒絕交易,因此首先在 init 加了一個方法來驗證金額必須是正的實數:

      • self._balance = Account.validate_real_number(initial_balance)
    • 然後這個驗證輸入參數是否為正實數的方法,由於無關乎 Class 或是 Instance,因此我們也是把它用 Static Method 來寫成驗證方法~

    • 最後則是 Withdraw Method 部分,我們在當中可加入一個是否拒絕交易的布林參數,而提款值與帳戶餘額的差是否為正值,則作為使這個布林參數是否翻轉的判斷式,當為正值,布林參數 True 可通過 if 條件式,往下進行交易:

      • 而交易部分,其實就是讓這個餘額減去提款金額,成為新的帳戶餘額,此外也產生這筆提款的交易代碼及交易確認(不論交易是否成功都會產生交易確認碼,只是交易代碼不一樣,返回的確認碼也會略為不一樣)
    • 最後則是除息交易部分(月利率的孳息): 除息部分因為每個帳戶都各自處理,因此只需使 Instance Method 來實作即可。

class Account:
    transaction_counter = itertools.count(100)
    _interest_rate = 0.5  # percentage
    
    _transaction_codes = {
        'deposit': 'D',
        'withdraw': 'W',
        'interest': 'I',
        'rejected': 'X'
    }
    
    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        #self._balance = float(initial_balance)  # force use of floats here, but maybe Decimal would be better
        self._balance = Account.validate_real_number(initial_balance)
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
          
    @classmethod
    def get_interest_rate(cls):
        return cls._interest_rate
    
    @classmethod
    def set_interest_rate(cls, value):
        if not isinstance(value, numbers.Real):
            raise ValueError('Interest rate must be a real number')
        if value < 0:
            raise ValueError('Interest rate cannot be negative.')
        cls._interest_rate = value
        
    def validate_and_set_name(self, property_name, value, field_title):
        if value is None or len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)

    @staticmethod
    def validate_real_number(value, min_value=None):
        if not isinstance(value, numbers.Real):
            raise ValueError('Value must be a real number.')
            
        if min_value is not None and value < min_value:
            raise ValueError(f'Value must be at least {min_value}')
            
        # validation passed, return valid value
        return value
        
    def generate_confirmation_code(self, transaction_code):
        # main difficulty here is to generate the current time in UTC using this formatting:
        # YYYYMMDDHHMMSS
        dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')
        return f'{transaction_code}-{self.account_number}-{dt_str}-{next(Account.transaction_counter)}'
    
    @staticmethod
    def parse_confirmation_code(confirmation_code, preferred_time_zone=None):
        # dummy-A100-20190325224918-101
        parts = confirmation_code.split('-')
        if len(parts) != 4:
            # really simplistic validation here - would need something better
            raise ValueError('Invalid confirmation code')
        
        # unpack into separate variables
        transaction_code, account_number, raw_dt_utc, transaction_id = parts
        
        # need to convert raw_dt_utc into a proper datetime object
        try:
            dt_utc = datetime.strptime(raw_dt_utc, '%Y%m%d%H%M%S')
        except ValueError as ex:
            # again, probably need better error handling here
            raise ValueError('Invalid transaction datetime') from ex
          
        if preferred_time_zone is None:
            preferred_time_zone = TimeZone('Asia/Taipei', 8, 0)
            
        if not isinstance(preferred_time_zone, TimeZone):
            raise ValueError('Invalid TimeZone specified.')
            
        dt_preferred = dt_utc + preferred_time_zone.offset
        dt_preferred_str = f"{dt_preferred.strftime('%Y-%m-%d %H:%M:%S')} ({preferred_time_zone.name})"
        
        return Confirmation(account_number, transaction_code, transaction_id, dt_utc.isoformat(), dt_preferred_str)
    
    def deposit(self, value):
        if not isinstance(value, numbers.Real):
            raise ValueError('Deposit value must be a real number.')
        if value <= 0:
            raise ValueError('Deposit value must be a positive number.')
        
        # get transaction code
        transaction_code = Account._transaction_codes['deposit']
        
        # generate a confirmation code
        conf_code = self.generate_confirmation_code(transaction_code)
        
        # make deposit and return conf code
        self._balance += value
        return conf_code
    
    def withdraw(self, value):
        value = Account.validate_real_number(value, min_value=0.01)
        accepted = False
        if self.balance - value < 0:
            # insufficient funds - we'll reject this transaction
            transaction_code = Account._transaction_codes['rejected']
        else:
            transaction_code = Account._transaction_codes['withdraw']
            accepted = True
            
        conf_code = self.generate_confirmation_code(transaction_code)
        
        # Doing this here in case there's a problem generating a confirmation code
        # - do not want to modify the balance if we cannot generate a transaction code successfully
        if accepted:
            self._balance -= value
            
        return conf_code
    
    def pay_interest(self):
        interest = self.balance * Account.get_interest_rate() / 100
        conf_code = self.generate_confirmation_code(self._transaction_codes['interest'])
        self._balance += interest
        return conf_code
    
    
a = Account('A100', 'Eric', 'Idle', initial_balance=100)
try:
    a.deposit(-100)
except ValueError as ex:
    print(ex)
try:
    a.withdraw("100")
except ValueError as ex:
    print(ex)
Deposit value must be a positive number.
Value must be a real number.

Project Solution – Testing with unittest

  • unittest 是確保程式碼能順暢運行的前哨站,依照老師的經驗,有時光寫 unittest 花的時間,比第一次寫出完整的功能程式碼還要久,而這裡不會提太多 unittest 部分的技術細節,只是依照我們的專案需求,撰寫一個 manual approach 的驗證程式。

  • 要撰寫 unittest 首先要了解一下 python unittest 的架構:

    • python unittest 模組主要包括四個部份:

      • 測試案例(Test case)測試的最小單元。

        • 對於測試案例的撰寫,unittest 模組提供了一個基礎類別 TestCase,你可以繼承它來建立新的測試案例。

        • 每個測試必須定義在一個 test 名稱為開頭的方法中,一個 TestCase 的子類別,通常用來為某個類別或模組的單元方法或函式定義測試。

      • 測試設備(Test fixture)執行一或多個測試前必要的預備資源,以及相關的清除資源動作。

        • 許多單元測試經常泛用相同的測試設備,你可以在 TestCase 的子類別中定義 setUp 與 tearDown 方法,測試執行器會在每個測試運行之前執行 setUp 方法,每個測試運行之後執行 tearDown 方法。
        • 一個實際情境可以像是在 setUp 方法中建立新表格並在表格中新增資料,執行測試之後,在 tearDown 方法中刪除表格。
      • 測試套件(Test suite)一組測試案例、測試套件或者是兩者的組合。

        • 根據測試的需求不同,你可能會想要將不同的測試組合在一起,例如,CalculatorTestCase 中可能有數個 test_xxx 方法,而這些測試方法可以組裝(例如:使用一個 list 來定義要組裝的 test_xxx 方法清單)成一個測試套件。
        • 可以任意組合測試,例如,將某個測試套件與某個 TestCase 中的 test_xxx 方法組合為另一個測試套件,或可以將許多測試套件再全部組合為另一個測試套件~
      • 測試執行器(Test runner)負責執行測試並提供測試結果的元件。

        • 可以在程式碼中直接使用 TextTestRunner 或是透過 unittest.main 函式來執行,如果不想透過程式碼定義,也可以在命令列中使用 unittest 模組來運行模組、類別或甚至個別的測試方法
    • 步驟:

      • 首先要跑 unittest 必須 import unittest module

      • 然後我們要定義一個 Function 來執行 unittest

      • 然後依據每 TestCase 設定一個 Test Class 來判定驗證的成功與否

  • 如以下範例所示:

import unittest


def run_tests(test_class):
    suite = unittest.TestLoader().loadTestsFromTestCase(test_class)
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)
    
    
class TestAccount(unittest.TestCase):
    def test_ok(self):
        self.assertEqual(1, 1)  
        
        
run_tests(TestAccount)


class TestAccount(unittest.TestCase):
    def test_ok(self):
        self.assertEqual(1, 0)
        

run_tests(TestAccount)
test_ok (__main__.TestAccount.test_ok) ... ok
...
  • 接著我們可以根據我們這個專案的需求來設計單元測試的 Test Case:
class TestAccount(unittest.TestCase):
    def setUp(self):
        print('Running setup...')
        self.account_number = 'A100'
        
    def tearDown(self):
        print('Running tear down...')
        
    def test_1(self):
        self.account_number = 'A200'
        self.assertTrue('A200', self.account_number)
        
    def test_2(self):
        self.assertTrue('A100', self.account_number)
        
        
run_tests(TestAccount)
test_1 (__main__.TestAccount.test_1) ... ok
...
  • 即便 test fail,tear down method 仍然會繼續執行!!!
class TestAccount(unittest.TestCase):
    def setUp(self):
        print('Running setup...')
        
    def tearDown(self):
        print('Running tear down...')
        
    def testOK(self):
        self.assertTrue(False)
        
        
run_tests(TestAccount)
testOK (__main__.TestAccount.testOK) ... FAIL
...
class TestAccount(unittest.TestCase):
    
    def test_create_timezone(self):
        tz = TimeZone('ABC', -1, -30)
        self.assertEqual('ABC', tz.name)
        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)
        
    def test_timezones_equal(self):
        tz1 = TimeZone('ABC', -1, -30)
        tz2 = TimeZone('ABC', -1, -30)
        self.assertEqual(tz1, tz2)
        
    def test_timezones_not_equal(self):
        tz = TimeZone('ABC', -1, -30)
        
        test_timezones = (
            TimeZone('DEF', -1, -30),
            TimeZone('ABC', -1, 0),
            TimeZone('ABC', 1, -30)
        )
        for i, test_tz in enumerate(test_timezones):
            with self.subTest(test_number=i):
                self.assertNotEqual(tz, test_tz)
                
    def test_create_account(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        tz = TimeZone('TZ', 1, 30)
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, tz, balance)
        self.assertEqual(account_number, a.account_number)
        self.assertEqual(first_name, a.first_name)
        self.assertEqual(last_name, a.last_name)
        self.assertEqual(first_name + ' ' + last_name, a.full_name)
        self.assertEqual(tz, a.timezone)
        self.assertEqual(balance, a.balance)
        
    def test_create_account_blank_first_name(self):
        account_number = 'A100'
        first_name = ''
        last_name = 'LAST'
        tz = TimeZone('TZ', 1, 30)
        balance = 100.00
        
        with self.assertRaises(ValueError):
            a = Account(account_number, first_name, last_name, tz, balance)
            
    def test_create_account_negative_balance(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        tz = TimeZone('TZ', 1, 30)
        balance = -100.00
        
        with self.assertRaises(ValueError):
            a = Account(account_number, first_name, last_name, tz, balance)
            
    def test_account_deposit_ok(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        conf_code = a.deposit(100)
        self.assertEqual(200, a.balance)
        self.assertIn('D-', conf_code)
    
    def test_account_deposit_negative_amount(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        with self.assertRaises(ValueError):
            conf_code = a.deposit(-100)
        
    def test_account_withdraw_ok(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        conf_code = a.withdraw(20)
        self.assertEqual(80, a.balance)
        self.assertIn('W-', conf_code)
        
    
    def test_account_withdraw_overdraw(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        conf_code = a.withdraw(200)
        self.assertIn('X-', conf_code)
        self.assertEqual(balance, a.balance)
        
        
run_tests(TestAccount)        
test_account_deposit_negative_amount (__main__.TestAccount.test_account_deposit_negative_amount) ... ok
...
  • 看起來從 TestCase 看出來,其中一項,使用負值創建帳戶,竟然沒有跳出例外警告,因此我們藉此來發現問題,修正程式中的 bug。
class Account:
    transaction_counter = itertools.count(100)
    _interest_rate = 0.5  # percentage
    
    _transaction_codes = {
        'deposit': 'D',
        'withdraw': 'W',
        'interest': 'I',
        'rejected': 'X'
    }
    
    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):
        # in practice we probably would want to add checks to make sure these values are valid / non-empty
        self._account_number = account_number
        self.first_name = first_name
        self.last_name = last_name
        
        if timezone is None:
            timezone = TimeZone('Asia/Taipei', 8, 0)
        self.timezone = timezone
        
        self._balance = Account.validate_real_number(initial_balance, min_value=0)
        
    @property
    def account_number(self):
        return self._account_number
    
    @property 
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self.validate_and_set_name('_first_name', value, 'First Name')
        
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self.validate_and_set_name('_last_name', value, 'Last Name')
        
    # also going to create a full_name computed property, for ease of use
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
        
    @property
    def timezone(self):
        return self._timezone
    
    @property
    def balance(self):
        return self._balance
    
    @timezone.setter
    def timezone(self, value):
        if not isinstance(value, TimeZone):
            raise ValueError('Time zone must be a valid TimeZone object.')
        self._timezone = value
          
    @classmethod
    def get_interest_rate(cls):
        return cls._interest_rate
    
    @classmethod
    def set_interest_rate(cls, value):
        if not isinstance(value, numbers.Real):
            raise ValueError('Interest rate must be a real number')
        if value < 0:
            raise ValueError('Interest rate cannot be negative.')
        cls._interest_rate = value
        
    def validate_and_set_name(self, property_name, value, field_title):
        if len(str(value).strip()) == 0:
            raise ValueError(f'{field_title} cannot be empty.')
        setattr(self, property_name, value)
        
    @staticmethod
    def validate_real_number(value, min_value=None):
        if not isinstance(value, numbers.Real):
            raise ValueError('Value must be a real number.')
            
        if min_value is not None and value < min_value:
            raise ValueError(f'Value must be at least {min_value}')
            
        # validation passed, return valid value
        return value
    
    def generate_confirmation_code(self, transaction_code):
        # main difficulty here is to generate the current time in UTC using this formatting:
        # YYYYMMDDHHMMSS
        dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')
        return f'{transaction_code}-{self.account_number}-{dt_str}-{next(Account.transaction_counter)}'
    
    @staticmethod
    def parse_confirmation_code(confirmation_code, preferred_time_zone=None):
        # dummy-A100-20190325224918-101
        parts = confirmation_code.split('-')
        if len(parts) != 4:
            # really simplistic validation here - would need something better
            raise ValueError('Invalid confirmation code')
        
        # unpack into separate variables
        transaction_code, account_number, raw_dt_utc, transaction_id = parts
        
        # need to convert raw_dt_utc into a proper datetime object
        try:
            dt_utc = datetime.strptime(raw_dt_utc, '%Y%m%d%H%M%S')
        except ValueError as ex:
            # again, probably need better error handling here
            raise ValueError('Invalid transaction datetime') from ex
          
        if preferred_time_zone is None:
            preferred_time_zone = TimeZone('Asia/Taipei', 8, 0)
            
        if not isinstance(preferred_time_zone, TimeZone):
            raise ValueError('Invalid TimeZone specified.')
            
        dt_preferred = dt_utc + preferred_time_zone.offset
        dt_preferred_str = f"{dt_preferred.strftime('%Y-%m-%d %H:%M:%S')} ({preferred_time_zone.name})"
        
        return Confirmation(account_number, transaction_code, transaction_id, dt_utc.isoformat(), dt_preferred_str)
    
    def deposit(self, value):
        value = Account.validate_real_number(value, min_value=0.01)
       
        # get transaction code
        transaction_code = Account._transaction_codes['deposit']
        
        # generate a confirmation code
        conf_code = self.generate_confirmation_code(transaction_code)
        
        # make deposit and return conf code
        self._balance += value
        return conf_code
    
    def withdraw(self, value):
        value = Account.validate_real_number(value, min_value=0.01)
        accepted = False
        if self.balance - value < 0:
            # insufficient funds - we'll reject this transaction
            transaction_code = Account._transaction_codes['rejected']
        else:
            transaction_code = Account._transaction_codes['withdraw']
            accepted = True
            
        conf_code = self.generate_confirmation_code(transaction_code)
        
        # Doing this here in case there's a problem generating a confirmation code
        # - do not want to modify the balance if we cannot generate a transaction code successfully
        if accepted:
            self._balance -= value
            
        return conf_code
    
    def pay_interest(self):
        interest = self.balance * Account.get_interest_rate() / 100
        conf_code = self.generate_confirmation_code(Account._transaction_codes['interest'])
        self._balance += interest
        return conf_code
    
    
run_tests(TestAccount)
test_account_deposit_negative_amount (__main__.TestAccount.test_account_deposit_negative_amount) ... ok
...

非常感謝 sky 兄幫我又編排過~

1個讚