zakat
xxx
"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف
_____ _ _ _ _ _
|__ /__ _| | ____ _| |_ | | (_) |__ _ __ __ _ _ __ _ _
/ // _` | |/ / _` | __| | | | | '_ \| '__/ _` | '__| | | |
/ /| (_| | < (_| | |_ | |___| | |_) | | | (_| | | | |_| |
/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_| \__,_|_| \__, |
... Never Trust, Always Verify ... |___/
This library provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
Zakat is a user-friendly Python library designed to simplify the tracking and calculation of Zakat, a fundamental pillar of Islamic finance. Whether you're an individual or an organization, Zakat provides the tools to accurately manage your Zakat obligations.
Get Started:
Install the Zakat library using pip:
pip install zakat
Testing
python -c "import zakat, sys; sys.exit(zakat.test())"
Example
from zakat import tracker, time
from datetime import datetime
from dateutil.relativedelta import relativedelta
ledger = tracker(':memory:') # or './zakat_db'
# Add balance (track a transaction)
ledger.track(10000, "Initial deposit") # default account is 1
# or
pocket_account_id = ledger.create_account("pocket")
ledger.track(
10000, # amount
"Initial deposit", # description
account=pocket_account_id,
created_time_ns=time(datetime.now()),
)
# or old transaction
box_ref = ledger.track(
10000, # amount
"Initial deposit", # description
account=ledger.create_account("bunker"),
created_time_ns=time(datetime.now() - relativedelta(years=1)),
)
# Note: If any account does not exist it will be automatically created.
# Subtract balance
ledger.subtract(500, "Plummer maintenance expense") # default account is 1
# or
subtract_report = ledger.subtract(
500, # amount
"Internet monthly subscription", # description
account=pocket_account_id,
created_time_ns=time(datetime.now()),
)
# Transfer balance
bank_account_id = ledger.create_account("bank")
ledger.transfer(100, pocket_account_id, bank_account_id) # default time is now
# or
transfer_report = ledger.transfer(
100,
from_account=pocket_account_id,
to_account=ledger.create_account("safe"),
created_time_ns=time(datetime.now()),
)
# or
bank_usd_account_id = ledger.create_account("bank (USD)")
ledger.exchange(bank_usd_account_id, rate=3.75) # suppose current currency is SAR rate=1
ledger.transfer(375, pocket_account_id, bank_usd_account_id) # This time exchange rates considered
# Note: The age of balances in all transactions are preserved while transfering.
# Estimate Zakat (generate a report)
zakat_report = ledger.check(silver_gram_price=2.5)
# Perform Zakat (Apply Zakat)
# discount from the same accounts if Zakat applicable individually or collectively
ledger.zakat(zakat_report) # --> True
# or Collect all Zakat and discount from selected accounts
parts = ledger.build_payment_parts(zakat_report.summary.total_zakatable_amount)
# modify `parts` to distribute your Zakat on selected accounts
ledger.zakat(zakat_report, parts) # --> False
Vault data structure:
The main data storage file system on disk is JSON
format, but it is shown here in JSON format for data generated by the example above (note: times will be different if re-applied by yourself):
{
"account": {
"1": {
"balance": 950000,
"created": 63879017256646705000,
"name": "",
"box": {
"63879017256646705152": {
"capital": 1000000,
"rest": 950000,
"zakat": {
"count": 0,
"last": 0,
"total": 0
}
}
},
"count": 2,
"log": {
"63879017256646705152": {
"value": 1000000,
"desc": "Initial deposit",
"ref": null,
"file": {}
},
"63879017256648155136": {
"value": -50000,
"desc": "Plummer maintenance expense",
"ref": null,
"file": {}
}
},
"hide": false,
"zakatable": true
},
"63879017256647188480": {
"balance": 892500,
"created": 63879017256647230000,
"name": "pocket",
"box": {
"63879017256647409664": {
"capital": 1000000,
"rest": 892500,
"zakat": {
"count": 0,
"last": 0,
"total": 0
}
}
},
"count": 5,
"log": {
"63879017256647409664": {
"value": 1000000,
"desc": "Initial deposit",
"ref": null,
"file": {}
},
"63879017256648392704": {
"value": -50000,
"desc": "Internet monthly subscription",
"ref": null,
"file": {}
},
"63879017256648802304": {
"value": -10000,
"desc": "",
"ref": null,
"file": {}
},
"63879017256649555968": {
"value": -10000,
"desc": "",
"ref": null,
"file": {}
},
"63879017256650096640": {
"value": -37500,
"desc": "",
"ref": null,
"file": {}
}
},
"hide": false,
"zakatable": true
},
"63879017256647622656": {
"balance": 975000,
"created": 63879017256647655000,
"name": "bunker",
"box": {
"63847481256647794688": {
"capital": 1000000,
"rest": 975000,
"zakat": {
"count": 1,
"last": 63879017256650820000,
"total": 25000
}
}
},
"count": 2,
"log": {
"63847481256647794688": {
"value": 1000000,
"desc": "Initial deposit",
"ref": null,
"file": {}
},
"63879017256650932224": {
"value": -25000,
"desc": "zakat-زكاة",
"ref": 63847481256647795000,
"file": {}
}
},
"hide": false,
"zakatable": true
},
"63879017256648605696": {
"balance": 10000,
"created": 63879017256648640000,
"name": "bank",
"box": {
"63879017256647409664": {
"capital": 10000,
"rest": 10000,
"zakat": {
"count": 0,
"last": 0,
"total": 0
}
}
},
"count": 1,
"log": {
"63879017256647409664": {
"value": 10000,
"desc": "",
"ref": null,
"file": {}
}
},
"hide": false,
"zakatable": true
},
"63879017256649383936": {
"balance": 10000,
"created": 63879017256649425000,
"name": "safe",
"box": {
"63879017256647409664": {
"capital": 10000,
"rest": 10000,
"zakat": {
"count": 0,
"last": 0,
"total": 0
}
}
},
"count": 1,
"log": {
"63879017256647409664": {
"value": 10000,
"desc": "",
"ref": null,
"file": {}
}
},
"hide": false,
"zakatable": true
},
"63879017256649859072": {
"balance": 10000,
"created": 63879017256649890000,
"name": "bank (USD)",
"box": {
"63879017256647409664": {
"capital": 10000,
"rest": 10000,
"zakat": {
"count": 0,
"last": 0,
"total": 0
}
}
},
"count": 1,
"log": {
"63879017256647409664": {
"value": 10000,
"desc": "",
"ref": null,
"file": {}
}
},
"hide": false,
"zakatable": true
}
},
"exchange": {
"63879017256649859072": {
"63879017256649998336": {
"rate": 3.75,
"description": null,
"time": 63879017256650000000
}
}
},
"history": {
"63879017256646787072": {
"63879017256646885376": {
"action": "CREATE",
"account": "1",
"ref": null,
"file": null,
"key": null,
"value": null,
"math": null
},
"63879017256647065600": {
"action": "LOG",
"account": "1",
"ref": 63879017256646705000,
"file": null,
"key": null,
"value": 1000000,
"math": null
},
"63879017256647139328": {
"action": "TRACK",
"account": "1",
"ref": 63879017256646705000,
"file": null,
"key": null,
"value": 1000000,
"math": null
}
},
"63879017256647254016": {
"63879017256647303168": {
"action": "CREATE",
"account": "63879017256647188480",
"ref": null,
"file": null,
"key": null,
"value": null,
"math": null
}
},
"63879017256647352320": {
"63879017256647385088": {
"action": "NAME",
"account": "63879017256647188480",
"ref": null,
"file": null,
"key": null,
"value": "",
"math": null
}
},
"63879017256647442432": {
"63879017256647540736": {
"action": "LOG",
"account": "63879017256647188480",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 1000000,
"math": null
},
"63879017256647589888": {
"action": "TRACK",
"account": "63879017256647188480",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 1000000,
"math": null
}
},
"63879017256647680000": {
"63879017256647712768": {
"action": "CREATE",
"account": "63879017256647622656",
"ref": null,
"file": null,
"key": null,
"value": null,
"math": null
}
},
"63879017256647745536": {
"63879017256647778304": {
"action": "NAME",
"account": "63879017256647622656",
"ref": null,
"file": null,
"key": null,
"value": "",
"math": null
}
},
"63879017256647999488": {
"63879017256648081408": {
"action": "LOG",
"account": "63879017256647622656",
"ref": 63847481256647795000,
"file": null,
"key": null,
"value": 1000000,
"math": null
},
"63879017256648122368": {
"action": "TRACK",
"account": "63879017256647622656",
"ref": 63847481256647795000,
"file": null,
"key": null,
"value": 1000000,
"math": null
}
},
"63879017256648187904": {
"63879017256648294400": {
"action": "LOG",
"account": "1",
"ref": 63879017256648155000,
"file": null,
"key": null,
"value": -50000,
"math": null
},
"63879017256648351744": {
"action": "SUBTRACT",
"account": "1",
"ref": 63879017256646705000,
"file": null,
"key": null,
"value": 50000,
"math": null
}
},
"63879017256648425472": {
"63879017256648531968": {
"action": "LOG",
"account": "63879017256647188480",
"ref": 63879017256648390000,
"file": null,
"key": null,
"value": -50000,
"math": null
},
"63879017256648564736": {
"action": "SUBTRACT",
"account": "63879017256647188480",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 50000,
"math": null
}
},
"63879017256648663040": {
"63879017256648704000": {
"action": "CREATE",
"account": "63879017256648605696",
"ref": null,
"file": null,
"key": null,
"value": null,
"math": null
}
},
"63879017256648736768": {
"63879017256648761344": {
"action": "NAME",
"account": "63879017256648605696",
"ref": null,
"file": null,
"key": null,
"value": "",
"math": null
}
},
"63879017256648818688": {
"63879017256649031680": {
"action": "LOG",
"account": "63879017256647188480",
"ref": 63879017256648800000,
"file": null,
"key": null,
"value": -10000,
"math": null
},
"63879017256649072640": {
"action": "SUBTRACT",
"account": "63879017256647188480",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
},
"63879017256649285632": {
"action": "LOG",
"account": "63879017256648605696",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
},
"63879017256649334784": {
"action": "TRACK",
"account": "63879017256648605696",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
}
},
"63879017256649441280": {
"63879017256649482240": {
"action": "CREATE",
"account": "63879017256649383936",
"ref": null,
"file": null,
"key": null,
"value": null,
"math": null
}
},
"63879017256649515008": {
"63879017256649539584": {
"action": "NAME",
"account": "63879017256649383936",
"ref": null,
"file": null,
"key": null,
"value": "",
"math": null
}
},
"63879017256649580544": {
"63879017256649662464": {
"action": "LOG",
"account": "63879017256647188480",
"ref": 63879017256649560000,
"file": null,
"key": null,
"value": -10000,
"math": null
},
"63879017256649695232": {
"action": "SUBTRACT",
"account": "63879017256647188480",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
},
"63879017256649801728": {
"action": "LOG",
"account": "63879017256649383936",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
},
"63879017256649826304": {
"action": "TRACK",
"account": "63879017256649383936",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
}
},
"63879017256649900032": {
"63879017256649932800": {
"action": "CREATE",
"account": "63879017256649859072",
"ref": null,
"file": null,
"key": null,
"value": null,
"math": null
}
},
"63879017256649957376": {
"63879017256649973760": {
"action": "NAME",
"account": "63879017256649859072",
"ref": null,
"file": null,
"key": null,
"value": "",
"math": null
}
},
"63879017256650022912": {
"63879017256650047488": {
"action": "EXCHANGE",
"account": "63879017256649859072",
"ref": 63879017256650000000,
"file": null,
"key": null,
"value": 3.75,
"math": null
}
},
"63879017256650121216": {
"63879017256650203136": {
"action": "LOG",
"account": "63879017256647188480",
"ref": 63879017256650100000,
"file": null,
"key": null,
"value": -37500,
"math": null
},
"63879017256650227712": {
"action": "SUBTRACT",
"account": "63879017256647188480",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 37500,
"math": null
},
"63879017256650334208": {
"action": "LOG",
"account": "63879017256649859072",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
},
"63879017256650366976": {
"action": "TRACK",
"account": "63879017256649859072",
"ref": 63879017256647410000,
"file": null,
"key": null,
"value": 10000,
"math": null
}
},
"63879017256650760192": {
"63879017256650801152": {
"action": "REPORT",
"account": null,
"ref": 63879017256650785000,
"file": null,
"key": null,
"value": null,
"math": null
},
"63879017256650866688": {
"action": "ZAKAT",
"account": "63879017256647622656",
"ref": 63847481256647795000,
"file": null,
"key": "last",
"value": 0,
"math": "EQUAL"
},
"63879017256650891264": {
"action": "ZAKAT",
"account": "63879017256647622656",
"ref": 63847481256647795000,
"file": null,
"key": "total",
"value": 25000,
"math": "ADDITION"
},
"63879017256650907648": {
"action": "ZAKAT",
"account": "63879017256647622656",
"ref": 63847481256647795000,
"file": null,
"key": "count",
"value": 1,
"math": "ADDITION"
},
"63879017256650964992": {
"action": "LOG",
"account": "63879017256647622656",
"ref": 63879017256650930000,
"file": null,
"key": null,
"value": -25000,
"math": null
}
}
},
"lock": null,
"report": {
"63879017256650784768": {
"valid": true,
"summary": {
"total_wealth": 2900000,
"num_wealth_items": 6,
"num_zakatable_items": 1,
"total_zakatable_amount": 1000000,
"total_zakat_due": 25000
},
"plan": {
"63879017256647622656": [
{
"box": {
"capital": 1000000,
"rest": 975000,
"zakat": {
"count": 1,
"last": 63879017256650820000,
"total": 25000
}
},
"log": {
"value": 1000000,
"desc": "Initial deposit",
"ref": null,
"file": {}
},
"exchange": {
"rate": 1,
"description": null,
"time": 63879017256650555000
},
"below_nisab": false,
"total": 25000,
"count": 1,
"ref": 63847481256647795000
}
]
}
}
}
}
Key Features:
Transaction Tracking: Easily record both income and expenses with detailed descriptions, ensuring comprehensive financial records.
Automated Zakat Calculation: Automatically calculate Zakat due based on the Nisab (minimum threshold), Haul (time cycles) and the current market price of silver, simplifying compliance with Islamic financial principles.
Customizable "Nisab": Set your own "Nisab" value based on your preferred calculation method or personal financial situation.
Customizable "Haul": Set your own "Haul" cycle based on your preferred calender method or personal financial situation.
Multiple Accounts: Manage Zakat for different assets or accounts separately for greater financial clarity.
Import/Export: Seamlessly import transaction data from CSV files [experimental] and export calculated Zakat reports in JSON format for further analysis or record-keeping.
Data Persistence: Securely save and load your Zakat tracker data for continued use across sessions.
History Tracking: Optionally enable a detailed history of actions for transparency and review (can be disabled optionally).
Benefits:
Accurate Zakat Calculation: Ensure precise calculation of Zakat obligations, promoting financial responsibility and spiritual well-being.
Streamlined Financial Management: Simplify the management of your finances by keeping track of transactions and Zakat calculations in one place.
Enhanced Transparency: Maintain a clear record of your financial activities and Zakat payments for greater accountability and peace of mind.
User-Friendly: Easily navigate through the library's intuitive interface and functionalities, even without extensive technical knowledge.
Customizable:
- Tailor the library's settings (e.g., Nisab value and Haul cycles) to your specific needs and preferences.
Who Can Benefit:
Individuals: Effectively manage personal finances and fulfill Zakat obligations.
Organizations: Streamline Zakat calculation and distribution for charitable projects and initiatives.
Islamic Financial Institutions: Integrate Zakat into existing systems for enhanced financial management and reporting.
Documentation
The Zakat Formula: A Mathematical Representation of Islamic Charity
Zakat-Aware Inventory Tracking Algorithm (with Lunar Cycle) [PLANNED]
Videos:
- Mastering Zakat: The Rooms and Boxes Algorithm Explained!
- طريقة الزكاة في العصر الرقمي: خوارزمية الغرف والصناديق
- Zakat Algorithm in 42 seconds
- How Exchange Rates Impact Zakat Calculation?
Explore the documentation, source code and examples to begin tracking your Zakat and achieving financial peace of mind in accordance with Islamic principles.
1""" 2"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف 3 4``` 5 _____ _ _ _ _ _ 6|__ /__ _| | ____ _| |_ | | (_) |__ _ __ __ _ _ __ _ _ 7 / // _` | |/ / _` | __| | | | | '_ \| '__/ _` | '__| | | | 8 / /| (_| | < (_| | |_ | |___| | |_) | | | (_| | | | |_| | 9/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_| \__,_|_| \__, | 10... Never Trust, Always Verify ... |___/ 11``` 12 13This library provides the ZakatLibrary classes, functions for tracking and calculating Zakat. 14 15.. include:: ../README.md 16""" 17# Importing necessary classes and functions from the main module 18from zakat.zakat_tracker import ( 19 Time, 20 SizeInfo, 21 FileInfo, 22 FileStats, 23 TimeSummary, 24 Transaction, 25 DailyRecords, 26 Timeline, 27 ImportStatistics, 28 CSVRecord, 29 ImportReport, 30 ZakatTracker, 31 AccountID, 32 AccountDetails, 33 Timestamp, 34 Box, 35 Log, 36 Account, 37 Exchange, 38 History, 39 Vault, 40 AccountPaymentPart, 41 PaymentParts, 42 SubtractAge, 43 SubtractAges, 44 SubtractReport, 45 TransferTime, 46 TransferTimes, 47 TransferRecord, 48 TransferReport, 49 BoxPlan, 50 ZakatSummary, 51 ZakatReport, 52 test, 53 Action, 54 JSONEncoder, 55 JSONDecoder, 56 MathOperation, 57 WeekDay, 58 StrictDataclass, 59 ImmutableWithSelectiveFreeze, 60) 61 62from zakat.file_server import ( 63 start_file_server, 64 find_available_port, 65 FileType, 66) 67 68# Shortcuts 69time = Time.time 70time_to_datetime = Time.time_to_datetime 71tracker = ZakatTracker 72 73# Version information for the module 74__version__ = ZakatTracker.Version() 75__all__ = [ 76 "Time", 77 "time", 78 "time_to_datetime", 79 "tracker", 80 "SizeInfo", 81 "FileInfo", 82 "FileStats", 83 "TimeSummary", 84 "Transaction", 85 "DailyRecords", 86 "Timeline", 87 "ImportStatistics", 88 "CSVRecord", 89 "ImportReport", 90 "ZakatTracker", 91 "AccountID", 92 "AccountDetails", 93 "Timestamp", 94 "Box", 95 "Log", 96 "Account", 97 "Exchange", 98 "History", 99 "Vault", 100 "AccountPaymentPart", 101 "PaymentParts", 102 "SubtractAge", 103 "SubtractAges", 104 "SubtractReport", 105 "TransferTime", 106 "TransferTimes", 107 "TransferRecord", 108 "TransferReport", 109 "BoxPlan", 110 "ZakatSummary", 111 "ZakatReport", 112 "test", 113 "Action", 114 "JSONEncoder", 115 "JSONDecoder", 116 "MathOperation", 117 "WeekDay", 118 "start_file_server", 119 "find_available_port", 120 "FileType", 121 "StrictDataclass", 122 "ImmutableWithSelectiveFreeze", 123]
1053class Time: 1054 """ 1055 Utility class for generating and manipulating nanosecond-precision timestamps. 1056 1057 This class provides static methods for converting between datetime objects and 1058 nanosecond-precision timestamps, ensuring uniqueness and monotonicity. 1059 """ 1060 __last_time_ns = None 1061 __time_diff_ns = None 1062 1063 @staticmethod 1064 def minimum_time_diff_ns() -> tuple[int, int]: 1065 """ 1066 Calculates the minimum time difference between two consecutive calls to 1067 `Time._time()` in nanoseconds. 1068 1069 This method is used internally to determine the minimum granularity of 1070 time measurements within the system. 1071 1072 Returns: 1073 - tuple[int, int]: 1074 - The minimum time difference in nanoseconds. 1075 - The number of iterations required to measure the difference. 1076 """ 1077 i = 0 1078 x = y = Time._time() 1079 while x == y: 1080 y = Time._time() 1081 i += 1 1082 return y - x, i 1083 1084 @staticmethod 1085 def _time(now: Optional[datetime.datetime] = None) -> Timestamp: 1086 """ 1087 Internal method to generate a nanosecond-precision timestamp from a datetime object. 1088 1089 Parameters: 1090 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. 1091 If not provided, the current datetime is used. 1092 1093 Returns: 1094 - int: The timestamp in nanoseconds since the epoch (January 1, 1AD). 1095 """ 1096 if now is None: 1097 now = datetime.datetime.now() 1098 ns_in_day = (now - now.replace( 1099 hour=0, 1100 minute=0, 1101 second=0, 1102 microsecond=0, 1103 )).total_seconds() * 10 ** 9 1104 return Timestamp(int(now.toordinal() * 86_400_000_000_000 + ns_in_day)) 1105 1106 @staticmethod 1107 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 1108 """ 1109 Generates a unique, monotonically increasing timestamp based on the provided 1110 datetime object or the current datetime. 1111 1112 This method ensures that timestamps are unique even if called in rapid succession 1113 by introducing a small delay if necessary, based on the system's minimum 1114 time resolution. 1115 1116 Parameters: 1117 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 1118 1119 Returns: 1120 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 1121 """ 1122 new_time = Time._time(now) 1123 if Time.__last_time_ns is None: 1124 Time.__last_time_ns = new_time 1125 return new_time 1126 while new_time == Time.__last_time_ns: 1127 if Time.__time_diff_ns is None: 1128 diff, _ = Time.minimum_time_diff_ns() 1129 Time.__time_diff_ns = math.ceil(diff) 1130 time.sleep(Time.__time_diff_ns / 1_000_000_000) 1131 new_time = Time._time() 1132 Time.__last_time_ns = new_time 1133 return new_time 1134 1135 @staticmethod 1136 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 1137 """ 1138 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 1139 back to a datetime object. 1140 1141 Parameters: 1142 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 1143 1144 Returns: 1145 - datetime.datetime: The corresponding datetime object. 1146 """ 1147 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 1148 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 1149 return datetime.datetime.combine(d, datetime.time()) + t 1150 1151 @staticmethod 1152 def duration_from_nanoseconds(ns: int, 1153 show_zeros_in_spoken_time: bool = False, 1154 spoken_time_separator=',', 1155 millennia: str = 'Millennia', 1156 century: str = 'Century', 1157 years: str = 'Years', 1158 days: str = 'Days', 1159 hours: str = 'Hours', 1160 minutes: str = 'Minutes', 1161 seconds: str = 'Seconds', 1162 milli_seconds: str = 'MilliSeconds', 1163 micro_seconds: str = 'MicroSeconds', 1164 nano_seconds: str = 'NanoSeconds', 1165 ) -> tuple: 1166 """ 1167 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1168 Convert NanoSeconds to Human Readable Time Format. 1169 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1170 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1171 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1172 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1173 1174 INPUT : ms (AKA: MilliSeconds) 1175 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1176 OUTPUT Variables: time_lapsed, spoken_time 1177 1178 Example Input: duration_from_nanoseconds(ns) 1179 **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'** 1180 Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia, 1 Century, 47 Years, 325 Days, 5 Hours, 2 Minutes, 3 Seconds, 456 MilliSeconds, 789 MicroSeconds, 12 NanoSeconds') 1181 duration_from_nanoseconds(1234567890123456789012) 1182 """ 1183 us, ns = divmod(ns, 1000) 1184 ms, us = divmod(us, 1000) 1185 s, ms = divmod(ms, 1000) 1186 m, s = divmod(s, 60) 1187 h, m = divmod(m, 60) 1188 d, h = divmod(h, 24) 1189 y, d = divmod(d, 365) 1190 c, y = divmod(y, 100) 1191 n, c = divmod(c, 10) 1192 time_lapsed = f'{n:03.0f}:{c:04.0f}:{y:03.0f}:{d:03.0f}:{h:02.0f}:{m:02.0f}:{s:02.0f}::{ms:03.0f}::{us:03.0f}::{ns:03.0f}' 1193 spoken_time_part = [] 1194 if n > 0 or show_zeros_in_spoken_time: 1195 spoken_time_part.append(f'{n: 3d} {millennia}') 1196 if c > 0 or show_zeros_in_spoken_time: 1197 spoken_time_part.append(f'{c: 4d} {century}') 1198 if y > 0 or show_zeros_in_spoken_time: 1199 spoken_time_part.append(f'{y: 3d} {years}') 1200 if d > 0 or show_zeros_in_spoken_time: 1201 spoken_time_part.append(f'{d: 4d} {days}') 1202 if h > 0 or show_zeros_in_spoken_time: 1203 spoken_time_part.append(f'{h: 2d} {hours}') 1204 if m > 0 or show_zeros_in_spoken_time: 1205 spoken_time_part.append(f'{m: 2d} {minutes}') 1206 if s > 0 or show_zeros_in_spoken_time: 1207 spoken_time_part.append(f'{s: 2d} {seconds}') 1208 if ms > 0 or show_zeros_in_spoken_time: 1209 spoken_time_part.append(f'{ms: 3d} {milli_seconds}') 1210 if us > 0 or show_zeros_in_spoken_time: 1211 spoken_time_part.append(f'{us: 3d} {micro_seconds}') 1212 if ns > 0 or show_zeros_in_spoken_time: 1213 spoken_time_part.append(f'{ns: 3d} {nano_seconds}') 1214 return time_lapsed, spoken_time_separator.join(spoken_time_part) 1215 1216 @staticmethod 1217 def test(debug: bool = False): 1218 """ 1219 Performs unit tests to verify the correctness of the `Time` class methods. 1220 1221 This method checks the conversion between datetime objects and timestamps, 1222 ensuring accuracy and consistency across various date ranges. 1223 1224 Parameters: 1225 - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False. 1226 """ 1227 test_cases = [ 1228 datetime.datetime(1, 1, 1), 1229 datetime.datetime(1970, 1, 1), 1230 datetime.datetime(1969, 12, 31), 1231 datetime.datetime.now(), 1232 datetime.datetime(9999, 12, 31, 23, 59, 59), 1233 ] 1234 1235 for test_date in test_cases: 1236 timestamp = Time.time(test_date) 1237 converted = Time.time_to_datetime(timestamp) 1238 if debug: 1239 print(f'{timestamp} <=> {converted}') 1240 assert timestamp > 0 1241 assert test_date.year == converted.year 1242 assert test_date.month == converted.month 1243 assert test_date.day == converted.day 1244 assert test_date.hour == converted.hour 1245 assert test_date.minute == converted.minute 1246 assert test_date.second in [converted.second - 1, converted.second, converted.second + 1] 1247 1248 # sanity check - convert date since 1AD to 9999AD 1249 1250 for year in range(1, 10_000): 1251 ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f')) 1252 date = Time.time_to_datetime(ns) 1253 if debug: 1254 print(date, date.microsecond) 1255 assert ns > 0 1256 assert date.year == year 1257 assert date.month == 12 1258 assert date.day == 30 1259 assert date.hour == 18 1260 assert date.minute == 30 1261 assert date.second in [44, 45] 1262 #assert date.microsecond == 906030
Utility class for generating and manipulating nanosecond-precision timestamps.
This class provides static methods for converting between datetime objects and nanosecond-precision timestamps, ensuring uniqueness and monotonicity.
1063 @staticmethod 1064 def minimum_time_diff_ns() -> tuple[int, int]: 1065 """ 1066 Calculates the minimum time difference between two consecutive calls to 1067 `Time._time()` in nanoseconds. 1068 1069 This method is used internally to determine the minimum granularity of 1070 time measurements within the system. 1071 1072 Returns: 1073 - tuple[int, int]: 1074 - The minimum time difference in nanoseconds. 1075 - The number of iterations required to measure the difference. 1076 """ 1077 i = 0 1078 x = y = Time._time() 1079 while x == y: 1080 y = Time._time() 1081 i += 1 1082 return y - x, i
Calculates the minimum time difference between two consecutive calls to
Time._time()
in nanoseconds.
This method is used internally to determine the minimum granularity of time measurements within the system.
Returns:
- tuple[int, int]:
- The minimum time difference in nanoseconds.
- The number of iterations required to measure the difference.
1106 @staticmethod 1107 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 1108 """ 1109 Generates a unique, monotonically increasing timestamp based on the provided 1110 datetime object or the current datetime. 1111 1112 This method ensures that timestamps are unique even if called in rapid succession 1113 by introducing a small delay if necessary, based on the system's minimum 1114 time resolution. 1115 1116 Parameters: 1117 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 1118 1119 Returns: 1120 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 1121 """ 1122 new_time = Time._time(now) 1123 if Time.__last_time_ns is None: 1124 Time.__last_time_ns = new_time 1125 return new_time 1126 while new_time == Time.__last_time_ns: 1127 if Time.__time_diff_ns is None: 1128 diff, _ = Time.minimum_time_diff_ns() 1129 Time.__time_diff_ns = math.ceil(diff) 1130 time.sleep(Time.__time_diff_ns / 1_000_000_000) 1131 new_time = Time._time() 1132 Time.__last_time_ns = new_time 1133 return new_time
Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.
This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.
Parameters:
- now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
Returns:
- Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1135 @staticmethod 1136 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 1137 """ 1138 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 1139 back to a datetime object. 1140 1141 Parameters: 1142 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 1143 1144 Returns: 1145 - datetime.datetime: The corresponding datetime object. 1146 """ 1147 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 1148 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 1149 return datetime.datetime.combine(d, datetime.time()) + t
Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.
Parameters:
- ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
Returns:
- datetime.datetime: The corresponding datetime object.
1151 @staticmethod 1152 def duration_from_nanoseconds(ns: int, 1153 show_zeros_in_spoken_time: bool = False, 1154 spoken_time_separator=',', 1155 millennia: str = 'Millennia', 1156 century: str = 'Century', 1157 years: str = 'Years', 1158 days: str = 'Days', 1159 hours: str = 'Hours', 1160 minutes: str = 'Minutes', 1161 seconds: str = 'Seconds', 1162 milli_seconds: str = 'MilliSeconds', 1163 micro_seconds: str = 'MicroSeconds', 1164 nano_seconds: str = 'NanoSeconds', 1165 ) -> tuple: 1166 """ 1167 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1168 Convert NanoSeconds to Human Readable Time Format. 1169 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1170 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1171 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1172 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1173 1174 INPUT : ms (AKA: MilliSeconds) 1175 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1176 OUTPUT Variables: time_lapsed, spoken_time 1177 1178 Example Input: duration_from_nanoseconds(ns) 1179 **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'** 1180 Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia, 1 Century, 47 Years, 325 Days, 5 Hours, 2 Minutes, 3 Seconds, 456 MilliSeconds, 789 MicroSeconds, 12 NanoSeconds') 1181 duration_from_nanoseconds(1234567890123456789012) 1182 """ 1183 us, ns = divmod(ns, 1000) 1184 ms, us = divmod(us, 1000) 1185 s, ms = divmod(ms, 1000) 1186 m, s = divmod(s, 60) 1187 h, m = divmod(m, 60) 1188 d, h = divmod(h, 24) 1189 y, d = divmod(d, 365) 1190 c, y = divmod(y, 100) 1191 n, c = divmod(c, 10) 1192 time_lapsed = f'{n:03.0f}:{c:04.0f}:{y:03.0f}:{d:03.0f}:{h:02.0f}:{m:02.0f}:{s:02.0f}::{ms:03.0f}::{us:03.0f}::{ns:03.0f}' 1193 spoken_time_part = [] 1194 if n > 0 or show_zeros_in_spoken_time: 1195 spoken_time_part.append(f'{n: 3d} {millennia}') 1196 if c > 0 or show_zeros_in_spoken_time: 1197 spoken_time_part.append(f'{c: 4d} {century}') 1198 if y > 0 or show_zeros_in_spoken_time: 1199 spoken_time_part.append(f'{y: 3d} {years}') 1200 if d > 0 or show_zeros_in_spoken_time: 1201 spoken_time_part.append(f'{d: 4d} {days}') 1202 if h > 0 or show_zeros_in_spoken_time: 1203 spoken_time_part.append(f'{h: 2d} {hours}') 1204 if m > 0 or show_zeros_in_spoken_time: 1205 spoken_time_part.append(f'{m: 2d} {minutes}') 1206 if s > 0 or show_zeros_in_spoken_time: 1207 spoken_time_part.append(f'{s: 2d} {seconds}') 1208 if ms > 0 or show_zeros_in_spoken_time: 1209 spoken_time_part.append(f'{ms: 3d} {milli_seconds}') 1210 if us > 0 or show_zeros_in_spoken_time: 1211 spoken_time_part.append(f'{us: 3d} {micro_seconds}') 1212 if ns > 0 or show_zeros_in_spoken_time: 1213 spoken_time_part.append(f'{ns: 3d} {nano_seconds}') 1214 return time_lapsed, spoken_time_separator.join(spoken_time_part)
REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 Convert NanoSeconds to Human Readable Time Format. A NanoSeconds is a unit of time in the International System of Units (SI) equal to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. Its symbol is μs, sometimes simplified to us when Unicode is not available. A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
INPUT : ms (AKA: MilliSeconds) OUTPUT: tuple(string time_lapsed, string spoken_time) like format. OUTPUT Variables: time_lapsed, spoken_time
Example Input: duration_from_nanoseconds(ns) 'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds' Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia, 1 Century, 47 Years, 325 Days, 5 Hours, 2 Minutes, 3 Seconds, 456 MilliSeconds, 789 MicroSeconds, 12 NanoSeconds') duration_from_nanoseconds(1234567890123456789012)
1216 @staticmethod 1217 def test(debug: bool = False): 1218 """ 1219 Performs unit tests to verify the correctness of the `Time` class methods. 1220 1221 This method checks the conversion between datetime objects and timestamps, 1222 ensuring accuracy and consistency across various date ranges. 1223 1224 Parameters: 1225 - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False. 1226 """ 1227 test_cases = [ 1228 datetime.datetime(1, 1, 1), 1229 datetime.datetime(1970, 1, 1), 1230 datetime.datetime(1969, 12, 31), 1231 datetime.datetime.now(), 1232 datetime.datetime(9999, 12, 31, 23, 59, 59), 1233 ] 1234 1235 for test_date in test_cases: 1236 timestamp = Time.time(test_date) 1237 converted = Time.time_to_datetime(timestamp) 1238 if debug: 1239 print(f'{timestamp} <=> {converted}') 1240 assert timestamp > 0 1241 assert test_date.year == converted.year 1242 assert test_date.month == converted.month 1243 assert test_date.day == converted.day 1244 assert test_date.hour == converted.hour 1245 assert test_date.minute == converted.minute 1246 assert test_date.second in [converted.second - 1, converted.second, converted.second + 1] 1247 1248 # sanity check - convert date since 1AD to 9999AD 1249 1250 for year in range(1, 10_000): 1251 ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f')) 1252 date = Time.time_to_datetime(ns) 1253 if debug: 1254 print(date, date.microsecond) 1255 assert ns > 0 1256 assert date.year == year 1257 assert date.month == 12 1258 assert date.day == 30 1259 assert date.hour == 18 1260 assert date.minute == 30 1261 assert date.second in [44, 45] 1262 #assert date.microsecond == 906030
Performs unit tests to verify the correctness of the Time
class methods.
This method checks the conversion between datetime objects and timestamps, ensuring accuracy and consistency across various date ranges.
Parameters:
- debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1106 @staticmethod 1107 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 1108 """ 1109 Generates a unique, monotonically increasing timestamp based on the provided 1110 datetime object or the current datetime. 1111 1112 This method ensures that timestamps are unique even if called in rapid succession 1113 by introducing a small delay if necessary, based on the system's minimum 1114 time resolution. 1115 1116 Parameters: 1117 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 1118 1119 Returns: 1120 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 1121 """ 1122 new_time = Time._time(now) 1123 if Time.__last_time_ns is None: 1124 Time.__last_time_ns = new_time 1125 return new_time 1126 while new_time == Time.__last_time_ns: 1127 if Time.__time_diff_ns is None: 1128 diff, _ = Time.minimum_time_diff_ns() 1129 Time.__time_diff_ns = math.ceil(diff) 1130 time.sleep(Time.__time_diff_ns / 1_000_000_000) 1131 new_time = Time._time() 1132 Time.__last_time_ns = new_time 1133 return new_time
Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.
This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.
Parameters:
- now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
Returns:
- Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1135 @staticmethod 1136 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 1137 """ 1138 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 1139 back to a datetime object. 1140 1141 Parameters: 1142 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 1143 1144 Returns: 1145 - datetime.datetime: The corresponding datetime object. 1146 """ 1147 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 1148 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 1149 return datetime.datetime.combine(d, datetime.time()) + t
Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.
Parameters:
- ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
Returns:
- datetime.datetime: The corresponding datetime object.
794@dataclasses.dataclass 795class SizeInfo(StrictDataclass): 796 """ 797 Represents size information in bytes and human-readable format. 798 799 Attributes: 800 - bytes (float): The size in bytes. 801 - human_readable (str): The human-readable representation of the size. 802 """ 803 bytes: float 804 human_readable: str
Represents size information in bytes and human-readable format.
Attributes:
- bytes (float): The size in bytes.
- human_readable (str): The human-readable representation of the size.
807@dataclasses.dataclass 808class FileInfo(StrictDataclass): 809 """ 810 Represents information about a file. 811 812 Attributes: 813 - type (str): The type of the file. 814 - path (str): The full path to the file. 815 - exists (bool): A boolean indicating whether the file exists. 816 - size (int): The size of the file in bytes. 817 - human_readable_size (str): The human-readable representation of the file size. 818 """ 819 type: str 820 path: str 821 exists: bool 822 size: int 823 human_readable_size: str
Represents information about a file.
Attributes:
- type (str): The type of the file.
- path (str): The full path to the file.
- exists (bool): A boolean indicating whether the file exists.
- size (int): The size of the file in bytes.
- human_readable_size (str): The human-readable representation of the file size.
826@dataclasses.dataclass 827class FileStats(StrictDataclass): 828 """ 829 Represents statistics related to file storage. 830 831 Attributes: 832 - ram (:class:`SizeInfo`): Information about the RAM usage. 833 - database (:class:`SizeInfo`): Information about the database size. 834 """ 835 ram: SizeInfo 836 database: SizeInfo
Represents statistics related to file storage.
Attributes:
839@dataclasses.dataclass 840class TimeSummary(StrictDataclass): 841 """Summary of positive, negative, and total values over a period.""" 842 positive: int = 0 843 negative: int = 0 844 total: int = 0
Summary of positive, negative, and total values over a period.
847@dataclasses.dataclass 848class Transaction(StrictDataclass): 849 """Represents a single transaction record.""" 850 account: str 851 account_id: AccountID 852 desc: str 853 file: dict[Timestamp, str] 854 value: int 855 time: Timestamp 856 transfer: bool
Represents a single transaction record.
859@dataclasses.dataclass 860class DailyRecords(TimeSummary, StrictDataclass): 861 """Represents the records for a single day, including a summary and a list of transactions.""" 862 rows: list[Transaction] = dataclasses.field(default_factory=list)
Represents the records for a single day, including a summary and a list of transactions.
Inherited Members
865@dataclasses.dataclass 866class Timeline(StrictDataclass): 867 """Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries.""" 868 daily: dict[str, DailyRecords] = dataclasses.field(default_factory=dict) 869 weekly: dict[datetime.datetime, TimeSummary] = dataclasses.field(default_factory=dict) 870 monthly: dict[str, TimeSummary] = dataclasses.field(default_factory=dict) 871 yearly: dict[int, TimeSummary] = dataclasses.field(default_factory=dict)
Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries.
738@dataclasses.dataclass 739class ImportStatistics(StrictDataclass): 740 """ 741 Statistics summarizing the results of an import operation. 742 743 Attributes: 744 - created (int): The number of new records successfully created. 745 - found (int): The number of existing records found and potentially updated. 746 - bad (int): The number of records that failed to import due to errors. 747 """ 748 created: int 749 found: int 750 bad: int
Statistics summarizing the results of an import operation.
Attributes:
- created (int): The number of new records successfully created.
- found (int): The number of existing records found and potentially updated.
- bad (int): The number of records that failed to import due to errors.
753@dataclasses.dataclass 754class CSVRecord(StrictDataclass): 755 """ 756 Represents a single record read from a CSV file. 757 758 Attributes: 759 - index (int): The original row number of the record in the CSV file (0-based). 760 - account (str): The account identifier. 761 - desc (str): A description associated with the record. 762 - value (int): The numerical value of the record. 763 - date (str): The date associated with the record (format may vary). 764 - rate (float): A rate or factor associated with the record. 765 - reference (str): An optional reference string. 766 - hashed (str): A hashed representation of the record's content. 767 - error (str): An error message if there was an issue processing this record. 768 """ 769 index: int 770 account: str 771 desc: str 772 value: int 773 date: str 774 rate: float 775 reference: str 776 hashed: str 777 error: str
Represents a single record read from a CSV file.
Attributes:
- index (int): The original row number of the record in the CSV file (0-based).
- account (str): The account identifier.
- desc (str): A description associated with the record.
- value (int): The numerical value of the record.
- date (str): The date associated with the record (format may vary).
- rate (float): A rate or factor associated with the record.
- reference (str): An optional reference string.
- hashed (str): A hashed representation of the record's content.
- error (str): An error message if there was an issue processing this record.
780@dataclasses.dataclass 781class ImportReport(StrictDataclass): 782 """ 783 A report summarizing the outcome of an import operation. 784 785 Attributes: 786 - statistics (ImportStatistics): Statistical information about the import. 787 - bad (list[CSVRecord]): A list of CSV records that failed to import, 788 including any error messages. 789 """ 790 statistics: ImportStatistics 791 bad: list[CSVRecord]
A report summarizing the outcome of an import operation.
Attributes:
- statistics (ImportStatistics): Statistical information about the import.
- bad (list[CSVRecord]): A list of CSV records that failed to import, including any error messages.
1274class ZakatTracker: 1275 """ 1276 A class for tracking and calculating Zakat. 1277 1278 This class provides functionalities for recording transactions, calculating Zakat due, 1279 and managing account balances. It also offers features like importing transactions from 1280 CSV files, exporting data to JSON format, and saving/loading the tracker state. 1281 1282 The `ZakatTracker` class is designed to handle both positive and negative transactions, 1283 allowing for flexible tracking of financial activities related to Zakat. It also supports 1284 the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due 1285 based on the current silver price. 1286 1287 The class uses a json file as its database to persist the tracker state, 1288 ensuring data integrity across sessions. It also provides options for enabling or 1289 disabling history tracking, allowing users to choose their preferred level of detail. 1290 1291 In addition, the `ZakatTracker` class includes various helper methods like 1292 `time`, `time_to_datetime`, `lock`, `free`, `recall`, `save`, `load` 1293 and more. These methods provide additional functionalities and flexibility 1294 for interacting with and managing the Zakat tracker. 1295 1296 Attributes: 1297 - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage. 1298 - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat. 1299 - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price. 1300 - ZakatTracker.Version (function): The version of the ZakatTracker class. 1301 1302 Data Structure: 1303 1304 The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data, here below is just a demonstration: 1305 1306 __vault (dict): 1307 - account (dict): 1308 - {account_id} (dict): 1309 - balance (int): The current balance of the account. 1310 - name (str): The name of the account. 1311 - created (int): The creation time for the account. 1312 - box (dict): A dictionary storing transaction details. 1313 - {timestamp} (dict): 1314 - capital (int): The initial amount of the transaction. 1315 - rest (int): The remaining amount after Zakat deductions and withdrawal. 1316 - zakat (dict): 1317 - count (int): The number of times Zakat has been calculated for this transaction. 1318 - last (int): The timestamp of the last Zakat calculation. 1319 - total (int): The total Zakat deducted from this transaction. 1320 - count (int): The total number of transactions for the account. 1321 - log (dict): A dictionary storing transaction logs. 1322 - {timestamp} (dict): 1323 - value (int): The transaction amount (positive or negative). 1324 - desc (str): The description of the transaction. 1325 - ref (int): The box reference (positive or None). 1326 - file (dict): A dictionary storing file references associated with the transaction. 1327 - hide (bool): Indicates whether the account is hidden or not. 1328 - zakatable (bool): Indicates whether the account is subject to Zakat. 1329 - exchange (dict): 1330 - {account_id} (dict): 1331 - {timestamps} (dict): 1332 - rate (float): Exchange rate when compared to local currency. 1333 - description (str): The description of the exchange rate. 1334 - history (dict): 1335 - {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed. 1336 - {order_timestamp} (dict): 1337 - {action_dict} (dict): 1338 - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT). 1339 - account (str): The account reference associated with the action. 1340 - ref (int): The reference number of the transaction. 1341 - file (int): The reference number of the file (if applicable). 1342 - key (str): The key associated with the action (e.g., 'rest', 'total'). 1343 - value (int): The value associated with the action. 1344 - math (MathOperation): The mathematical operation performed (if applicable). 1345 - lock (int or None): The timestamp indicating the current lock status (None if not locked). 1346 - report (dict): 1347 - {timestamp} (tuple): A tuple storing Zakat report details. 1348 """ 1349 1350 @staticmethod 1351 def Version() -> str: 1352 """ 1353 Returns the current version of the software. 1354 1355 This function returns a string representing the current version of the software, 1356 including major, minor, and patch version numbers in the format 'X.Y.Z'. 1357 1358 Returns: 1359 - str: The current version of the software. 1360 """ 1361 version = '0.3.3' 1362 git_hash, unstaged_count, commit_count_since_last_tag = get_git_status() 1363 if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0): 1364 version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}" 1365 print(version) 1366 return version 1367 1368 @staticmethod 1369 def ZakatCut(x: float) -> float: 1370 """ 1371 Calculates the Zakat amount due on an asset. 1372 1373 This function calculates the zakat amount due on a given asset value over one lunar year. 1374 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 1375 that exceeds a certain threshold (Nisab). 1376 1377 Parameters: 1378 - x (float): The total value of the asset on which Zakat is to be calculated. 1379 1380 Returns: 1381 - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 1382 """ 1383 return 0.025 * x # Zakat Cut in one Lunar Year 1384 1385 @staticmethod 1386 def TimeCycle(days: int = 355) -> int: 1387 """ 1388 Calculates the approximate duration of a lunar year in nanoseconds. 1389 1390 This function calculates the approximate duration of a lunar year based on the given number of days. 1391 It converts the given number of days into nanoseconds for use in high-precision timing applications. 1392 1393 Parameters: 1394 - days (int, optional): The number of days in a lunar year. Defaults to 355, 1395 which is an approximation of the average length of a lunar year. 1396 1397 Returns: 1398 - int: The approximate duration of a lunar year in nanoseconds. 1399 """ 1400 return int(60 * 60 * 24 * days * 1e9) # Lunar Year in nanoseconds 1401 1402 @staticmethod 1403 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 1404 """ 1405 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 1406 1407 This function calculates the Nisab value, which is the minimum threshold of wealth, 1408 that makes an individual liable for paying Zakat. 1409 The Nisab value is determined by the equivalent value of a specific amount 1410 of gold or silver (currently 595 grams in silver) in the local currency. 1411 1412 Parameters: 1413 - gram_price (float): The price per gram of Nisab. 1414 - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver. 1415 1416 Returns: 1417 - float: The total value of Nisab based on the given price per gram. 1418 """ 1419 return gram_price * gram_quantity 1420 1421 @staticmethod 1422 def ext() -> str: 1423 """ 1424 Returns the file extension used by the ZakatTracker class. 1425 1426 Parameters: 1427 None 1428 1429 Returns: 1430 - str: The file extension used by the ZakatTracker class, which is 'json'. 1431 """ 1432 return 'json' 1433 1434 __base_path = pathlib.Path("") 1435 __vault_path = pathlib.Path("") 1436 __memory_mode = False 1437 __debug_output: list[any] = [] 1438 __vault: Vault 1439 1440 def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True): 1441 """ 1442 Initialize ZakatTracker with database path and history mode. 1443 1444 Parameters: 1445 - db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database. 1446 - history_mode (bool, optional): The mode for tracking history. Default is True. 1447 1448 Returns: 1449 None 1450 """ 1451 self.reset() 1452 self.__memory_mode = db_path == ':memory:' 1453 self.__history(history_mode) 1454 if not self.__memory_mode: 1455 self.path(f'{db_path}/db.{self.ext()}') 1456 1457 def memory_mode(self) -> bool: 1458 """ 1459 Check if the ZakatTracker is operating in memory mode. 1460 1461 Returns: 1462 - bool: True if the database is in memory, False otherwise. 1463 """ 1464 return self.__memory_mode 1465 1466 def path(self, path: Optional[str] = None) -> str: 1467 """ 1468 Set or get the path to the database file. 1469 1470 If no path is provided, the current path is returned. 1471 If a path is provided, it is set as the new path. 1472 The function also creates the necessary directories if the provided path is a file. 1473 1474 Parameters: 1475 - path (str, optional): The new path to the database file. If not provided, the current path is returned. 1476 1477 Returns: 1478 - str: The current or new path to the database file. 1479 """ 1480 if path is None: 1481 return str(self.__vault_path) 1482 self.__vault_path = pathlib.Path(path).resolve() 1483 base_path = pathlib.Path(path).resolve() 1484 if base_path.is_file() or base_path.suffix: 1485 base_path = base_path.parent 1486 base_path.mkdir(parents=True, exist_ok=True) 1487 self.__base_path = base_path 1488 return str(self.__vault_path) 1489 1490 def base_path(self, *args) -> str: 1491 """ 1492 Generate a base path by joining the provided arguments with the existing base path. 1493 1494 Parameters: 1495 - *args (str): Variable length argument list of strings to be joined with the base path. 1496 1497 Returns: 1498 - str: The generated base path. If no arguments are provided, the existing base path is returned. 1499 """ 1500 if not args: 1501 return str(self.__base_path) 1502 filtered_args = [] 1503 ignored_filename = None 1504 for arg in args: 1505 if pathlib.Path(arg).suffix: 1506 ignored_filename = arg 1507 else: 1508 filtered_args.append(arg) 1509 base_path = pathlib.Path(self.__base_path) 1510 full_path = base_path.joinpath(*filtered_args) 1511 full_path.mkdir(parents=True, exist_ok=True) 1512 if ignored_filename is not None: 1513 return full_path.resolve() / ignored_filename # Join with the ignored filename 1514 return str(full_path.resolve()) 1515 1516 @staticmethod 1517 def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int: 1518 """ 1519 Scales a numerical value by a specified power of 10, returning an integer. 1520 1521 This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and 1522 facilitate precise scaling operations, particularly useful in financial or scientific calculations. 1523 1524 Parameters: 1525 - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal. 1526 - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled 1527 by a factor of 100 (e.g., converts 1.23 to 123). 1528 1529 Returns: 1530 - The scaled value, rounded to the nearest integer. 1531 1532 Raises: 1533 - TypeError: If the input `x` is not a valid numeric type. 1534 1535 Examples: 1536 ```bash 1537 >>> ZakatTracker.scale(3.14159) 1538 314 1539 >>> ZakatTracker.scale(1234, decimal_places=3) 1540 1234000 1541 >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4) 1542 50 1543 ``` 1544 """ 1545 if not isinstance(x, (float, int, decimal.Decimal)): 1546 raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.') 1547 return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places)) 1548 1549 @staticmethod 1550 def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal: 1551 """ 1552 Unscales an integer by a power of 10. 1553 1554 Parameters: 1555 - x (int): The integer to unscale. 1556 - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float. 1557 - decimal_places (int, optional): The power of 10 to use. Defaults to 2. 1558 1559 Returns: 1560 - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type. 1561 1562 Raises: 1563 - TypeError: If the return_type is not float or decimal.Decimal. 1564 """ 1565 if return_type not in (float, decimal.Decimal): 1566 raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.') 1567 return round(return_type(x / (10 ** decimal_places)), decimal_places) 1568 1569 def reset(self) -> None: 1570 """ 1571 Reset the internal data structure to its initial state. 1572 1573 Parameters: 1574 None 1575 1576 Returns: 1577 None 1578 """ 1579 self.__vault = Vault() 1580 1581 def clean_history(self, lock: Optional[Timestamp] = None) -> int: 1582 """ 1583 Cleans up the empty history records of actions performed on the ZakatTracker instance. 1584 1585 Parameters: 1586 - lock (Timestamp, optional): The lock ID is used to clean up the empty history. 1587 If not provided, it cleans up the empty history records for all locks. 1588 1589 Returns: 1590 - int: The number of locks cleaned up. 1591 """ 1592 count = 0 1593 if lock in self.__vault.history: 1594 if len(self.__vault.history[lock]) <= 0: 1595 count += 1 1596 del self.__vault.history[lock] 1597 return count 1598 for key in self.__vault.history: 1599 if len(self.__vault.history[key]) <= 0: 1600 count += 1 1601 del self.__vault.history[key] 1602 return count 1603 1604 def __history(self, status: Optional[bool] = None) -> bool: 1605 """ 1606 Enable or disable history tracking. 1607 1608 Parameters: 1609 - status (bool, optional): The status of history tracking. Default is True. 1610 1611 Returns: 1612 None 1613 """ 1614 if status is not None: 1615 self.__history_mode = status 1616 return self.__history_mode 1617 1618 def __step(self, action: Optional[Action] = None, 1619 account: Optional[AccountID] = None, 1620 ref: Optional[Timestamp] = None, 1621 file: Optional[Timestamp] = None, 1622 value: Optional[any] = None, # !!! 1623 key: Optional[str] = None, 1624 math_operation: Optional[MathOperation] = None, 1625 lock_once: bool = True, 1626 debug: bool = False, 1627 ) -> Optional[Timestamp]: 1628 """ 1629 This method is responsible for recording the actions performed on the ZakatTracker. 1630 1631 Parameters: 1632 - action (Action, optional): The type of action performed. 1633 - account (AccountID, optional): The account reference on which the action was performed. 1634 - ref (Optional, optional): The reference number of the action. 1635 - file (Timestamp, optional): The file reference number of the action. 1636 - value (any, optional): The value associated with the action. 1637 - key (str, optional): The key associated with the action. 1638 - math_operation (MathOperation, optional): The mathematical operation performed during the action. 1639 - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True. 1640 - debug (bool, optional): If True, the function will print debug information. Default is False. 1641 1642 Returns: 1643 - Optional[Timestamp]: The lock time of the recorded action. If no lock was performed, it returns 0. 1644 """ 1645 if not self.__history(): 1646 return None 1647 no_lock = self.nolock() 1648 lock = self.__vault.lock 1649 if no_lock: 1650 lock = self.__vault.lock = Time.time() 1651 self.__vault.history[lock] = {} 1652 if action is None: 1653 if lock_once: 1654 assert no_lock, 'forbidden: lock called twice!!!' 1655 return lock 1656 if debug: 1657 print_stack() 1658 assert lock is not None 1659 assert lock > 0 1660 assert account is None or action != Action.REPORT 1661 self.__vault.history[lock][Time.time()] = History( 1662 action=action, 1663 account=account, 1664 ref=ref, 1665 file=file, 1666 key=key, 1667 value=value, 1668 math=math_operation, 1669 ) 1670 return lock 1671 1672 def nolock(self) -> bool: 1673 """ 1674 Check if the vault lock is currently not set. 1675 1676 Parameters: 1677 None 1678 1679 Returns: 1680 - bool: True if the vault lock is not set, False otherwise. 1681 """ 1682 return self.__vault.lock is None 1683 1684 def __lock(self) -> Optional[Timestamp]: 1685 """ 1686 Acquires a lock, potentially repeatedly, by calling the internal `_step` method. 1687 1688 This method specifically invokes the `_step` method with `lock_once` set to `False` 1689 indicating that the lock should be acquired even if it was previously acquired. 1690 This is useful for ensuring a lock is held throughout a critical section of code 1691 1692 Returns: 1693 - Optional[Timestamp]: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt. 1694 """ 1695 return self.__step(lock_once=False) 1696 1697 def lock(self) -> Optional[Timestamp]: 1698 """ 1699 Acquires a lock on the ZakatTracker instance. 1700 1701 Parameters: 1702 None 1703 1704 Returns: 1705 - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later. 1706 """ 1707 return self.__step() 1708 1709 def steps(self) -> dict: 1710 """ 1711 Returns a copy of the history of steps taken in the ZakatTracker. 1712 1713 The history is a dictionary where each key is a unique identifier for a step, 1714 and the corresponding value is a dictionary containing information about the step. 1715 1716 Parameters: 1717 None 1718 1719 Returns: 1720 - dict: A copy of the history of steps taken in the ZakatTracker. 1721 """ 1722 return { 1723 lock: { 1724 timestamp: dataclasses.asdict(history) 1725 for timestamp, history in steps.items() 1726 } 1727 for lock, steps in self.__vault.history.items() 1728 } 1729 1730 def free(self, lock: Timestamp, auto_save: bool = True) -> bool: 1731 """ 1732 Releases the lock on the database. 1733 1734 Parameters: 1735 - lock (Timestamp): The lock ID to be released. 1736 - auto_save (bool, optional): Whether to automatically save the database after releasing the lock. 1737 1738 Returns: 1739 - bool: True if the lock is successfully released and (optionally) saved, False otherwise. 1740 """ 1741 if lock == self.__vault.lock: 1742 self.clean_history(lock) 1743 self.__vault.lock = None 1744 if auto_save and not self.memory_mode(): 1745 return self.save(self.path()) 1746 return True 1747 return False 1748 1749 def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool: 1750 """ 1751 Revert the last operation. 1752 1753 Parameters: 1754 - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 1755 - lock (Timestamp, optional): An optional lock value to ensure the recall 1756 operation is performed on the expected history entry. If provided, 1757 it checks if the current lock and the most recent history key 1758 match the given lock value. Defaults to None. 1759 - debug (bool, optional): If True, the function will print debug information. Default is False. 1760 1761 Returns: 1762 - bool: True if the operation was successful, False otherwise. 1763 """ 1764 if not self.nolock() or len(self.__vault.history) == 0: 1765 return False 1766 if len(self.__vault.history) <= 0: 1767 return False 1768 ref = sorted(self.__vault.history.keys())[-1] 1769 if debug: 1770 print('recall', ref) 1771 memory = sorted(self.__vault.history[ref], reverse=True) 1772 if debug: 1773 print(type(memory), 'memory', memory) 1774 if lock is not None: 1775 assert self.__vault.lock == lock, "Invalid current lock" 1776 assert ref == lock, "Invalid last lock" 1777 assert self.__history(), "History mode should be enabled, found off!!!" 1778 sub_positive_log_negative = 0 1779 for i in memory: 1780 x = self.__vault.history[ref][i] 1781 if debug: 1782 print(type(x), x) 1783 if x.action != Action.REPORT: 1784 assert x.account is not None 1785 if x.action != Action.EXCHANGE: 1786 assert self.account_exists(x.account) 1787 match x.action: 1788 case Action.CREATE: 1789 if debug: 1790 print('account', self.__vault.account[x.account]) 1791 assert len(self.__vault.account[x.account].box) == 0 1792 assert len(self.__vault.account[x.account].log) == 0 1793 assert self.__vault.account[x.account].balance == 0 1794 assert self.__vault.account[x.account].count == 0 1795 assert self.__vault.account[x.account].name == '' 1796 if dry: 1797 continue 1798 del self.__vault.account[x.account] 1799 1800 case Action.NAME: 1801 assert x.value is not None 1802 if dry: 1803 continue 1804 self.__vault.account[x.account].name = x.value 1805 1806 case Action.TRACK: 1807 assert x.value is not None 1808 assert x.ref is not None 1809 if dry: 1810 continue 1811 self.__vault.account[x.account].balance -= x.value 1812 self.__vault.account[x.account].count -= 1 1813 del self.__vault.account[x.account].box[x.ref] 1814 1815 case Action.LOG: 1816 assert x.ref in self.__vault.account[x.account].log 1817 assert x.value is not None 1818 if dry: 1819 continue 1820 if sub_positive_log_negative == -x.value: 1821 self.__vault.account[x.account].count -= 1 1822 sub_positive_log_negative = 0 1823 box_ref = self.__vault.account[x.account].log[x.ref].ref 1824 if not box_ref is None: 1825 assert self.box_exists(x.account, box_ref) 1826 box_value = self.__vault.account[x.account].log[x.ref].value 1827 assert box_value < 0 1828 1829 try: 1830 self.__vault.account[x.account].box[box_ref].rest += -box_value 1831 except TypeError: 1832 self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value) 1833 1834 try: 1835 self.__vault.account[x.account].balance += -box_value 1836 except TypeError: 1837 self.__vault.account[x.account].balance += decimal.Decimal(-box_value) 1838 1839 self.__vault.account[x.account].count -= 1 1840 del self.__vault.account[x.account].log[x.ref] 1841 1842 case Action.SUBTRACT: 1843 assert x.ref in self.__vault.account[x.account].box 1844 assert x.value is not None 1845 if dry: 1846 continue 1847 self.__vault.account[x.account].box[x.ref].rest += x.value 1848 self.__vault.account[x.account].balance += x.value 1849 sub_positive_log_negative = x.value 1850 1851 case Action.ADD_FILE: 1852 assert x.ref in self.__vault.account[x.account].log 1853 assert x.file is not None 1854 assert dry or x.file in self.__vault.account[x.account].log[x.ref].file 1855 if dry: 1856 continue 1857 del self.__vault.account[x.account].log[x.ref].file[x.file] 1858 1859 case Action.REMOVE_FILE: 1860 assert x.ref in self.__vault.account[x.account].log 1861 assert x.file is not None 1862 assert x.value is not None 1863 if dry: 1864 continue 1865 self.__vault.account[x.account].log[x.ref].file[x.file] = x.value 1866 1867 case Action.BOX_TRANSFER: 1868 assert x.ref in self.__vault.account[x.account].box 1869 assert x.value is not None 1870 if dry: 1871 continue 1872 self.__vault.account[x.account].box[x.ref].rest -= x.value 1873 1874 case Action.EXCHANGE: 1875 assert x.account in self.__vault.exchange 1876 assert x.ref in self.__vault.exchange[x.account] 1877 if dry: 1878 continue 1879 del self.__vault.exchange[x.account][x.ref] 1880 1881 case Action.REPORT: 1882 assert x.ref in self.__vault.report 1883 if dry: 1884 continue 1885 del self.__vault.report[x.ref] 1886 1887 case Action.ZAKAT: 1888 assert x.ref in self.__vault.account[x.account].box 1889 assert x.key is not None 1890 assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) 1891 if dry: 1892 continue 1893 match x.math: 1894 case MathOperation.ADDITION: 1895 setattr( 1896 self.__vault.account[x.account].box[x.ref].zakat, 1897 x.key, 1898 getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value, 1899 ) 1900 case MathOperation.EQUAL: 1901 setattr( 1902 self.__vault.account[x.account].box[x.ref].zakat, 1903 x.key, 1904 x.value, 1905 ) 1906 case MathOperation.SUBTRACTION: 1907 setattr( 1908 self.__vault.account[x.account].box[x.ref], 1909 x.key, 1910 getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value, 1911 ) 1912 1913 if not dry: 1914 del self.__vault.history[ref] 1915 return True 1916 1917 def vault(self) -> dict: 1918 """ 1919 Returns a copy of the internal vault dictionary. 1920 1921 This method is used to retrieve the current state of the ZakatTracker object. 1922 It provides a snapshot of the internal data structure, allowing for further 1923 processing or analysis. 1924 1925 Parameters: 1926 None 1927 1928 Returns: 1929 - dict: A copy of the internal vault dictionary. 1930 """ 1931 return dataclasses.asdict(self.__vault) 1932 1933 @staticmethod 1934 def stats_init() -> FileStats: 1935 """ 1936 Initialize and return the initial file statistics. 1937 1938 Returns: 1939 - FileStats: A :class:`FileStats` instance with initial values 1940 of 0 bytes for both RAM and database. 1941 """ 1942 return FileStats( 1943 database=SizeInfo(0, '0'), 1944 ram=SizeInfo(0, '0'), 1945 ) 1946 1947 def stats(self, ignore_ram: bool = True) -> FileStats: 1948 """ 1949 Calculates and returns statistics about the object's data storage. 1950 1951 This method determines the size of the database file on disk and the 1952 size of the data currently held in RAM (likely within a dictionary). 1953 Both sizes are reported in bytes and in a human-readable format 1954 (e.g., KB, MB). 1955 1956 Parameters: 1957 - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True 1958 1959 Returns: 1960 - FileStats: A dataclass containing the following statistics: 1961 1962 * 'database': A tuple with two elements: 1963 - The database file size in bytes (float). 1964 - The database file size in human-readable format (str). 1965 * 'ram': A tuple with two elements: 1966 - The RAM usage (dictionary size) in bytes (float). 1967 - The RAM usage in human-readable format (str). 1968 1969 Example: 1970 ```bash 1971 >>> x = ZakatTracker() 1972 >>> stats = x.stats() 1973 >>> print(stats.database) 1974 SizeInfo(bytes=256000, human_readable='250.0 KB') 1975 >>> print(stats.ram) 1976 SizeInfo(bytes=12345, human_readable='12.1 KB') 1977 ``` 1978 """ 1979 ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault()) 1980 file_size = os.path.getsize(self.path()) 1981 return FileStats( 1982 database=SizeInfo(file_size, self.human_readable_size(file_size)), 1983 ram=SizeInfo(ram_size, self.human_readable_size(ram_size)), 1984 ) 1985 1986 def files(self) -> list[FileInfo]: 1987 """ 1988 Retrieves information about files associated with this class. 1989 1990 This class method provides a standardized way to gather details about 1991 files used by the class for storage, snapshots, and CSV imports. 1992 1993 Parameters: 1994 None 1995 1996 Returns: 1997 - list[FileInfo]: A list of dataclass, each containing information 1998 about a specific file: 1999 2000 * type (str): The type of file ('database', 'snapshot', 'import_csv'). 2001 * path (str): The full file path. 2002 * exists (bool): Whether the file exists on the filesystem. 2003 * size (int): The file size in bytes (0 if the file doesn't exist). 2004 * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB'). 2005 """ 2006 result = [] 2007 for file_type, path in { 2008 'database': self.path(), 2009 'snapshot': self.snapshot_cache_path(), 2010 'import_csv': self.import_csv_cache_path(), 2011 }.items(): 2012 exists = os.path.exists(path) 2013 size = os.path.getsize(path) if exists else 0 2014 human_readable_size = self.human_readable_size(size) if exists else '0' 2015 result.append(FileInfo( 2016 type=file_type, 2017 path=path, 2018 exists=exists, 2019 size=size, 2020 human_readable_size=human_readable_size, 2021 )) 2022 return result 2023 2024 def account_exists(self, account: AccountID) -> bool: 2025 """ 2026 Check if the given account exists in the vault. 2027 2028 Parameters: 2029 - account (AccountID): The account reference to check. 2030 2031 Returns: 2032 - bool: True if the account exists, False otherwise. 2033 """ 2034 account = AccountID(account) 2035 return account in self.__vault.account 2036 2037 def box_size(self, account: AccountID) -> int: 2038 """ 2039 Calculate the size of the box for a specific account. 2040 2041 Parameters: 2042 - account (AccountID): The account reference for which the box size needs to be calculated. 2043 2044 Returns: 2045 - int: The size of the box for the given account. If the account does not exist, -1 is returned. 2046 """ 2047 if self.account_exists(account): 2048 return len(self.__vault.account[account].box) 2049 return -1 2050 2051 def log_size(self, account: AccountID) -> int: 2052 """ 2053 Get the size of the log for a specific account. 2054 2055 Parameters: 2056 - account (AccountID): The account reference for which the log size needs to be calculated. 2057 2058 Returns: 2059 - int: The size of the log for the given account. If the account does not exist, -1 is returned. 2060 """ 2061 if self.account_exists(account): 2062 return len(self.__vault.account[account].log) 2063 return -1 2064 2065 @staticmethod 2066 def hash_data(data: bytes, algorithm: str = 'blake2b') -> str: 2067 """ 2068 Calculates the hash of given byte data using the specified algorithm. 2069 2070 Parameters: 2071 - data (bytes): The byte data to hash. 2072 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2073 2074 Returns: 2075 - str: The hexadecimal representation of the data's hash. 2076 """ 2077 hash_obj = hashlib.new(algorithm) 2078 hash_obj.update(data) 2079 return hash_obj.hexdigest() 2080 2081 @staticmethod 2082 def hash_file(file_path: str, algorithm: str = 'blake2b') -> str: 2083 """ 2084 Calculates the hash of a file using the specified algorithm. 2085 2086 Parameters: 2087 - file_path (str): The path to the file. 2088 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2089 2090 Returns: 2091 - str: The hexadecimal representation of the file's hash. 2092 """ 2093 hash_obj = hashlib.new(algorithm) # Create the hash object 2094 with open(file_path, 'rb') as file: # Open file in binary mode for reading 2095 for chunk in iter(lambda: file.read(4096), b''): # Read file in chunks 2096 hash_obj.update(chunk) 2097 return hash_obj.hexdigest() # Return the hash as a hexadecimal string 2098 2099 def snapshot_cache_path(self): 2100 """ 2101 Generate the path for the cache file used to store snapshots. 2102 2103 The cache file is a json file that stores the timestamps of the snapshots. 2104 The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'. 2105 2106 Parameters: 2107 None 2108 2109 Returns: 2110 - str: The path to the cache file. 2111 """ 2112 path = str(self.path()) 2113 ext = self.ext() 2114 ext_len = len(ext) 2115 if path.endswith(f'.{ext}'): 2116 path = path[:-ext_len - 1] 2117 _, filename = os.path.split(path + f'.snapshots.{ext}') 2118 return self.base_path(filename) 2119 2120 def snapshot(self) -> bool: 2121 """ 2122 This function creates a snapshot of the current database state. 2123 2124 The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. 2125 If a snapshot with the same hash exists, the function returns True without creating a new snapshot. 2126 If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state 2127 in a new json file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp. 2128 2129 Parameters: 2130 None 2131 2132 Returns: 2133 - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails. 2134 """ 2135 current_hash = self.hash_file(self.path()) 2136 cache: dict[str, int] = {} # hash: time_ns 2137 try: 2138 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2139 cache = json.load(stream, cls=JSONDecoder) 2140 except: 2141 pass 2142 if current_hash in cache: 2143 return True 2144 ref = time.time_ns() 2145 cache[current_hash] = ref 2146 if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')): 2147 return False 2148 with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream: 2149 stream.write(json.dumps(cache, cls=JSONEncoder)) 2150 return True 2151 2152 def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \ 2153 -> dict[int, tuple[str, str, bool]]: 2154 """ 2155 Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status. 2156 2157 Parameters: 2158 - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True. 2159 - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False. 2160 2161 Returns: 2162 - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, 2163 and the values are tuples containing the snapshot's hash, path, and existence status. 2164 """ 2165 cache: dict[str, int] = {} # hash: time_ns 2166 try: 2167 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2168 cache = json.load(stream, cls=JSONDecoder) 2169 except: 2170 pass 2171 if not cache: 2172 return {} 2173 result: dict[int, tuple[str, str, bool]] = {} # time_ns: (hash, path, exists) 2174 for hash_file, ref in cache.items(): 2175 path = self.base_path('snapshots', f'{ref}.{self.ext()}') 2176 exists = os.path.exists(path) 2177 valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True 2178 if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists): 2179 continue 2180 if exists or not hide_missing: 2181 result[ref] = (hash_file, path, exists) 2182 return result 2183 2184 def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool: 2185 """ 2186 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 2187 2188 Parameters: 2189 - account (AccountID): The account reference for which to check the existence of the reference. 2190 - ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 2191 - ref (Timestamp): The reference (transaction) number to check for existence. 2192 2193 Returns: 2194 - bool: True if the reference exists for the given account and reference type, False otherwise. 2195 """ 2196 account = AccountID(account) 2197 if account in self.__vault.account: 2198 return ref in getattr(self.__vault.account[account], ref_type) 2199 return False 2200 2201 def box_exists(self, account: AccountID, ref: Timestamp) -> bool: 2202 """ 2203 Check if a specific box (transaction) exists in the vault for a given account and reference. 2204 2205 Parameters: 2206 - account (AccountID): The account reference for which to check the existence of the box. 2207 - ref (Timestamp): The reference (transaction) number to check for existence. 2208 2209 Returns: 2210 - bool: True if the box exists for the given account and reference, False otherwise. 2211 """ 2212 return self.ref_exists(account, 'box', ref) 2213 2214 def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'), 2215 created_time_ns: Optional[Timestamp] = None, 2216 debug: bool = False) -> Optional[Timestamp]: 2217 """ 2218 This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box. 2219 2220 Parameters: 2221 - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0. 2222 - desc (str, optional): The description of the transaction. Default is an empty string. 2223 - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'. 2224 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None. 2225 - debug (bool, optional): Whether to print debug information. Default is False. 2226 2227 Returns: 2228 - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD). 2229 2230 Raises: 2231 - ValueError: The created_time_ns should be greater than zero. 2232 - ValueError: The log transaction happened again in the same nanosecond time. 2233 - ValueError: The box transaction happened again in the same nanosecond time. 2234 """ 2235 return self.__track( 2236 unscaled_value=unscaled_value, 2237 desc=desc, 2238 account=account, 2239 logging=True, 2240 created_time_ns=created_time_ns, 2241 debug=debug, 2242 ) 2243 2244 def __track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'), 2245 logging: bool = True, 2246 created_time_ns: Optional[Timestamp] = None, 2247 debug: bool = False) -> Optional[Timestamp]: 2248 """ 2249 Internal function to track a transaction. 2250 2251 This function handles the core logic for tracking a transaction, including account creation, logging, and box creation. 2252 2253 Parameters: 2254 - unscaled_value (float | int | decimal.Decimal, optional): The monetary value of the transaction. Defaults to 0. 2255 - desc (str, optional): A description of the transaction. Defaults to an empty string. 2256 - account (AccountID, optional): The reference of the account to track the transaction for. Defaults to '1'. 2257 - logging (bool, optional): Enables transaction logging. Defaults to True. 2258 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since the epoch. If not provided, the current time is used. Defaults to None. 2259 - debug (bool, optional): Enables debug printing. Defaults to False. 2260 2261 Returns: 2262 - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since the epoch. 2263 2264 Raises: 2265 - ValueError: If `created_time_ns` is not greater than zero. 2266 - ValueError: If a box transaction already exists for the given `account` and `created_time_ns`. 2267 """ 2268 if debug: 2269 print('track', f'unscaled_value={unscaled_value}, debug={debug}') 2270 account = AccountID(account) 2271 if created_time_ns is None: 2272 created_time_ns = Time.time() 2273 if created_time_ns <= 0: 2274 raise ValueError('The created should be greater than zero.') 2275 no_lock = self.nolock() 2276 lock = self.__lock() 2277 if not self.account_exists(account): 2278 if debug: 2279 print(f'account {account} created') 2280 self.__vault.account[account] = Account( 2281 balance=0, 2282 created=created_time_ns, 2283 ) 2284 self.__step(Action.CREATE, account) 2285 if unscaled_value == 0: 2286 if no_lock: 2287 assert lock is not None 2288 self.free(lock) 2289 return None 2290 value = self.scale(unscaled_value) 2291 if logging: 2292 self.__log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 2293 if debug: 2294 print('create-box', created_time_ns) 2295 if self.box_exists(account, created_time_ns): 2296 raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).') 2297 if debug: 2298 print('created-box', created_time_ns) 2299 self.__vault.account[account].box[created_time_ns] = Box( 2300 capital=value, 2301 rest=value, 2302 zakat=BoxZakat(0, 0, 0), 2303 ) 2304 self.__step(Action.TRACK, account, ref=created_time_ns, value=value) 2305 if no_lock: 2306 assert lock is not None 2307 self.free(lock) 2308 return created_time_ns 2309 2310 def log_exists(self, account: AccountID, ref: Timestamp) -> bool: 2311 """ 2312 Checks if a specific transaction log entry exists for a given account. 2313 2314 Parameters: 2315 - account (AccountID): The account reference associated with the transaction log. 2316 - ref (Timestamp): The reference to the transaction log entry. 2317 2318 Returns: 2319 - bool: True if the transaction log entry exists, False otherwise. 2320 """ 2321 return self.ref_exists(account, 'log', ref) 2322 2323 def __log(self, value: int, desc: str = '', account: AccountID = AccountID('1'), 2324 created_time_ns: Optional[Timestamp] = None, 2325 ref: Optional[Timestamp] = None, 2326 debug: bool = False) -> Timestamp: 2327 """ 2328 Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details. 2329 It also creates a step in the history of the transaction. 2330 2331 Parameters: 2332 - value (int): The value of the transaction. 2333 - desc (str, optional): The description of the transaction. 2334 - account (AccountID, optional): The account reference to log the transaction into. Default is '1'. 2335 - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 2336 If not provided, it will be generated. 2337 - ref (Timestamp, optional): The reference of the object. 2338 - debug (bool, optional): Whether to print debug information. Default is False. 2339 2340 Returns: 2341 - Timestamp: The timestamp of the logged transaction. 2342 2343 Raises: 2344 - ValueError: The created_time_ns should be greater than zero. 2345 - ValueError: The log transaction happened again in the same nanosecond time. 2346 """ 2347 if debug: 2348 print('_log', f'debug={debug}') 2349 account = AccountID(account) 2350 if created_time_ns is None: 2351 created_time_ns = Time.time() 2352 if created_time_ns <= 0: 2353 raise ValueError('The created should be greater than zero.') 2354 try: 2355 self.__vault.account[account].balance += value 2356 except TypeError: 2357 self.__vault.account[account].balance += decimal.Decimal(value) 2358 self.__vault.account[account].count += 1 2359 if debug: 2360 print('create-log', created_time_ns) 2361 if self.log_exists(account, created_time_ns): 2362 raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).') 2363 if debug: 2364 print('created-log', created_time_ns) 2365 self.__vault.account[account].log[created_time_ns] = Log( 2366 value=value, 2367 desc=desc, 2368 ref=ref, 2369 file={}, 2370 ) 2371 self.__step(Action.LOG, account, ref=created_time_ns, value=value) 2372 return created_time_ns 2373 2374 def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None, 2375 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange: 2376 """ 2377 This method is used to record or retrieve exchange rates for a specific account. 2378 2379 Parameters: 2380 - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved. 2381 - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 2382 - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 2383 - description (str, optional): A description of the exchange rate. 2384 - debug (bool, optional): Whether to print debug information. Default is False. 2385 2386 Returns: 2387 - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 2388 it returns a dictionary with default values for the rate and description. 2389 2390 Raises: 2391 - ValueError: The created should be greater than zero. 2392 """ 2393 if debug: 2394 print('exchange', f'debug={debug}') 2395 account = AccountID(account) 2396 if created_time_ns is None: 2397 created_time_ns = Time.time() 2398 if created_time_ns <= 0: 2399 raise ValueError('The created should be greater than zero.') 2400 if rate is not None: 2401 if rate <= 0: 2402 return Exchange() 2403 if account not in self.__vault.exchange: 2404 self.__vault.exchange[account] = {} 2405 if len(self.__vault.exchange[account]) == 0 and rate <= 1: 2406 return Exchange(time=created_time_ns, rate=1) 2407 no_lock = self.nolock() 2408 lock = self.__lock() 2409 self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description) 2410 self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate) 2411 if no_lock: 2412 assert lock is not None 2413 self.free(lock) 2414 if debug: 2415 print('exchange-created-1', 2416 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2417 2418 if account in self.__vault.exchange: 2419 valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns] 2420 if valid_rates: 2421 latest_rate = max(valid_rates, key=lambda x: x[0]) 2422 if debug: 2423 print('exchange-read-1', 2424 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}', 2425 'latest_rate', latest_rate) 2426 result = latest_rate[1] 2427 result.time = latest_rate[0] 2428 return result # إرجاع قاموس يحتوي على المعدل والوصف 2429 if debug: 2430 print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2431 return Exchange(time=created_time_ns, rate=1, description=None) # إرجاع القيمة الافتراضية مع وصف فارغ 2432 2433 @staticmethod 2434 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 2435 """ 2436 This function calculates the exchanged amount of a currency. 2437 2438 Parameters: 2439 - x (float): The original amount of the currency. 2440 - x_rate (float): The exchange rate of the original currency. 2441 - y_rate (float): The exchange rate of the target currency. 2442 2443 Returns: 2444 - float: The exchanged amount of the target currency. 2445 """ 2446 return (x * x_rate) / y_rate 2447 2448 def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]: 2449 """ 2450 Retrieve the recorded exchange rates for all accounts. 2451 2452 Parameters: 2453 None 2454 2455 Returns: 2456 - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. 2457 The keys are account references or numbers, and the values are dictionaries containing the exchange rates. 2458 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 2459 """ 2460 return self.__vault.exchange.copy() 2461 2462 def accounts(self) -> dict[AccountID, AccountDetails]: 2463 """ 2464 Returns a dictionary containing account references as keys and their respective account details as values. 2465 2466 Parameters: 2467 None 2468 2469 Returns: 2470 - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details. 2471 """ 2472 return { 2473 account_id: AccountDetails( 2474 account_id=account_id, 2475 account_name=self.__vault.account[account_id].name, 2476 balance=self.__vault.account[account_id].balance, 2477 ) 2478 for account_id in self.__vault.account 2479 } 2480 2481 def boxes(self, account: AccountID) -> dict[Timestamp, Box]: 2482 """ 2483 Retrieve the boxes (transactions) associated with a specific account. 2484 2485 Parameters: 2486 - account (AccountID): The account reference for which to retrieve the boxes. 2487 2488 Returns: 2489 - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account. 2490 If the account does not exist, an empty dictionary is returned. 2491 """ 2492 if self.account_exists(account): 2493 return self.__vault.account[account].box 2494 return {} 2495 2496 def logs(self, account: AccountID) -> dict[Timestamp, Log]: 2497 """ 2498 Retrieve the logs (transactions) associated with a specific account. 2499 2500 Parameters: 2501 - account (AccountID): The account reference for which to retrieve the logs. 2502 2503 Returns: 2504 - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. 2505 If the account does not exist, an empty dictionary is returned. 2506 """ 2507 if self.account_exists(account): 2508 return self.__vault.account[account].log 2509 return {} 2510 2511 def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline: 2512 """ 2513 Aggregates transaction logs into a structured timeline. 2514 2515 This method retrieves transaction logs from all accounts and organizes them 2516 into daily, weekly, monthly, and yearly summaries. Each level of the 2517 timeline includes a `TimeSummary` object with the total positive, negative, 2518 and overall values for that period. The daily level also includes a list 2519 of individual `Transaction` records. 2520 2521 Parameters: 2522 - weekday (WeekDay, optional): The day of the week to use as the anchor 2523 for weekly summaries. Defaults to WeekDay.FRIDAY. 2524 - debug (bool, optional): If True, prints intermediate debug information 2525 during processing. Defaults to False. 2526 2527 Returns: 2528 - Timeline: An object containing the aggregated transaction data, organized 2529 into daily, weekly, monthly, and yearly summaries. The 'daily' 2530 attribute is a dictionary where keys are dates (YYYY-MM-DD) and 2531 values are `DailyRecords` objects. The 'weekly' attribute is a 2532 dictionary where keys are the starting datetime of the week and 2533 values are `TimeSummary` objects. The 'monthly' attribute is a 2534 dictionary where keys are year-month strings (YYYY-MM) and values 2535 are `TimeSummary` objects. The 'yearly' attribute is a dictionary 2536 where keys are years (YYYY) and values are `TimeSummary` objects. 2537 2538 Example: 2539 ```bash 2540 >>> from zakat import tracker 2541 >>> ledger = tracker(':memory:') 2542 >>> account1_id = ledger.create_account('account1') 2543 >>> account2_id = ledger.create_account('account2') 2544 >>> ledger.subtract(51, 'desc', account1_id) 2545 >>> ref = ledger.track(100, 'desc', account2_id) 2546 >>> ledger.add_file(account2_id, ref, 'file_0') 2547 >>> ledger.add_file(account2_id, ref, 'file_1') 2548 >>> ledger.add_file(account2_id, ref, 'file_2') 2549 >>> ledger.timeline() 2550 Timeline( 2551 daily={ 2552 "2025-04-06": DailyRecords( 2553 positive=10000, 2554 negative=5100, 2555 total=4900, 2556 rows=[ 2557 Transaction( 2558 account="account2", 2559 account_id="63879638114290122752", 2560 desc="desc2", 2561 file={ 2562 63879638220705865728: "file_0", 2563 63879638223391350784: "file_1", 2564 63879638225766047744: "file_2", 2565 }, 2566 value=10000, 2567 time=63879638181936513024, 2568 transfer=False, 2569 ), 2570 Transaction( 2571 account="account1", 2572 account_id="63879638104007106560", 2573 desc="desc", 2574 file={}, 2575 value=-5100, 2576 time=63879638149199421440, 2577 transfer=False, 2578 ), 2579 ], 2580 ) 2581 }, 2582 weekly={ 2583 datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary( 2584 positive=10000, negative=0, total=10000 2585 ), 2586 datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary( 2587 positive=0, negative=5100, total=-5100 2588 ), 2589 }, 2590 monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)}, 2591 yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)}, 2592 ) 2593 ``` 2594 """ 2595 logs: dict[Timestamp, list[Transaction]] = {} 2596 for account_id in self.accounts(): 2597 for log_ref, log in self.logs(account_id).items(): 2598 if log_ref not in logs: 2599 logs[log_ref] = [] 2600 logs[log_ref].append(Transaction( 2601 account=self.name(account_id), 2602 account_id=account_id, 2603 desc=log.desc, 2604 file=log.file, 2605 value=log.value, 2606 time=log_ref, 2607 transfer=False, 2608 )) 2609 if debug: 2610 print('logs', logs) 2611 y = Timeline() 2612 for i in sorted(logs, reverse=True): 2613 dt = Time.time_to_datetime(i) 2614 daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}' 2615 weekly = dt - datetime.timedelta(days=weekday.value) 2616 monthly = f'{dt.year}-{dt.month:02d}' 2617 yearly = dt.year 2618 # daily 2619 if daily not in y.daily: 2620 y.daily[daily] = DailyRecords() 2621 transfer = len(logs[i]) > 1 2622 if debug: 2623 print('logs[i]', logs[i]) 2624 for z in logs[i]: 2625 if debug: 2626 print('z', z) 2627 # daily 2628 value = z.value 2629 if value > 0: 2630 y.daily[daily].positive += value 2631 else: 2632 y.daily[daily].negative += -value 2633 y.daily[daily].total += value 2634 z.transfer = transfer 2635 y.daily[daily].rows.append(z) 2636 # weekly 2637 if weekly not in y.weekly: 2638 y.weekly[weekly] = TimeSummary() 2639 if value > 0: 2640 y.weekly[weekly].positive += value 2641 else: 2642 y.weekly[weekly].negative += -value 2643 y.weekly[weekly].total += value 2644 # monthly 2645 if monthly not in y.monthly: 2646 y.monthly[monthly] = TimeSummary() 2647 if value > 0: 2648 y.monthly[monthly].positive += value 2649 else: 2650 y.monthly[monthly].negative += -value 2651 y.monthly[monthly].total += value 2652 # yearly 2653 if yearly not in y.yearly: 2654 y.yearly[yearly] = TimeSummary() 2655 if value > 0: 2656 y.yearly[yearly].positive += value 2657 else: 2658 y.yearly[yearly].negative += -value 2659 y.yearly[yearly].total += value 2660 if debug: 2661 print('y', y) 2662 return y 2663 2664 def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp: 2665 """ 2666 Adds a file reference to a specific transaction log entry in the vault. 2667 2668 Parameters: 2669 - account (AccountID): The account reference associated with the transaction log. 2670 - ref (Timestamp): The reference to the transaction log entry. 2671 - path (str): The path of the file to be added. 2672 2673 Returns: 2674 - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 2675 """ 2676 if self.account_exists(account): 2677 if ref in self.__vault.account[account].log: 2678 no_lock = self.nolock() 2679 lock = self.__lock() 2680 file_ref = Time.time() 2681 self.__vault.account[account].log[ref].file[file_ref] = path 2682 self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref) 2683 if no_lock: 2684 assert lock is not None 2685 self.free(lock) 2686 return file_ref 2687 return Timestamp(0) 2688 2689 def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool: 2690 """ 2691 Removes a file reference from a specific transaction log entry in the vault. 2692 2693 Parameters: 2694 - account (AccountID): The account reference associated with the transaction log. 2695 - ref (Timestamp): The reference to the transaction log entry. 2696 - file_ref (Timestamp): The reference of the file to be removed. 2697 2698 Returns: 2699 - bool: True if the file reference is successfully removed, False otherwise. 2700 """ 2701 if self.account_exists(account): 2702 if ref in self.__vault.account[account].log: 2703 if file_ref in self.__vault.account[account].log[ref].file: 2704 no_lock = self.nolock() 2705 lock = self.__lock() 2706 x = self.__vault.account[account].log[ref].file[file_ref] 2707 del self.__vault.account[account].log[ref].file[file_ref] 2708 self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 2709 if no_lock: 2710 assert lock is not None 2711 self.free(lock) 2712 return True 2713 return False 2714 2715 def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int: 2716 """ 2717 Calculate and return the balance of a specific account. 2718 2719 Parameters: 2720 - account (AccountID, optional): The account reference. Default is '1'. 2721 - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 2722 2723 Returns: 2724 - int: The balance of the account. 2725 2726 Notes: 2727 - If cached is True, the function returns the cached balance. 2728 - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 2729 """ 2730 account = AccountID(account) 2731 if cached: 2732 return self.__vault.account[account].balance 2733 x = 0 2734 return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1] 2735 2736 def hide(self, account: AccountID, status: Optional[bool] = None) -> bool: 2737 """ 2738 Check or set the hide status of a specific account. 2739 2740 Parameters: 2741 - account (AccountID): The account reference. 2742 - status (bool, optional): The new hide status. If not provided, the function will return the current status. 2743 2744 Returns: 2745 - bool: The current or updated hide status of the account. 2746 2747 Raises: 2748 None 2749 2750 Example: 2751 ```bash 2752 >>> tracker = ZakatTracker() 2753 >>> ref = tracker.track(51, 'desc', 'account1') 2754 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 2755 False 2756 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 2757 True 2758 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 2759 True 2760 >>> tracker.hide('account1', False) 2761 False 2762 ``` 2763 """ 2764 if self.account_exists(account): 2765 if status is None: 2766 return self.__vault.account[account].hide 2767 self.__vault.account[account].hide = status 2768 return status 2769 return False 2770 2771 def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]: 2772 """ 2773 Retrieves an AccountDetails object for the first account matching the given name. 2774 2775 This method searches for accounts with names that contain the provided 'name' 2776 (case-insensitive substring matching). If a match is found, it returns an 2777 AccountDetails object containing the account's ID, name and balance. If no matching 2778 account is found, it returns None. 2779 2780 Parameters: 2781 - name: The name (or partial name) of the account to retrieve. 2782 - exact: If True, performs a case-insensitive exact match. 2783 If False, performs a case-insensitive substring search. 2784 Defaults to True. 2785 2786 Returns: 2787 - AccountDetails: An AccountDetails object representing the found account, or None if no 2788 matching account exists. 2789 """ 2790 for account_name, account_id in self.names(name).items(): 2791 if not exact or account_name.lower() == name.lower(): 2792 return AccountDetails( 2793 account_id=account_id, 2794 account_name=account_name, 2795 balance=self.__vault.account[account_id].balance, 2796 ) 2797 return None 2798 2799 def create_account(self, name: str) -> AccountID: 2800 """ 2801 Creates a new account with the given name and returns its unique ID. 2802 2803 This method: 2804 1. Checks if an account with the same name (case-insensitive) already exists. 2805 2. Generates a unique `AccountID` based on the current time. 2806 3. Tracks the account creation internally. 2807 4. Sets the account's name. 2808 5. Verifies that the name was set correctly. 2809 2810 Parameters: 2811 - name: The name of the new account. 2812 2813 Returns: 2814 - AccountID: The unique `AccountID` of the newly created account. 2815 2816 Raises: 2817 - AssertionError: Empty account name is forbidden. 2818 - AssertionError: Account name in number is forbidden. 2819 - AssertionError: If an account with the same name already exists (case-insensitive). 2820 - AssertionError: If the provided name does not match the name set for the account. 2821 """ 2822 assert name.strip(), 'empty account name is forbidden' 2823 assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden' 2824 account_ref = self.account(name, exact=True) 2825 # check if account not exists 2826 assert account_ref is None, f'account name({name}) already used' 2827 # create new account 2828 account_id = AccountID(Time.time()) 2829 self.__track(0, '', account_id) 2830 new_name = self.name( 2831 account=account_id, 2832 new_name=name, 2833 ) 2834 assert name == new_name 2835 return account_id 2836 2837 def names(self, keyword: str = '') -> dict[str, AccountID]: 2838 """ 2839 Retrieves a dictionary of account IDs and names, optionally filtered by a keyword. 2840 2841 Parameters: 2842 - keyword: An optional string to filter account names. If provided, only accounts whose 2843 names contain the keyword (case-insensitive) will be included in the result. 2844 Defaults to an empty string, which returns all accounts. 2845 2846 Returns: 2847 - A dictionary where keys are account names and values are AccountIDs. The dictionary 2848 contains only accounts that match the provided keyword (if any). 2849 """ 2850 return { 2851 account.name: account_id 2852 for account_id, account in self.__vault.account.items() 2853 if keyword.lower() in account.name.lower() 2854 } 2855 2856 def name(self, account: AccountID, new_name: Optional[str] = None) -> str: 2857 """ 2858 Retrieves or sets the name of an account. 2859 2860 Parameters: 2861 - account: The AccountID of the account. 2862 - new_name: The new name to set for the account. If None, the current name is retrieved. 2863 2864 Returns: 2865 - The current name of the account if `new_name` is None, or the `new_name` if it is set. 2866 2867 Note: Returns an empty string if the account does not exist. 2868 """ 2869 if self.account_exists(account): 2870 if new_name is None: 2871 return self.__vault.account[account].name 2872 assert new_name != '' 2873 no_lock = self.nolock() 2874 lock = self.__lock() 2875 self.__step(Action.NAME, account, value=self.__vault.account[account].name) 2876 self.__vault.account[account].name = new_name 2877 if no_lock: 2878 assert lock is not None 2879 self.free(lock) 2880 return new_name 2881 return '' 2882 2883 def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool: 2884 """ 2885 Check or set the zakatable status of a specific account. 2886 2887 Parameters: 2888 - account (AccountID): The account reference. 2889 - status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 2890 2891 Returns: 2892 - bool: The current or updated zakatable status of the account. 2893 2894 Raises: 2895 None 2896 2897 Example: 2898 ```bash 2899 >>> tracker = ZakatTracker() 2900 >>> ref = tracker.track(51, 'desc', 'account1') 2901 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 2902 True 2903 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 2904 True 2905 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 2906 True 2907 >>> tracker.zakatable('account1', False) 2908 False 2909 ``` 2910 """ 2911 if self.account_exists(account): 2912 if status is None: 2913 return self.__vault.account[account].zakatable 2914 self.__vault.account[account].zakatable = status 2915 return status 2916 return False 2917 2918 def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'), 2919 created_time_ns: Optional[Timestamp] = None, 2920 debug: bool = False) \ 2921 -> SubtractReport: 2922 """ 2923 Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, 2924 the remaining amount will be transferred to a new transaction with a negative value. 2925 2926 Parameters: 2927 - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted. 2928 - desc (str, optional): A description for the transaction. Defaults to an empty string. 2929 - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'. 2930 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 2931 If not provided, the current timestamp will be used. 2932 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 2933 2934 Returns: 2935 - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 2936 2937 Raises: 2938 - ValueError: The unscaled_value should be greater than zero. 2939 - ValueError: The created_time_ns should be greater than zero. 2940 - ValueError: The box transaction happened again in the same nanosecond time. 2941 - ValueError: The log transaction happened again in the same nanosecond time. 2942 """ 2943 if debug: 2944 print('sub', f'debug={debug}') 2945 account = AccountID(account) 2946 if unscaled_value <= 0: 2947 raise ValueError('The unscaled_value should be greater than zero.') 2948 if created_time_ns is None: 2949 created_time_ns = Time.time() 2950 if created_time_ns <= 0: 2951 raise ValueError('The created should be greater than zero.') 2952 no_lock = self.nolock() 2953 lock = self.__lock() 2954 self.__track(0, '', account) 2955 value = self.scale(unscaled_value) 2956 self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 2957 ids = sorted(self.__vault.account[account].box.keys()) 2958 limit = len(ids) + 1 2959 target = value 2960 if debug: 2961 print('ids', ids) 2962 ages = SubtractAges() 2963 for i in range(-1, -limit, -1): 2964 if target == 0: 2965 break 2966 j = ids[i] 2967 if debug: 2968 print('i', i, 'j', j) 2969 rest = self.__vault.account[account].box[j].rest 2970 if rest >= target: 2971 self.__vault.account[account].box[j].rest -= target 2972 self.__step(Action.SUBTRACT, account, ref=j, value=target) 2973 ages.append(SubtractAge(box_ref=j, total=target)) 2974 target = 0 2975 break 2976 elif target > rest > 0: 2977 chunk = rest 2978 target -= chunk 2979 self.__vault.account[account].box[j].rest = 0 2980 self.__step(Action.SUBTRACT, account, ref=j, value=chunk) 2981 ages.append(SubtractAge(box_ref=j, total=chunk)) 2982 if target > 0: 2983 self.__track( 2984 unscaled_value=self.unscale(-target), 2985 desc=desc, 2986 account=account, 2987 logging=False, 2988 created_time_ns=created_time_ns, 2989 ) 2990 ages.append(SubtractAge(box_ref=created_time_ns, total=target)) 2991 if no_lock: 2992 assert lock is not None 2993 self.free(lock) 2994 return SubtractReport( 2995 log_ref=created_time_ns, 2996 ages=ages, 2997 ) 2998 2999 def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '', 3000 created_time_ns: Optional[Timestamp] = None, 3001 debug: bool = False) -> Optional[TransferReport]: 3002 """ 3003 Transfers a specified value from one account to another. 3004 3005 Parameters: 3006 - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred. 3007 - from_account (AccountID): The account reference from which the value will be transferred. 3008 - to_account (AccountID): The account reference to which the value will be transferred. 3009 - desc (str, optional): A description for the transaction. Defaults to an empty string. 3010 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used. 3011 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 3012 3013 Returns: 3014 - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer. 3015 3016 Raises: 3017 - ValueError: Transfer to the same account is forbidden. 3018 - ValueError: The created_time_ns should be greater than zero. 3019 - ValueError: The box transaction happened again in the same nanosecond time. 3020 - ValueError: The log transaction happened again in the same nanosecond time. 3021 """ 3022 if debug: 3023 print('transfer', f'debug={debug}') 3024 from_account = AccountID(from_account) 3025 to_account = AccountID(to_account) 3026 if from_account == to_account: 3027 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 3028 if unscaled_amount <= 0: 3029 return None 3030 if created_time_ns is None: 3031 created_time_ns = Time.time() 3032 if created_time_ns <= 0: 3033 raise ValueError('The created should be greater than zero.') 3034 no_lock = self.nolock() 3035 lock = self.__lock() 3036 subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug) 3037 source_exchange = self.exchange(from_account, created_time_ns) 3038 target_exchange = self.exchange(to_account, created_time_ns) 3039 3040 if debug: 3041 print('ages', subtract_report.ages) 3042 3043 transfer_report = TransferReport() 3044 for subtract in subtract_report.ages: 3045 times = TransferTimes() 3046 age = subtract.box_ref 3047 value = subtract.total 3048 assert source_exchange.rate is not None 3049 assert target_exchange.rate is not None 3050 target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate)) 3051 if debug: 3052 print('target_amount', target_amount) 3053 # Perform the transfer 3054 if self.box_exists(to_account, age): 3055 if debug: 3056 print('box_exists', age) 3057 capital = self.__vault.account[to_account].box[age].capital 3058 rest = self.__vault.account[to_account].box[age].rest 3059 if debug: 3060 print( 3061 f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3062 selected_age = age 3063 if rest + target_amount > capital: 3064 self.__vault.account[to_account].box[age].capital += target_amount 3065 selected_age = Time.time() 3066 self.__vault.account[to_account].box[age].rest += target_amount 3067 self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 3068 y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 3069 created_time_ns=None, ref=None, debug=debug) 3070 times.append(TransferTime(box_ref=age, log_ref=y)) 3071 continue 3072 if debug: 3073 print( 3074 f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3075 box_ref = self.__track( 3076 unscaled_value=self.unscale(int(target_amount)), 3077 desc=desc, 3078 account=to_account, 3079 logging=True, 3080 created_time_ns=age, 3081 debug=debug, 3082 ) 3083 transfer_report.append(TransferRecord( 3084 box_ref=box_ref, 3085 times=times, 3086 )) 3087 if no_lock: 3088 assert lock is not None 3089 self.free(lock) 3090 return transfer_report 3091 3092 def check(self, 3093 silver_gram_price: float, 3094 unscaled_nisab: Optional[float | int | decimal.Decimal] = None, 3095 debug: bool = False, 3096 created_time_ns: Optional[Timestamp] = None, 3097 cycle: Optional[float] = None) -> ZakatReport: 3098 """ 3099 Check the eligibility for Zakat based on the given parameters. 3100 3101 Parameters: 3102 - silver_gram_price (float): The price of a gram of silver. 3103 - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. 3104 If not provided, it will be calculated based on the silver_gram_price. 3105 - debug (bool, optional): Flag to enable debug mode. 3106 - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time(). 3107 - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 3108 3109 Returns: 3110 - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, 3111 a list of brief statistics, and a dictionary containing the Zakat plan. 3112 """ 3113 if debug: 3114 print('check', f'debug={debug}') 3115 before_parameters = { 3116 "silver_gram_price": silver_gram_price, 3117 "unscaled_nisab": unscaled_nisab, 3118 "debug": debug, 3119 "created_time_ns": created_time_ns, 3120 "cycle": cycle, 3121 } 3122 if created_time_ns is None: 3123 created_time_ns = Time.time() 3124 if cycle is None: 3125 cycle = ZakatTracker.TimeCycle() 3126 if unscaled_nisab is None: 3127 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 3128 nisab = self.scale(unscaled_nisab) 3129 plan: dict[AccountID, list[BoxPlan]] = {} 3130 summary = ZakatSummary() 3131 below_nisab = 0 3132 valid = False 3133 after_parameters = { 3134 "silver_gram_price": silver_gram_price, 3135 "unscaled_nisab": unscaled_nisab, 3136 "debug": debug, 3137 "created_time_ns": created_time_ns, 3138 "cycle": cycle, 3139 } 3140 if debug: 3141 print('exchanges', self.exchanges()) 3142 for x in self.__vault.account: 3143 if not self.zakatable(x): 3144 continue 3145 _box = self.__vault.account[x].box 3146 _log = self.__vault.account[x].log 3147 limit = len(_box) + 1 3148 ids = sorted(self.__vault.account[x].box.keys()) 3149 for i in range(-1, -limit, -1): 3150 j = ids[i] 3151 rest = float(_box[j].rest) 3152 if rest <= 0: 3153 continue 3154 exchange = self.exchange(x, created_time_ns=Time.time()) 3155 assert exchange.rate is not None 3156 rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1) 3157 summary.num_wealth_items += 1 3158 summary.total_wealth += rest 3159 epoch = (created_time_ns - j) / cycle 3160 if debug: 3161 print(f'Epoch: {epoch}', _box[j]) 3162 if _box[j].zakat.last > 0: 3163 epoch = (created_time_ns - _box[j].zakat.last) / cycle 3164 if debug: 3165 print(f'Epoch: {epoch}') 3166 epoch = math.floor(epoch) 3167 if debug: 3168 print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch) 3169 if epoch == 0: 3170 continue 3171 if debug: 3172 print('Epoch - PASSED') 3173 summary.num_zakatable_items += 1 3174 summary.total_zakatable_amount += rest 3175 is_nisab = rest >= nisab 3176 total = 0 3177 if is_nisab: 3178 for _ in range(epoch): 3179 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 3180 valid = total > 0 3181 elif rest > 0: 3182 below_nisab += rest 3183 total = ZakatTracker.ZakatCut(float(rest)) 3184 if total > 0: 3185 if x not in plan: 3186 plan[x] = [] 3187 summary.total_zakat_due += total 3188 plan[x].append(BoxPlan( 3189 below_nisab=not is_nisab, 3190 total=total, 3191 count=epoch, 3192 ref=j, 3193 box=_box[j], 3194 log=_log[j], 3195 exchange=exchange, 3196 )) 3197 valid = valid or below_nisab >= nisab 3198 if debug: 3199 print(f'below_nisab({below_nisab}) >= nisab({nisab})') 3200 report = ZakatReport( 3201 created=Time.time(), 3202 valid=valid, 3203 summary=summary, 3204 plan=plan, 3205 parameters={ 3206 'before': before_parameters, 3207 'after': after_parameters, 3208 }, 3209 ) 3210 self.__vault.cache.zakat = report 3211 return report 3212 3213 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts: 3214 """ 3215 Build payment parts for the Zakat distribution. 3216 3217 Parameters: 3218 - scaled_demand (int): The total demand for payment in local currency. 3219 - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True. 3220 3221 Returns: 3222 - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: 3223 { 3224 'account': { 3225 'account_id': {'balance': float, 'rate': float, 'part': float}, 3226 ... 3227 }, 3228 'exceed': bool, 3229 'demand': int, 3230 'total': float, 3231 } 3232 """ 3233 total = 0.0 3234 parts = PaymentParts( 3235 account={}, 3236 exceed=False, 3237 demand=int(round(scaled_demand)), 3238 total=0, 3239 ) 3240 for x, y in self.accounts().items(): 3241 if positive_only and y.balance <= 0: 3242 continue 3243 total += float(y.balance) 3244 exchange = self.exchange(x) 3245 parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0) 3246 parts.total = total 3247 return parts 3248 3249 @staticmethod 3250 def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int: 3251 """ 3252 Checks the validity of payment parts. 3253 3254 Parameters: 3255 - parts (dict[str, PaymentParts): A dictionary containing payment parts information. 3256 - debug (bool, optional): Flag to enable debug mode. 3257 3258 Returns: 3259 - int: Returns 0 if the payment parts are valid, otherwise returns the error code. 3260 3261 Error Codes: 3262 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 3263 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3264 3: 'part' value in parts['account'][x] is less than 0. 3265 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 3266 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 3267 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 3268 """ 3269 if debug: 3270 print('check_payment_parts', f'debug={debug}') 3271 # for i in ['demand', 'account', 'total', 'exceed']: 3272 # if i not in parts: 3273 # return 1 3274 exceed = parts.exceed 3275 # for j in ['balance', 'rate', 'part']: 3276 # if j not in parts.account[x]: 3277 # return 2 3278 for x in parts.account: 3279 if parts.account[x].part < 0: 3280 return 3 3281 if not exceed and parts.account[x].balance <= 0: 3282 return 4 3283 demand = parts.demand 3284 z = 0.0 3285 for _, y in parts.account.items(): 3286 if not exceed and y.part > y.balance: 3287 return 5 3288 z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0) 3289 z = round(z, 2) 3290 demand = round(demand, 2) 3291 if debug: 3292 print('check_payment_parts', f'z = {z}, demand = {demand}') 3293 print('check_payment_parts', type(z), type(demand)) 3294 print('check_payment_parts', z != demand) 3295 print('check_payment_parts', str(z) != str(demand)) 3296 if z != demand and str(z) != str(demand): 3297 return 6 3298 return 0 3299 3300 def zakat(self, report: ZakatReport, 3301 parts: Optional[PaymentParts] = None, debug: bool = False) -> bool: 3302 """ 3303 Perform Zakat calculation based on the given report and optional parts. 3304 3305 Parameters: 3306 - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan. 3307 - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat. 3308 - debug (bool, optional): A flag indicating whether to print debug information. 3309 3310 Returns: 3311 - bool: True if the zakat calculation is successful, False otherwise. 3312 3313 Raises: 3314 - AssertionError: Bad Zakat report, call `check` first then call `zakat`. 3315 """ 3316 if debug: 3317 print('zakat', f'debug={debug}') 3318 if not report.valid: 3319 return report.valid 3320 assert report.plan 3321 parts_exist = parts is not None 3322 if parts_exist: 3323 if self.check_payment_parts(parts, debug=debug) != 0: 3324 return False 3325 if debug: 3326 print('######### zakat #######') 3327 print('parts_exist', parts_exist) 3328 assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`" 3329 no_lock = self.nolock() 3330 lock = self.__lock() 3331 report_time = Time.time() 3332 self.__vault.report[report_time] = report 3333 self.__step(Action.REPORT, ref=report_time) 3334 created_time_ns = Time.time() 3335 for x in report.plan: 3336 target_exchange = self.exchange(x) 3337 if debug: 3338 print(report.plan[x]) 3339 print('-------------') 3340 print(self.__vault.account[x].box) 3341 if debug: 3342 print('plan[x]', report.plan[x]) 3343 for plan in report.plan[x]: 3344 j = plan.ref 3345 if debug: 3346 print('j', j) 3347 assert j 3348 self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last, 3349 key='last', 3350 math_operation=MathOperation.EQUAL) 3351 self.__vault.account[x].box[j].zakat.last = created_time_ns 3352 assert target_exchange.rate is not None 3353 amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate)) 3354 self.__vault.account[x].box[j].zakat.total += amount 3355 self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 3356 math_operation=MathOperation.ADDITION) 3357 self.__vault.account[x].box[j].zakat.count += plan.count 3358 self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count', 3359 math_operation=MathOperation.ADDITION) 3360 if not parts_exist: 3361 try: 3362 self.__vault.account[x].box[j].rest -= amount 3363 except TypeError: 3364 self.__vault.account[x].box[j].rest -= decimal.Decimal(amount) 3365 # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 3366 # math_operation=MathOperation.SUBTRACTION) 3367 self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug) 3368 if parts_exist: 3369 for account, part in parts.account.items(): 3370 if part.part == 0: 3371 continue 3372 if debug: 3373 print('zakat-part', account, part.rate) 3374 target_exchange = self.exchange(account) 3375 assert target_exchange.rate is not None 3376 amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate) 3377 unscaled_amount = self.unscale(int(amount)) 3378 if unscaled_amount <= 0: 3379 if debug: 3380 print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.") 3381 continue 3382 self.subtract( 3383 unscaled_value=unscaled_amount, 3384 desc='zakat-part-دفعة-زكاة', 3385 account=account, 3386 debug=debug, 3387 ) 3388 if no_lock: 3389 assert lock is not None 3390 self.free(lock) 3391 self.__vault.cache.zakat = None 3392 return True 3393 3394 @staticmethod 3395 def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]: 3396 """Splits a string at the last occurrence of a given symbol. 3397 3398 Parameters: 3399 - data (str): The input string. 3400 - symbol (str): The symbol to split at. 3401 3402 Returns: 3403 - tuple[str, str]: A tuple containing two strings, the part before the last symbol and 3404 the part after the last symbol. If the symbol is not found, returns (data, ""). 3405 """ 3406 last_symbol_index = data.rfind(symbol) 3407 3408 if last_symbol_index != -1: 3409 before_symbol = data[:last_symbol_index] 3410 after_symbol = data[last_symbol_index + len(symbol):] 3411 return before_symbol, after_symbol 3412 return data, "" 3413 3414 def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool: 3415 """ 3416 Saves the ZakatTracker's current state to a json file. 3417 3418 This method serializes the internal data (`__vault`). 3419 3420 Parameters: 3421 - path (str, optional): File path for saving. Defaults to a predefined location. 3422 - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True. 3423 3424 Returns: 3425 - bool: True if the save operation is successful, False otherwise. 3426 """ 3427 if path is None: 3428 path = self.path() 3429 # first save in tmp file 3430 temp = f'{path}.tmp' 3431 try: 3432 with open(temp, 'w', encoding='utf-8') as stream: 3433 data = json.dumps(self.__vault, cls=JSONEncoder) 3434 stream.write(data) 3435 if hash_required: 3436 hashed = self.hash_data(data.encode()) 3437 stream.write(f'//{hashed}') 3438 # then move tmp file to original location 3439 shutil.move(temp, path) 3440 return True 3441 except (IOError, OSError) as e: 3442 print(f'Error saving file: {e}') 3443 if os.path.exists(temp): 3444 os.remove(temp) 3445 return False 3446 3447 @staticmethod 3448 def load_vault_from_json(json_string: str) -> Vault: 3449 """Loads a Vault dataclass from a JSON string.""" 3450 data = json.loads(json_string) 3451 3452 vault = Vault() 3453 3454 # Load Accounts 3455 for account_reference, account_data in data.get("account", {}).items(): 3456 account_reference = AccountID(account_reference) 3457 box_data = account_data.get('box', {}) 3458 box = { 3459 Timestamp(ts): Box( 3460 capital=box_data[str(ts)]["capital"], 3461 rest=box_data[str(ts)]["rest"], 3462 zakat=BoxZakat(**box_data[str(ts)]["zakat"]), 3463 ) 3464 for ts in box_data 3465 } 3466 3467 log_data = account_data.get('log', {}) 3468 log = {Timestamp(ts): Log( 3469 value=log_data[str(ts)]['value'], 3470 desc=log_data[str(ts)]['desc'], 3471 ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None, 3472 file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()}, 3473 ) for ts in log_data} 3474 3475 vault.account[account_reference] = Account( 3476 balance=account_data["balance"], 3477 created=Timestamp(account_data["created"]), 3478 name=account_data.get("name", ""), 3479 box=box, 3480 count=account_data.get("count", 0), 3481 log=log, 3482 hide=account_data.get("hide", False), 3483 zakatable=account_data.get("zakatable", True), 3484 ) 3485 3486 # Load Exchanges 3487 for account_reference, exchange_data in data.get("exchange", {}).items(): 3488 account_reference = AccountID(account_reference) 3489 vault.exchange[account_reference] = {} 3490 for timestamp, exchange_details in exchange_data.items(): 3491 vault.exchange[account_reference][Timestamp(timestamp)] = Exchange( 3492 rate=exchange_details.get("rate"), 3493 description=exchange_details.get("description"), 3494 time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None, 3495 ) 3496 3497 # Load History 3498 for timestamp, history_dict in data.get("history", {}).items(): 3499 vault.history[Timestamp(timestamp)] = {} 3500 for history_key, history_data in history_dict.items(): 3501 vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History( 3502 action=Action(history_data["action"]), 3503 account=AccountID(history_data["account"]) if history_data.get("account") is not None else None, 3504 ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None, 3505 file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None, 3506 key=history_data.get("key"), 3507 value=history_data.get("value"), 3508 math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None, 3509 ) 3510 3511 # Load Lock 3512 vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None 3513 3514 # Load Report 3515 for timestamp, report_data in data.get("report", {}).items(): 3516 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3517 for account_reference, box_plans in report_data.get("plan", {}).items(): 3518 account_reference = AccountID(account_reference) 3519 zakat_plan[account_reference] = [] 3520 for box_plan_data in box_plans: 3521 zakat_plan[account_reference].append(BoxPlan( 3522 box=Box( 3523 capital=box_plan_data["box"]["capital"], 3524 rest=box_plan_data["box"]["rest"], 3525 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3526 ), 3527 log=Log(**box_plan_data["log"]), 3528 exchange=Exchange(**box_plan_data["exchange"]), 3529 below_nisab=box_plan_data["below_nisab"], 3530 total=box_plan_data["total"], 3531 count=box_plan_data["count"], 3532 ref=Timestamp(box_plan_data["ref"]), 3533 )) 3534 3535 vault.report[Timestamp(timestamp)] = ZakatReport( 3536 created=report_data["created"], 3537 valid=report_data["valid"], 3538 summary=ZakatSummary(**report_data["summary"]), 3539 plan=zakat_plan, 3540 parameters=report_data["parameters"], 3541 ) 3542 3543 # Load Cache 3544 vault.cache = Cache() 3545 cache_data = data.get("cache", {}) 3546 if "zakat" in cache_data: 3547 cache_zakat_data = cache_data.get("zakat", {}) 3548 if cache_zakat_data: 3549 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3550 for account_reference, box_plans in cache_zakat_data.get("plan", {}).items(): 3551 account_reference = AccountID(account_reference) 3552 zakat_plan[account_reference] = [] 3553 for box_plan_data in box_plans: 3554 zakat_plan[account_reference].append(BoxPlan( 3555 box=Box( 3556 capital=box_plan_data["box"]["capital"], 3557 rest=box_plan_data["box"]["rest"], 3558 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3559 ), 3560 log=Log(**box_plan_data["log"]), 3561 exchange=Exchange(**box_plan_data["exchange"]), 3562 below_nisab=box_plan_data["below_nisab"], 3563 total=box_plan_data["total"], 3564 count=box_plan_data["count"], 3565 ref=Timestamp(box_plan_data["ref"]), 3566 )) 3567 3568 vault.cache.zakat = ZakatReport( 3569 created=cache_zakat_data["created"], 3570 valid=cache_zakat_data["valid"], 3571 summary=ZakatSummary(**cache_zakat_data["summary"]), 3572 plan=zakat_plan, 3573 parameters=cache_zakat_data["parameters"], 3574 ) 3575 3576 return vault 3577 3578 def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool: 3579 """ 3580 Load the current state of the ZakatTracker object from a json file. 3581 3582 Parameters: 3583 - path (str, optional): The path where the json file is located. If not provided, it will use the default path. 3584 - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True. 3585 - debug (bool, optional): Flag to enable debug mode. 3586 3587 Returns: 3588 - bool: True if the load operation is successful, False otherwise. 3589 """ 3590 if path is None: 3591 path = self.path() 3592 try: 3593 if os.path.exists(path): 3594 with open(path, 'r', encoding='utf-8') as stream: 3595 file = stream.read() 3596 data, hashed = self.split_at_last_symbol(file, '//') 3597 if hash_required: 3598 assert hashed 3599 if debug: 3600 print('[debug-load]', hashed) 3601 new_hash = self.hash_data(data.encode()) 3602 if debug: 3603 print('[debug-load]', new_hash) 3604 assert hashed == new_hash, "Hash verification failed. File may be corrupted." 3605 self.__vault = self.load_vault_from_json(data) 3606 return True 3607 else: 3608 print(f'File not found: {path}') 3609 return False 3610 except (IOError, OSError) as e: 3611 print(f'Error loading file: {e}') 3612 return False 3613 3614 def import_csv_cache_path(self): 3615 """ 3616 Generates the cache file path for imported CSV data. 3617 3618 This function constructs the file path where cached data from CSV imports 3619 will be stored. The cache file is a json file (.json extension) appended 3620 to the base path of the object. 3621 3622 Parameters: 3623 None 3624 3625 Returns: 3626 - str: The full path to the import CSV cache file. 3627 3628 Example: 3629 ```bash 3630 >>> obj = ZakatTracker('/data/reports') 3631 >>> obj.import_csv_cache_path() 3632 '/data/reports.import_csv.json' 3633 ``` 3634 """ 3635 path = str(self.path()) 3636 ext = self.ext() 3637 ext_len = len(ext) 3638 if path.endswith(f'.{ext}'): 3639 path = path[:-ext_len - 1] 3640 _, filename = os.path.split(path + f'.import_csv.{ext}') 3641 return self.base_path(filename) 3642 3643 @staticmethod 3644 def get_transaction_csv_headers() -> list[str]: 3645 """ 3646 Returns a list of strings representing the headers for a transaction CSV file. 3647 3648 The headers include: 3649 - account: The account associated with the transaction. 3650 - desc: A description of the transaction. 3651 - value: The monetary value of the transaction. 3652 - date: The date of the transaction. 3653 - rate: The applicable rate (if any) for the transaction. 3654 - reference: An optional reference number or identifier for the transaction. 3655 3656 Returns: 3657 - list[str]: A list containing the CSV header strings. 3658 """ 3659 return [ 3660 "account", 3661 "desc", 3662 "value", 3663 "date", 3664 "rate", 3665 "reference", 3666 ] 3667 3668 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport: 3669 """ 3670 The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system. 3671 3672 Parameters: 3673 - path (str, optional): The path to the CSV file. Default is 'file.csv'. 3674 - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0. 3675 - debug (bool, optional): A flag indicating whether to print debug information. 3676 3677 Returns: 3678 - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, 3679 and a dictionary of bad transactions. 3680 3681 Notes: 3682 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3683 are appropriate for the currency pairs involved in the conversions. 3684 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3685 to 1.0 or the previous rate for that account. 3686 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3687 transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and 3688 `zakat` operations. 3689 3690 Example: 3691 The CSV file should have the following format, rate and reference are optionals per transaction: 3692 account, desc, value, date, rate, reference 3693 For example: 3694 safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554 3695 """ 3696 if debug: 3697 print('import_csv', f'debug={debug}') 3698 cache: list[int] = [] 3699 try: 3700 if not self.memory_mode(): 3701 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3702 cache = json.load(stream) 3703 except Exception as e: 3704 if debug: 3705 print(e) 3706 date_formats = [ 3707 '%Y-%m-%d %H:%M:%S.%f', 3708 '%Y-%m-%dT%H:%M:%S.%f', 3709 '%Y-%m-%dT%H%M%S.%f', 3710 '%Y-%m-%d', 3711 ] 3712 statistics = ImportStatistics(0, 0, 0) 3713 data: dict[int, list[CSVRecord]] = {} 3714 with open(path, newline='', encoding='utf-8') as f: 3715 i = 0 3716 for row in csv.reader(f, delimiter=','): 3717 if debug: 3718 print(f"csv_row({i})", row, type(row)) 3719 if row == self.get_transaction_csv_headers(): 3720 continue 3721 i += 1 3722 hashed = hash(tuple(row)) 3723 if hashed in cache: 3724 statistics.found += 1 3725 continue 3726 account = row[0] 3727 desc = row[1] 3728 value = float(row[2]) 3729 rate = 1.0 3730 reference = '' 3731 if row[4:5]: # Empty list if index is out of range 3732 rate = float(row[4]) 3733 if row[5:6]: 3734 reference = row[5] 3735 date: int = 0 3736 for time_format in date_formats: 3737 try: 3738 date_str = row[3] 3739 if "." not in date_str: 3740 date_str += ".000000" 3741 date = Time.time(datetime.datetime.strptime(date_str, time_format)) 3742 break 3743 except Exception as e: 3744 if debug: 3745 print(e) 3746 record = CSVRecord( 3747 index=i, 3748 account=account, 3749 desc=desc, 3750 value=value, 3751 date=date, 3752 rate=rate, 3753 reference=reference, 3754 hashed=hashed, 3755 error='', 3756 ) 3757 if date <= 0: 3758 record.error = 'invalid date' 3759 statistics.bad += 1 3760 if value == 0: 3761 record.error = 'invalid value' 3762 statistics.bad += 1 3763 continue 3764 if date not in data: 3765 data[date] = [] 3766 data[date].append(record) 3767 3768 if debug: 3769 print('import_csv', len(data)) 3770 3771 if statistics.bad > 0: 3772 return ImportReport( 3773 statistics=statistics, 3774 bad=[ 3775 item 3776 for sublist in data.values() 3777 for item in sublist 3778 if item.error 3779 ], 3780 ) 3781 3782 no_lock = self.nolock() 3783 lock = self.__lock() 3784 names = self.names() 3785 3786 # sync accounts 3787 if debug: 3788 print('before-names', names, len(names)) 3789 for date, rows in sorted(data.items()): 3790 new_rows: list[CSVRecord] = [] 3791 for row in rows: 3792 if row.account not in names: 3793 account_id = self.create_account(row.account) 3794 names[row.account] = account_id 3795 account_id = names[row.account] 3796 assert account_id 3797 row.account = account_id 3798 new_rows.append(row) 3799 assert new_rows 3800 assert date in data 3801 data[date] = new_rows 3802 if debug: 3803 print('after-names', names, len(names)) 3804 assert names == self.names() 3805 3806 # do ops 3807 for date, rows in sorted(data.items()): 3808 try: 3809 def process(x: CSVRecord): 3810 x.value = self.unscale( 3811 x.value, 3812 decimal_places=scale_decimal_places, 3813 ) if scale_decimal_places > 0 else x.value 3814 if x.rate > 0: 3815 self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate) 3816 if x.value > 0: 3817 self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3818 elif x.value < 0: 3819 self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3820 return x.hashed 3821 len_rows = len(rows) 3822 # If records are found at the same time with different accounts in the same amount 3823 # (one positive and the other negative), this indicates it is a transfer. 3824 if len_rows > 2 or len_rows == 1: 3825 for row in rows: 3826 hashed = process(row) 3827 assert hashed not in cache 3828 cache.append(hashed) 3829 statistics.created += 1 3830 continue 3831 x1 = rows[0] 3832 x2 = rows[1] 3833 if x1.account == x2.account: 3834 continue 3835 # raise Exception(f'invalid transfer') 3836 # not transfer - same time - normal ops 3837 if abs(x1.value) != abs(x2.value) and x1.date == x2.date: 3838 rows[1].date += 1 3839 for row in rows: 3840 hashed = process(row) 3841 assert hashed not in cache 3842 cache.append(hashed) 3843 statistics.created += 1 3844 continue 3845 if x1.rate > 0: 3846 self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate) 3847 if x2.rate > 0: 3848 self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate) 3849 x1.value = self.unscale( 3850 x1.value, 3851 decimal_places=scale_decimal_places, 3852 ) if scale_decimal_places > 0 else x1.value 3853 x2.value = self.unscale( 3854 x2.value, 3855 decimal_places=scale_decimal_places, 3856 ) if scale_decimal_places > 0 else x2.value 3857 # just transfer 3858 values = { 3859 x1.value: x1.account, 3860 x2.value: x2.account, 3861 } 3862 if debug: 3863 print('values', values) 3864 if len(values) <= 1: 3865 continue 3866 self.transfer( 3867 unscaled_amount=abs(x1.value), 3868 from_account=values[min(values.keys())], 3869 to_account=values[max(values.keys())], 3870 desc=x1.desc, 3871 created_time_ns=x1.date, 3872 ) 3873 except Exception as e: 3874 for row in rows: 3875 _tuple = tuple() 3876 for field in row: 3877 _tuple += (field,) 3878 _tuple += (e,) 3879 bad[i] = _tuple 3880 break 3881 if not self.memory_mode(): 3882 with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream: 3883 stream.write(json.dumps(cache)) 3884 if no_lock: 3885 assert lock is not None 3886 self.free(lock) 3887 report = ImportReport( 3888 statistics=statistics, 3889 bad=[ 3890 item 3891 for sublist in data.values() 3892 for item in sublist 3893 if item.error 3894 ], 3895 ) 3896 if debug: 3897 debug_path = f'{self.import_csv_cache_path()}.debug.json' 3898 with open(debug_path, 'w', encoding='utf-8') as file: 3899 json.dump(report, file, indent=4, cls=JSONEncoder) 3900 print(f'generated debug report @ `{debug_path}`...') 3901 return report 3902 3903 ######## 3904 # TESTS # 3905 ####### 3906 3907 @staticmethod 3908 def human_readable_size(size: float, decimal_places: int = 2) -> str: 3909 """ 3910 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 3911 3912 This function iterates through progressively larger units of information 3913 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 3914 range that can be expressed with a reasonable number before the unit. 3915 3916 Parameters: 3917 - size (float): The size in bytes to convert. 3918 - decimal_places (int, optional): The number of decimal places to display 3919 in the result. Defaults to 2. 3920 3921 Returns: 3922 - str: A string representation of the size in a human-readable format, 3923 rounded to the specified number of decimal places. For example: 3924 - '1.50 KB' (1536 bytes) 3925 - '23.00 MB' (24117248 bytes) 3926 - '1.23 GB' (1325899906 bytes) 3927 """ 3928 if type(size) not in (float, int): 3929 raise TypeError('size must be a float or integer') 3930 if type(decimal_places) != int: 3931 raise TypeError('decimal_places must be an integer') 3932 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 3933 if size < 1024.0: 3934 break 3935 size /= 1024.0 3936 return f'{size:.{decimal_places}f} {unit}' 3937 3938 @staticmethod 3939 def get_dict_size(obj: dict, seen: Optional[set] = None) -> float: 3940 """ 3941 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 3942 3943 This function traverses the dictionary structure, accounting for the size of keys, values, 3944 and any nested objects. It handles various data types commonly found in dictionaries 3945 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 3946 of circular references. 3947 3948 Parameters: 3949 - obj (dict): The dictionary whose size is to be calculated. 3950 - seen (set, optional): A set used internally to track visited objects 3951 and avoid circular references. Defaults to None. 3952 3953 Returns: 3954 - float: An approximate size of the dictionary and its contents in bytes. 3955 3956 Notes: 3957 - This function is a method of the `ZakatTracker` class and is likely used to 3958 estimate the memory footprint of data structures relevant to Zakat calculations. 3959 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 3960 not account for all memory overhead depending on the Python implementation. 3961 - Circular references are handled to prevent infinite recursion. 3962 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 3963 - String sizes are estimated based on character length and encoding. 3964 """ 3965 size = 0 3966 if seen is None: 3967 seen = set() 3968 3969 obj_id = id(obj) 3970 if obj_id in seen: 3971 return 0 3972 3973 seen.add(obj_id) 3974 size += sys.getsizeof(obj) 3975 3976 if isinstance(obj, dict): 3977 for k, v in obj.items(): 3978 size += ZakatTracker.get_dict_size(k, seen) 3979 size += ZakatTracker.get_dict_size(v, seen) 3980 elif isinstance(obj, (list, tuple, set, frozenset)): 3981 for item in obj: 3982 size += ZakatTracker.get_dict_size(item, seen) 3983 elif isinstance(obj, (int, float, complex)): # Handle numbers 3984 pass # Basic numbers have a fixed size, so nothing to add here 3985 elif isinstance(obj, str): # Handle strings 3986 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 3987 return size 3988 3989 @staticmethod 3990 def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp: # افتراض أن الشهر هو يونيو والسنة 2024 3991 """ 3992 Convert a specific day, month, and year into a timestamp. 3993 3994 Parameters: 3995 - day (int): The day of the month. 3996 - month (int, optional): The month of the year. Default is 6 (June). 3997 - year (int, optional): The year. Default is 2024. 3998 3999 Returns: 4000 - Timestamp: The timestamp representing the given day, month, and year. 4001 4002 Note: 4003 - This method assumes the default month and year if not provided. 4004 """ 4005 return Time.time(datetime.datetime(year, month, day)) 4006 4007 @staticmethod 4008 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 4009 """ 4010 Generate a random date between two given dates. 4011 4012 Parameters: 4013 - start_date (datetime.datetime): The start date from which to generate a random date. 4014 - end_date (datetime.datetime): The end date until which to generate a random date. 4015 4016 Returns: 4017 - datetime.datetime: A random date between the start_date and end_date. 4018 """ 4019 time_between_dates = end_date - start_date 4020 days_between_dates = time_between_dates.days 4021 random_number_of_days = random.randrange(days_between_dates) 4022 return start_date + datetime.timedelta(days=random_number_of_days) 4023 4024 @staticmethod 4025 def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False, 4026 debug: bool = False) -> int: 4027 """ 4028 Generate a random CSV file with specified parameters. 4029 The function generates a CSV file at the specified path with the given count of rows. 4030 Each row contains a randomly generated account, description, value, and date. 4031 The value is randomly generated between 1000 and 100000, 4032 and the date is randomly generated between 1950-01-01 and 2023-12-31. 4033 If the row number is not divisible by 13, the value is multiplied by -1. 4034 4035 Parameters: 4036 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 4037 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 4038 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 4039 - debug (bool, optional): A flag indicating whether to print debug information. 4040 4041 Returns: 4042 - int: number of generated records. 4043 """ 4044 if debug: 4045 print('generate_random_csv_file', f'debug={debug}') 4046 i = 0 4047 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 4048 writer = csv.writer(csvfile) 4049 writer.writerow(ZakatTracker.get_transaction_csv_headers()) 4050 for i in range(count): 4051 account = f'acc-{random.randint(1, count)}' 4052 desc = f'Some text {random.randint(1, count)}' 4053 value = random.randint(1000, 100000) 4054 date = ZakatTracker.generate_random_date( 4055 datetime.datetime(1000, 1, 1), 4056 datetime.datetime(2023, 12, 31), 4057 ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S') 4058 if not i % 13 == 0: 4059 value *= -1 4060 row = [account, desc, value, date] 4061 if with_rate: 4062 rate = random.randint(1, 100) * 0.12 4063 if debug: 4064 print('before-append', row) 4065 row.append(rate) 4066 if debug: 4067 print('after-append', row) 4068 if i % 2 == 1: 4069 row += (Time.time(),) 4070 writer.writerow(row) 4071 i = i + 1 4072 return i 4073 4074 @staticmethod 4075 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 4076 """ 4077 Creates a list of random integers whose sum does not exceed the specified maximum. 4078 4079 Parameters: 4080 - max_sum (int): The maximum allowed sum of the list elements. 4081 - min_value (int, optional): The minimum possible value for an element (inclusive). 4082 - max_value (int, optional): The maximum possible value for an element (inclusive). 4083 4084 Returns: 4085 - A list of random integers. 4086 """ 4087 result = [] 4088 current_sum = 0 4089 4090 while current_sum < max_sum: 4091 # Calculate the remaining space for the next element 4092 remaining_sum = max_sum - current_sum 4093 # Determine the maximum possible value for the next element 4094 next_max_value = min(remaining_sum, max_value) 4095 # Generate a random element within the allowed range 4096 next_element = random.randint(min_value, next_max_value) 4097 result.append(next_element) 4098 current_sum += next_element 4099 4100 return result 4101 4102 def _test_core(self, restore: bool = False, debug: bool = False): 4103 4104 random.seed(1234567890) 4105 4106 # sanity check - core 4107 4108 assert sorted([6, 0, 9, 3], reverse=False) == [0, 3, 6, 9] 4109 assert sorted([6, 0, 9, 3], reverse=True) == [9, 6, 3, 0] 4110 assert sorted( 4111 {6: '6', 0: '0', 9: '9', 3: '3'}.items(), 4112 reverse=False, 4113 ) == [(0, '0'), (3, '3'), (6, '6'), (9, '9')] 4114 assert sorted( 4115 {6: '6', 0: '0', 9: '9', 3: '3'}.items(), 4116 reverse=True, 4117 ) == [(9, '9'), (6, '6'), (3, '3'), (0, '0')] 4118 assert sorted( 4119 {'6': 6, '0': 0, '9': 9, '3': 3}.items(), 4120 reverse=False, 4121 ) == [('0', 0), ('3', 3), ('6', 6), ('9', 9)] 4122 assert sorted( 4123 {'6': 6, '0': 0, '9': 9, '3': 3}.items(), 4124 reverse=True, 4125 ) == [('9', 9), ('6', 6), ('3', 3), ('0', 0)] 4126 4127 Timestamp.test() 4128 AccountID.test(debug) 4129 Time.test(debug) 4130 4131 # test to prevents setting non-existent attributes 4132 4133 for cls in [ 4134 StrictDataclass, 4135 BoxZakat, 4136 Box, 4137 Log, 4138 Account, 4139 Exchange, 4140 History, 4141 BoxPlan, 4142 ZakatSummary, 4143 ZakatReport, 4144 Vault, 4145 AccountPaymentPart, 4146 PaymentParts, 4147 SubtractAge, 4148 SubtractAges, 4149 SubtractReport, 4150 TransferTime, 4151 TransferTimes, 4152 TransferRecord, 4153 ImportStatistics, 4154 CSVRecord, 4155 ImportReport, 4156 SizeInfo, 4157 FileInfo, 4158 FileStats, 4159 TimeSummary, 4160 Transaction, 4161 DailyRecords, 4162 Timeline, 4163 ]: 4164 failed = False 4165 try: 4166 x = cls() 4167 x.x = 123 4168 except: 4169 failed = True 4170 assert failed 4171 4172 # sanity check - random forward time 4173 4174 xlist = [] 4175 limit = 1000 4176 for _ in range(limit): 4177 y = Time.time() 4178 z = '-' 4179 if y not in xlist: 4180 xlist.append(y) 4181 else: 4182 z = 'x' 4183 if debug: 4184 print(z, y) 4185 xx = len(xlist) 4186 if debug: 4187 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 4188 assert limit == xx 4189 4190 # test ZakatTracker.split_at_last_symbol 4191 4192 test_cases = [ 4193 ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")), 4194 ("No symbol here.", '$', ("No symbol here.", "")), 4195 ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")), 4196 ("Here is a symbol%", '%', ("Here is a symbol", "")), 4197 ("@only a symbol", '@', ("", "only a symbol")), 4198 ("", '#', ("", "")), 4199 ("test/test/test.txt", '/', ("test/test", "test.txt")), 4200 ("abc#def#ghi", "#", ("abc#def", "ghi")), 4201 ("abc", "#", ("abc", "")), 4202 ("//https://test", '//', ("//https:", "test")), 4203 ] 4204 4205 for data, symbol, expected in test_cases: 4206 result = ZakatTracker.split_at_last_symbol(data, symbol) 4207 assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}" 4208 4209 # human_readable_size 4210 4211 assert ZakatTracker.human_readable_size(0) == '0.00 B' 4212 assert ZakatTracker.human_readable_size(512) == '512.00 B' 4213 assert ZakatTracker.human_readable_size(1023) == '1023.00 B' 4214 4215 assert ZakatTracker.human_readable_size(1024) == '1.00 KB' 4216 assert ZakatTracker.human_readable_size(2048) == '2.00 KB' 4217 assert ZakatTracker.human_readable_size(5120) == '5.00 KB' 4218 4219 assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB' 4220 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB' 4221 4222 assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB' 4223 assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB' 4224 assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB' 4225 4226 assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB' 4227 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB' 4228 assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB' 4229 4230 try: 4231 # noinspection PyTypeChecker 4232 ZakatTracker.human_readable_size('not a number') 4233 assert False, 'Expected TypeError for invalid input' 4234 except TypeError: 4235 pass 4236 4237 try: 4238 # noinspection PyTypeChecker 4239 ZakatTracker.human_readable_size(1024, decimal_places='not an int') 4240 assert False, 'Expected TypeError for invalid decimal_places' 4241 except TypeError: 4242 pass 4243 4244 # get_dict_size 4245 assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch' 4246 assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary' 4247 4248 # number scale 4249 error = 0 4250 total = 0 4251 for sign in ['', '-']: 4252 for max_i, max_j, decimal_places in [ 4253 (101, 101, 2), # fiat currency minimum unit took 2 decimal places 4254 (1, 1_000, 8), # cryptocurrency like Satoshi in Bitcoin took 8 decimal places 4255 (1, 1_000, 18) # cryptocurrency like Wei in Ethereum took 18 decimal places 4256 ]: 4257 for return_type in ( 4258 float, 4259 decimal.Decimal, 4260 ): 4261 for i in range(max_i): 4262 for j in range(max_j): 4263 total += 1 4264 num_str = f'{sign}{i}.{j:0{decimal_places}d}' 4265 num = return_type(num_str) 4266 scaled = self.scale(num, decimal_places=decimal_places) 4267 unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places) 4268 if debug: 4269 print( 4270 f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}') 4271 if unscaled != num: 4272 if debug: 4273 print('***** SCALE ERROR *****') 4274 error += 1 4275 if debug: 4276 print(f'total: {total}, error({error}): {100 * error / total}%') 4277 assert error == 0 4278 4279 # test lock 4280 4281 assert self.nolock() 4282 assert self.__history() is True 4283 lock = self.lock() 4284 assert lock is not None 4285 assert lock > 0 4286 failed = False 4287 try: 4288 self.lock() 4289 except: 4290 failed = True 4291 assert failed 4292 assert self.free(lock) 4293 assert not self.free(lock) 4294 4295 wallet_account_id = self.create_account('wallet') 4296 4297 table = { 4298 AccountID('1'): [ 4299 (0, 10, 1000, 1000, 1000, 1, 1), 4300 (0, 20, 3000, 3000, 3000, 2, 2), 4301 (0, 30, 6000, 6000, 6000, 3, 3), 4302 (1, 15, 4500, 4500, 4500, 3, 4), 4303 (1, 50, -500, -500, -500, 4, 5), 4304 (1, 100, -10500, -10500, -10500, 5, 6), 4305 ], 4306 wallet_account_id: [ 4307 (1, 90, -9000, -9000, -9000, 1, 1), 4308 (0, 100, 1000, 1000, 1000, 2, 2), 4309 (1, 190, -18000, -18000, -18000, 3, 3), 4310 (0, 1000, 82000, 82000, 82000, 4, 4), 4311 ], 4312 } 4313 for x in table: 4314 for y in table[x]: 4315 lock = self.lock() 4316 if y[0] == 0: 4317 ref = self.track( 4318 unscaled_value=y[1], 4319 desc='test-add', 4320 account=x, 4321 created_time_ns=Time.time(), 4322 debug=debug, 4323 ) 4324 else: 4325 report = self.subtract( 4326 unscaled_value=y[1], 4327 desc='test-sub', 4328 account=x, 4329 created_time_ns=Time.time(), 4330 ) 4331 ref = report.log_ref 4332 if debug: 4333 print('_sub', z, Time.time()) 4334 assert ref != 0 4335 assert len(self.__vault.account[x].log[ref].file) == 0 4336 for i in range(3): 4337 file_ref = self.add_file(x, ref, 'file_' + str(i)) 4338 assert file_ref != 0 4339 if debug: 4340 print('ref', ref, 'file', file_ref) 4341 assert len(self.__vault.account[x].log[ref].file) == i + 1 4342 assert file_ref in self.__vault.account[x].log[ref].file 4343 file_ref = self.add_file(x, ref, 'file_' + str(3)) 4344 assert self.remove_file(x, ref, file_ref) 4345 timeline = self.timeline(debug=debug) 4346 if debug: 4347 print('timeline', timeline) 4348 assert timeline.daily 4349 assert timeline.weekly 4350 assert timeline.monthly 4351 assert timeline.yearly 4352 z = self.balance(x) 4353 if debug: 4354 print('debug-0', z, y) 4355 assert z == y[2] 4356 z = self.balance(x, False) 4357 if debug: 4358 print('debug-1', z, y[3]) 4359 assert z == y[3] 4360 o = self.__vault.account[x].log 4361 z = 0 4362 for i in o: 4363 z += o[i].value 4364 if debug: 4365 print('debug-2', z, type(z)) 4366 print('debug-2', y[4], type(y[4])) 4367 assert z == y[4] 4368 if debug: 4369 print('debug-2 - PASSED') 4370 assert self.box_size(x) == y[5] 4371 assert self.log_size(x) == y[6] 4372 assert not self.nolock() 4373 assert lock is not None 4374 self.free(lock) 4375 assert self.nolock() 4376 assert self.boxes(x) != {} 4377 assert self.logs(x) != {} 4378 4379 assert not self.hide(x) 4380 assert self.hide(x, False) is False 4381 assert self.hide(x) is False 4382 assert self.hide(x, True) 4383 assert self.hide(x) 4384 4385 assert self.zakatable(x) 4386 assert self.zakatable(x, False) is False 4387 assert self.zakatable(x) is False 4388 assert self.zakatable(x, True) 4389 assert self.zakatable(x) 4390 4391 if restore is True: 4392 # invalid restore point 4393 for lock in [0, time.time_ns(), Time.time()]: 4394 failed = False 4395 try: 4396 self.recall(dry=True, lock=lock) 4397 except: 4398 failed = True 4399 assert failed 4400 count = len(self.__vault.history) 4401 if debug: 4402 print('history-count', count) 4403 assert count == 12 4404 # try mode 4405 for _ in range(count): 4406 assert self.recall(dry=True, debug=debug) 4407 count = len(self.__vault.history) 4408 if debug: 4409 print('history-count', count) 4410 assert count == 12 4411 _accounts = list(table.keys()) 4412 accounts_limit = len(_accounts) + 1 4413 for i in range(-1, -accounts_limit, -1): 4414 account = _accounts[i] 4415 if debug: 4416 print(account, len(table[account])) 4417 transaction_limit = len(table[account]) + 1 4418 for j in range(-1, -transaction_limit, -1): 4419 row = table[account][j] 4420 if debug: 4421 print(row, self.balance(account), self.balance(account, False)) 4422 assert self.balance(account) == self.balance(account, False) 4423 assert self.balance(account) == row[2] 4424 assert self.recall(dry=False, debug=debug) 4425 assert self.recall(dry=False, debug=debug) 4426 assert self.recall(dry=False, debug=debug) 4427 assert not self.recall(dry=False, debug=debug) 4428 count = len(self.__vault.history) 4429 if debug: 4430 print('history-count', count) 4431 assert count == 0 4432 self.reset() 4433 4434 def _test_storage(self, account_id: Optional[AccountID] = None, debug: bool = False): 4435 old_vault = dataclasses.replace(self.__vault) 4436 old_vault_deep = copy.deepcopy(self.__vault) 4437 old_vault_dict = dataclasses.asdict(self.__vault) 4438 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 4439 if os.path.exists(_path): 4440 os.remove(_path) 4441 for hashed in [False, True]: 4442 self.save(hash_required=hashed) 4443 assert os.path.getsize(_path) > 0 4444 self.reset() 4445 assert self.recall(dry=False, debug=debug) is False 4446 for hash_required in [False, True]: 4447 if debug: 4448 print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4449 self.load(hash_required=hashed and hash_required) 4450 if debug: 4451 print('[debug]', type(self.__vault)) 4452 assert self.__vault.account is not None 4453 assert old_vault == self.__vault 4454 assert old_vault_deep == self.__vault 4455 assert old_vault_dict == dataclasses.asdict(self.__vault) 4456 if account_id is not None: 4457 # corrupt the data 4458 log_ref = None 4459 tmp_file_ref = Time.time() 4460 for k in self.__vault.account[account_id].log: 4461 log_ref = k 4462 self.__vault.account[account_id].log[k].file[tmp_file_ref] = 'HACKED' 4463 break 4464 assert old_vault != self.__vault 4465 assert old_vault_deep != self.__vault 4466 assert old_vault_dict != dataclasses.asdict(self.__vault) 4467 # fix the data 4468 del self.__vault.account[account_id].log[log_ref].file[tmp_file_ref] 4469 assert old_vault == self.__vault 4470 assert old_vault_deep == self.__vault 4471 assert old_vault_dict == dataclasses.asdict(self.__vault) 4472 if hashed: 4473 continue 4474 failed = False 4475 try: 4476 hash_required = True 4477 if debug: 4478 print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4479 self.load(hash_required=True) 4480 except: 4481 failed = True 4482 assert failed 4483 4484 def test(self, debug: bool = False) -> bool: 4485 if debug: 4486 print('test', f'debug={debug}') 4487 try: 4488 4489 self._test_core(True, debug) 4490 self._test_core(False, debug) 4491 4492 # test_names 4493 self.reset() 4494 x = "test_names" 4495 failed = False 4496 try: 4497 assert self.name(x) == '' 4498 except: 4499 failed = True 4500 assert failed 4501 assert self.names() == {} 4502 failed = False 4503 try: 4504 assert self.name(x, 'qwe') == '' 4505 except: 4506 failed = True 4507 assert failed 4508 account_id0 = self.create_account(x) 4509 assert isinstance(account_id0, AccountID) 4510 assert int(account_id0) > 0 4511 assert self.name(account_id0) == x 4512 assert self.name(account_id0, 'qwe') == 'qwe' 4513 if debug: 4514 print(self.names(keyword='qwe')) 4515 assert self.names(keyword='asd') == {} 4516 assert self.names(keyword='qwe') == {'qwe': account_id0} 4517 4518 # test_create_account 4519 account_name = "test_account" 4520 assert self.names(keyword=account_name) == {} 4521 account_id = self.create_account(account_name) 4522 assert isinstance(account_id, AccountID) 4523 assert int(account_id) > 0 4524 assert account_id in self.__vault.account 4525 assert self.name(account_id) == account_name 4526 assert self.names(keyword=account_name) == {account_name: account_id} 4527 4528 failed = False 4529 try: 4530 self.create_account(account_name) 4531 except: 4532 failed = True 4533 assert failed 4534 4535 # bad are names is forbidden 4536 4537 for bad_name in [ 4538 None, 4539 '', 4540 Time.time(), 4541 -Time.time(), 4542 f'{Time.time()}', 4543 f'{-Time.time()}', 4544 0.0, 4545 '0.0', 4546 ' ', 4547 ]: 4548 failed = False 4549 try: 4550 self.create_account(bad_name) 4551 except: 4552 failed = True 4553 assert failed 4554 4555 # rename account 4556 assert self.name(account_id) == account_name 4557 assert self.name(account_id, 'asd') == 'asd' 4558 assert self.name(account_id) == 'asd' 4559 # use old and not used name 4560 account_id2 = self.create_account(account_name) 4561 assert int(account_id2) > 0 4562 assert account_id != account_id2 4563 assert self.name(account_id2) == account_name 4564 assert self.names(keyword=account_name) == {account_name: account_id2} 4565 4566 assert self.__history() 4567 count = len(self.__vault.history) 4568 if debug: 4569 print('history-count', count) 4570 assert count == 8 4571 4572 assert self.recall(dry=False, debug=debug) 4573 assert self.name(account_id2) == '' 4574 assert self.account_exists(account_id2) 4575 assert self.recall(dry=False, debug=debug) 4576 assert not self.account_exists(account_id2) 4577 assert self.recall(dry=False, debug=debug) 4578 assert self.name(account_id) == account_name 4579 assert self.recall(dry=False, debug=debug) 4580 assert self.account_exists(account_id) 4581 assert self.recall(dry=False, debug=debug) 4582 assert not self.account_exists(account_id) 4583 assert self.names(keyword='qwe') == {'qwe': account_id0} 4584 assert self.recall(dry=False, debug=debug) 4585 assert self.names(keyword='qwe') == {} 4586 assert self.name(account_id0) == x 4587 assert self.recall(dry=False, debug=debug) 4588 assert self.name(account_id0) == '' 4589 assert self.account_exists(account_id0) 4590 assert self.recall(dry=False, debug=debug) 4591 assert not self.account_exists(account_id0) 4592 assert not self.recall(dry=False, debug=debug) 4593 4594 # Not allowed for duplicate transactions in the same account and time 4595 4596 created = Time.time() 4597 same_account_id = self.create_account('same') 4598 self.track(100, 'test-1', same_account_id, True, created) 4599 failed = False 4600 try: 4601 self.track(50, 'test-1', same_account_id, True, created) 4602 except: 4603 failed = True 4604 assert failed is True 4605 4606 self.reset() 4607 4608 # Same account transfer 4609 for x in [1, 'a', True, 1.8, None]: 4610 failed = False 4611 try: 4612 self.transfer(1, x, x, 'same-account', debug=debug) 4613 except: 4614 failed = True 4615 assert failed is True 4616 4617 # Always preserve box age during transfer 4618 4619 series: list[tuple[int, int]] = [ 4620 (30, 4), 4621 (60, 3), 4622 (90, 2), 4623 ] 4624 case = { 4625 3000: { 4626 'series': series, 4627 'rest': 15000, 4628 }, 4629 6000: { 4630 'series': series, 4631 'rest': 12000, 4632 }, 4633 9000: { 4634 'series': series, 4635 'rest': 9000, 4636 }, 4637 18000: { 4638 'series': series, 4639 'rest': 0, 4640 }, 4641 27000: { 4642 'series': series, 4643 'rest': -9000, 4644 }, 4645 36000: { 4646 'series': series, 4647 'rest': -18000, 4648 }, 4649 } 4650 4651 selected_time = Time.time() - ZakatTracker.TimeCycle() 4652 ages_account_id = self.create_account('ages') 4653 future_account_id = self.create_account('future') 4654 4655 for total in case: 4656 if debug: 4657 print('--------------------------------------------------------') 4658 print(f'case[{total}]', case[total]) 4659 for x in case[total]['series']: 4660 self.track( 4661 unscaled_value=x[0], 4662 desc=f'test-{x} ages', 4663 account=ages_account_id, 4664 created_time_ns=selected_time * x[1], 4665 ) 4666 4667 unscaled_total = self.unscale(total) 4668 if debug: 4669 print('unscaled_total', unscaled_total) 4670 refs = self.transfer( 4671 unscaled_amount=unscaled_total, 4672 from_account=ages_account_id, 4673 to_account=future_account_id, 4674 desc='Zakat Movement', 4675 debug=debug, 4676 ) 4677 4678 if debug: 4679 print('refs', refs) 4680 4681 ages_cache_balance = self.balance(ages_account_id) 4682 ages_fresh_balance = self.balance(ages_account_id, False) 4683 rest = case[total]['rest'] 4684 if debug: 4685 print('source', ages_cache_balance, ages_fresh_balance, rest) 4686 assert ages_cache_balance == rest 4687 assert ages_fresh_balance == rest 4688 4689 future_cache_balance = self.balance(future_account_id) 4690 future_fresh_balance = self.balance(future_account_id, False) 4691 if debug: 4692 print('target', future_cache_balance, future_fresh_balance, total) 4693 print('refs', refs) 4694 assert future_cache_balance == total 4695 assert future_fresh_balance == total 4696 4697 # TODO: check boxes times for `ages` should equal box times in `future` 4698 for ref in self.__vault.account[ages_account_id].box: 4699 ages_capital = self.__vault.account[ages_account_id].box[ref].capital 4700 ages_rest = self.__vault.account[ages_account_id].box[ref].rest 4701 future_capital = 0 4702 if ref in self.__vault.account[future_account_id].box: 4703 future_capital = self.__vault.account[future_account_id].box[ref].capital 4704 future_rest = 0 4705 if ref in self.__vault.account[future_account_id].box: 4706 future_rest = self.__vault.account[future_account_id].box[ref].rest 4707 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 4708 if debug: 4709 print('================================================================') 4710 print('ages', ages_capital, ages_rest) 4711 print('future', future_capital, future_rest) 4712 if ages_rest == 0: 4713 assert ages_capital == future_capital 4714 elif ages_rest < 0: 4715 assert -ages_capital == future_capital 4716 elif ages_rest > 0: 4717 assert ages_capital == ages_rest + future_capital 4718 self.reset() 4719 assert len(self.__vault.history) == 0 4720 4721 assert self.__history() 4722 assert self.__history(False) is False 4723 assert self.__history() is False 4724 assert self.__history(True) 4725 assert self.__history() 4726 if debug: 4727 print('####################################################################') 4728 4729 wallet_account_id = self.create_account('wallet') 4730 safe_account_id = self.create_account('safe') 4731 bank_account_id = self.create_account('bank') 4732 transaction = [ 4733 ( 4734 20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1, 4735 2000, 2000, 2000, 1, 1, 4736 ), 4737 ( 4738 750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2, 4739 75000, 75000, 75000, 1, 1, 4740 ), 4741 ( 4742 600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2, 4743 60000, 60000, 60000, 1, 1, 4744 ), 4745 ] 4746 for z in transaction: 4747 lock = self.lock() 4748 x = z[1] 4749 y = z[2] 4750 self.transfer( 4751 unscaled_amount=z[0], 4752 from_account=x, 4753 to_account=y, 4754 desc='test-transfer', 4755 debug=debug, 4756 ) 4757 zz = self.balance(x) 4758 if debug: 4759 print(zz, z) 4760 assert zz == z[3] 4761 xx = self.accounts()[x] 4762 assert xx.balance == z[3] 4763 assert self.balance(x, False) == z[4] 4764 assert xx.balance == z[4] 4765 4766 s = 0 4767 log = self.__vault.account[x].log 4768 for i in log: 4769 s += log[i].value 4770 if debug: 4771 print('s', s, 'z[5]', z[5]) 4772 assert s == z[5] 4773 4774 assert self.box_size(x) == z[6] 4775 assert self.log_size(x) == z[7] 4776 4777 yy = self.accounts()[y] 4778 assert self.balance(y) == z[8] 4779 assert yy.balance == z[8] 4780 assert self.balance(y, False) == z[9] 4781 assert yy.balance == z[9] 4782 4783 s = 0 4784 log = self.__vault.account[y].log 4785 for i in log: 4786 s += log[i].value 4787 assert s == z[10] 4788 4789 assert self.box_size(y) == z[11] 4790 assert self.log_size(y) == z[12] 4791 assert lock is not None 4792 assert self.free(lock) 4793 4794 if debug: 4795 pp().pprint(self.check(2.17)) 4796 4797 assert self.nolock() 4798 history_count = len(self.__vault.history) 4799 transaction_count = len(transaction) 4800 if debug: 4801 print('history-count', history_count, transaction_count) 4802 assert history_count == transaction_count * 3 4803 assert not self.free(Time.time()) 4804 assert self.free(self.lock()) 4805 assert self.nolock() 4806 assert len(self.__vault.history) == transaction_count * 3 4807 4808 # recall 4809 4810 assert self.nolock() 4811 for i in range(transaction_count * 3, 0, -1): 4812 assert len(self.__vault.history) == i 4813 assert self.recall(dry=False, debug=debug) is True 4814 assert len(self.__vault.history) == 0 4815 assert self.recall(dry=False, debug=debug) is False 4816 assert len(self.__vault.history) == 0 4817 4818 # exchange 4819 4820 cash_account_id = self.create_account('cash') 4821 self.exchange(cash_account_id, 25, 3.75, '2024-06-25') 4822 self.exchange(cash_account_id, 22, 3.73, '2024-06-22') 4823 self.exchange(cash_account_id, 15, 3.69, '2024-06-15') 4824 self.exchange(cash_account_id, 10, 3.66) 4825 4826 assert self.nolock() 4827 4828 bank_account_id = self.create_account('bank') 4829 for i in range(1, 30): 4830 exchange = self.exchange(cash_account_id, i) 4831 rate, description, created = exchange.rate, exchange.description, exchange.time 4832 if debug: 4833 print(i, rate, description, created) 4834 assert created 4835 if i < 10: 4836 assert rate == 1 4837 assert description is None 4838 elif i == 10: 4839 assert rate == 3.66 4840 assert description is None 4841 elif i < 15: 4842 assert rate == 3.66 4843 assert description is None 4844 elif i == 15: 4845 assert rate == 3.69 4846 assert description is not None 4847 elif i < 22: 4848 assert rate == 3.69 4849 assert description is not None 4850 elif i == 22: 4851 assert rate == 3.73 4852 assert description is not None 4853 elif i >= 25: 4854 assert rate == 3.75 4855 assert description is not None 4856 exchange = self.exchange(bank_account_id, i) 4857 rate, description, created = exchange.rate, exchange.description, exchange.time 4858 if debug: 4859 print(i, rate, description, created) 4860 assert created 4861 assert rate == 1 4862 assert description is None 4863 4864 assert len(self.__vault.exchange) == 1 4865 assert len(self.exchanges()) == 1 4866 self.__vault.exchange.clear() 4867 assert len(self.__vault.exchange) == 0 4868 assert len(self.exchanges()) == 0 4869 self.reset() 4870 4871 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 4872 cash_account_id = self.create_account('cash') 4873 self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 4874 self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 4875 self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 4876 self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66) 4877 4878 assert self.nolock() 4879 4880 test_account_id = self.create_account('test') 4881 for i in [x * 0.12 for x in range(-15, 21)]: 4882 if i <= 0: 4883 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange() 4884 else: 4885 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange() 4886 4887 assert self.nolock() 4888 4889 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 4890 bank_account_id = self.create_account('bank') 4891 for i in range(1, 31): 4892 timestamp_ns = ZakatTracker.day_to_time(i) 4893 exchange = self.exchange(cash_account_id, timestamp_ns) 4894 rate, description, created = exchange.rate, exchange.description, exchange.time 4895 if debug: 4896 print(i, rate, description, created) 4897 assert created 4898 if i < 10: 4899 assert rate == 1 4900 assert description is None 4901 elif i == 10: 4902 assert rate == 3.66 4903 assert description is None 4904 elif i < 15: 4905 assert rate == 3.66 4906 assert description is None 4907 elif i == 15: 4908 assert rate == 3.69 4909 assert description is not None 4910 elif i < 22: 4911 assert rate == 3.69 4912 assert description is not None 4913 elif i == 22: 4914 assert rate == 3.73 4915 assert description is not None 4916 elif i >= 25: 4917 assert rate == 3.75 4918 assert description is not None 4919 exchange = self.exchange(bank_account_id, i) 4920 rate, description, created = exchange.rate, exchange.description, exchange.time 4921 if debug: 4922 print(i, rate, description, created) 4923 assert created 4924 assert rate == 1 4925 assert description is None 4926 4927 assert self.nolock() 4928 if debug: 4929 print(self.__vault.history, len(self.__vault.history)) 4930 for _ in range(len(self.__vault.history)): 4931 assert self.recall(dry=False, debug=debug) 4932 assert not self.recall(dry=False, debug=debug) 4933 4934 self.reset() 4935 4936 # test transfer between accounts with different exchange rate 4937 4938 a_SAR = self.create_account('Bank (SAR)') 4939 b_USD = self.create_account('Bank (USD)') 4940 c_SAR = self.create_account('Safe (SAR)') 4941 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 4942 for case in [ 4943 (0, a_SAR, 'SAR Gift', 1000, 100000), 4944 (1, a_SAR, 1), 4945 (0, b_USD, 'USD Gift', 500, 50000), 4946 (1, b_USD, 1), 4947 (2, b_USD, 3.75), 4948 (1, b_USD, 3.75), 4949 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 4950 (0, c_SAR, 'Salary', 750, 75000), 4951 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 4952 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 4953 ]: 4954 if debug: 4955 print('case', case) 4956 match (case[0]): 4957 case 0: # track 4958 _, account, desc, x, balance = case 4959 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 4960 4961 cached_value = self.balance(account, cached=True) 4962 fresh_value = self.balance(account, cached=False) 4963 if debug: 4964 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 4965 assert cached_value == balance 4966 assert fresh_value == balance 4967 case 1: # check-exchange 4968 _, account, expected_rate = case 4969 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4970 if debug: 4971 print('t-exchange', t_exchange) 4972 assert t_exchange.rate == expected_rate 4973 case 2: # do-exchange 4974 _, account, rate = case 4975 self.exchange(account, rate=rate, debug=debug) 4976 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4977 if debug: 4978 print('b-exchange', b_exchange) 4979 assert b_exchange.rate == rate 4980 case 3: # transfer 4981 _, x, a, b, desc, a_balance, b_balance = case 4982 self.transfer(x, a, b, desc, debug=debug) 4983 4984 cached_value = self.balance(a, cached=True) 4985 fresh_value = self.balance(a, cached=False) 4986 if debug: 4987 print( 4988 'account', a, 4989 'cached_value', cached_value, 4990 'fresh_value', fresh_value, 4991 'a_balance', a_balance, 4992 ) 4993 assert cached_value == a_balance 4994 assert fresh_value == a_balance 4995 4996 cached_value = self.balance(b, cached=True) 4997 fresh_value = self.balance(b, cached=False) 4998 if debug: 4999 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 5000 assert cached_value == b_balance 5001 assert fresh_value == b_balance 5002 5003 # Transfer all in many chunks randomly from B to A 5004 a_SAR_balance = 137125 5005 b_USD_balance = 50100 5006 b_USD_exchange = self.exchange(b_USD) 5007 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 5008 if debug: 5009 print('amounts', amounts) 5010 i = 0 5011 for x in amounts: 5012 if debug: 5013 print(f'{i} - transfer-with-exchange({x})') 5014 self.transfer( 5015 unscaled_amount=self.unscale(x), 5016 from_account=b_USD, 5017 to_account=a_SAR, 5018 desc=f'{x} USD -> SAR', 5019 debug=debug, 5020 ) 5021 5022 b_USD_balance -= x 5023 cached_value = self.balance(b_USD, cached=True) 5024 fresh_value = self.balance(b_USD, cached=False) 5025 if debug: 5026 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5027 b_USD_balance) 5028 assert cached_value == b_USD_balance 5029 assert fresh_value == b_USD_balance 5030 5031 a_SAR_balance += int(x * b_USD_exchange.rate) 5032 cached_value = self.balance(a_SAR, cached=True) 5033 fresh_value = self.balance(a_SAR, cached=False) 5034 if debug: 5035 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5036 a_SAR_balance, 'rate', b_USD_exchange.rate) 5037 assert cached_value == a_SAR_balance 5038 assert fresh_value == a_SAR_balance 5039 i += 1 5040 5041 # Transfer all in many chunks randomly from C to A 5042 c_SAR_balance = 37500 5043 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 5044 if debug: 5045 print('amounts', amounts) 5046 i = 0 5047 for x in amounts: 5048 if debug: 5049 print(f'{i} - transfer-with-exchange({x})') 5050 self.transfer( 5051 unscaled_amount=self.unscale(x), 5052 from_account=c_SAR, 5053 to_account=a_SAR, 5054 desc=f'{x} SAR -> a_SAR', 5055 debug=debug, 5056 ) 5057 5058 c_SAR_balance -= x 5059 cached_value = self.balance(c_SAR, cached=True) 5060 fresh_value = self.balance(c_SAR, cached=False) 5061 if debug: 5062 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5063 c_SAR_balance) 5064 assert cached_value == c_SAR_balance 5065 assert fresh_value == c_SAR_balance 5066 5067 a_SAR_balance += x 5068 cached_value = self.balance(a_SAR, cached=True) 5069 fresh_value = self.balance(a_SAR, cached=False) 5070 if debug: 5071 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5072 a_SAR_balance) 5073 assert cached_value == a_SAR_balance 5074 assert fresh_value == a_SAR_balance 5075 i += 1 5076 5077 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 5078 5079 # check & zakat with exchange rates for many cycles 5080 5081 lock = None 5082 safe_account_id = self.create_account('safe') 5083 cave_account_id = self.create_account('cave') 5084 for rate, values in { 5085 1: { 5086 'in': [1000, 2000, 10000], 5087 'exchanged': [100000, 200000, 1000000], 5088 'out': [2500, 5000, 73140], 5089 }, 5090 3.75: { 5091 'in': [200, 1000, 5000], 5092 'exchanged': [75000, 375000, 1875000], 5093 'out': [1875, 9375, 137138], 5094 }, 5095 }.items(): 5096 a, b, c = values['in'] 5097 m, n, o = values['exchanged'] 5098 x, y, z = values['out'] 5099 if debug: 5100 print('rate', rate, 'values', values) 5101 for case in [ 5102 (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5103 {safe_account_id: {0: {'below_nisab': x}}}, 5104 ], False, m), 5105 (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5106 {safe_account_id: {0: {'count': 1, 'total': y}}}, 5107 ], True, n), 5108 (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [ 5109 {cave_account_id: {0: {'count': 3, 'total': z}}}, 5110 ], True, o), 5111 ]: 5112 if debug: 5113 print(f'############# check(rate: {rate}) #############') 5114 print('case', case) 5115 self.reset() 5116 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 5117 self.track( 5118 unscaled_value=case[0], 5119 desc='test-check', 5120 account=case[1], 5121 created_time_ns=case[2], 5122 ) 5123 assert self.snapshot() 5124 5125 # assert self.nolock() 5126 # history_size = len(self.__vault.history) 5127 # print('history_size', history_size) 5128 # assert history_size == 2 5129 lock = self.lock() 5130 assert lock 5131 assert not self.nolock() 5132 report = self.check(2.17, None, debug) 5133 if debug: 5134 print('[report]', report) 5135 assert case[4] == report.valid 5136 assert case[5] == report.summary.total_wealth 5137 assert case[5] == report.summary.total_zakatable_amount 5138 if report.valid: 5139 if debug: 5140 pp().pprint(report.plan) 5141 assert report.plan 5142 assert self.zakat(report, debug=debug) 5143 if debug: 5144 pp().pprint(self.__vault) 5145 self._test_storage(debug=debug) 5146 5147 for x in report.plan: 5148 assert case[1] == x 5149 if report.plan[x][0].below_nisab: 5150 if debug: 5151 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 5152 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 5153 else: 5154 if debug: 5155 print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total']) 5156 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 5157 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 5158 assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total'] 5159 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 5160 assert report.plan[x][0].count == case[3][0][x][0]['count'] 5161 else: 5162 if debug: 5163 pp().pprint(report) 5164 result = self.zakat(report, debug=debug) 5165 if debug: 5166 print('zakat-result', result, case[4]) 5167 assert result == case[4] 5168 report = self.check(2.17, None, debug) 5169 assert report.valid is False 5170 self._test_storage(account_id=cave_account_id, debug=debug) 5171 5172 # recall after zakat 5173 5174 history_size = len(self.__vault.history) 5175 if debug: 5176 print('history_size', history_size) 5177 assert history_size == 3 5178 assert not self.nolock() 5179 assert self.recall(dry=False, debug=debug) is False 5180 self.free(lock) 5181 assert self.nolock() 5182 5183 for i in range(3, 0, -1): 5184 history_size = len(self.__vault.history) 5185 if debug: 5186 print('history_size', history_size) 5187 assert history_size == i 5188 assert self.recall(dry=False, debug=debug) is True 5189 5190 assert self.nolock() 5191 assert self.recall(dry=False, debug=debug) is False 5192 5193 history_size = len(self.__vault.history) 5194 if debug: 5195 print('history_size', history_size) 5196 assert history_size == 0 5197 5198 account_size = len(self.__vault.account) 5199 if debug: 5200 print('account_size', account_size) 5201 assert account_size == 0 5202 5203 report_size = len(self.__vault.report) 5204 if debug: 5205 print('report_size', report_size) 5206 assert report_size == 0 5207 5208 assert self.nolock() 5209 5210 # csv 5211 5212 csv_count = 1000 5213 5214 for with_rate, path in { 5215 False: 'test-import_csv-no-exchange', 5216 True: 'test-import_csv-with-exchange', 5217 }.items(): 5218 5219 if debug: 5220 print('test_import_csv', with_rate, path) 5221 5222 csv_path = path + '.csv' 5223 if os.path.exists(csv_path): 5224 os.remove(csv_path) 5225 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 5226 if debug: 5227 print('generate_random_csv_file', c) 5228 assert c == csv_count 5229 assert os.path.getsize(csv_path) > 0 5230 cache_path = self.import_csv_cache_path() 5231 if os.path.exists(cache_path): 5232 os.remove(cache_path) 5233 self.reset() 5234 lock = self.lock() 5235 import_report = self.import_csv(csv_path, debug=debug) 5236 bad_count = len(import_report.bad) 5237 if debug: 5238 print(f'csv-imported: {import_report.statistics} = count({csv_count})') 5239 print('bad', import_report.bad) 5240 assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count 5241 assert import_report.statistics.created == csv_count 5242 assert bad_count == 0 5243 assert bad_count == import_report.statistics.bad 5244 tmp_size = os.path.getsize(cache_path) 5245 assert tmp_size > 0 5246 5247 import_report_2 = self.import_csv(csv_path, debug=debug) 5248 bad_2_count = len(import_report_2.bad) 5249 if debug: 5250 print(f'csv-imported: {import_report_2}') 5251 print('bad', import_report_2.bad) 5252 assert tmp_size == os.path.getsize(cache_path) 5253 assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count 5254 assert import_report.statistics.created == import_report_2.statistics.found 5255 assert bad_count == bad_2_count 5256 assert import_report_2.statistics.found == csv_count 5257 assert bad_2_count == 0 5258 assert bad_2_count == import_report_2.statistics.bad 5259 assert import_report_2.statistics.created == 0 5260 5261 # payment parts 5262 5263 positive_parts = self.build_payment_parts(100, positive_only=True) 5264 assert self.check_payment_parts(positive_parts) != 0 5265 assert self.check_payment_parts(positive_parts) != 0 5266 all_parts = self.build_payment_parts(300, positive_only=False) 5267 assert self.check_payment_parts(all_parts) != 0 5268 assert self.check_payment_parts(all_parts) != 0 5269 if debug: 5270 pp().pprint(positive_parts) 5271 pp().pprint(all_parts) 5272 # dynamic discount 5273 suite = [] 5274 count = 3 5275 for exceed in [False, True]: 5276 case = [] 5277 for part in [positive_parts, all_parts]: 5278 #part = parts.copy() 5279 demand = part.demand 5280 if debug: 5281 print(demand, part.total) 5282 i = 0 5283 z = demand / count 5284 cp = PaymentParts( 5285 demand=demand, 5286 exceed=exceed, 5287 total=part.total, 5288 ) 5289 j = '' 5290 for x, y in part.account.items(): 5291 x_exchange = self.exchange(x) 5292 zz = self.exchange_calc(z, 1, x_exchange.rate) 5293 if exceed and zz <= demand: 5294 i += 1 5295 y.part = zz 5296 if debug: 5297 print(exceed, y) 5298 cp.account[x] = y 5299 case.append(y) 5300 elif not exceed and y.balance >= zz: 5301 i += 1 5302 y.part = zz 5303 if debug: 5304 print(exceed, y) 5305 cp.account[x] = y 5306 case.append(y) 5307 j = x 5308 if i >= count: 5309 break 5310 if debug: 5311 print('[debug]', j) 5312 print('[debug]', cp.account[j]) 5313 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 5314 suite.append(cp) 5315 if debug: 5316 print('suite', len(suite)) 5317 for case in suite: 5318 if debug: 5319 print('case', case) 5320 result = self.check_payment_parts(case) 5321 if debug: 5322 print('check_payment_parts', result, f'exceed: {exceed}') 5323 assert result == 0 5324 5325 report = self.check(2.17, None, debug) 5326 if debug: 5327 print('valid', report.valid) 5328 zakat_result = self.zakat(report, parts=case, debug=debug) 5329 if debug: 5330 print('zakat-result', zakat_result) 5331 assert report.valid == zakat_result 5332 # test verified zakat report is required 5333 if zakat_result: 5334 failed = False 5335 try: 5336 self.zakat(report, parts=case, debug=debug) 5337 except: 5338 failed = True 5339 assert failed 5340 5341 assert self.free(lock) 5342 5343 assert self.save(path + f'.{self.ext()}') 5344 5345 assert self.save(f'1000-transactions-test.{self.ext()}') 5346 return True 5347 except Exception as e: 5348 if self.__debug_output: 5349 pp().pprint(self.__vault) 5350 print('============================================================================') 5351 pp().pprint(self.__debug_output) 5352 assert self.save(f'test-snapshot.{self.ext()}') 5353 raise e
A class for tracking and calculating Zakat.
This class provides functionalities for recording transactions, calculating Zakat due, and managing account balances. It also offers features like importing transactions from CSV files, exporting data to JSON format, and saving/loading the tracker state.
The ZakatTracker
class is designed to handle both positive and negative transactions,
allowing for flexible tracking of financial activities related to Zakat. It also supports
the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due
based on the current silver price.
The class uses a json file as its database to persist the tracker state, ensuring data integrity across sessions. It also provides options for enabling or disabling history tracking, allowing users to choose their preferred level of detail.
In addition, the ZakatTracker
class includes various helper methods like
time
, time_to_datetime
, lock
, free
, recall
, save
, load
and more. These methods provide additional functionalities and flexibility
for interacting with and managing the Zakat tracker.
Attributes:
- ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage.
- ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat.
- ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price.
- ZakatTracker.Version (function): The version of the ZakatTracker class.
Data Structure:
The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data, here below is just a demonstration:
__vault (dict):
- account (dict):
- {account_id} (dict):
- balance (int): The current balance of the account.
- name (str): The name of the account.
- created (int): The creation time for the account.
- box (dict): A dictionary storing transaction details.
- {timestamp} (dict):
- capital (int): The initial amount of the transaction.
- rest (int): The remaining amount after Zakat deductions and withdrawal.
- zakat (dict):
- count (int): The number of times Zakat has been calculated for this transaction.
- last (int): The timestamp of the last Zakat calculation.
- total (int): The total Zakat deducted from this transaction.
- count (int): The total number of transactions for the account.
- log (dict): A dictionary storing transaction logs.
- {timestamp} (dict):
- value (int): The transaction amount (positive or negative).
- desc (str): The description of the transaction.
- ref (int): The box reference (positive or None).
- file (dict): A dictionary storing file references associated with the transaction.
- hide (bool): Indicates whether the account is hidden or not.
- zakatable (bool): Indicates whether the account is subject to Zakat.
- exchange (dict):
- {account_id} (dict):
- {timestamps} (dict):
- rate (float): Exchange rate when compared to local currency.
- description (str): The description of the exchange rate.
- history (dict):
- {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed.
- {order_timestamp} (dict):
- {action_dict} (dict):
- action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
- account (str): The account reference associated with the action.
- ref (int): The reference number of the transaction.
- file (int): The reference number of the file (if applicable).
- key (str): The key associated with the action (e.g., 'rest', 'total').
- value (int): The value associated with the action.
- math (MathOperation): The mathematical operation performed (if applicable).
- lock (int or None): The timestamp indicating the current lock status (None if not locked).
- report (dict):
- {timestamp} (tuple): A tuple storing Zakat report details.
1440 def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True): 1441 """ 1442 Initialize ZakatTracker with database path and history mode. 1443 1444 Parameters: 1445 - db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database. 1446 - history_mode (bool, optional): The mode for tracking history. Default is True. 1447 1448 Returns: 1449 None 1450 """ 1451 self.reset() 1452 self.__memory_mode = db_path == ':memory:' 1453 self.__history(history_mode) 1454 if not self.__memory_mode: 1455 self.path(f'{db_path}/db.{self.ext()}')
Initialize ZakatTracker with database path and history mode.
Parameters:
- db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
- history_mode (bool, optional): The mode for tracking history. Default is True.
Returns: None
1350 @staticmethod 1351 def Version() -> str: 1352 """ 1353 Returns the current version of the software. 1354 1355 This function returns a string representing the current version of the software, 1356 including major, minor, and patch version numbers in the format 'X.Y.Z'. 1357 1358 Returns: 1359 - str: The current version of the software. 1360 """ 1361 version = '0.3.3' 1362 git_hash, unstaged_count, commit_count_since_last_tag = get_git_status() 1363 if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0): 1364 version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}" 1365 print(version) 1366 return version
Returns the current version of the software.
This function returns a string representing the current version of the software, including major, minor, and patch version numbers in the format 'X.Y.Z'.
Returns:
- str: The current version of the software.
1368 @staticmethod 1369 def ZakatCut(x: float) -> float: 1370 """ 1371 Calculates the Zakat amount due on an asset. 1372 1373 This function calculates the zakat amount due on a given asset value over one lunar year. 1374 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 1375 that exceeds a certain threshold (Nisab). 1376 1377 Parameters: 1378 - x (float): The total value of the asset on which Zakat is to be calculated. 1379 1380 Returns: 1381 - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 1382 """ 1383 return 0.025 * x # Zakat Cut in one Lunar Year
Calculates the Zakat amount due on an asset.
This function calculates the zakat amount due on a given asset value over one lunar year. Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth that exceeds a certain threshold (Nisab).
Parameters:
- x (float): The total value of the asset on which Zakat is to be calculated.
Returns:
- float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
1385 @staticmethod 1386 def TimeCycle(days: int = 355) -> int: 1387 """ 1388 Calculates the approximate duration of a lunar year in nanoseconds. 1389 1390 This function calculates the approximate duration of a lunar year based on the given number of days. 1391 It converts the given number of days into nanoseconds for use in high-precision timing applications. 1392 1393 Parameters: 1394 - days (int, optional): The number of days in a lunar year. Defaults to 355, 1395 which is an approximation of the average length of a lunar year. 1396 1397 Returns: 1398 - int: The approximate duration of a lunar year in nanoseconds. 1399 """ 1400 return int(60 * 60 * 24 * days * 1e9) # Lunar Year in nanoseconds
Calculates the approximate duration of a lunar year in nanoseconds.
This function calculates the approximate duration of a lunar year based on the given number of days. It converts the given number of days into nanoseconds for use in high-precision timing applications.
Parameters:
- days (int, optional): The number of days in a lunar year. Defaults to 355, which is an approximation of the average length of a lunar year.
Returns:
- int: The approximate duration of a lunar year in nanoseconds.
1402 @staticmethod 1403 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 1404 """ 1405 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 1406 1407 This function calculates the Nisab value, which is the minimum threshold of wealth, 1408 that makes an individual liable for paying Zakat. 1409 The Nisab value is determined by the equivalent value of a specific amount 1410 of gold or silver (currently 595 grams in silver) in the local currency. 1411 1412 Parameters: 1413 - gram_price (float): The price per gram of Nisab. 1414 - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver. 1415 1416 Returns: 1417 - float: The total value of Nisab based on the given price per gram. 1418 """ 1419 return gram_price * gram_quantity
Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
This function calculates the Nisab value, which is the minimum threshold of wealth, that makes an individual liable for paying Zakat. The Nisab value is determined by the equivalent value of a specific amount of gold or silver (currently 595 grams in silver) in the local currency.
Parameters:
- gram_price (float): The price per gram of Nisab.
- gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
Returns:
- float: The total value of Nisab based on the given price per gram.
1421 @staticmethod 1422 def ext() -> str: 1423 """ 1424 Returns the file extension used by the ZakatTracker class. 1425 1426 Parameters: 1427 None 1428 1429 Returns: 1430 - str: The file extension used by the ZakatTracker class, which is 'json'. 1431 """ 1432 return 'json'
Returns the file extension used by the ZakatTracker class.
Parameters: None
Returns:
- str: The file extension used by the ZakatTracker class, which is 'json'.
1457 def memory_mode(self) -> bool: 1458 """ 1459 Check if the ZakatTracker is operating in memory mode. 1460 1461 Returns: 1462 - bool: True if the database is in memory, False otherwise. 1463 """ 1464 return self.__memory_mode
Check if the ZakatTracker is operating in memory mode.
Returns:
- bool: True if the database is in memory, False otherwise.
1466 def path(self, path: Optional[str] = None) -> str: 1467 """ 1468 Set or get the path to the database file. 1469 1470 If no path is provided, the current path is returned. 1471 If a path is provided, it is set as the new path. 1472 The function also creates the necessary directories if the provided path is a file. 1473 1474 Parameters: 1475 - path (str, optional): The new path to the database file. If not provided, the current path is returned. 1476 1477 Returns: 1478 - str: The current or new path to the database file. 1479 """ 1480 if path is None: 1481 return str(self.__vault_path) 1482 self.__vault_path = pathlib.Path(path).resolve() 1483 base_path = pathlib.Path(path).resolve() 1484 if base_path.is_file() or base_path.suffix: 1485 base_path = base_path.parent 1486 base_path.mkdir(parents=True, exist_ok=True) 1487 self.__base_path = base_path 1488 return str(self.__vault_path)
Set or get the path to the database file.
If no path is provided, the current path is returned. If a path is provided, it is set as the new path. The function also creates the necessary directories if the provided path is a file.
Parameters:
- path (str, optional): The new path to the database file. If not provided, the current path is returned.
Returns:
- str: The current or new path to the database file.
1490 def base_path(self, *args) -> str: 1491 """ 1492 Generate a base path by joining the provided arguments with the existing base path. 1493 1494 Parameters: 1495 - *args (str): Variable length argument list of strings to be joined with the base path. 1496 1497 Returns: 1498 - str: The generated base path. If no arguments are provided, the existing base path is returned. 1499 """ 1500 if not args: 1501 return str(self.__base_path) 1502 filtered_args = [] 1503 ignored_filename = None 1504 for arg in args: 1505 if pathlib.Path(arg).suffix: 1506 ignored_filename = arg 1507 else: 1508 filtered_args.append(arg) 1509 base_path = pathlib.Path(self.__base_path) 1510 full_path = base_path.joinpath(*filtered_args) 1511 full_path.mkdir(parents=True, exist_ok=True) 1512 if ignored_filename is not None: 1513 return full_path.resolve() / ignored_filename # Join with the ignored filename 1514 return str(full_path.resolve())
Generate a base path by joining the provided arguments with the existing base path.
Parameters:
- *args (str): Variable length argument list of strings to be joined with the base path.
Returns:
- str: The generated base path. If no arguments are provided, the existing base path is returned.
1516 @staticmethod 1517 def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int: 1518 """ 1519 Scales a numerical value by a specified power of 10, returning an integer. 1520 1521 This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and 1522 facilitate precise scaling operations, particularly useful in financial or scientific calculations. 1523 1524 Parameters: 1525 - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal. 1526 - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled 1527 by a factor of 100 (e.g., converts 1.23 to 123). 1528 1529 Returns: 1530 - The scaled value, rounded to the nearest integer. 1531 1532 Raises: 1533 - TypeError: If the input `x` is not a valid numeric type. 1534 1535 Examples: 1536 ```bash 1537 >>> ZakatTracker.scale(3.14159) 1538 314 1539 >>> ZakatTracker.scale(1234, decimal_places=3) 1540 1234000 1541 >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4) 1542 50 1543 ``` 1544 """ 1545 if not isinstance(x, (float, int, decimal.Decimal)): 1546 raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.') 1547 return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places))
Scales a numerical value by a specified power of 10, returning an integer.
This function is designed to handle various numeric types (float
, int
, or decimal.Decimal
) and
facilitate precise scaling operations, particularly useful in financial or scientific calculations.
Parameters:
- x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
- decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled by a factor of 100 (e.g., converts 1.23 to 123).
Returns:
- The scaled value, rounded to the nearest integer.
Raises:
- TypeError: If the input
x
is not a valid numeric type.
Examples:
>>> ZakatTracker.scale(3.14159)
314
>>> ZakatTracker.scale(1234, decimal_places=3)
1234000
>>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
50
1549 @staticmethod 1550 def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal: 1551 """ 1552 Unscales an integer by a power of 10. 1553 1554 Parameters: 1555 - x (int): The integer to unscale. 1556 - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float. 1557 - decimal_places (int, optional): The power of 10 to use. Defaults to 2. 1558 1559 Returns: 1560 - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type. 1561 1562 Raises: 1563 - TypeError: If the return_type is not float or decimal.Decimal. 1564 """ 1565 if return_type not in (float, decimal.Decimal): 1566 raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.') 1567 return round(return_type(x / (10 ** decimal_places)), decimal_places)
Unscales an integer by a power of 10.
Parameters:
- x (int): The integer to unscale.
- return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
- decimal_places (int, optional): The power of 10 to use. Defaults to 2.
Returns:
- float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
Raises:
- TypeError: If the return_type is not float or decimal.Decimal.
1569 def reset(self) -> None: 1570 """ 1571 Reset the internal data structure to its initial state. 1572 1573 Parameters: 1574 None 1575 1576 Returns: 1577 None 1578 """ 1579 self.__vault = Vault()
Reset the internal data structure to its initial state.
Parameters: None
Returns: None
1581 def clean_history(self, lock: Optional[Timestamp] = None) -> int: 1582 """ 1583 Cleans up the empty history records of actions performed on the ZakatTracker instance. 1584 1585 Parameters: 1586 - lock (Timestamp, optional): The lock ID is used to clean up the empty history. 1587 If not provided, it cleans up the empty history records for all locks. 1588 1589 Returns: 1590 - int: The number of locks cleaned up. 1591 """ 1592 count = 0 1593 if lock in self.__vault.history: 1594 if len(self.__vault.history[lock]) <= 0: 1595 count += 1 1596 del self.__vault.history[lock] 1597 return count 1598 for key in self.__vault.history: 1599 if len(self.__vault.history[key]) <= 0: 1600 count += 1 1601 del self.__vault.history[key] 1602 return count
Cleans up the empty history records of actions performed on the ZakatTracker instance.
Parameters:
- lock (Timestamp, optional): The lock ID is used to clean up the empty history. If not provided, it cleans up the empty history records for all locks.
Returns:
- int: The number of locks cleaned up.
1672 def nolock(self) -> bool: 1673 """ 1674 Check if the vault lock is currently not set. 1675 1676 Parameters: 1677 None 1678 1679 Returns: 1680 - bool: True if the vault lock is not set, False otherwise. 1681 """ 1682 return self.__vault.lock is None
Check if the vault lock is currently not set.
Parameters: None
Returns:
- bool: True if the vault lock is not set, False otherwise.
1697 def lock(self) -> Optional[Timestamp]: 1698 """ 1699 Acquires a lock on the ZakatTracker instance. 1700 1701 Parameters: 1702 None 1703 1704 Returns: 1705 - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later. 1706 """ 1707 return self.__step()
Acquires a lock on the ZakatTracker instance.
Parameters: None
Returns:
- Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1709 def steps(self) -> dict: 1710 """ 1711 Returns a copy of the history of steps taken in the ZakatTracker. 1712 1713 The history is a dictionary where each key is a unique identifier for a step, 1714 and the corresponding value is a dictionary containing information about the step. 1715 1716 Parameters: 1717 None 1718 1719 Returns: 1720 - dict: A copy of the history of steps taken in the ZakatTracker. 1721 """ 1722 return { 1723 lock: { 1724 timestamp: dataclasses.asdict(history) 1725 for timestamp, history in steps.items() 1726 } 1727 for lock, steps in self.__vault.history.items() 1728 }
Returns a copy of the history of steps taken in the ZakatTracker.
The history is a dictionary where each key is a unique identifier for a step, and the corresponding value is a dictionary containing information about the step.
Parameters: None
Returns:
- dict: A copy of the history of steps taken in the ZakatTracker.
1730 def free(self, lock: Timestamp, auto_save: bool = True) -> bool: 1731 """ 1732 Releases the lock on the database. 1733 1734 Parameters: 1735 - lock (Timestamp): The lock ID to be released. 1736 - auto_save (bool, optional): Whether to automatically save the database after releasing the lock. 1737 1738 Returns: 1739 - bool: True if the lock is successfully released and (optionally) saved, False otherwise. 1740 """ 1741 if lock == self.__vault.lock: 1742 self.clean_history(lock) 1743 self.__vault.lock = None 1744 if auto_save and not self.memory_mode(): 1745 return self.save(self.path()) 1746 return True 1747 return False
Releases the lock on the database.
Parameters:
- lock (Timestamp): The lock ID to be released.
- auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
Returns:
- bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1749 def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool: 1750 """ 1751 Revert the last operation. 1752 1753 Parameters: 1754 - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 1755 - lock (Timestamp, optional): An optional lock value to ensure the recall 1756 operation is performed on the expected history entry. If provided, 1757 it checks if the current lock and the most recent history key 1758 match the given lock value. Defaults to None. 1759 - debug (bool, optional): If True, the function will print debug information. Default is False. 1760 1761 Returns: 1762 - bool: True if the operation was successful, False otherwise. 1763 """ 1764 if not self.nolock() or len(self.__vault.history) == 0: 1765 return False 1766 if len(self.__vault.history) <= 0: 1767 return False 1768 ref = sorted(self.__vault.history.keys())[-1] 1769 if debug: 1770 print('recall', ref) 1771 memory = sorted(self.__vault.history[ref], reverse=True) 1772 if debug: 1773 print(type(memory), 'memory', memory) 1774 if lock is not None: 1775 assert self.__vault.lock == lock, "Invalid current lock" 1776 assert ref == lock, "Invalid last lock" 1777 assert self.__history(), "History mode should be enabled, found off!!!" 1778 sub_positive_log_negative = 0 1779 for i in memory: 1780 x = self.__vault.history[ref][i] 1781 if debug: 1782 print(type(x), x) 1783 if x.action != Action.REPORT: 1784 assert x.account is not None 1785 if x.action != Action.EXCHANGE: 1786 assert self.account_exists(x.account) 1787 match x.action: 1788 case Action.CREATE: 1789 if debug: 1790 print('account', self.__vault.account[x.account]) 1791 assert len(self.__vault.account[x.account].box) == 0 1792 assert len(self.__vault.account[x.account].log) == 0 1793 assert self.__vault.account[x.account].balance == 0 1794 assert self.__vault.account[x.account].count == 0 1795 assert self.__vault.account[x.account].name == '' 1796 if dry: 1797 continue 1798 del self.__vault.account[x.account] 1799 1800 case Action.NAME: 1801 assert x.value is not None 1802 if dry: 1803 continue 1804 self.__vault.account[x.account].name = x.value 1805 1806 case Action.TRACK: 1807 assert x.value is not None 1808 assert x.ref is not None 1809 if dry: 1810 continue 1811 self.__vault.account[x.account].balance -= x.value 1812 self.__vault.account[x.account].count -= 1 1813 del self.__vault.account[x.account].box[x.ref] 1814 1815 case Action.LOG: 1816 assert x.ref in self.__vault.account[x.account].log 1817 assert x.value is not None 1818 if dry: 1819 continue 1820 if sub_positive_log_negative == -x.value: 1821 self.__vault.account[x.account].count -= 1 1822 sub_positive_log_negative = 0 1823 box_ref = self.__vault.account[x.account].log[x.ref].ref 1824 if not box_ref is None: 1825 assert self.box_exists(x.account, box_ref) 1826 box_value = self.__vault.account[x.account].log[x.ref].value 1827 assert box_value < 0 1828 1829 try: 1830 self.__vault.account[x.account].box[box_ref].rest += -box_value 1831 except TypeError: 1832 self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value) 1833 1834 try: 1835 self.__vault.account[x.account].balance += -box_value 1836 except TypeError: 1837 self.__vault.account[x.account].balance += decimal.Decimal(-box_value) 1838 1839 self.__vault.account[x.account].count -= 1 1840 del self.__vault.account[x.account].log[x.ref] 1841 1842 case Action.SUBTRACT: 1843 assert x.ref in self.__vault.account[x.account].box 1844 assert x.value is not None 1845 if dry: 1846 continue 1847 self.__vault.account[x.account].box[x.ref].rest += x.value 1848 self.__vault.account[x.account].balance += x.value 1849 sub_positive_log_negative = x.value 1850 1851 case Action.ADD_FILE: 1852 assert x.ref in self.__vault.account[x.account].log 1853 assert x.file is not None 1854 assert dry or x.file in self.__vault.account[x.account].log[x.ref].file 1855 if dry: 1856 continue 1857 del self.__vault.account[x.account].log[x.ref].file[x.file] 1858 1859 case Action.REMOVE_FILE: 1860 assert x.ref in self.__vault.account[x.account].log 1861 assert x.file is not None 1862 assert x.value is not None 1863 if dry: 1864 continue 1865 self.__vault.account[x.account].log[x.ref].file[x.file] = x.value 1866 1867 case Action.BOX_TRANSFER: 1868 assert x.ref in self.__vault.account[x.account].box 1869 assert x.value is not None 1870 if dry: 1871 continue 1872 self.__vault.account[x.account].box[x.ref].rest -= x.value 1873 1874 case Action.EXCHANGE: 1875 assert x.account in self.__vault.exchange 1876 assert x.ref in self.__vault.exchange[x.account] 1877 if dry: 1878 continue 1879 del self.__vault.exchange[x.account][x.ref] 1880 1881 case Action.REPORT: 1882 assert x.ref in self.__vault.report 1883 if dry: 1884 continue 1885 del self.__vault.report[x.ref] 1886 1887 case Action.ZAKAT: 1888 assert x.ref in self.__vault.account[x.account].box 1889 assert x.key is not None 1890 assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) 1891 if dry: 1892 continue 1893 match x.math: 1894 case MathOperation.ADDITION: 1895 setattr( 1896 self.__vault.account[x.account].box[x.ref].zakat, 1897 x.key, 1898 getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value, 1899 ) 1900 case MathOperation.EQUAL: 1901 setattr( 1902 self.__vault.account[x.account].box[x.ref].zakat, 1903 x.key, 1904 x.value, 1905 ) 1906 case MathOperation.SUBTRACTION: 1907 setattr( 1908 self.__vault.account[x.account].box[x.ref], 1909 x.key, 1910 getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value, 1911 ) 1912 1913 if not dry: 1914 del self.__vault.history[ref] 1915 return True
Revert the last operation.
Parameters:
- dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
- lock (Timestamp, optional): An optional lock value to ensure the recall operation is performed on the expected history entry. If provided, it checks if the current lock and the most recent history key match the given lock value. Defaults to None.
- debug (bool, optional): If True, the function will print debug information. Default is False.
Returns:
- bool: True if the operation was successful, False otherwise.
1917 def vault(self) -> dict: 1918 """ 1919 Returns a copy of the internal vault dictionary. 1920 1921 This method is used to retrieve the current state of the ZakatTracker object. 1922 It provides a snapshot of the internal data structure, allowing for further 1923 processing or analysis. 1924 1925 Parameters: 1926 None 1927 1928 Returns: 1929 - dict: A copy of the internal vault dictionary. 1930 """ 1931 return dataclasses.asdict(self.__vault)
Returns a copy of the internal vault dictionary.
This method is used to retrieve the current state of the ZakatTracker object. It provides a snapshot of the internal data structure, allowing for further processing or analysis.
Parameters: None
Returns:
- dict: A copy of the internal vault dictionary.
1933 @staticmethod 1934 def stats_init() -> FileStats: 1935 """ 1936 Initialize and return the initial file statistics. 1937 1938 Returns: 1939 - FileStats: A :class:`FileStats` instance with initial values 1940 of 0 bytes for both RAM and database. 1941 """ 1942 return FileStats( 1943 database=SizeInfo(0, '0'), 1944 ram=SizeInfo(0, '0'), 1945 )
Initialize and return the initial file statistics.
Returns:
- FileStats: A
FileStats
instance with initial values of 0 bytes for both RAM and database.
1947 def stats(self, ignore_ram: bool = True) -> FileStats: 1948 """ 1949 Calculates and returns statistics about the object's data storage. 1950 1951 This method determines the size of the database file on disk and the 1952 size of the data currently held in RAM (likely within a dictionary). 1953 Both sizes are reported in bytes and in a human-readable format 1954 (e.g., KB, MB). 1955 1956 Parameters: 1957 - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True 1958 1959 Returns: 1960 - FileStats: A dataclass containing the following statistics: 1961 1962 * 'database': A tuple with two elements: 1963 - The database file size in bytes (float). 1964 - The database file size in human-readable format (str). 1965 * 'ram': A tuple with two elements: 1966 - The RAM usage (dictionary size) in bytes (float). 1967 - The RAM usage in human-readable format (str). 1968 1969 Example: 1970 ```bash 1971 >>> x = ZakatTracker() 1972 >>> stats = x.stats() 1973 >>> print(stats.database) 1974 SizeInfo(bytes=256000, human_readable='250.0 KB') 1975 >>> print(stats.ram) 1976 SizeInfo(bytes=12345, human_readable='12.1 KB') 1977 ``` 1978 """ 1979 ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault()) 1980 file_size = os.path.getsize(self.path()) 1981 return FileStats( 1982 database=SizeInfo(file_size, self.human_readable_size(file_size)), 1983 ram=SizeInfo(ram_size, self.human_readable_size(ram_size)), 1984 )
Calculates and returns statistics about the object's data storage.
This method determines the size of the database file on disk and the size of the data currently held in RAM (likely within a dictionary). Both sizes are reported in bytes and in a human-readable format (e.g., KB, MB).
Parameters:
- ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
Returns:
- FileStats: A dataclass containing the following statistics:
* 'database': A tuple with two elements:
- The database file size in bytes (float).
- The database file size in human-readable format (str).
* 'ram': A tuple with two elements:
- The RAM usage (dictionary size) in bytes (float).
- The RAM usage in human-readable format (str).
Example:
>>> x = ZakatTracker()
>>> stats = x.stats()
>>> print(stats.database)
SizeInfo(bytes=256000, human_readable='250.0 KB')
>>> print(stats.ram)
SizeInfo(bytes=12345, human_readable='12.1 KB')
1986 def files(self) -> list[FileInfo]: 1987 """ 1988 Retrieves information about files associated with this class. 1989 1990 This class method provides a standardized way to gather details about 1991 files used by the class for storage, snapshots, and CSV imports. 1992 1993 Parameters: 1994 None 1995 1996 Returns: 1997 - list[FileInfo]: A list of dataclass, each containing information 1998 about a specific file: 1999 2000 * type (str): The type of file ('database', 'snapshot', 'import_csv'). 2001 * path (str): The full file path. 2002 * exists (bool): Whether the file exists on the filesystem. 2003 * size (int): The file size in bytes (0 if the file doesn't exist). 2004 * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB'). 2005 """ 2006 result = [] 2007 for file_type, path in { 2008 'database': self.path(), 2009 'snapshot': self.snapshot_cache_path(), 2010 'import_csv': self.import_csv_cache_path(), 2011 }.items(): 2012 exists = os.path.exists(path) 2013 size = os.path.getsize(path) if exists else 0 2014 human_readable_size = self.human_readable_size(size) if exists else '0' 2015 result.append(FileInfo( 2016 type=file_type, 2017 path=path, 2018 exists=exists, 2019 size=size, 2020 human_readable_size=human_readable_size, 2021 )) 2022 return result
Retrieves information about files associated with this class.
This class method provides a standardized way to gather details about files used by the class for storage, snapshots, and CSV imports.
Parameters: None
Returns:
- list[FileInfo]: A list of dataclass, each containing information about a specific file:
* type (str): The type of file ('database', 'snapshot', 'import_csv').
* path (str): The full file path.
* exists (bool): Whether the file exists on the filesystem.
* size (int): The file size in bytes (0 if the file doesn't exist).
* human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
2024 def account_exists(self, account: AccountID) -> bool: 2025 """ 2026 Check if the given account exists in the vault. 2027 2028 Parameters: 2029 - account (AccountID): The account reference to check. 2030 2031 Returns: 2032 - bool: True if the account exists, False otherwise. 2033 """ 2034 account = AccountID(account) 2035 return account in self.__vault.account
Check if the given account exists in the vault.
Parameters:
- account (AccountID): The account reference to check.
Returns:
- bool: True if the account exists, False otherwise.
2037 def box_size(self, account: AccountID) -> int: 2038 """ 2039 Calculate the size of the box for a specific account. 2040 2041 Parameters: 2042 - account (AccountID): The account reference for which the box size needs to be calculated. 2043 2044 Returns: 2045 - int: The size of the box for the given account. If the account does not exist, -1 is returned. 2046 """ 2047 if self.account_exists(account): 2048 return len(self.__vault.account[account].box) 2049 return -1
Calculate the size of the box for a specific account.
Parameters:
- account (AccountID): The account reference for which the box size needs to be calculated.
Returns:
- int: The size of the box for the given account. If the account does not exist, -1 is returned.
2051 def log_size(self, account: AccountID) -> int: 2052 """ 2053 Get the size of the log for a specific account. 2054 2055 Parameters: 2056 - account (AccountID): The account reference for which the log size needs to be calculated. 2057 2058 Returns: 2059 - int: The size of the log for the given account. If the account does not exist, -1 is returned. 2060 """ 2061 if self.account_exists(account): 2062 return len(self.__vault.account[account].log) 2063 return -1
Get the size of the log for a specific account.
Parameters:
- account (AccountID): The account reference for which the log size needs to be calculated.
Returns:
- int: The size of the log for the given account. If the account does not exist, -1 is returned.
2065 @staticmethod 2066 def hash_data(data: bytes, algorithm: str = 'blake2b') -> str: 2067 """ 2068 Calculates the hash of given byte data using the specified algorithm. 2069 2070 Parameters: 2071 - data (bytes): The byte data to hash. 2072 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2073 2074 Returns: 2075 - str: The hexadecimal representation of the data's hash. 2076 """ 2077 hash_obj = hashlib.new(algorithm) 2078 hash_obj.update(data) 2079 return hash_obj.hexdigest()
Calculates the hash of given byte data using the specified algorithm.
Parameters:
- data (bytes): The byte data to hash.
- algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
Returns:
- str: The hexadecimal representation of the data's hash.
2081 @staticmethod 2082 def hash_file(file_path: str, algorithm: str = 'blake2b') -> str: 2083 """ 2084 Calculates the hash of a file using the specified algorithm. 2085 2086 Parameters: 2087 - file_path (str): The path to the file. 2088 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2089 2090 Returns: 2091 - str: The hexadecimal representation of the file's hash. 2092 """ 2093 hash_obj = hashlib.new(algorithm) # Create the hash object 2094 with open(file_path, 'rb') as file: # Open file in binary mode for reading 2095 for chunk in iter(lambda: file.read(4096), b''): # Read file in chunks 2096 hash_obj.update(chunk) 2097 return hash_obj.hexdigest() # Return the hash as a hexadecimal string
Calculates the hash of a file using the specified algorithm.
Parameters:
- file_path (str): The path to the file.
- algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
Returns:
- str: The hexadecimal representation of the file's hash.
2099 def snapshot_cache_path(self): 2100 """ 2101 Generate the path for the cache file used to store snapshots. 2102 2103 The cache file is a json file that stores the timestamps of the snapshots. 2104 The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'. 2105 2106 Parameters: 2107 None 2108 2109 Returns: 2110 - str: The path to the cache file. 2111 """ 2112 path = str(self.path()) 2113 ext = self.ext() 2114 ext_len = len(ext) 2115 if path.endswith(f'.{ext}'): 2116 path = path[:-ext_len - 1] 2117 _, filename = os.path.split(path + f'.snapshots.{ext}') 2118 return self.base_path(filename)
Generate the path for the cache file used to store snapshots.
The cache file is a json file that stores the timestamps of the snapshots. The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
Parameters: None
Returns:
- str: The path to the cache file.
2120 def snapshot(self) -> bool: 2121 """ 2122 This function creates a snapshot of the current database state. 2123 2124 The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. 2125 If a snapshot with the same hash exists, the function returns True without creating a new snapshot. 2126 If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state 2127 in a new json file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp. 2128 2129 Parameters: 2130 None 2131 2132 Returns: 2133 - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails. 2134 """ 2135 current_hash = self.hash_file(self.path()) 2136 cache: dict[str, int] = {} # hash: time_ns 2137 try: 2138 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2139 cache = json.load(stream, cls=JSONDecoder) 2140 except: 2141 pass 2142 if current_hash in cache: 2143 return True 2144 ref = time.time_ns() 2145 cache[current_hash] = ref 2146 if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')): 2147 return False 2148 with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream: 2149 stream.write(json.dumps(cache, cls=JSONEncoder)) 2150 return True
This function creates a snapshot of the current database state.
The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. If a snapshot with the same hash exists, the function returns True without creating a new snapshot. If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state in a new json file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.
Parameters: None
Returns:
- bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2152 def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \ 2153 -> dict[int, tuple[str, str, bool]]: 2154 """ 2155 Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status. 2156 2157 Parameters: 2158 - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True. 2159 - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False. 2160 2161 Returns: 2162 - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, 2163 and the values are tuples containing the snapshot's hash, path, and existence status. 2164 """ 2165 cache: dict[str, int] = {} # hash: time_ns 2166 try: 2167 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2168 cache = json.load(stream, cls=JSONDecoder) 2169 except: 2170 pass 2171 if not cache: 2172 return {} 2173 result: dict[int, tuple[str, str, bool]] = {} # time_ns: (hash, path, exists) 2174 for hash_file, ref in cache.items(): 2175 path = self.base_path('snapshots', f'{ref}.{self.ext()}') 2176 exists = os.path.exists(path) 2177 valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True 2178 if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists): 2179 continue 2180 if exists or not hide_missing: 2181 result[ref] = (hash_file, path, exists) 2182 return result
Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
Parameters:
- hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
- verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
Returns:
- dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, and the values are tuples containing the snapshot's hash, path, and existence status.
2184 def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool: 2185 """ 2186 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 2187 2188 Parameters: 2189 - account (AccountID): The account reference for which to check the existence of the reference. 2190 - ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 2191 - ref (Timestamp): The reference (transaction) number to check for existence. 2192 2193 Returns: 2194 - bool: True if the reference exists for the given account and reference type, False otherwise. 2195 """ 2196 account = AccountID(account) 2197 if account in self.__vault.account: 2198 return ref in getattr(self.__vault.account[account], ref_type) 2199 return False
Check if a specific reference (transaction) exists in the vault for a given account and reference type.
Parameters:
- account (AccountID): The account reference for which to check the existence of the reference.
- ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
- ref (Timestamp): The reference (transaction) number to check for existence.
Returns:
- bool: True if the reference exists for the given account and reference type, False otherwise.
2201 def box_exists(self, account: AccountID, ref: Timestamp) -> bool: 2202 """ 2203 Check if a specific box (transaction) exists in the vault for a given account and reference. 2204 2205 Parameters: 2206 - account (AccountID): The account reference for which to check the existence of the box. 2207 - ref (Timestamp): The reference (transaction) number to check for existence. 2208 2209 Returns: 2210 - bool: True if the box exists for the given account and reference, False otherwise. 2211 """ 2212 return self.ref_exists(account, 'box', ref)
Check if a specific box (transaction) exists in the vault for a given account and reference.
Parameters:
- account (AccountID): The account reference for which to check the existence of the box.
- ref (Timestamp): The reference (transaction) number to check for existence.
Returns:
- bool: True if the box exists for the given account and reference, False otherwise.
2214 def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'), 2215 created_time_ns: Optional[Timestamp] = None, 2216 debug: bool = False) -> Optional[Timestamp]: 2217 """ 2218 This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box. 2219 2220 Parameters: 2221 - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0. 2222 - desc (str, optional): The description of the transaction. Default is an empty string. 2223 - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'. 2224 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None. 2225 - debug (bool, optional): Whether to print debug information. Default is False. 2226 2227 Returns: 2228 - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD). 2229 2230 Raises: 2231 - ValueError: The created_time_ns should be greater than zero. 2232 - ValueError: The log transaction happened again in the same nanosecond time. 2233 - ValueError: The box transaction happened again in the same nanosecond time. 2234 """ 2235 return self.__track( 2236 unscaled_value=unscaled_value, 2237 desc=desc, 2238 account=account, 2239 logging=True, 2240 created_time_ns=created_time_ns, 2241 debug=debug, 2242 )
This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
Parameters:
- unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
- desc (str, optional): The description of the transaction. Default is an empty string.
- account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
- created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
- debug (bool, optional): Whether to print debug information. Default is False.
Returns:
- Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
Raises:
- ValueError: The created_time_ns should be greater than zero.
- ValueError: The log transaction happened again in the same nanosecond time.
- ValueError: The box transaction happened again in the same nanosecond time.
2310 def log_exists(self, account: AccountID, ref: Timestamp) -> bool: 2311 """ 2312 Checks if a specific transaction log entry exists for a given account. 2313 2314 Parameters: 2315 - account (AccountID): The account reference associated with the transaction log. 2316 - ref (Timestamp): The reference to the transaction log entry. 2317 2318 Returns: 2319 - bool: True if the transaction log entry exists, False otherwise. 2320 """ 2321 return self.ref_exists(account, 'log', ref)
Checks if a specific transaction log entry exists for a given account.
Parameters:
- account (AccountID): The account reference associated with the transaction log.
- ref (Timestamp): The reference to the transaction log entry.
Returns:
- bool: True if the transaction log entry exists, False otherwise.
2374 def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None, 2375 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange: 2376 """ 2377 This method is used to record or retrieve exchange rates for a specific account. 2378 2379 Parameters: 2380 - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved. 2381 - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 2382 - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 2383 - description (str, optional): A description of the exchange rate. 2384 - debug (bool, optional): Whether to print debug information. Default is False. 2385 2386 Returns: 2387 - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 2388 it returns a dictionary with default values for the rate and description. 2389 2390 Raises: 2391 - ValueError: The created should be greater than zero. 2392 """ 2393 if debug: 2394 print('exchange', f'debug={debug}') 2395 account = AccountID(account) 2396 if created_time_ns is None: 2397 created_time_ns = Time.time() 2398 if created_time_ns <= 0: 2399 raise ValueError('The created should be greater than zero.') 2400 if rate is not None: 2401 if rate <= 0: 2402 return Exchange() 2403 if account not in self.__vault.exchange: 2404 self.__vault.exchange[account] = {} 2405 if len(self.__vault.exchange[account]) == 0 and rate <= 1: 2406 return Exchange(time=created_time_ns, rate=1) 2407 no_lock = self.nolock() 2408 lock = self.__lock() 2409 self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description) 2410 self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate) 2411 if no_lock: 2412 assert lock is not None 2413 self.free(lock) 2414 if debug: 2415 print('exchange-created-1', 2416 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2417 2418 if account in self.__vault.exchange: 2419 valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns] 2420 if valid_rates: 2421 latest_rate = max(valid_rates, key=lambda x: x[0]) 2422 if debug: 2423 print('exchange-read-1', 2424 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}', 2425 'latest_rate', latest_rate) 2426 result = latest_rate[1] 2427 result.time = latest_rate[0] 2428 return result # إرجاع قاموس يحتوي على المعدل والوصف 2429 if debug: 2430 print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2431 return Exchange(time=created_time_ns, rate=1, description=None) # إرجاع القيمة الافتراضية مع وصف فارغ
This method is used to record or retrieve exchange rates for a specific account.
Parameters:
- account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
- created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
- rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
- description (str, optional): A description of the exchange rate.
- debug (bool, optional): Whether to print debug information. Default is False.
Returns:
- Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
Raises:
- ValueError: The created should be greater than zero.
2433 @staticmethod 2434 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 2435 """ 2436 This function calculates the exchanged amount of a currency. 2437 2438 Parameters: 2439 - x (float): The original amount of the currency. 2440 - x_rate (float): The exchange rate of the original currency. 2441 - y_rate (float): The exchange rate of the target currency. 2442 2443 Returns: 2444 - float: The exchanged amount of the target currency. 2445 """ 2446 return (x * x_rate) / y_rate
This function calculates the exchanged amount of a currency.
Parameters:
- x (float): The original amount of the currency.
- x_rate (float): The exchange rate of the original currency.
- y_rate (float): The exchange rate of the target currency.
Returns:
- float: The exchanged amount of the target currency.
2448 def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]: 2449 """ 2450 Retrieve the recorded exchange rates for all accounts. 2451 2452 Parameters: 2453 None 2454 2455 Returns: 2456 - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. 2457 The keys are account references or numbers, and the values are dictionaries containing the exchange rates. 2458 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 2459 """ 2460 return self.__vault.exchange.copy()
Retrieve the recorded exchange rates for all accounts.
Parameters: None
Returns:
- dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. The keys are account references or numbers, and the values are dictionaries containing the exchange rates. Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2462 def accounts(self) -> dict[AccountID, AccountDetails]: 2463 """ 2464 Returns a dictionary containing account references as keys and their respective account details as values. 2465 2466 Parameters: 2467 None 2468 2469 Returns: 2470 - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details. 2471 """ 2472 return { 2473 account_id: AccountDetails( 2474 account_id=account_id, 2475 account_name=self.__vault.account[account_id].name, 2476 balance=self.__vault.account[account_id].balance, 2477 ) 2478 for account_id in self.__vault.account 2479 }
Returns a dictionary containing account references as keys and their respective account details as values.
Parameters: None
Returns:
- dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2481 def boxes(self, account: AccountID) -> dict[Timestamp, Box]: 2482 """ 2483 Retrieve the boxes (transactions) associated with a specific account. 2484 2485 Parameters: 2486 - account (AccountID): The account reference for which to retrieve the boxes. 2487 2488 Returns: 2489 - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account. 2490 If the account does not exist, an empty dictionary is returned. 2491 """ 2492 if self.account_exists(account): 2493 return self.__vault.account[account].box 2494 return {}
Retrieve the boxes (transactions) associated with a specific account.
Parameters:
- account (AccountID): The account reference for which to retrieve the boxes.
Returns:
- dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account. If the account does not exist, an empty dictionary is returned.
2496 def logs(self, account: AccountID) -> dict[Timestamp, Log]: 2497 """ 2498 Retrieve the logs (transactions) associated with a specific account. 2499 2500 Parameters: 2501 - account (AccountID): The account reference for which to retrieve the logs. 2502 2503 Returns: 2504 - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. 2505 If the account does not exist, an empty dictionary is returned. 2506 """ 2507 if self.account_exists(account): 2508 return self.__vault.account[account].log 2509 return {}
Retrieve the logs (transactions) associated with a specific account.
Parameters:
- account (AccountID): The account reference for which to retrieve the logs.
Returns:
- dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.
2511 def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline: 2512 """ 2513 Aggregates transaction logs into a structured timeline. 2514 2515 This method retrieves transaction logs from all accounts and organizes them 2516 into daily, weekly, monthly, and yearly summaries. Each level of the 2517 timeline includes a `TimeSummary` object with the total positive, negative, 2518 and overall values for that period. The daily level also includes a list 2519 of individual `Transaction` records. 2520 2521 Parameters: 2522 - weekday (WeekDay, optional): The day of the week to use as the anchor 2523 for weekly summaries. Defaults to WeekDay.FRIDAY. 2524 - debug (bool, optional): If True, prints intermediate debug information 2525 during processing. Defaults to False. 2526 2527 Returns: 2528 - Timeline: An object containing the aggregated transaction data, organized 2529 into daily, weekly, monthly, and yearly summaries. The 'daily' 2530 attribute is a dictionary where keys are dates (YYYY-MM-DD) and 2531 values are `DailyRecords` objects. The 'weekly' attribute is a 2532 dictionary where keys are the starting datetime of the week and 2533 values are `TimeSummary` objects. The 'monthly' attribute is a 2534 dictionary where keys are year-month strings (YYYY-MM) and values 2535 are `TimeSummary` objects. The 'yearly' attribute is a dictionary 2536 where keys are years (YYYY) and values are `TimeSummary` objects. 2537 2538 Example: 2539 ```bash 2540 >>> from zakat import tracker 2541 >>> ledger = tracker(':memory:') 2542 >>> account1_id = ledger.create_account('account1') 2543 >>> account2_id = ledger.create_account('account2') 2544 >>> ledger.subtract(51, 'desc', account1_id) 2545 >>> ref = ledger.track(100, 'desc', account2_id) 2546 >>> ledger.add_file(account2_id, ref, 'file_0') 2547 >>> ledger.add_file(account2_id, ref, 'file_1') 2548 >>> ledger.add_file(account2_id, ref, 'file_2') 2549 >>> ledger.timeline() 2550 Timeline( 2551 daily={ 2552 "2025-04-06": DailyRecords( 2553 positive=10000, 2554 negative=5100, 2555 total=4900, 2556 rows=[ 2557 Transaction( 2558 account="account2", 2559 account_id="63879638114290122752", 2560 desc="desc2", 2561 file={ 2562 63879638220705865728: "file_0", 2563 63879638223391350784: "file_1", 2564 63879638225766047744: "file_2", 2565 }, 2566 value=10000, 2567 time=63879638181936513024, 2568 transfer=False, 2569 ), 2570 Transaction( 2571 account="account1", 2572 account_id="63879638104007106560", 2573 desc="desc", 2574 file={}, 2575 value=-5100, 2576 time=63879638149199421440, 2577 transfer=False, 2578 ), 2579 ], 2580 ) 2581 }, 2582 weekly={ 2583 datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary( 2584 positive=10000, negative=0, total=10000 2585 ), 2586 datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary( 2587 positive=0, negative=5100, total=-5100 2588 ), 2589 }, 2590 monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)}, 2591 yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)}, 2592 ) 2593 ``` 2594 """ 2595 logs: dict[Timestamp, list[Transaction]] = {} 2596 for account_id in self.accounts(): 2597 for log_ref, log in self.logs(account_id).items(): 2598 if log_ref not in logs: 2599 logs[log_ref] = [] 2600 logs[log_ref].append(Transaction( 2601 account=self.name(account_id), 2602 account_id=account_id, 2603 desc=log.desc, 2604 file=log.file, 2605 value=log.value, 2606 time=log_ref, 2607 transfer=False, 2608 )) 2609 if debug: 2610 print('logs', logs) 2611 y = Timeline() 2612 for i in sorted(logs, reverse=True): 2613 dt = Time.time_to_datetime(i) 2614 daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}' 2615 weekly = dt - datetime.timedelta(days=weekday.value) 2616 monthly = f'{dt.year}-{dt.month:02d}' 2617 yearly = dt.year 2618 # daily 2619 if daily not in y.daily: 2620 y.daily[daily] = DailyRecords() 2621 transfer = len(logs[i]) > 1 2622 if debug: 2623 print('logs[i]', logs[i]) 2624 for z in logs[i]: 2625 if debug: 2626 print('z', z) 2627 # daily 2628 value = z.value 2629 if value > 0: 2630 y.daily[daily].positive += value 2631 else: 2632 y.daily[daily].negative += -value 2633 y.daily[daily].total += value 2634 z.transfer = transfer 2635 y.daily[daily].rows.append(z) 2636 # weekly 2637 if weekly not in y.weekly: 2638 y.weekly[weekly] = TimeSummary() 2639 if value > 0: 2640 y.weekly[weekly].positive += value 2641 else: 2642 y.weekly[weekly].negative += -value 2643 y.weekly[weekly].total += value 2644 # monthly 2645 if monthly not in y.monthly: 2646 y.monthly[monthly] = TimeSummary() 2647 if value > 0: 2648 y.monthly[monthly].positive += value 2649 else: 2650 y.monthly[monthly].negative += -value 2651 y.monthly[monthly].total += value 2652 # yearly 2653 if yearly not in y.yearly: 2654 y.yearly[yearly] = TimeSummary() 2655 if value > 0: 2656 y.yearly[yearly].positive += value 2657 else: 2658 y.yearly[yearly].negative += -value 2659 y.yearly[yearly].total += value 2660 if debug: 2661 print('y', y) 2662 return y
Aggregates transaction logs into a structured timeline.
This method retrieves transaction logs from all accounts and organizes them
into daily, weekly, monthly, and yearly summaries. Each level of the
timeline includes a TimeSummary
object with the total positive, negative,
and overall values for that period. The daily level also includes a list
of individual Transaction
records.
Parameters:
- weekday (WeekDay, optional): The day of the week to use as the anchor for weekly summaries. Defaults to WeekDay.FRIDAY.
- debug (bool, optional): If True, prints intermediate debug information during processing. Defaults to False.
Returns:
- Timeline: An object containing the aggregated transaction data, organized
into daily, weekly, monthly, and yearly summaries. The 'daily'
attribute is a dictionary where keys are dates (YYYY-MM-DD) and
values are
DailyRecords
objects. The 'weekly' attribute is a dictionary where keys are the starting datetime of the week and values areTimeSummary
objects. The 'monthly' attribute is a dictionary where keys are year-month strings (YYYY-MM) and values areTimeSummary
objects. The 'yearly' attribute is a dictionary where keys are years (YYYY) and values areTimeSummary
objects.
Example:
>>> from zakat import tracker
>>> ledger = tracker(':memory:')
>>> account1_id = ledger.create_account('account1')
>>> account2_id = ledger.create_account('account2')
>>> ledger.subtract(51, 'desc', account1_id)
>>> ref = ledger.track(100, 'desc', account2_id)
>>> ledger.add_file(account2_id, ref, 'file_0')
>>> ledger.add_file(account2_id, ref, 'file_1')
>>> ledger.add_file(account2_id, ref, 'file_2')
>>> ledger.timeline()
Timeline(
daily={
"2025-04-06": DailyRecords(
positive=10000,
negative=5100,
total=4900,
rows=[
Transaction(
account="account2",
account_id="63879638114290122752",
desc="desc2",
file={
63879638220705865728: "file_0",
63879638223391350784: "file_1",
63879638225766047744: "file_2",
},
value=10000,
time=63879638181936513024,
transfer=False,
),
Transaction(
account="account1",
account_id="63879638104007106560",
desc="desc",
file={},
value=-5100,
time=63879638149199421440,
transfer=False,
),
],
)
},
weekly={
datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
positive=10000, negative=0, total=10000
),
datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
positive=0, negative=5100, total=-5100
),
},
monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
)
2664 def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp: 2665 """ 2666 Adds a file reference to a specific transaction log entry in the vault. 2667 2668 Parameters: 2669 - account (AccountID): The account reference associated with the transaction log. 2670 - ref (Timestamp): The reference to the transaction log entry. 2671 - path (str): The path of the file to be added. 2672 2673 Returns: 2674 - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 2675 """ 2676 if self.account_exists(account): 2677 if ref in self.__vault.account[account].log: 2678 no_lock = self.nolock() 2679 lock = self.__lock() 2680 file_ref = Time.time() 2681 self.__vault.account[account].log[ref].file[file_ref] = path 2682 self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref) 2683 if no_lock: 2684 assert lock is not None 2685 self.free(lock) 2686 return file_ref 2687 return Timestamp(0)
Adds a file reference to a specific transaction log entry in the vault.
Parameters:
- account (AccountID): The account reference associated with the transaction log.
- ref (Timestamp): The reference to the transaction log entry.
- path (str): The path of the file to be added.
Returns:
- Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2689 def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool: 2690 """ 2691 Removes a file reference from a specific transaction log entry in the vault. 2692 2693 Parameters: 2694 - account (AccountID): The account reference associated with the transaction log. 2695 - ref (Timestamp): The reference to the transaction log entry. 2696 - file_ref (Timestamp): The reference of the file to be removed. 2697 2698 Returns: 2699 - bool: True if the file reference is successfully removed, False otherwise. 2700 """ 2701 if self.account_exists(account): 2702 if ref in self.__vault.account[account].log: 2703 if file_ref in self.__vault.account[account].log[ref].file: 2704 no_lock = self.nolock() 2705 lock = self.__lock() 2706 x = self.__vault.account[account].log[ref].file[file_ref] 2707 del self.__vault.account[account].log[ref].file[file_ref] 2708 self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 2709 if no_lock: 2710 assert lock is not None 2711 self.free(lock) 2712 return True 2713 return False
Removes a file reference from a specific transaction log entry in the vault.
Parameters:
- account (AccountID): The account reference associated with the transaction log.
- ref (Timestamp): The reference to the transaction log entry.
- file_ref (Timestamp): The reference of the file to be removed.
Returns:
- bool: True if the file reference is successfully removed, False otherwise.
2715 def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int: 2716 """ 2717 Calculate and return the balance of a specific account. 2718 2719 Parameters: 2720 - account (AccountID, optional): The account reference. Default is '1'. 2721 - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 2722 2723 Returns: 2724 - int: The balance of the account. 2725 2726 Notes: 2727 - If cached is True, the function returns the cached balance. 2728 - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 2729 """ 2730 account = AccountID(account) 2731 if cached: 2732 return self.__vault.account[account].balance 2733 x = 0 2734 return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1]
Calculate and return the balance of a specific account.
Parameters:
- account (AccountID, optional): The account reference. Default is '1'.
- cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
Returns:
- int: The balance of the account.
Notes:
- If cached is True, the function returns the cached balance.
- If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2736 def hide(self, account: AccountID, status: Optional[bool] = None) -> bool: 2737 """ 2738 Check or set the hide status of a specific account. 2739 2740 Parameters: 2741 - account (AccountID): The account reference. 2742 - status (bool, optional): The new hide status. If not provided, the function will return the current status. 2743 2744 Returns: 2745 - bool: The current or updated hide status of the account. 2746 2747 Raises: 2748 None 2749 2750 Example: 2751 ```bash 2752 >>> tracker = ZakatTracker() 2753 >>> ref = tracker.track(51, 'desc', 'account1') 2754 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 2755 False 2756 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 2757 True 2758 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 2759 True 2760 >>> tracker.hide('account1', False) 2761 False 2762 ``` 2763 """ 2764 if self.account_exists(account): 2765 if status is None: 2766 return self.__vault.account[account].hide 2767 self.__vault.account[account].hide = status 2768 return status 2769 return False
Check or set the hide status of a specific account.
Parameters:
- account (AccountID): The account reference.
- status (bool, optional): The new hide status. If not provided, the function will return the current status.
Returns:
- bool: The current or updated hide status of the account.
Raises: None
Example:
>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.hide('account1') # Set the hide status of 'account1' to True
False
>>> tracker.hide('account1', True) # Set the hide status of 'account1' to True
True
>>> tracker.hide('account1') # Get the hide status of 'account1' by default
True
>>> tracker.hide('account1', False)
False
2771 def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]: 2772 """ 2773 Retrieves an AccountDetails object for the first account matching the given name. 2774 2775 This method searches for accounts with names that contain the provided 'name' 2776 (case-insensitive substring matching). If a match is found, it returns an 2777 AccountDetails object containing the account's ID, name and balance. If no matching 2778 account is found, it returns None. 2779 2780 Parameters: 2781 - name: The name (or partial name) of the account to retrieve. 2782 - exact: If True, performs a case-insensitive exact match. 2783 If False, performs a case-insensitive substring search. 2784 Defaults to True. 2785 2786 Returns: 2787 - AccountDetails: An AccountDetails object representing the found account, or None if no 2788 matching account exists. 2789 """ 2790 for account_name, account_id in self.names(name).items(): 2791 if not exact or account_name.lower() == name.lower(): 2792 return AccountDetails( 2793 account_id=account_id, 2794 account_name=account_name, 2795 balance=self.__vault.account[account_id].balance, 2796 ) 2797 return None
Retrieves an AccountDetails object for the first account matching the given name.
This method searches for accounts with names that contain the provided 'name' (case-insensitive substring matching). If a match is found, it returns an AccountDetails object containing the account's ID, name and balance. If no matching account is found, it returns None.
Parameters:
- name: The name (or partial name) of the account to retrieve.
- exact: If True, performs a case-insensitive exact match. If False, performs a case-insensitive substring search. Defaults to True.
Returns:
- AccountDetails: An AccountDetails object representing the found account, or None if no matching account exists.
2799 def create_account(self, name: str) -> AccountID: 2800 """ 2801 Creates a new account with the given name and returns its unique ID. 2802 2803 This method: 2804 1. Checks if an account with the same name (case-insensitive) already exists. 2805 2. Generates a unique `AccountID` based on the current time. 2806 3. Tracks the account creation internally. 2807 4. Sets the account's name. 2808 5. Verifies that the name was set correctly. 2809 2810 Parameters: 2811 - name: The name of the new account. 2812 2813 Returns: 2814 - AccountID: The unique `AccountID` of the newly created account. 2815 2816 Raises: 2817 - AssertionError: Empty account name is forbidden. 2818 - AssertionError: Account name in number is forbidden. 2819 - AssertionError: If an account with the same name already exists (case-insensitive). 2820 - AssertionError: If the provided name does not match the name set for the account. 2821 """ 2822 assert name.strip(), 'empty account name is forbidden' 2823 assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden' 2824 account_ref = self.account(name, exact=True) 2825 # check if account not exists 2826 assert account_ref is None, f'account name({name}) already used' 2827 # create new account 2828 account_id = AccountID(Time.time()) 2829 self.__track(0, '', account_id) 2830 new_name = self.name( 2831 account=account_id, 2832 new_name=name, 2833 ) 2834 assert name == new_name 2835 return account_id
Creates a new account with the given name and returns its unique ID.
This method:
- Checks if an account with the same name (case-insensitive) already exists.
- Generates a unique
AccountID
based on the current time. - Tracks the account creation internally.
- Sets the account's name.
- Verifies that the name was set correctly.
Parameters:
- name: The name of the new account.
Returns:
- AccountID: The unique
AccountID
of the newly created account.
Raises:
- AssertionError: Empty account name is forbidden.
- AssertionError: Account name in number is forbidden.
- AssertionError: If an account with the same name already exists (case-insensitive).
- AssertionError: If the provided name does not match the name set for the account.
2837 def names(self, keyword: str = '') -> dict[str, AccountID]: 2838 """ 2839 Retrieves a dictionary of account IDs and names, optionally filtered by a keyword. 2840 2841 Parameters: 2842 - keyword: An optional string to filter account names. If provided, only accounts whose 2843 names contain the keyword (case-insensitive) will be included in the result. 2844 Defaults to an empty string, which returns all accounts. 2845 2846 Returns: 2847 - A dictionary where keys are account names and values are AccountIDs. The dictionary 2848 contains only accounts that match the provided keyword (if any). 2849 """ 2850 return { 2851 account.name: account_id 2852 for account_id, account in self.__vault.account.items() 2853 if keyword.lower() in account.name.lower() 2854 }
Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
Parameters:
- keyword: An optional string to filter account names. If provided, only accounts whose names contain the keyword (case-insensitive) will be included in the result. Defaults to an empty string, which returns all accounts.
Returns:
- A dictionary where keys are account names and values are AccountIDs. The dictionary contains only accounts that match the provided keyword (if any).
2856 def name(self, account: AccountID, new_name: Optional[str] = None) -> str: 2857 """ 2858 Retrieves or sets the name of an account. 2859 2860 Parameters: 2861 - account: The AccountID of the account. 2862 - new_name: The new name to set for the account. If None, the current name is retrieved. 2863 2864 Returns: 2865 - The current name of the account if `new_name` is None, or the `new_name` if it is set. 2866 2867 Note: Returns an empty string if the account does not exist. 2868 """ 2869 if self.account_exists(account): 2870 if new_name is None: 2871 return self.__vault.account[account].name 2872 assert new_name != '' 2873 no_lock = self.nolock() 2874 lock = self.__lock() 2875 self.__step(Action.NAME, account, value=self.__vault.account[account].name) 2876 self.__vault.account[account].name = new_name 2877 if no_lock: 2878 assert lock is not None 2879 self.free(lock) 2880 return new_name 2881 return ''
Retrieves or sets the name of an account.
Parameters:
- account: The AccountID of the account.
- new_name: The new name to set for the account. If None, the current name is retrieved.
Returns:
- The current name of the account if
new_name
is None, or thenew_name
if it is set.
Note: Returns an empty string if the account does not exist.
2883 def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool: 2884 """ 2885 Check or set the zakatable status of a specific account. 2886 2887 Parameters: 2888 - account (AccountID): The account reference. 2889 - status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 2890 2891 Returns: 2892 - bool: The current or updated zakatable status of the account. 2893 2894 Raises: 2895 None 2896 2897 Example: 2898 ```bash 2899 >>> tracker = ZakatTracker() 2900 >>> ref = tracker.track(51, 'desc', 'account1') 2901 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 2902 True 2903 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 2904 True 2905 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 2906 True 2907 >>> tracker.zakatable('account1', False) 2908 False 2909 ``` 2910 """ 2911 if self.account_exists(account): 2912 if status is None: 2913 return self.__vault.account[account].zakatable 2914 self.__vault.account[account].zakatable = status 2915 return status 2916 return False
Check or set the zakatable status of a specific account.
Parameters:
- account (AccountID): The account reference.
- status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
Returns:
- bool: The current or updated zakatable status of the account.
Raises: None
Example:
>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
2918 def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'), 2919 created_time_ns: Optional[Timestamp] = None, 2920 debug: bool = False) \ 2921 -> SubtractReport: 2922 """ 2923 Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, 2924 the remaining amount will be transferred to a new transaction with a negative value. 2925 2926 Parameters: 2927 - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted. 2928 - desc (str, optional): A description for the transaction. Defaults to an empty string. 2929 - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'. 2930 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 2931 If not provided, the current timestamp will be used. 2932 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 2933 2934 Returns: 2935 - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 2936 2937 Raises: 2938 - ValueError: The unscaled_value should be greater than zero. 2939 - ValueError: The created_time_ns should be greater than zero. 2940 - ValueError: The box transaction happened again in the same nanosecond time. 2941 - ValueError: The log transaction happened again in the same nanosecond time. 2942 """ 2943 if debug: 2944 print('sub', f'debug={debug}') 2945 account = AccountID(account) 2946 if unscaled_value <= 0: 2947 raise ValueError('The unscaled_value should be greater than zero.') 2948 if created_time_ns is None: 2949 created_time_ns = Time.time() 2950 if created_time_ns <= 0: 2951 raise ValueError('The created should be greater than zero.') 2952 no_lock = self.nolock() 2953 lock = self.__lock() 2954 self.__track(0, '', account) 2955 value = self.scale(unscaled_value) 2956 self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 2957 ids = sorted(self.__vault.account[account].box.keys()) 2958 limit = len(ids) + 1 2959 target = value 2960 if debug: 2961 print('ids', ids) 2962 ages = SubtractAges() 2963 for i in range(-1, -limit, -1): 2964 if target == 0: 2965 break 2966 j = ids[i] 2967 if debug: 2968 print('i', i, 'j', j) 2969 rest = self.__vault.account[account].box[j].rest 2970 if rest >= target: 2971 self.__vault.account[account].box[j].rest -= target 2972 self.__step(Action.SUBTRACT, account, ref=j, value=target) 2973 ages.append(SubtractAge(box_ref=j, total=target)) 2974 target = 0 2975 break 2976 elif target > rest > 0: 2977 chunk = rest 2978 target -= chunk 2979 self.__vault.account[account].box[j].rest = 0 2980 self.__step(Action.SUBTRACT, account, ref=j, value=chunk) 2981 ages.append(SubtractAge(box_ref=j, total=chunk)) 2982 if target > 0: 2983 self.__track( 2984 unscaled_value=self.unscale(-target), 2985 desc=desc, 2986 account=account, 2987 logging=False, 2988 created_time_ns=created_time_ns, 2989 ) 2990 ages.append(SubtractAge(box_ref=created_time_ns, total=target)) 2991 if no_lock: 2992 assert lock is not None 2993 self.free(lock) 2994 return SubtractReport( 2995 log_ref=created_time_ns, 2996 ages=ages, 2997 )
Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, the remaining amount will be transferred to a new transaction with a negative value.
Parameters:
- unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
- desc (str, optional): A description for the transaction. Defaults to an empty string.
- account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
- created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
- debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
Returns:
- SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
Raises:
- ValueError: The unscaled_value should be greater than zero.
- ValueError: The created_time_ns should be greater than zero.
- ValueError: The box transaction happened again in the same nanosecond time.
- ValueError: The log transaction happened again in the same nanosecond time.
2999 def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '', 3000 created_time_ns: Optional[Timestamp] = None, 3001 debug: bool = False) -> Optional[TransferReport]: 3002 """ 3003 Transfers a specified value from one account to another. 3004 3005 Parameters: 3006 - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred. 3007 - from_account (AccountID): The account reference from which the value will be transferred. 3008 - to_account (AccountID): The account reference to which the value will be transferred. 3009 - desc (str, optional): A description for the transaction. Defaults to an empty string. 3010 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used. 3011 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 3012 3013 Returns: 3014 - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer. 3015 3016 Raises: 3017 - ValueError: Transfer to the same account is forbidden. 3018 - ValueError: The created_time_ns should be greater than zero. 3019 - ValueError: The box transaction happened again in the same nanosecond time. 3020 - ValueError: The log transaction happened again in the same nanosecond time. 3021 """ 3022 if debug: 3023 print('transfer', f'debug={debug}') 3024 from_account = AccountID(from_account) 3025 to_account = AccountID(to_account) 3026 if from_account == to_account: 3027 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 3028 if unscaled_amount <= 0: 3029 return None 3030 if created_time_ns is None: 3031 created_time_ns = Time.time() 3032 if created_time_ns <= 0: 3033 raise ValueError('The created should be greater than zero.') 3034 no_lock = self.nolock() 3035 lock = self.__lock() 3036 subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug) 3037 source_exchange = self.exchange(from_account, created_time_ns) 3038 target_exchange = self.exchange(to_account, created_time_ns) 3039 3040 if debug: 3041 print('ages', subtract_report.ages) 3042 3043 transfer_report = TransferReport() 3044 for subtract in subtract_report.ages: 3045 times = TransferTimes() 3046 age = subtract.box_ref 3047 value = subtract.total 3048 assert source_exchange.rate is not None 3049 assert target_exchange.rate is not None 3050 target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate)) 3051 if debug: 3052 print('target_amount', target_amount) 3053 # Perform the transfer 3054 if self.box_exists(to_account, age): 3055 if debug: 3056 print('box_exists', age) 3057 capital = self.__vault.account[to_account].box[age].capital 3058 rest = self.__vault.account[to_account].box[age].rest 3059 if debug: 3060 print( 3061 f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3062 selected_age = age 3063 if rest + target_amount > capital: 3064 self.__vault.account[to_account].box[age].capital += target_amount 3065 selected_age = Time.time() 3066 self.__vault.account[to_account].box[age].rest += target_amount 3067 self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 3068 y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 3069 created_time_ns=None, ref=None, debug=debug) 3070 times.append(TransferTime(box_ref=age, log_ref=y)) 3071 continue 3072 if debug: 3073 print( 3074 f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3075 box_ref = self.__track( 3076 unscaled_value=self.unscale(int(target_amount)), 3077 desc=desc, 3078 account=to_account, 3079 logging=True, 3080 created_time_ns=age, 3081 debug=debug, 3082 ) 3083 transfer_report.append(TransferRecord( 3084 box_ref=box_ref, 3085 times=times, 3086 )) 3087 if no_lock: 3088 assert lock is not None 3089 self.free(lock) 3090 return transfer_report
Transfers a specified value from one account to another.
Parameters:
- unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
- from_account (AccountID): The account reference from which the value will be transferred.
- to_account (AccountID): The account reference to which the value will be transferred.
- desc (str, optional): A description for the transaction. Defaults to an empty string.
- created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
- debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
Returns:
- Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
Raises:
- ValueError: Transfer to the same account is forbidden.
- ValueError: The created_time_ns should be greater than zero.
- ValueError: The box transaction happened again in the same nanosecond time.
- ValueError: The log transaction happened again in the same nanosecond time.
3092 def check(self, 3093 silver_gram_price: float, 3094 unscaled_nisab: Optional[float | int | decimal.Decimal] = None, 3095 debug: bool = False, 3096 created_time_ns: Optional[Timestamp] = None, 3097 cycle: Optional[float] = None) -> ZakatReport: 3098 """ 3099 Check the eligibility for Zakat based on the given parameters. 3100 3101 Parameters: 3102 - silver_gram_price (float): The price of a gram of silver. 3103 - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. 3104 If not provided, it will be calculated based on the silver_gram_price. 3105 - debug (bool, optional): Flag to enable debug mode. 3106 - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time(). 3107 - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 3108 3109 Returns: 3110 - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, 3111 a list of brief statistics, and a dictionary containing the Zakat plan. 3112 """ 3113 if debug: 3114 print('check', f'debug={debug}') 3115 before_parameters = { 3116 "silver_gram_price": silver_gram_price, 3117 "unscaled_nisab": unscaled_nisab, 3118 "debug": debug, 3119 "created_time_ns": created_time_ns, 3120 "cycle": cycle, 3121 } 3122 if created_time_ns is None: 3123 created_time_ns = Time.time() 3124 if cycle is None: 3125 cycle = ZakatTracker.TimeCycle() 3126 if unscaled_nisab is None: 3127 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 3128 nisab = self.scale(unscaled_nisab) 3129 plan: dict[AccountID, list[BoxPlan]] = {} 3130 summary = ZakatSummary() 3131 below_nisab = 0 3132 valid = False 3133 after_parameters = { 3134 "silver_gram_price": silver_gram_price, 3135 "unscaled_nisab": unscaled_nisab, 3136 "debug": debug, 3137 "created_time_ns": created_time_ns, 3138 "cycle": cycle, 3139 } 3140 if debug: 3141 print('exchanges', self.exchanges()) 3142 for x in self.__vault.account: 3143 if not self.zakatable(x): 3144 continue 3145 _box = self.__vault.account[x].box 3146 _log = self.__vault.account[x].log 3147 limit = len(_box) + 1 3148 ids = sorted(self.__vault.account[x].box.keys()) 3149 for i in range(-1, -limit, -1): 3150 j = ids[i] 3151 rest = float(_box[j].rest) 3152 if rest <= 0: 3153 continue 3154 exchange = self.exchange(x, created_time_ns=Time.time()) 3155 assert exchange.rate is not None 3156 rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1) 3157 summary.num_wealth_items += 1 3158 summary.total_wealth += rest 3159 epoch = (created_time_ns - j) / cycle 3160 if debug: 3161 print(f'Epoch: {epoch}', _box[j]) 3162 if _box[j].zakat.last > 0: 3163 epoch = (created_time_ns - _box[j].zakat.last) / cycle 3164 if debug: 3165 print(f'Epoch: {epoch}') 3166 epoch = math.floor(epoch) 3167 if debug: 3168 print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch) 3169 if epoch == 0: 3170 continue 3171 if debug: 3172 print('Epoch - PASSED') 3173 summary.num_zakatable_items += 1 3174 summary.total_zakatable_amount += rest 3175 is_nisab = rest >= nisab 3176 total = 0 3177 if is_nisab: 3178 for _ in range(epoch): 3179 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 3180 valid = total > 0 3181 elif rest > 0: 3182 below_nisab += rest 3183 total = ZakatTracker.ZakatCut(float(rest)) 3184 if total > 0: 3185 if x not in plan: 3186 plan[x] = [] 3187 summary.total_zakat_due += total 3188 plan[x].append(BoxPlan( 3189 below_nisab=not is_nisab, 3190 total=total, 3191 count=epoch, 3192 ref=j, 3193 box=_box[j], 3194 log=_log[j], 3195 exchange=exchange, 3196 )) 3197 valid = valid or below_nisab >= nisab 3198 if debug: 3199 print(f'below_nisab({below_nisab}) >= nisab({nisab})') 3200 report = ZakatReport( 3201 created=Time.time(), 3202 valid=valid, 3203 summary=summary, 3204 plan=plan, 3205 parameters={ 3206 'before': before_parameters, 3207 'after': after_parameters, 3208 }, 3209 ) 3210 self.__vault.cache.zakat = report 3211 return report
Check the eligibility for Zakat based on the given parameters.
Parameters:
- silver_gram_price (float): The price of a gram of silver.
- unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price.
- debug (bool, optional): Flag to enable debug mode.
- created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
- cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
Returns:
- ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
3213 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts: 3214 """ 3215 Build payment parts for the Zakat distribution. 3216 3217 Parameters: 3218 - scaled_demand (int): The total demand for payment in local currency. 3219 - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True. 3220 3221 Returns: 3222 - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: 3223 { 3224 'account': { 3225 'account_id': {'balance': float, 'rate': float, 'part': float}, 3226 ... 3227 }, 3228 'exceed': bool, 3229 'demand': int, 3230 'total': float, 3231 } 3232 """ 3233 total = 0.0 3234 parts = PaymentParts( 3235 account={}, 3236 exceed=False, 3237 demand=int(round(scaled_demand)), 3238 total=0, 3239 ) 3240 for x, y in self.accounts().items(): 3241 if positive_only and y.balance <= 0: 3242 continue 3243 total += float(y.balance) 3244 exchange = self.exchange(x) 3245 parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0) 3246 parts.total = total 3247 return parts
Build payment parts for the Zakat distribution.
Parameters:
- scaled_demand (int): The total demand for payment in local currency.
- positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
Returns:
- PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: { 'account': { 'account_id': {'balance': float, 'rate': float, 'part': float}, ... }, 'exceed': bool, 'demand': int, 'total': float, }
3249 @staticmethod 3250 def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int: 3251 """ 3252 Checks the validity of payment parts. 3253 3254 Parameters: 3255 - parts (dict[str, PaymentParts): A dictionary containing payment parts information. 3256 - debug (bool, optional): Flag to enable debug mode. 3257 3258 Returns: 3259 - int: Returns 0 if the payment parts are valid, otherwise returns the error code. 3260 3261 Error Codes: 3262 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 3263 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3264 3: 'part' value in parts['account'][x] is less than 0. 3265 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 3266 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 3267 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 3268 """ 3269 if debug: 3270 print('check_payment_parts', f'debug={debug}') 3271 # for i in ['demand', 'account', 'total', 'exceed']: 3272 # if i not in parts: 3273 # return 1 3274 exceed = parts.exceed 3275 # for j in ['balance', 'rate', 'part']: 3276 # if j not in parts.account[x]: 3277 # return 2 3278 for x in parts.account: 3279 if parts.account[x].part < 0: 3280 return 3 3281 if not exceed and parts.account[x].balance <= 0: 3282 return 4 3283 demand = parts.demand 3284 z = 0.0 3285 for _, y in parts.account.items(): 3286 if not exceed and y.part > y.balance: 3287 return 5 3288 z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0) 3289 z = round(z, 2) 3290 demand = round(demand, 2) 3291 if debug: 3292 print('check_payment_parts', f'z = {z}, demand = {demand}') 3293 print('check_payment_parts', type(z), type(demand)) 3294 print('check_payment_parts', z != demand) 3295 print('check_payment_parts', str(z) != str(demand)) 3296 if z != demand and str(z) != str(demand): 3297 return 6 3298 return 0
Checks the validity of payment parts.
Parameters:
- parts (dict[str, PaymentParts): A dictionary containing payment parts information.
- debug (bool, optional): Flag to enable debug mode.
Returns:
- int: Returns 0 if the payment parts are valid, otherwise returns the error code.
Error Codes: 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3: 'part' value in parts['account'][x] is less than 0. 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3300 def zakat(self, report: ZakatReport, 3301 parts: Optional[PaymentParts] = None, debug: bool = False) -> bool: 3302 """ 3303 Perform Zakat calculation based on the given report and optional parts. 3304 3305 Parameters: 3306 - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan. 3307 - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat. 3308 - debug (bool, optional): A flag indicating whether to print debug information. 3309 3310 Returns: 3311 - bool: True if the zakat calculation is successful, False otherwise. 3312 3313 Raises: 3314 - AssertionError: Bad Zakat report, call `check` first then call `zakat`. 3315 """ 3316 if debug: 3317 print('zakat', f'debug={debug}') 3318 if not report.valid: 3319 return report.valid 3320 assert report.plan 3321 parts_exist = parts is not None 3322 if parts_exist: 3323 if self.check_payment_parts(parts, debug=debug) != 0: 3324 return False 3325 if debug: 3326 print('######### zakat #######') 3327 print('parts_exist', parts_exist) 3328 assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`" 3329 no_lock = self.nolock() 3330 lock = self.__lock() 3331 report_time = Time.time() 3332 self.__vault.report[report_time] = report 3333 self.__step(Action.REPORT, ref=report_time) 3334 created_time_ns = Time.time() 3335 for x in report.plan: 3336 target_exchange = self.exchange(x) 3337 if debug: 3338 print(report.plan[x]) 3339 print('-------------') 3340 print(self.__vault.account[x].box) 3341 if debug: 3342 print('plan[x]', report.plan[x]) 3343 for plan in report.plan[x]: 3344 j = plan.ref 3345 if debug: 3346 print('j', j) 3347 assert j 3348 self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last, 3349 key='last', 3350 math_operation=MathOperation.EQUAL) 3351 self.__vault.account[x].box[j].zakat.last = created_time_ns 3352 assert target_exchange.rate is not None 3353 amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate)) 3354 self.__vault.account[x].box[j].zakat.total += amount 3355 self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 3356 math_operation=MathOperation.ADDITION) 3357 self.__vault.account[x].box[j].zakat.count += plan.count 3358 self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count', 3359 math_operation=MathOperation.ADDITION) 3360 if not parts_exist: 3361 try: 3362 self.__vault.account[x].box[j].rest -= amount 3363 except TypeError: 3364 self.__vault.account[x].box[j].rest -= decimal.Decimal(amount) 3365 # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 3366 # math_operation=MathOperation.SUBTRACTION) 3367 self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug) 3368 if parts_exist: 3369 for account, part in parts.account.items(): 3370 if part.part == 0: 3371 continue 3372 if debug: 3373 print('zakat-part', account, part.rate) 3374 target_exchange = self.exchange(account) 3375 assert target_exchange.rate is not None 3376 amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate) 3377 unscaled_amount = self.unscale(int(amount)) 3378 if unscaled_amount <= 0: 3379 if debug: 3380 print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.") 3381 continue 3382 self.subtract( 3383 unscaled_value=unscaled_amount, 3384 desc='zakat-part-دفعة-زكاة', 3385 account=account, 3386 debug=debug, 3387 ) 3388 if no_lock: 3389 assert lock is not None 3390 self.free(lock) 3391 self.__vault.cache.zakat = None 3392 return True
Perform Zakat calculation based on the given report and optional parts.
Parameters:
- report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
- parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
- debug (bool, optional): A flag indicating whether to print debug information.
Returns:
- bool: True if the zakat calculation is successful, False otherwise.
Raises:
3394 @staticmethod 3395 def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]: 3396 """Splits a string at the last occurrence of a given symbol. 3397 3398 Parameters: 3399 - data (str): The input string. 3400 - symbol (str): The symbol to split at. 3401 3402 Returns: 3403 - tuple[str, str]: A tuple containing two strings, the part before the last symbol and 3404 the part after the last symbol. If the symbol is not found, returns (data, ""). 3405 """ 3406 last_symbol_index = data.rfind(symbol) 3407 3408 if last_symbol_index != -1: 3409 before_symbol = data[:last_symbol_index] 3410 after_symbol = data[last_symbol_index + len(symbol):] 3411 return before_symbol, after_symbol 3412 return data, ""
Splits a string at the last occurrence of a given symbol.
Parameters:
- data (str): The input string.
- symbol (str): The symbol to split at.
Returns:
- tuple[str, str]: A tuple containing two strings, the part before the last symbol and the part after the last symbol. If the symbol is not found, returns (data, "").
3414 def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool: 3415 """ 3416 Saves the ZakatTracker's current state to a json file. 3417 3418 This method serializes the internal data (`__vault`). 3419 3420 Parameters: 3421 - path (str, optional): File path for saving. Defaults to a predefined location. 3422 - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True. 3423 3424 Returns: 3425 - bool: True if the save operation is successful, False otherwise. 3426 """ 3427 if path is None: 3428 path = self.path() 3429 # first save in tmp file 3430 temp = f'{path}.tmp' 3431 try: 3432 with open(temp, 'w', encoding='utf-8') as stream: 3433 data = json.dumps(self.__vault, cls=JSONEncoder) 3434 stream.write(data) 3435 if hash_required: 3436 hashed = self.hash_data(data.encode()) 3437 stream.write(f'//{hashed}') 3438 # then move tmp file to original location 3439 shutil.move(temp, path) 3440 return True 3441 except (IOError, OSError) as e: 3442 print(f'Error saving file: {e}') 3443 if os.path.exists(temp): 3444 os.remove(temp) 3445 return False
Saves the ZakatTracker's current state to a json file.
This method serializes the internal data (__vault
).
Parameters:
- path (str, optional): File path for saving. Defaults to a predefined location.
- hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
Returns:
- bool: True if the save operation is successful, False otherwise.
3447 @staticmethod 3448 def load_vault_from_json(json_string: str) -> Vault: 3449 """Loads a Vault dataclass from a JSON string.""" 3450 data = json.loads(json_string) 3451 3452 vault = Vault() 3453 3454 # Load Accounts 3455 for account_reference, account_data in data.get("account", {}).items(): 3456 account_reference = AccountID(account_reference) 3457 box_data = account_data.get('box', {}) 3458 box = { 3459 Timestamp(ts): Box( 3460 capital=box_data[str(ts)]["capital"], 3461 rest=box_data[str(ts)]["rest"], 3462 zakat=BoxZakat(**box_data[str(ts)]["zakat"]), 3463 ) 3464 for ts in box_data 3465 } 3466 3467 log_data = account_data.get('log', {}) 3468 log = {Timestamp(ts): Log( 3469 value=log_data[str(ts)]['value'], 3470 desc=log_data[str(ts)]['desc'], 3471 ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None, 3472 file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()}, 3473 ) for ts in log_data} 3474 3475 vault.account[account_reference] = Account( 3476 balance=account_data["balance"], 3477 created=Timestamp(account_data["created"]), 3478 name=account_data.get("name", ""), 3479 box=box, 3480 count=account_data.get("count", 0), 3481 log=log, 3482 hide=account_data.get("hide", False), 3483 zakatable=account_data.get("zakatable", True), 3484 ) 3485 3486 # Load Exchanges 3487 for account_reference, exchange_data in data.get("exchange", {}).items(): 3488 account_reference = AccountID(account_reference) 3489 vault.exchange[account_reference] = {} 3490 for timestamp, exchange_details in exchange_data.items(): 3491 vault.exchange[account_reference][Timestamp(timestamp)] = Exchange( 3492 rate=exchange_details.get("rate"), 3493 description=exchange_details.get("description"), 3494 time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None, 3495 ) 3496 3497 # Load History 3498 for timestamp, history_dict in data.get("history", {}).items(): 3499 vault.history[Timestamp(timestamp)] = {} 3500 for history_key, history_data in history_dict.items(): 3501 vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History( 3502 action=Action(history_data["action"]), 3503 account=AccountID(history_data["account"]) if history_data.get("account") is not None else None, 3504 ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None, 3505 file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None, 3506 key=history_data.get("key"), 3507 value=history_data.get("value"), 3508 math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None, 3509 ) 3510 3511 # Load Lock 3512 vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None 3513 3514 # Load Report 3515 for timestamp, report_data in data.get("report", {}).items(): 3516 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3517 for account_reference, box_plans in report_data.get("plan", {}).items(): 3518 account_reference = AccountID(account_reference) 3519 zakat_plan[account_reference] = [] 3520 for box_plan_data in box_plans: 3521 zakat_plan[account_reference].append(BoxPlan( 3522 box=Box( 3523 capital=box_plan_data["box"]["capital"], 3524 rest=box_plan_data["box"]["rest"], 3525 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3526 ), 3527 log=Log(**box_plan_data["log"]), 3528 exchange=Exchange(**box_plan_data["exchange"]), 3529 below_nisab=box_plan_data["below_nisab"], 3530 total=box_plan_data["total"], 3531 count=box_plan_data["count"], 3532 ref=Timestamp(box_plan_data["ref"]), 3533 )) 3534 3535 vault.report[Timestamp(timestamp)] = ZakatReport( 3536 created=report_data["created"], 3537 valid=report_data["valid"], 3538 summary=ZakatSummary(**report_data["summary"]), 3539 plan=zakat_plan, 3540 parameters=report_data["parameters"], 3541 ) 3542 3543 # Load Cache 3544 vault.cache = Cache() 3545 cache_data = data.get("cache", {}) 3546 if "zakat" in cache_data: 3547 cache_zakat_data = cache_data.get("zakat", {}) 3548 if cache_zakat_data: 3549 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3550 for account_reference, box_plans in cache_zakat_data.get("plan", {}).items(): 3551 account_reference = AccountID(account_reference) 3552 zakat_plan[account_reference] = [] 3553 for box_plan_data in box_plans: 3554 zakat_plan[account_reference].append(BoxPlan( 3555 box=Box( 3556 capital=box_plan_data["box"]["capital"], 3557 rest=box_plan_data["box"]["rest"], 3558 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3559 ), 3560 log=Log(**box_plan_data["log"]), 3561 exchange=Exchange(**box_plan_data["exchange"]), 3562 below_nisab=box_plan_data["below_nisab"], 3563 total=box_plan_data["total"], 3564 count=box_plan_data["count"], 3565 ref=Timestamp(box_plan_data["ref"]), 3566 )) 3567 3568 vault.cache.zakat = ZakatReport( 3569 created=cache_zakat_data["created"], 3570 valid=cache_zakat_data["valid"], 3571 summary=ZakatSummary(**cache_zakat_data["summary"]), 3572 plan=zakat_plan, 3573 parameters=cache_zakat_data["parameters"], 3574 ) 3575 3576 return vault
Loads a Vault dataclass from a JSON string.
3578 def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool: 3579 """ 3580 Load the current state of the ZakatTracker object from a json file. 3581 3582 Parameters: 3583 - path (str, optional): The path where the json file is located. If not provided, it will use the default path. 3584 - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True. 3585 - debug (bool, optional): Flag to enable debug mode. 3586 3587 Returns: 3588 - bool: True if the load operation is successful, False otherwise. 3589 """ 3590 if path is None: 3591 path = self.path() 3592 try: 3593 if os.path.exists(path): 3594 with open(path, 'r', encoding='utf-8') as stream: 3595 file = stream.read() 3596 data, hashed = self.split_at_last_symbol(file, '//') 3597 if hash_required: 3598 assert hashed 3599 if debug: 3600 print('[debug-load]', hashed) 3601 new_hash = self.hash_data(data.encode()) 3602 if debug: 3603 print('[debug-load]', new_hash) 3604 assert hashed == new_hash, "Hash verification failed. File may be corrupted." 3605 self.__vault = self.load_vault_from_json(data) 3606 return True 3607 else: 3608 print(f'File not found: {path}') 3609 return False 3610 except (IOError, OSError) as e: 3611 print(f'Error loading file: {e}') 3612 return False
Load the current state of the ZakatTracker object from a json file.
Parameters:
- path (str, optional): The path where the json file is located. If not provided, it will use the default path.
- hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
- debug (bool, optional): Flag to enable debug mode.
Returns:
- bool: True if the load operation is successful, False otherwise.
3614 def import_csv_cache_path(self): 3615 """ 3616 Generates the cache file path for imported CSV data. 3617 3618 This function constructs the file path where cached data from CSV imports 3619 will be stored. The cache file is a json file (.json extension) appended 3620 to the base path of the object. 3621 3622 Parameters: 3623 None 3624 3625 Returns: 3626 - str: The full path to the import CSV cache file. 3627 3628 Example: 3629 ```bash 3630 >>> obj = ZakatTracker('/data/reports') 3631 >>> obj.import_csv_cache_path() 3632 '/data/reports.import_csv.json' 3633 ``` 3634 """ 3635 path = str(self.path()) 3636 ext = self.ext() 3637 ext_len = len(ext) 3638 if path.endswith(f'.{ext}'): 3639 path = path[:-ext_len - 1] 3640 _, filename = os.path.split(path + f'.import_csv.{ext}') 3641 return self.base_path(filename)
Generates the cache file path for imported CSV data.
This function constructs the file path where cached data from CSV imports will be stored. The cache file is a json file (.json extension) appended to the base path of the object.
Parameters: None
Returns:
- str: The full path to the import CSV cache file.
Example:
>>> obj = ZakatTracker('/data/reports')
>>> obj.import_csv_cache_path()
'/data/reports.import_csv.json'
3643 @staticmethod 3644 def get_transaction_csv_headers() -> list[str]: 3645 """ 3646 Returns a list of strings representing the headers for a transaction CSV file. 3647 3648 The headers include: 3649 - account: The account associated with the transaction. 3650 - desc: A description of the transaction. 3651 - value: The monetary value of the transaction. 3652 - date: The date of the transaction. 3653 - rate: The applicable rate (if any) for the transaction. 3654 - reference: An optional reference number or identifier for the transaction. 3655 3656 Returns: 3657 - list[str]: A list containing the CSV header strings. 3658 """ 3659 return [ 3660 "account", 3661 "desc", 3662 "value", 3663 "date", 3664 "rate", 3665 "reference", 3666 ]
Returns a list of strings representing the headers for a transaction CSV file.
The headers include:
- account: The account associated with the transaction.
- desc: A description of the transaction.
- value: The monetary value of the transaction.
- date: The date of the transaction.
- rate: The applicable rate (if any) for the transaction.
- reference: An optional reference number or identifier for the transaction.
Returns:
- list[str]: A list containing the CSV header strings.
3668 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport: 3669 """ 3670 The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system. 3671 3672 Parameters: 3673 - path (str, optional): The path to the CSV file. Default is 'file.csv'. 3674 - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0. 3675 - debug (bool, optional): A flag indicating whether to print debug information. 3676 3677 Returns: 3678 - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, 3679 and a dictionary of bad transactions. 3680 3681 Notes: 3682 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3683 are appropriate for the currency pairs involved in the conversions. 3684 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3685 to 1.0 or the previous rate for that account. 3686 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3687 transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and 3688 `zakat` operations. 3689 3690 Example: 3691 The CSV file should have the following format, rate and reference are optionals per transaction: 3692 account, desc, value, date, rate, reference 3693 For example: 3694 safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554 3695 """ 3696 if debug: 3697 print('import_csv', f'debug={debug}') 3698 cache: list[int] = [] 3699 try: 3700 if not self.memory_mode(): 3701 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3702 cache = json.load(stream) 3703 except Exception as e: 3704 if debug: 3705 print(e) 3706 date_formats = [ 3707 '%Y-%m-%d %H:%M:%S.%f', 3708 '%Y-%m-%dT%H:%M:%S.%f', 3709 '%Y-%m-%dT%H%M%S.%f', 3710 '%Y-%m-%d', 3711 ] 3712 statistics = ImportStatistics(0, 0, 0) 3713 data: dict[int, list[CSVRecord]] = {} 3714 with open(path, newline='', encoding='utf-8') as f: 3715 i = 0 3716 for row in csv.reader(f, delimiter=','): 3717 if debug: 3718 print(f"csv_row({i})", row, type(row)) 3719 if row == self.get_transaction_csv_headers(): 3720 continue 3721 i += 1 3722 hashed = hash(tuple(row)) 3723 if hashed in cache: 3724 statistics.found += 1 3725 continue 3726 account = row[0] 3727 desc = row[1] 3728 value = float(row[2]) 3729 rate = 1.0 3730 reference = '' 3731 if row[4:5]: # Empty list if index is out of range 3732 rate = float(row[4]) 3733 if row[5:6]: 3734 reference = row[5] 3735 date: int = 0 3736 for time_format in date_formats: 3737 try: 3738 date_str = row[3] 3739 if "." not in date_str: 3740 date_str += ".000000" 3741 date = Time.time(datetime.datetime.strptime(date_str, time_format)) 3742 break 3743 except Exception as e: 3744 if debug: 3745 print(e) 3746 record = CSVRecord( 3747 index=i, 3748 account=account, 3749 desc=desc, 3750 value=value, 3751 date=date, 3752 rate=rate, 3753 reference=reference, 3754 hashed=hashed, 3755 error='', 3756 ) 3757 if date <= 0: 3758 record.error = 'invalid date' 3759 statistics.bad += 1 3760 if value == 0: 3761 record.error = 'invalid value' 3762 statistics.bad += 1 3763 continue 3764 if date not in data: 3765 data[date] = [] 3766 data[date].append(record) 3767 3768 if debug: 3769 print('import_csv', len(data)) 3770 3771 if statistics.bad > 0: 3772 return ImportReport( 3773 statistics=statistics, 3774 bad=[ 3775 item 3776 for sublist in data.values() 3777 for item in sublist 3778 if item.error 3779 ], 3780 ) 3781 3782 no_lock = self.nolock() 3783 lock = self.__lock() 3784 names = self.names() 3785 3786 # sync accounts 3787 if debug: 3788 print('before-names', names, len(names)) 3789 for date, rows in sorted(data.items()): 3790 new_rows: list[CSVRecord] = [] 3791 for row in rows: 3792 if row.account not in names: 3793 account_id = self.create_account(row.account) 3794 names[row.account] = account_id 3795 account_id = names[row.account] 3796 assert account_id 3797 row.account = account_id 3798 new_rows.append(row) 3799 assert new_rows 3800 assert date in data 3801 data[date] = new_rows 3802 if debug: 3803 print('after-names', names, len(names)) 3804 assert names == self.names() 3805 3806 # do ops 3807 for date, rows in sorted(data.items()): 3808 try: 3809 def process(x: CSVRecord): 3810 x.value = self.unscale( 3811 x.value, 3812 decimal_places=scale_decimal_places, 3813 ) if scale_decimal_places > 0 else x.value 3814 if x.rate > 0: 3815 self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate) 3816 if x.value > 0: 3817 self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3818 elif x.value < 0: 3819 self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3820 return x.hashed 3821 len_rows = len(rows) 3822 # If records are found at the same time with different accounts in the same amount 3823 # (one positive and the other negative), this indicates it is a transfer. 3824 if len_rows > 2 or len_rows == 1: 3825 for row in rows: 3826 hashed = process(row) 3827 assert hashed not in cache 3828 cache.append(hashed) 3829 statistics.created += 1 3830 continue 3831 x1 = rows[0] 3832 x2 = rows[1] 3833 if x1.account == x2.account: 3834 continue 3835 # raise Exception(f'invalid transfer') 3836 # not transfer - same time - normal ops 3837 if abs(x1.value) != abs(x2.value) and x1.date == x2.date: 3838 rows[1].date += 1 3839 for row in rows: 3840 hashed = process(row) 3841 assert hashed not in cache 3842 cache.append(hashed) 3843 statistics.created += 1 3844 continue 3845 if x1.rate > 0: 3846 self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate) 3847 if x2.rate > 0: 3848 self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate) 3849 x1.value = self.unscale( 3850 x1.value, 3851 decimal_places=scale_decimal_places, 3852 ) if scale_decimal_places > 0 else x1.value 3853 x2.value = self.unscale( 3854 x2.value, 3855 decimal_places=scale_decimal_places, 3856 ) if scale_decimal_places > 0 else x2.value 3857 # just transfer 3858 values = { 3859 x1.value: x1.account, 3860 x2.value: x2.account, 3861 } 3862 if debug: 3863 print('values', values) 3864 if len(values) <= 1: 3865 continue 3866 self.transfer( 3867 unscaled_amount=abs(x1.value), 3868 from_account=values[min(values.keys())], 3869 to_account=values[max(values.keys())], 3870 desc=x1.desc, 3871 created_time_ns=x1.date, 3872 ) 3873 except Exception as e: 3874 for row in rows: 3875 _tuple = tuple() 3876 for field in row: 3877 _tuple += (field,) 3878 _tuple += (e,) 3879 bad[i] = _tuple 3880 break 3881 if not self.memory_mode(): 3882 with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream: 3883 stream.write(json.dumps(cache)) 3884 if no_lock: 3885 assert lock is not None 3886 self.free(lock) 3887 report = ImportReport( 3888 statistics=statistics, 3889 bad=[ 3890 item 3891 for sublist in data.values() 3892 for item in sublist 3893 if item.error 3894 ], 3895 ) 3896 if debug: 3897 debug_path = f'{self.import_csv_cache_path()}.debug.json' 3898 with open(debug_path, 'w', encoding='utf-8') as file: 3899 json.dump(report, file, indent=4, cls=JSONEncoder) 3900 print(f'generated debug report @ `{debug_path}`...') 3901 return report
The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
Parameters:
- path (str, optional): The path to the CSV file. Default is 'file.csv'.
- scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
- debug (bool, optional): A flag indicating whether to print debug information.
Returns:
- ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.
Notes:
- Currency Pair Assumption: This function assumes that the exchange rates stored for each account are appropriate for the currency pairs involved in the conversions.
- The exchange rate for each account is based on the last encountered transaction rate that is not equal to 1.0 or the previous rate for that account.
- Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
transactions of the same account within the whole imported and existing dataset when doing
transfer
,check
andzakat
operations.
Example: The CSV file should have the following format, rate and reference are optionals per transaction: account, desc, value, date, rate, reference For example: safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3907 @staticmethod 3908 def human_readable_size(size: float, decimal_places: int = 2) -> str: 3909 """ 3910 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 3911 3912 This function iterates through progressively larger units of information 3913 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 3914 range that can be expressed with a reasonable number before the unit. 3915 3916 Parameters: 3917 - size (float): The size in bytes to convert. 3918 - decimal_places (int, optional): The number of decimal places to display 3919 in the result. Defaults to 2. 3920 3921 Returns: 3922 - str: A string representation of the size in a human-readable format, 3923 rounded to the specified number of decimal places. For example: 3924 - '1.50 KB' (1536 bytes) 3925 - '23.00 MB' (24117248 bytes) 3926 - '1.23 GB' (1325899906 bytes) 3927 """ 3928 if type(size) not in (float, int): 3929 raise TypeError('size must be a float or integer') 3930 if type(decimal_places) != int: 3931 raise TypeError('decimal_places must be an integer') 3932 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 3933 if size < 1024.0: 3934 break 3935 size /= 1024.0 3936 return f'{size:.{decimal_places}f} {unit}'
Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
This function iterates through progressively larger units of information (B, KB, MB, GB, etc.) and divides the input size until it fits within a range that can be expressed with a reasonable number before the unit.
Parameters:
- size (float): The size in bytes to convert.
- decimal_places (int, optional): The number of decimal places to display in the result. Defaults to 2.
Returns:
- str: A string representation of the size in a human-readable format, rounded to the specified number of decimal places. For example: - '1.50 KB' (1536 bytes) - '23.00 MB' (24117248 bytes) - '1.23 GB' (1325899906 bytes)
3938 @staticmethod 3939 def get_dict_size(obj: dict, seen: Optional[set] = None) -> float: 3940 """ 3941 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 3942 3943 This function traverses the dictionary structure, accounting for the size of keys, values, 3944 and any nested objects. It handles various data types commonly found in dictionaries 3945 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 3946 of circular references. 3947 3948 Parameters: 3949 - obj (dict): The dictionary whose size is to be calculated. 3950 - seen (set, optional): A set used internally to track visited objects 3951 and avoid circular references. Defaults to None. 3952 3953 Returns: 3954 - float: An approximate size of the dictionary and its contents in bytes. 3955 3956 Notes: 3957 - This function is a method of the `ZakatTracker` class and is likely used to 3958 estimate the memory footprint of data structures relevant to Zakat calculations. 3959 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 3960 not account for all memory overhead depending on the Python implementation. 3961 - Circular references are handled to prevent infinite recursion. 3962 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 3963 - String sizes are estimated based on character length and encoding. 3964 """ 3965 size = 0 3966 if seen is None: 3967 seen = set() 3968 3969 obj_id = id(obj) 3970 if obj_id in seen: 3971 return 0 3972 3973 seen.add(obj_id) 3974 size += sys.getsizeof(obj) 3975 3976 if isinstance(obj, dict): 3977 for k, v in obj.items(): 3978 size += ZakatTracker.get_dict_size(k, seen) 3979 size += ZakatTracker.get_dict_size(v, seen) 3980 elif isinstance(obj, (list, tuple, set, frozenset)): 3981 for item in obj: 3982 size += ZakatTracker.get_dict_size(item, seen) 3983 elif isinstance(obj, (int, float, complex)): # Handle numbers 3984 pass # Basic numbers have a fixed size, so nothing to add here 3985 elif isinstance(obj, str): # Handle strings 3986 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 3987 return size
Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
This function traverses the dictionary structure, accounting for the size of keys, values, and any nested objects. It handles various data types commonly found in dictionaries (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case of circular references.
Parameters:
- obj (dict): The dictionary whose size is to be calculated.
- seen (set, optional): A set used internally to track visited objects and avoid circular references. Defaults to None.
Returns:
- float: An approximate size of the dictionary and its contents in bytes.
Notes:
- This function is a method of the
ZakatTracker
class and is likely used to estimate the memory footprint of data structures relevant to Zakat calculations. - The size calculation is approximate as it relies on
sys.getsizeof()
, which might not account for all memory overhead depending on the Python implementation. - Circular references are handled to prevent infinite recursion.
- Basic numeric types (int, float, complex) are assumed to have fixed sizes.
- String sizes are estimated based on character length and encoding.
3989 @staticmethod 3990 def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp: # افتراض أن الشهر هو يونيو والسنة 2024 3991 """ 3992 Convert a specific day, month, and year into a timestamp. 3993 3994 Parameters: 3995 - day (int): The day of the month. 3996 - month (int, optional): The month of the year. Default is 6 (June). 3997 - year (int, optional): The year. Default is 2024. 3998 3999 Returns: 4000 - Timestamp: The timestamp representing the given day, month, and year. 4001 4002 Note: 4003 - This method assumes the default month and year if not provided. 4004 """ 4005 return Time.time(datetime.datetime(year, month, day))
Convert a specific day, month, and year into a timestamp.
Parameters:
- day (int): The day of the month.
- month (int, optional): The month of the year. Default is 6 (June).
- year (int, optional): The year. Default is 2024.
Returns:
- Timestamp: The timestamp representing the given day, month, and year.
Note:
- This method assumes the default month and year if not provided.
4007 @staticmethod 4008 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 4009 """ 4010 Generate a random date between two given dates. 4011 4012 Parameters: 4013 - start_date (datetime.datetime): The start date from which to generate a random date. 4014 - end_date (datetime.datetime): The end date until which to generate a random date. 4015 4016 Returns: 4017 - datetime.datetime: A random date between the start_date and end_date. 4018 """ 4019 time_between_dates = end_date - start_date 4020 days_between_dates = time_between_dates.days 4021 random_number_of_days = random.randrange(days_between_dates) 4022 return start_date + datetime.timedelta(days=random_number_of_days)
Generate a random date between two given dates.
Parameters:
- start_date (datetime.datetime): The start date from which to generate a random date.
- end_date (datetime.datetime): The end date until which to generate a random date.
Returns:
- datetime.datetime: A random date between the start_date and end_date.
4024 @staticmethod 4025 def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False, 4026 debug: bool = False) -> int: 4027 """ 4028 Generate a random CSV file with specified parameters. 4029 The function generates a CSV file at the specified path with the given count of rows. 4030 Each row contains a randomly generated account, description, value, and date. 4031 The value is randomly generated between 1000 and 100000, 4032 and the date is randomly generated between 1950-01-01 and 2023-12-31. 4033 If the row number is not divisible by 13, the value is multiplied by -1. 4034 4035 Parameters: 4036 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 4037 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 4038 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 4039 - debug (bool, optional): A flag indicating whether to print debug information. 4040 4041 Returns: 4042 - int: number of generated records. 4043 """ 4044 if debug: 4045 print('generate_random_csv_file', f'debug={debug}') 4046 i = 0 4047 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 4048 writer = csv.writer(csvfile) 4049 writer.writerow(ZakatTracker.get_transaction_csv_headers()) 4050 for i in range(count): 4051 account = f'acc-{random.randint(1, count)}' 4052 desc = f'Some text {random.randint(1, count)}' 4053 value = random.randint(1000, 100000) 4054 date = ZakatTracker.generate_random_date( 4055 datetime.datetime(1000, 1, 1), 4056 datetime.datetime(2023, 12, 31), 4057 ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S') 4058 if not i % 13 == 0: 4059 value *= -1 4060 row = [account, desc, value, date] 4061 if with_rate: 4062 rate = random.randint(1, 100) * 0.12 4063 if debug: 4064 print('before-append', row) 4065 row.append(rate) 4066 if debug: 4067 print('after-append', row) 4068 if i % 2 == 1: 4069 row += (Time.time(),) 4070 writer.writerow(row) 4071 i = i + 1 4072 return i
Generate a random CSV file with specified parameters. The function generates a CSV file at the specified path with the given count of rows. Each row contains a randomly generated account, description, value, and date. The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. If the row number is not divisible by 13, the value is multiplied by -1.
Parameters:
- path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
- count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
- with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
- debug (bool, optional): A flag indicating whether to print debug information.
Returns:
- int: number of generated records.
4074 @staticmethod 4075 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 4076 """ 4077 Creates a list of random integers whose sum does not exceed the specified maximum. 4078 4079 Parameters: 4080 - max_sum (int): The maximum allowed sum of the list elements. 4081 - min_value (int, optional): The minimum possible value for an element (inclusive). 4082 - max_value (int, optional): The maximum possible value for an element (inclusive). 4083 4084 Returns: 4085 - A list of random integers. 4086 """ 4087 result = [] 4088 current_sum = 0 4089 4090 while current_sum < max_sum: 4091 # Calculate the remaining space for the next element 4092 remaining_sum = max_sum - current_sum 4093 # Determine the maximum possible value for the next element 4094 next_max_value = min(remaining_sum, max_value) 4095 # Generate a random element within the allowed range 4096 next_element = random.randint(min_value, next_max_value) 4097 result.append(next_element) 4098 current_sum += next_element 4099 4100 return result
Creates a list of random integers whose sum does not exceed the specified maximum.
Parameters:
- max_sum (int): The maximum allowed sum of the list elements.
- min_value (int, optional): The minimum possible value for an element (inclusive).
- max_value (int, optional): The maximum possible value for an element (inclusive).
Returns:
- A list of random integers.
4484 def test(self, debug: bool = False) -> bool: 4485 if debug: 4486 print('test', f'debug={debug}') 4487 try: 4488 4489 self._test_core(True, debug) 4490 self._test_core(False, debug) 4491 4492 # test_names 4493 self.reset() 4494 x = "test_names" 4495 failed = False 4496 try: 4497 assert self.name(x) == '' 4498 except: 4499 failed = True 4500 assert failed 4501 assert self.names() == {} 4502 failed = False 4503 try: 4504 assert self.name(x, 'qwe') == '' 4505 except: 4506 failed = True 4507 assert failed 4508 account_id0 = self.create_account(x) 4509 assert isinstance(account_id0, AccountID) 4510 assert int(account_id0) > 0 4511 assert self.name(account_id0) == x 4512 assert self.name(account_id0, 'qwe') == 'qwe' 4513 if debug: 4514 print(self.names(keyword='qwe')) 4515 assert self.names(keyword='asd') == {} 4516 assert self.names(keyword='qwe') == {'qwe': account_id0} 4517 4518 # test_create_account 4519 account_name = "test_account" 4520 assert self.names(keyword=account_name) == {} 4521 account_id = self.create_account(account_name) 4522 assert isinstance(account_id, AccountID) 4523 assert int(account_id) > 0 4524 assert account_id in self.__vault.account 4525 assert self.name(account_id) == account_name 4526 assert self.names(keyword=account_name) == {account_name: account_id} 4527 4528 failed = False 4529 try: 4530 self.create_account(account_name) 4531 except: 4532 failed = True 4533 assert failed 4534 4535 # bad are names is forbidden 4536 4537 for bad_name in [ 4538 None, 4539 '', 4540 Time.time(), 4541 -Time.time(), 4542 f'{Time.time()}', 4543 f'{-Time.time()}', 4544 0.0, 4545 '0.0', 4546 ' ', 4547 ]: 4548 failed = False 4549 try: 4550 self.create_account(bad_name) 4551 except: 4552 failed = True 4553 assert failed 4554 4555 # rename account 4556 assert self.name(account_id) == account_name 4557 assert self.name(account_id, 'asd') == 'asd' 4558 assert self.name(account_id) == 'asd' 4559 # use old and not used name 4560 account_id2 = self.create_account(account_name) 4561 assert int(account_id2) > 0 4562 assert account_id != account_id2 4563 assert self.name(account_id2) == account_name 4564 assert self.names(keyword=account_name) == {account_name: account_id2} 4565 4566 assert self.__history() 4567 count = len(self.__vault.history) 4568 if debug: 4569 print('history-count', count) 4570 assert count == 8 4571 4572 assert self.recall(dry=False, debug=debug) 4573 assert self.name(account_id2) == '' 4574 assert self.account_exists(account_id2) 4575 assert self.recall(dry=False, debug=debug) 4576 assert not self.account_exists(account_id2) 4577 assert self.recall(dry=False, debug=debug) 4578 assert self.name(account_id) == account_name 4579 assert self.recall(dry=False, debug=debug) 4580 assert self.account_exists(account_id) 4581 assert self.recall(dry=False, debug=debug) 4582 assert not self.account_exists(account_id) 4583 assert self.names(keyword='qwe') == {'qwe': account_id0} 4584 assert self.recall(dry=False, debug=debug) 4585 assert self.names(keyword='qwe') == {} 4586 assert self.name(account_id0) == x 4587 assert self.recall(dry=False, debug=debug) 4588 assert self.name(account_id0) == '' 4589 assert self.account_exists(account_id0) 4590 assert self.recall(dry=False, debug=debug) 4591 assert not self.account_exists(account_id0) 4592 assert not self.recall(dry=False, debug=debug) 4593 4594 # Not allowed for duplicate transactions in the same account and time 4595 4596 created = Time.time() 4597 same_account_id = self.create_account('same') 4598 self.track(100, 'test-1', same_account_id, True, created) 4599 failed = False 4600 try: 4601 self.track(50, 'test-1', same_account_id, True, created) 4602 except: 4603 failed = True 4604 assert failed is True 4605 4606 self.reset() 4607 4608 # Same account transfer 4609 for x in [1, 'a', True, 1.8, None]: 4610 failed = False 4611 try: 4612 self.transfer(1, x, x, 'same-account', debug=debug) 4613 except: 4614 failed = True 4615 assert failed is True 4616 4617 # Always preserve box age during transfer 4618 4619 series: list[tuple[int, int]] = [ 4620 (30, 4), 4621 (60, 3), 4622 (90, 2), 4623 ] 4624 case = { 4625 3000: { 4626 'series': series, 4627 'rest': 15000, 4628 }, 4629 6000: { 4630 'series': series, 4631 'rest': 12000, 4632 }, 4633 9000: { 4634 'series': series, 4635 'rest': 9000, 4636 }, 4637 18000: { 4638 'series': series, 4639 'rest': 0, 4640 }, 4641 27000: { 4642 'series': series, 4643 'rest': -9000, 4644 }, 4645 36000: { 4646 'series': series, 4647 'rest': -18000, 4648 }, 4649 } 4650 4651 selected_time = Time.time() - ZakatTracker.TimeCycle() 4652 ages_account_id = self.create_account('ages') 4653 future_account_id = self.create_account('future') 4654 4655 for total in case: 4656 if debug: 4657 print('--------------------------------------------------------') 4658 print(f'case[{total}]', case[total]) 4659 for x in case[total]['series']: 4660 self.track( 4661 unscaled_value=x[0], 4662 desc=f'test-{x} ages', 4663 account=ages_account_id, 4664 created_time_ns=selected_time * x[1], 4665 ) 4666 4667 unscaled_total = self.unscale(total) 4668 if debug: 4669 print('unscaled_total', unscaled_total) 4670 refs = self.transfer( 4671 unscaled_amount=unscaled_total, 4672 from_account=ages_account_id, 4673 to_account=future_account_id, 4674 desc='Zakat Movement', 4675 debug=debug, 4676 ) 4677 4678 if debug: 4679 print('refs', refs) 4680 4681 ages_cache_balance = self.balance(ages_account_id) 4682 ages_fresh_balance = self.balance(ages_account_id, False) 4683 rest = case[total]['rest'] 4684 if debug: 4685 print('source', ages_cache_balance, ages_fresh_balance, rest) 4686 assert ages_cache_balance == rest 4687 assert ages_fresh_balance == rest 4688 4689 future_cache_balance = self.balance(future_account_id) 4690 future_fresh_balance = self.balance(future_account_id, False) 4691 if debug: 4692 print('target', future_cache_balance, future_fresh_balance, total) 4693 print('refs', refs) 4694 assert future_cache_balance == total 4695 assert future_fresh_balance == total 4696 4697 # TODO: check boxes times for `ages` should equal box times in `future` 4698 for ref in self.__vault.account[ages_account_id].box: 4699 ages_capital = self.__vault.account[ages_account_id].box[ref].capital 4700 ages_rest = self.__vault.account[ages_account_id].box[ref].rest 4701 future_capital = 0 4702 if ref in self.__vault.account[future_account_id].box: 4703 future_capital = self.__vault.account[future_account_id].box[ref].capital 4704 future_rest = 0 4705 if ref in self.__vault.account[future_account_id].box: 4706 future_rest = self.__vault.account[future_account_id].box[ref].rest 4707 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 4708 if debug: 4709 print('================================================================') 4710 print('ages', ages_capital, ages_rest) 4711 print('future', future_capital, future_rest) 4712 if ages_rest == 0: 4713 assert ages_capital == future_capital 4714 elif ages_rest < 0: 4715 assert -ages_capital == future_capital 4716 elif ages_rest > 0: 4717 assert ages_capital == ages_rest + future_capital 4718 self.reset() 4719 assert len(self.__vault.history) == 0 4720 4721 assert self.__history() 4722 assert self.__history(False) is False 4723 assert self.__history() is False 4724 assert self.__history(True) 4725 assert self.__history() 4726 if debug: 4727 print('####################################################################') 4728 4729 wallet_account_id = self.create_account('wallet') 4730 safe_account_id = self.create_account('safe') 4731 bank_account_id = self.create_account('bank') 4732 transaction = [ 4733 ( 4734 20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1, 4735 2000, 2000, 2000, 1, 1, 4736 ), 4737 ( 4738 750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2, 4739 75000, 75000, 75000, 1, 1, 4740 ), 4741 ( 4742 600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2, 4743 60000, 60000, 60000, 1, 1, 4744 ), 4745 ] 4746 for z in transaction: 4747 lock = self.lock() 4748 x = z[1] 4749 y = z[2] 4750 self.transfer( 4751 unscaled_amount=z[0], 4752 from_account=x, 4753 to_account=y, 4754 desc='test-transfer', 4755 debug=debug, 4756 ) 4757 zz = self.balance(x) 4758 if debug: 4759 print(zz, z) 4760 assert zz == z[3] 4761 xx = self.accounts()[x] 4762 assert xx.balance == z[3] 4763 assert self.balance(x, False) == z[4] 4764 assert xx.balance == z[4] 4765 4766 s = 0 4767 log = self.__vault.account[x].log 4768 for i in log: 4769 s += log[i].value 4770 if debug: 4771 print('s', s, 'z[5]', z[5]) 4772 assert s == z[5] 4773 4774 assert self.box_size(x) == z[6] 4775 assert self.log_size(x) == z[7] 4776 4777 yy = self.accounts()[y] 4778 assert self.balance(y) == z[8] 4779 assert yy.balance == z[8] 4780 assert self.balance(y, False) == z[9] 4781 assert yy.balance == z[9] 4782 4783 s = 0 4784 log = self.__vault.account[y].log 4785 for i in log: 4786 s += log[i].value 4787 assert s == z[10] 4788 4789 assert self.box_size(y) == z[11] 4790 assert self.log_size(y) == z[12] 4791 assert lock is not None 4792 assert self.free(lock) 4793 4794 if debug: 4795 pp().pprint(self.check(2.17)) 4796 4797 assert self.nolock() 4798 history_count = len(self.__vault.history) 4799 transaction_count = len(transaction) 4800 if debug: 4801 print('history-count', history_count, transaction_count) 4802 assert history_count == transaction_count * 3 4803 assert not self.free(Time.time()) 4804 assert self.free(self.lock()) 4805 assert self.nolock() 4806 assert len(self.__vault.history) == transaction_count * 3 4807 4808 # recall 4809 4810 assert self.nolock() 4811 for i in range(transaction_count * 3, 0, -1): 4812 assert len(self.__vault.history) == i 4813 assert self.recall(dry=False, debug=debug) is True 4814 assert len(self.__vault.history) == 0 4815 assert self.recall(dry=False, debug=debug) is False 4816 assert len(self.__vault.history) == 0 4817 4818 # exchange 4819 4820 cash_account_id = self.create_account('cash') 4821 self.exchange(cash_account_id, 25, 3.75, '2024-06-25') 4822 self.exchange(cash_account_id, 22, 3.73, '2024-06-22') 4823 self.exchange(cash_account_id, 15, 3.69, '2024-06-15') 4824 self.exchange(cash_account_id, 10, 3.66) 4825 4826 assert self.nolock() 4827 4828 bank_account_id = self.create_account('bank') 4829 for i in range(1, 30): 4830 exchange = self.exchange(cash_account_id, i) 4831 rate, description, created = exchange.rate, exchange.description, exchange.time 4832 if debug: 4833 print(i, rate, description, created) 4834 assert created 4835 if i < 10: 4836 assert rate == 1 4837 assert description is None 4838 elif i == 10: 4839 assert rate == 3.66 4840 assert description is None 4841 elif i < 15: 4842 assert rate == 3.66 4843 assert description is None 4844 elif i == 15: 4845 assert rate == 3.69 4846 assert description is not None 4847 elif i < 22: 4848 assert rate == 3.69 4849 assert description is not None 4850 elif i == 22: 4851 assert rate == 3.73 4852 assert description is not None 4853 elif i >= 25: 4854 assert rate == 3.75 4855 assert description is not None 4856 exchange = self.exchange(bank_account_id, i) 4857 rate, description, created = exchange.rate, exchange.description, exchange.time 4858 if debug: 4859 print(i, rate, description, created) 4860 assert created 4861 assert rate == 1 4862 assert description is None 4863 4864 assert len(self.__vault.exchange) == 1 4865 assert len(self.exchanges()) == 1 4866 self.__vault.exchange.clear() 4867 assert len(self.__vault.exchange) == 0 4868 assert len(self.exchanges()) == 0 4869 self.reset() 4870 4871 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 4872 cash_account_id = self.create_account('cash') 4873 self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 4874 self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 4875 self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 4876 self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66) 4877 4878 assert self.nolock() 4879 4880 test_account_id = self.create_account('test') 4881 for i in [x * 0.12 for x in range(-15, 21)]: 4882 if i <= 0: 4883 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange() 4884 else: 4885 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange() 4886 4887 assert self.nolock() 4888 4889 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 4890 bank_account_id = self.create_account('bank') 4891 for i in range(1, 31): 4892 timestamp_ns = ZakatTracker.day_to_time(i) 4893 exchange = self.exchange(cash_account_id, timestamp_ns) 4894 rate, description, created = exchange.rate, exchange.description, exchange.time 4895 if debug: 4896 print(i, rate, description, created) 4897 assert created 4898 if i < 10: 4899 assert rate == 1 4900 assert description is None 4901 elif i == 10: 4902 assert rate == 3.66 4903 assert description is None 4904 elif i < 15: 4905 assert rate == 3.66 4906 assert description is None 4907 elif i == 15: 4908 assert rate == 3.69 4909 assert description is not None 4910 elif i < 22: 4911 assert rate == 3.69 4912 assert description is not None 4913 elif i == 22: 4914 assert rate == 3.73 4915 assert description is not None 4916 elif i >= 25: 4917 assert rate == 3.75 4918 assert description is not None 4919 exchange = self.exchange(bank_account_id, i) 4920 rate, description, created = exchange.rate, exchange.description, exchange.time 4921 if debug: 4922 print(i, rate, description, created) 4923 assert created 4924 assert rate == 1 4925 assert description is None 4926 4927 assert self.nolock() 4928 if debug: 4929 print(self.__vault.history, len(self.__vault.history)) 4930 for _ in range(len(self.__vault.history)): 4931 assert self.recall(dry=False, debug=debug) 4932 assert not self.recall(dry=False, debug=debug) 4933 4934 self.reset() 4935 4936 # test transfer between accounts with different exchange rate 4937 4938 a_SAR = self.create_account('Bank (SAR)') 4939 b_USD = self.create_account('Bank (USD)') 4940 c_SAR = self.create_account('Safe (SAR)') 4941 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 4942 for case in [ 4943 (0, a_SAR, 'SAR Gift', 1000, 100000), 4944 (1, a_SAR, 1), 4945 (0, b_USD, 'USD Gift', 500, 50000), 4946 (1, b_USD, 1), 4947 (2, b_USD, 3.75), 4948 (1, b_USD, 3.75), 4949 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 4950 (0, c_SAR, 'Salary', 750, 75000), 4951 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 4952 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 4953 ]: 4954 if debug: 4955 print('case', case) 4956 match (case[0]): 4957 case 0: # track 4958 _, account, desc, x, balance = case 4959 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 4960 4961 cached_value = self.balance(account, cached=True) 4962 fresh_value = self.balance(account, cached=False) 4963 if debug: 4964 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 4965 assert cached_value == balance 4966 assert fresh_value == balance 4967 case 1: # check-exchange 4968 _, account, expected_rate = case 4969 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4970 if debug: 4971 print('t-exchange', t_exchange) 4972 assert t_exchange.rate == expected_rate 4973 case 2: # do-exchange 4974 _, account, rate = case 4975 self.exchange(account, rate=rate, debug=debug) 4976 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4977 if debug: 4978 print('b-exchange', b_exchange) 4979 assert b_exchange.rate == rate 4980 case 3: # transfer 4981 _, x, a, b, desc, a_balance, b_balance = case 4982 self.transfer(x, a, b, desc, debug=debug) 4983 4984 cached_value = self.balance(a, cached=True) 4985 fresh_value = self.balance(a, cached=False) 4986 if debug: 4987 print( 4988 'account', a, 4989 'cached_value', cached_value, 4990 'fresh_value', fresh_value, 4991 'a_balance', a_balance, 4992 ) 4993 assert cached_value == a_balance 4994 assert fresh_value == a_balance 4995 4996 cached_value = self.balance(b, cached=True) 4997 fresh_value = self.balance(b, cached=False) 4998 if debug: 4999 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 5000 assert cached_value == b_balance 5001 assert fresh_value == b_balance 5002 5003 # Transfer all in many chunks randomly from B to A 5004 a_SAR_balance = 137125 5005 b_USD_balance = 50100 5006 b_USD_exchange = self.exchange(b_USD) 5007 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 5008 if debug: 5009 print('amounts', amounts) 5010 i = 0 5011 for x in amounts: 5012 if debug: 5013 print(f'{i} - transfer-with-exchange({x})') 5014 self.transfer( 5015 unscaled_amount=self.unscale(x), 5016 from_account=b_USD, 5017 to_account=a_SAR, 5018 desc=f'{x} USD -> SAR', 5019 debug=debug, 5020 ) 5021 5022 b_USD_balance -= x 5023 cached_value = self.balance(b_USD, cached=True) 5024 fresh_value = self.balance(b_USD, cached=False) 5025 if debug: 5026 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5027 b_USD_balance) 5028 assert cached_value == b_USD_balance 5029 assert fresh_value == b_USD_balance 5030 5031 a_SAR_balance += int(x * b_USD_exchange.rate) 5032 cached_value = self.balance(a_SAR, cached=True) 5033 fresh_value = self.balance(a_SAR, cached=False) 5034 if debug: 5035 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5036 a_SAR_balance, 'rate', b_USD_exchange.rate) 5037 assert cached_value == a_SAR_balance 5038 assert fresh_value == a_SAR_balance 5039 i += 1 5040 5041 # Transfer all in many chunks randomly from C to A 5042 c_SAR_balance = 37500 5043 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 5044 if debug: 5045 print('amounts', amounts) 5046 i = 0 5047 for x in amounts: 5048 if debug: 5049 print(f'{i} - transfer-with-exchange({x})') 5050 self.transfer( 5051 unscaled_amount=self.unscale(x), 5052 from_account=c_SAR, 5053 to_account=a_SAR, 5054 desc=f'{x} SAR -> a_SAR', 5055 debug=debug, 5056 ) 5057 5058 c_SAR_balance -= x 5059 cached_value = self.balance(c_SAR, cached=True) 5060 fresh_value = self.balance(c_SAR, cached=False) 5061 if debug: 5062 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5063 c_SAR_balance) 5064 assert cached_value == c_SAR_balance 5065 assert fresh_value == c_SAR_balance 5066 5067 a_SAR_balance += x 5068 cached_value = self.balance(a_SAR, cached=True) 5069 fresh_value = self.balance(a_SAR, cached=False) 5070 if debug: 5071 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5072 a_SAR_balance) 5073 assert cached_value == a_SAR_balance 5074 assert fresh_value == a_SAR_balance 5075 i += 1 5076 5077 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 5078 5079 # check & zakat with exchange rates for many cycles 5080 5081 lock = None 5082 safe_account_id = self.create_account('safe') 5083 cave_account_id = self.create_account('cave') 5084 for rate, values in { 5085 1: { 5086 'in': [1000, 2000, 10000], 5087 'exchanged': [100000, 200000, 1000000], 5088 'out': [2500, 5000, 73140], 5089 }, 5090 3.75: { 5091 'in': [200, 1000, 5000], 5092 'exchanged': [75000, 375000, 1875000], 5093 'out': [1875, 9375, 137138], 5094 }, 5095 }.items(): 5096 a, b, c = values['in'] 5097 m, n, o = values['exchanged'] 5098 x, y, z = values['out'] 5099 if debug: 5100 print('rate', rate, 'values', values) 5101 for case in [ 5102 (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5103 {safe_account_id: {0: {'below_nisab': x}}}, 5104 ], False, m), 5105 (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5106 {safe_account_id: {0: {'count': 1, 'total': y}}}, 5107 ], True, n), 5108 (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [ 5109 {cave_account_id: {0: {'count': 3, 'total': z}}}, 5110 ], True, o), 5111 ]: 5112 if debug: 5113 print(f'############# check(rate: {rate}) #############') 5114 print('case', case) 5115 self.reset() 5116 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 5117 self.track( 5118 unscaled_value=case[0], 5119 desc='test-check', 5120 account=case[1], 5121 created_time_ns=case[2], 5122 ) 5123 assert self.snapshot() 5124 5125 # assert self.nolock() 5126 # history_size = len(self.__vault.history) 5127 # print('history_size', history_size) 5128 # assert history_size == 2 5129 lock = self.lock() 5130 assert lock 5131 assert not self.nolock() 5132 report = self.check(2.17, None, debug) 5133 if debug: 5134 print('[report]', report) 5135 assert case[4] == report.valid 5136 assert case[5] == report.summary.total_wealth 5137 assert case[5] == report.summary.total_zakatable_amount 5138 if report.valid: 5139 if debug: 5140 pp().pprint(report.plan) 5141 assert report.plan 5142 assert self.zakat(report, debug=debug) 5143 if debug: 5144 pp().pprint(self.__vault) 5145 self._test_storage(debug=debug) 5146 5147 for x in report.plan: 5148 assert case[1] == x 5149 if report.plan[x][0].below_nisab: 5150 if debug: 5151 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 5152 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 5153 else: 5154 if debug: 5155 print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total']) 5156 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 5157 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 5158 assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total'] 5159 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 5160 assert report.plan[x][0].count == case[3][0][x][0]['count'] 5161 else: 5162 if debug: 5163 pp().pprint(report) 5164 result = self.zakat(report, debug=debug) 5165 if debug: 5166 print('zakat-result', result, case[4]) 5167 assert result == case[4] 5168 report = self.check(2.17, None, debug) 5169 assert report.valid is False 5170 self._test_storage(account_id=cave_account_id, debug=debug) 5171 5172 # recall after zakat 5173 5174 history_size = len(self.__vault.history) 5175 if debug: 5176 print('history_size', history_size) 5177 assert history_size == 3 5178 assert not self.nolock() 5179 assert self.recall(dry=False, debug=debug) is False 5180 self.free(lock) 5181 assert self.nolock() 5182 5183 for i in range(3, 0, -1): 5184 history_size = len(self.__vault.history) 5185 if debug: 5186 print('history_size', history_size) 5187 assert history_size == i 5188 assert self.recall(dry=False, debug=debug) is True 5189 5190 assert self.nolock() 5191 assert self.recall(dry=False, debug=debug) is False 5192 5193 history_size = len(self.__vault.history) 5194 if debug: 5195 print('history_size', history_size) 5196 assert history_size == 0 5197 5198 account_size = len(self.__vault.account) 5199 if debug: 5200 print('account_size', account_size) 5201 assert account_size == 0 5202 5203 report_size = len(self.__vault.report) 5204 if debug: 5205 print('report_size', report_size) 5206 assert report_size == 0 5207 5208 assert self.nolock() 5209 5210 # csv 5211 5212 csv_count = 1000 5213 5214 for with_rate, path in { 5215 False: 'test-import_csv-no-exchange', 5216 True: 'test-import_csv-with-exchange', 5217 }.items(): 5218 5219 if debug: 5220 print('test_import_csv', with_rate, path) 5221 5222 csv_path = path + '.csv' 5223 if os.path.exists(csv_path): 5224 os.remove(csv_path) 5225 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 5226 if debug: 5227 print('generate_random_csv_file', c) 5228 assert c == csv_count 5229 assert os.path.getsize(csv_path) > 0 5230 cache_path = self.import_csv_cache_path() 5231 if os.path.exists(cache_path): 5232 os.remove(cache_path) 5233 self.reset() 5234 lock = self.lock() 5235 import_report = self.import_csv(csv_path, debug=debug) 5236 bad_count = len(import_report.bad) 5237 if debug: 5238 print(f'csv-imported: {import_report.statistics} = count({csv_count})') 5239 print('bad', import_report.bad) 5240 assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count 5241 assert import_report.statistics.created == csv_count 5242 assert bad_count == 0 5243 assert bad_count == import_report.statistics.bad 5244 tmp_size = os.path.getsize(cache_path) 5245 assert tmp_size > 0 5246 5247 import_report_2 = self.import_csv(csv_path, debug=debug) 5248 bad_2_count = len(import_report_2.bad) 5249 if debug: 5250 print(f'csv-imported: {import_report_2}') 5251 print('bad', import_report_2.bad) 5252 assert tmp_size == os.path.getsize(cache_path) 5253 assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count 5254 assert import_report.statistics.created == import_report_2.statistics.found 5255 assert bad_count == bad_2_count 5256 assert import_report_2.statistics.found == csv_count 5257 assert bad_2_count == 0 5258 assert bad_2_count == import_report_2.statistics.bad 5259 assert import_report_2.statistics.created == 0 5260 5261 # payment parts 5262 5263 positive_parts = self.build_payment_parts(100, positive_only=True) 5264 assert self.check_payment_parts(positive_parts) != 0 5265 assert self.check_payment_parts(positive_parts) != 0 5266 all_parts = self.build_payment_parts(300, positive_only=False) 5267 assert self.check_payment_parts(all_parts) != 0 5268 assert self.check_payment_parts(all_parts) != 0 5269 if debug: 5270 pp().pprint(positive_parts) 5271 pp().pprint(all_parts) 5272 # dynamic discount 5273 suite = [] 5274 count = 3 5275 for exceed in [False, True]: 5276 case = [] 5277 for part in [positive_parts, all_parts]: 5278 #part = parts.copy() 5279 demand = part.demand 5280 if debug: 5281 print(demand, part.total) 5282 i = 0 5283 z = demand / count 5284 cp = PaymentParts( 5285 demand=demand, 5286 exceed=exceed, 5287 total=part.total, 5288 ) 5289 j = '' 5290 for x, y in part.account.items(): 5291 x_exchange = self.exchange(x) 5292 zz = self.exchange_calc(z, 1, x_exchange.rate) 5293 if exceed and zz <= demand: 5294 i += 1 5295 y.part = zz 5296 if debug: 5297 print(exceed, y) 5298 cp.account[x] = y 5299 case.append(y) 5300 elif not exceed and y.balance >= zz: 5301 i += 1 5302 y.part = zz 5303 if debug: 5304 print(exceed, y) 5305 cp.account[x] = y 5306 case.append(y) 5307 j = x 5308 if i >= count: 5309 break 5310 if debug: 5311 print('[debug]', j) 5312 print('[debug]', cp.account[j]) 5313 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 5314 suite.append(cp) 5315 if debug: 5316 print('suite', len(suite)) 5317 for case in suite: 5318 if debug: 5319 print('case', case) 5320 result = self.check_payment_parts(case) 5321 if debug: 5322 print('check_payment_parts', result, f'exceed: {exceed}') 5323 assert result == 0 5324 5325 report = self.check(2.17, None, debug) 5326 if debug: 5327 print('valid', report.valid) 5328 zakat_result = self.zakat(report, parts=case, debug=debug) 5329 if debug: 5330 print('zakat-result', zakat_result) 5331 assert report.valid == zakat_result 5332 # test verified zakat report is required 5333 if zakat_result: 5334 failed = False 5335 try: 5336 self.zakat(report, parts=case, debug=debug) 5337 except: 5338 failed = True 5339 assert failed 5340 5341 assert self.free(lock) 5342 5343 assert self.save(path + f'.{self.ext()}') 5344 5345 assert self.save(f'1000-transactions-test.{self.ext()}') 5346 return True 5347 except Exception as e: 5348 if self.__debug_output: 5349 pp().pprint(self.__vault) 5350 print('============================================================================') 5351 pp().pprint(self.__debug_output) 5352 assert self.save(f'test-snapshot.{self.ext()}') 5353 raise e
236class AccountID(str): 237 """ 238 A class representing an Account ID, which is a string that must be a positive integer greater than zero. 239 Inherits from str, so it behaves like a string. 240 """ 241 242 def __new__(cls, value): 243 """ 244 Creates a new AccountID instance. 245 246 Parameters: 247 - value (str): The string value to be used as the AccountID. 248 249 Raises: 250 - ValueError: If the provided value is not a valid AccountID. 251 252 Returns: 253 - AccountID: A new AccountID instance. 254 """ 255 if isinstance(value, Timestamp): 256 value = str(value) # convert timestamp to string 257 if not cls.is_valid_account_id(value): 258 raise ValueError(f"Invalid AccountID: '{value}'") 259 return super().__new__(cls, value) 260 261 @staticmethod 262 def is_valid_account_id(s: str) -> bool: 263 """ 264 Checks if a string is a valid AccountID (positive integer greater than zero). 265 266 Parameters: 267 - s (str): The string to check. 268 269 Returns: 270 - bool: True if the string is a valid AccountID, False otherwise. 271 """ 272 if not s: 273 return False 274 275 try: 276 if s[0] == '0': 277 return False 278 if s.startswith('-'): 279 return False 280 if not s.isdigit(): 281 return False 282 except: 283 pass 284 285 try: 286 num = int(s) 287 return num > 0 288 except ValueError: 289 return False 290 291 @classmethod 292 def test(cls, debug: bool = False): 293 """ 294 Runs tests for the AccountID class to ensure it behaves correctly. 295 296 This method tests various valid and invalid input strings to verify that: 297 - Valid AccountIDs are created successfully. 298 - Invalid AccountIDs raise ValueError exceptions. 299 """ 300 test_data = { 301 "123": True, 302 "0": False, 303 "01": False, 304 "-1": False, 305 "abc": False, 306 "12.3": False, 307 "": False, 308 "9999999999999999999999999999999999999": True, 309 "1": True, 310 "10": True, 311 "000000000000000001": False, 312 " ": False, 313 "1 ": False, 314 " 1": False, 315 "1.0": False, 316 Timestamp(12345): True, # Test timestamp input 317 } 318 319 for input_value, expected_output in test_data.items(): 320 if expected_output: 321 try: 322 account_id = cls(input_value) 323 if debug: 324 print(f'"{str(account_id)}", "{input_value}"') 325 if isinstance(input_value, Timestamp): 326 input_value = str(input_value) 327 assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'" 328 except ValueError as e: 329 assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}" 330 else: 331 try: 332 cls(input_value) 333 assert False, f"Expected ValueError for invalid input: '{input_value}'" 334 except ValueError as e: 335 pass # Expected exception
A class representing an Account ID, which is a string that must be a positive integer greater than zero. Inherits from str, so it behaves like a string.
242 def __new__(cls, value): 243 """ 244 Creates a new AccountID instance. 245 246 Parameters: 247 - value (str): The string value to be used as the AccountID. 248 249 Raises: 250 - ValueError: If the provided value is not a valid AccountID. 251 252 Returns: 253 - AccountID: A new AccountID instance. 254 """ 255 if isinstance(value, Timestamp): 256 value = str(value) # convert timestamp to string 257 if not cls.is_valid_account_id(value): 258 raise ValueError(f"Invalid AccountID: '{value}'") 259 return super().__new__(cls, value)
Creates a new AccountID instance.
Parameters:
- value (str): The string value to be used as the AccountID.
Raises:
- ValueError: If the provided value is not a valid AccountID.
Returns:
- AccountID: A new AccountID instance.
261 @staticmethod 262 def is_valid_account_id(s: str) -> bool: 263 """ 264 Checks if a string is a valid AccountID (positive integer greater than zero). 265 266 Parameters: 267 - s (str): The string to check. 268 269 Returns: 270 - bool: True if the string is a valid AccountID, False otherwise. 271 """ 272 if not s: 273 return False 274 275 try: 276 if s[0] == '0': 277 return False 278 if s.startswith('-'): 279 return False 280 if not s.isdigit(): 281 return False 282 except: 283 pass 284 285 try: 286 num = int(s) 287 return num > 0 288 except ValueError: 289 return False
Checks if a string is a valid AccountID (positive integer greater than zero).
Parameters:
- s (str): The string to check.
Returns:
- bool: True if the string is a valid AccountID, False otherwise.
291 @classmethod 292 def test(cls, debug: bool = False): 293 """ 294 Runs tests for the AccountID class to ensure it behaves correctly. 295 296 This method tests various valid and invalid input strings to verify that: 297 - Valid AccountIDs are created successfully. 298 - Invalid AccountIDs raise ValueError exceptions. 299 """ 300 test_data = { 301 "123": True, 302 "0": False, 303 "01": False, 304 "-1": False, 305 "abc": False, 306 "12.3": False, 307 "": False, 308 "9999999999999999999999999999999999999": True, 309 "1": True, 310 "10": True, 311 "000000000000000001": False, 312 " ": False, 313 "1 ": False, 314 " 1": False, 315 "1.0": False, 316 Timestamp(12345): True, # Test timestamp input 317 } 318 319 for input_value, expected_output in test_data.items(): 320 if expected_output: 321 try: 322 account_id = cls(input_value) 323 if debug: 324 print(f'"{str(account_id)}", "{input_value}"') 325 if isinstance(input_value, Timestamp): 326 input_value = str(input_value) 327 assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'" 328 except ValueError as e: 329 assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}" 330 else: 331 try: 332 cls(input_value) 333 assert False, f"Expected ValueError for invalid input: '{input_value}'" 334 except ValueError as e: 335 pass # Expected exception
Runs tests for the AccountID class to ensure it behaves correctly.
This method tests various valid and invalid input strings to verify that: - Valid AccountIDs are created successfully. - Invalid AccountIDs raise ValueError exceptions.
338@dataclasses.dataclass 339class AccountDetails: 340 """ 341 Details of an account. 342 343 Attributes: 344 - account_id: The unique identifier (ID) of the account. 345 - account_name: Human-readable name of the account. 346 - balance: The current cached balance of the account. 347 """ 348 account_id: AccountID 349 account_name: str 350 balance: int
Details of an account.
Attributes:
- account_id: The unique identifier (ID) of the account.
- account_name: Human-readable name of the account.
- balance: The current cached balance of the account.
174class Timestamp(int): 175 """Represents a timestamp as an integer, which must be greater than zero.""" 176 177 def __new__(cls, value): 178 """ 179 Creates a new Timestamp instance. 180 181 Parameters: 182 - value (int or str): The integer value to be used as the timestamp. 183 184 Raises: 185 - TypeError: If the provided value is not an integer or a string representing an integer. 186 - ValueError: If the provided value is not greater than zero. 187 188 Returns: 189 - Timestamp: A new Timestamp instance. 190 """ 191 if isinstance(value, str): 192 try: 193 value = int(value) 194 except ValueError: 195 raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.") 196 if not isinstance(value, int): 197 raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.") 198 199 if value <= 0: 200 raise ValueError("Timestamp value must be greater than zero.") 201 202 return super().__new__(cls, value) 203 204 @classmethod 205 def test(cls): 206 """ 207 Runs tests for the Timestamp class to ensure it behaves correctly. 208 """ 209 test_data = { 210 123: True, 211 "123": True, 212 0: False, 213 "0": False, 214 -1: False, 215 "-1": False, 216 "abc": False, 217 1: True, 218 "1": True, 219 } 220 221 for input_value, expected_output in test_data.items(): 222 if expected_output: 223 try: 224 timestamp = cls(input_value) 225 assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'" 226 except (TypeError, ValueError) as e: 227 assert False, f"Unexpected error for valid input: '{input_value}': {e}" 228 else: 229 try: 230 cls(input_value) 231 assert False, f"Expected error for invalid input: '{input_value}'" 232 except (TypeError, ValueError): 233 pass # Expected exception
Represents a timestamp as an integer, which must be greater than zero.
177 def __new__(cls, value): 178 """ 179 Creates a new Timestamp instance. 180 181 Parameters: 182 - value (int or str): The integer value to be used as the timestamp. 183 184 Raises: 185 - TypeError: If the provided value is not an integer or a string representing an integer. 186 - ValueError: If the provided value is not greater than zero. 187 188 Returns: 189 - Timestamp: A new Timestamp instance. 190 """ 191 if isinstance(value, str): 192 try: 193 value = int(value) 194 except ValueError: 195 raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.") 196 if not isinstance(value, int): 197 raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.") 198 199 if value <= 0: 200 raise ValueError("Timestamp value must be greater than zero.") 201 202 return super().__new__(cls, value)
Creates a new Timestamp instance.
Parameters:
- value (int or str): The integer value to be used as the timestamp.
Raises:
- TypeError: If the provided value is not an integer or a string representing an integer.
- ValueError: If the provided value is not greater than zero.
Returns:
- Timestamp: A new Timestamp instance.
204 @classmethod 205 def test(cls): 206 """ 207 Runs tests for the Timestamp class to ensure it behaves correctly. 208 """ 209 test_data = { 210 123: True, 211 "123": True, 212 0: False, 213 "0": False, 214 -1: False, 215 "-1": False, 216 "abc": False, 217 1: True, 218 "1": True, 219 } 220 221 for input_value, expected_output in test_data.items(): 222 if expected_output: 223 try: 224 timestamp = cls(input_value) 225 assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'" 226 except (TypeError, ValueError) as e: 227 assert False, f"Unexpected error for valid input: '{input_value}': {e}" 228 else: 229 try: 230 cls(input_value) 231 assert False, f"Expected error for invalid input: '{input_value}'" 232 except (TypeError, ValueError): 233 pass # Expected exception
Runs tests for the Timestamp class to ensure it behaves correctly.
446@dataclasses.dataclass 447class Box( 448 StrictDataclass, 449 # ImmutableWithSelectiveFreeze, 450 ): 451 """ 452 Represents a financial box with capital, remaining value, and zakat details. 453 454 Attributes: 455 - capital (int): The initial capital value of the box. 456 - rest (int): The current remaining value within the box. 457 - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box. 458 """ 459 capital: int #= dataclasses.field(metadata={"frozen": True}) 460 rest: int 461 zakat: BoxZakat
Represents a financial box with capital, remaining value, and zakat details.
Attributes:
- capital (int): The initial capital value of the box.
- rest (int): The current remaining value within the box.
- zakat (BoxZakat): A
BoxZakat
object containing the accumulated zakat information for the box.
464@dataclasses.dataclass 465class Log(StrictDataclass): 466 """ 467 Represents a log entry for an account. 468 469 Attributes: 470 - value: The value of the log entry. 471 - desc: A description of the log entry. 472 - ref: An optional timestamp reference. 473 - file: A dictionary mapping timestamps to file paths. 474 """ 475 value: int 476 desc: str 477 ref: Optional[Timestamp] 478 file: dict[Timestamp, str] = dataclasses.field(default_factory=dict)
Represents a log entry for an account.
Attributes:
- value: The value of the log entry.
- desc: A description of the log entry.
- ref: An optional timestamp reference.
- file: A dictionary mapping timestamps to file paths.
481@dataclasses.dataclass 482class Account(StrictDataclass): 483 """ 484 Represents a financial account. 485 486 Attributes: 487 - balance: The current balance of the account. 488 - created: The timestamp when the account was created. 489 - name: The name of the account. 490 - box: A dictionary mapping timestamps to Box objects. 491 - count: A counter for logs, initialized to 0. 492 - log: A dictionary mapping timestamps to Log objects. 493 - hide: A boolean indicating whether the account is hidden. 494 - zakatable: A boolean indicating whether the account is subject to zakat. 495 """ 496 balance: int 497 created: Timestamp 498 name: str = '' 499 box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict) 500 count: int = dataclasses.field(default_factory=factory_value(0)) 501 log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict) 502 hide: bool = dataclasses.field(default_factory=factory_value(False)) 503 zakatable: bool = dataclasses.field(default_factory=factory_value(True))
Represents a financial account.
Attributes:
- balance: The current balance of the account.
- created: The timestamp when the account was created.
- name: The name of the account.
- box: A dictionary mapping timestamps to Box objects.
- count: A counter for logs, initialized to 0.
- log: A dictionary mapping timestamps to Log objects.
- hide: A boolean indicating whether the account is hidden.
- zakatable: A boolean indicating whether the account is subject to zakat.
506@dataclasses.dataclass 507class Exchange(StrictDataclass): 508 """ 509 Represents an exchange rate and related information. 510 511 Attributes: 512 - rate: The exchange rate (optional). 513 - description: A description of the exchange (optional). 514 - time: The timestamp of the exchange (optional). 515 """ 516 rate: Optional[float] = None 517 description: Optional[str] = None 518 time: Optional[Timestamp] = None
Represents an exchange rate and related information.
Attributes:
- rate: The exchange rate (optional).
- description: A description of the exchange (optional).
- time: The timestamp of the exchange (optional).
521@dataclasses.dataclass 522class History(StrictDataclass): 523 """ 524 Represents a history entry for an account action. 525 526 Attributes: 527 - action: The action performed. 528 - account: The ID of the account (optional). 529 - ref: An optional timestamp reference. 530 - file: An optional timestamp for a file. 531 - key: An optional key. 532 - value: An optional value. 533 - math: An optional math operation. 534 """ 535 action: Action 536 account: Optional[AccountID] 537 ref: Optional[Timestamp] 538 file: Optional[Timestamp] 539 key: Optional[str] 540 value: Optional[any] # !!! 541 math: Optional[MathOperation]
Represents a history entry for an account action.
Attributes:
- action: The action performed.
- account: The ID of the account (optional).
- ref: An optional timestamp reference.
- file: An optional timestamp for a file.
- key: An optional key.
- value: An optional value.
- math: An optional math operation.
616@dataclasses.dataclass 617class Vault(StrictDataclass): 618 """ 619 Represents a vault containing accounts, exchanges, and history. 620 621 Attributes: 622 - account: A dictionary mapping account IDs to Account objects. 623 - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects. 624 - history: A dictionary mapping timestamps to dictionaries of History objects. 625 - lock: An optional timestamp for a lock. 626 - report: A dictionary mapping timestamps to tuples. 627 - cache: A Cache object containing cached Zakat-related data. 628 """ 629 account: dict[AccountID, Account] = dataclasses.field(default_factory=dict) 630 exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict) 631 history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict) 632 lock: Optional[Timestamp] = None 633 report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict) 634 cache: Cache = dataclasses.field(default_factory=Cache)
Represents a vault containing accounts, exchanges, and history.
Attributes:
- account: A dictionary mapping account IDs to Account objects.
- exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
- history: A dictionary mapping timestamps to dictionaries of History objects.
- lock: An optional timestamp for a lock.
- report: A dictionary mapping timestamps to tuples.
- cache: A Cache object containing cached Zakat-related data.
637@dataclasses.dataclass 638class AccountPaymentPart(StrictDataclass): 639 """ 640 Represents a payment part for an account. 641 642 Attributes: 643 - balance: The balance of the payment part. 644 - rate: The rate of the payment part. 645 - part: The part of the payment. 646 """ 647 balance: float 648 rate: float 649 part: float
Represents a payment part for an account.
Attributes:
- balance: The balance of the payment part.
- rate: The rate of the payment part.
- part: The part of the payment.
652@dataclasses.dataclass 653class PaymentParts(StrictDataclass): 654 """ 655 Represents payment parts for multiple accounts. 656 657 Attributes: 658 - exceed: A boolean indicating whether the payment exceeds a limit. 659 - demand: The demand for payment. 660 - total: The total payment. 661 - account: A dictionary mapping account references to AccountPaymentPart objects. 662 """ 663 exceed: bool 664 demand: int 665 total: float 666 account: dict[AccountID, AccountPaymentPart] = dataclasses.field(default_factory=dict)
Represents payment parts for multiple accounts.
Attributes:
- exceed: A boolean indicating whether the payment exceeds a limit.
- demand: The demand for payment.
- total: The total payment.
- account: A dictionary mapping account references to AccountPaymentPart objects.
669@dataclasses.dataclass 670class SubtractAge(StrictDataclass): 671 """ 672 Represents an age subtraction. 673 674 Attributes: 675 - box_ref: The timestamp reference for the box. 676 - total: The total amount to subtract. 677 """ 678 box_ref: Timestamp 679 total: int
Represents an age subtraction.
Attributes:
- box_ref: The timestamp reference for the box.
- total: The total amount to subtract.
682@dataclasses.dataclass 683class SubtractAges(StrictDataclass, list[SubtractAge]): 684 """A list of SubtractAge objects.""" 685 pass
A list of SubtractAge objects.
688@dataclasses.dataclass 689class SubtractReport(StrictDataclass): 690 """ 691 Represents a report of age subtractions. 692 693 Attributes: 694 - log_ref: The timestamp reference for the log. 695 - ages: A list of SubtractAge objects. 696 """ 697 log_ref: Timestamp 698 ages: SubtractAges
Represents a report of age subtractions.
Attributes:
- log_ref: The timestamp reference for the log.
- ages: A list of SubtractAge objects.
701@dataclasses.dataclass 702class TransferTime(StrictDataclass): 703 """ 704 Represents a transfer time. 705 706 Attributes: 707 - box_ref: The timestamp reference for the box. 708 - log_ref: The timestamp reference for the log. 709 """ 710 box_ref: Timestamp 711 log_ref: Timestamp
Represents a transfer time.
Attributes:
- box_ref: The timestamp reference for the box.
- log_ref: The timestamp reference for the log.
714@dataclasses.dataclass 715class TransferTimes(StrictDataclass, list[TransferTime]): 716 """A list of TransferTime objects.""" 717 pass
A list of TransferTime objects.
720@dataclasses.dataclass 721class TransferRecord(StrictDataclass): 722 """ 723 Represents a transfer record. 724 725 Attributes: 726 - box_ref: The timestamp reference for the box. 727 - times: A list of TransferTime objects. 728 """ 729 box_ref: Timestamp 730 times: TransferTimes
Represents a transfer record.
Attributes:
- box_ref: The timestamp reference for the box.
- times: A list of TransferTime objects.
733class TransferReport(StrictDataclass, list[TransferRecord]): 734 """A list of TransferRecord objects.""" 735 pass
A list of TransferRecord objects.
544@dataclasses.dataclass 545class BoxPlan(StrictDataclass): 546 """ 547 Represents a plan for a box. 548 549 Attributes: 550 - box: The Box object. 551 - log: The Log object. 552 - exchange: The Exchange object. 553 - below_nisab: A boolean indicating whether the value is below nisab. 554 - total: The total value. 555 - count: The count. 556 - ref: The timestamp reference for related Box & Log. 557 """ 558 box: Box 559 log: Log 560 exchange: Exchange 561 below_nisab: bool 562 total: float 563 count: int 564 ref: Timestamp
Represents a plan for a box.
Attributes:
- box: The Box object.
- log: The Log object.
- exchange: The Exchange object.
- below_nisab: A boolean indicating whether the value is below nisab.
- total: The total value.
- count: The count.
- ref: The timestamp reference for related Box & Log.
567@dataclasses.dataclass 568class ZakatSummary(StrictDataclass): 569 """ 570 Summarizes key financial figures for a Zakat calculation. 571 572 Attributes: 573 - total_wealth (int): The total wealth collected from all rest of transactions. 574 - num_wealth_items (int): The number of individual transactions contributing to the total wealth. 575 - num_zakatable_items (int): The number of transactions subject to Zakat. 576 - total_zakatable_amount (int): The total value of all transactions subject to Zakat. 577 - total_zakat_due (int): The calculated amount of Zakat payable. 578 """ 579 total_wealth: int = 0 580 num_wealth_items: int = 0 581 num_zakatable_items: int = 0 582 total_zakatable_amount: int = 0 583 total_zakat_due: int = 0
Summarizes key financial figures for a Zakat calculation.
Attributes:
- total_wealth (int): The total wealth collected from all rest of transactions.
- num_wealth_items (int): The number of individual transactions contributing to the total wealth.
- num_zakatable_items (int): The number of transactions subject to Zakat.
- total_zakatable_amount (int): The total value of all transactions subject to Zakat.
- total_zakat_due (int): The calculated amount of Zakat payable.
586@dataclasses.dataclass 587class ZakatReport(StrictDataclass): 588 """ 589 Represents a Zakat report containing the calculation summary, plan, and parameters. 590 591 Attributes: 592 - created: The timestamp when the report was created. 593 - valid: A boolean indicating whether the Zakat is available. 594 - summary: The ZakatSummary object. 595 - plan: A dictionary mapping account IDs to lists of BoxPlan objects. 596 - parameters: A dictionary holding the input parameters used during the Zakat calculation. 597 """ 598 created: Timestamp 599 valid: bool 600 summary: ZakatSummary 601 plan: dict[AccountID, list[BoxPlan]] 602 parameters: dict
Represents a Zakat report containing the calculation summary, plan, and parameters.
Attributes:
- created: The timestamp when the report was created.
- valid: A boolean indicating whether the Zakat is available.
- summary: The ZakatSummary object.
- plan: A dictionary mapping account IDs to lists of BoxPlan objects.
- parameters: A dictionary holding the input parameters used during the Zakat calculation.
5356def test(path: Optional[str] = None, debug: bool = False): 5357 """ 5358 Executes a test suite for the ZakatTracker. 5359 5360 This function initializes a ZakatTracker instance, optionally using a specified 5361 database path or a temporary directory. It then runs the test suite and, if debug 5362 mode is enabled, prints detailed test results and execution time. 5363 5364 Parameters: 5365 - path (str, optional): The path to the ZakatTracker database. If None, a 5366 temporary directory is created. Defaults to None. 5367 - debug (bool, optional): Enables debug mode, which prints detailed test 5368 results and execution time. Defaults to False. 5369 5370 Returns: 5371 None. The function asserts the result of the ZakatTracker's test suite. 5372 5373 Raises: 5374 - AssertionError: If the ZakatTracker's test suite fails. 5375 5376 Examples: 5377 - `test()` Runs tests using a temporary database. 5378 - `test(debug=True)` Runs the test suite in debug mode with a temporary directory. 5379 - `test(path="/path/to/my/db")` Runs tests using a specified database path. 5380 - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path. 5381 """ 5382 no_path = path is None 5383 if no_path: 5384 path = tempfile.mkdtemp() 5385 print(f"Random database path {path}") 5386 if os.path.exists(path): 5387 shutil.rmtree(path) 5388 assert ZakatTracker(':memory:').memory_mode() 5389 ledger = ZakatTracker( 5390 db_path=path, 5391 history_mode=True, 5392 ) 5393 start = time.time_ns() 5394 assert not ledger.memory_mode() 5395 assert ledger.test(debug=debug) 5396 if no_path and os.path.exists(path): 5397 shutil.rmtree(path) 5398 if debug: 5399 print('#########################') 5400 print('######## TEST DONE ########') 5401 print('#########################') 5402 print(Time.duration_from_nanoseconds(time.time_ns() - start)) 5403 print('#########################')
Executes a test suite for the ZakatTracker.
This function initializes a ZakatTracker instance, optionally using a specified database path or a temporary directory. It then runs the test suite and, if debug mode is enabled, prints detailed test results and execution time.
Parameters:
- path (str, optional): The path to the ZakatTracker database. If None, a temporary directory is created. Defaults to None.
- debug (bool, optional): Enables debug mode, which prints detailed test results and execution time. Defaults to False.
Returns: None. The function asserts the result of the ZakatTracker's test suite.
Raises:
- AssertionError: If the ZakatTracker's test suite fails.
Examples:
test()
Runs tests using a temporary database.test(debug=True)
Runs the test suite in debug mode with a temporary directory.test(path="/path/to/my/db")
Runs tests using a specified database path.test(path="/path/to/my/db", debug=False)
Runs test suite with specified path.
110@enum.unique 111class Action(enum.Enum): 112 """ 113 Enumeration representing various actions that can be performed. 114 115 Members: 116 - CREATE: Represents the creation action ('CREATE'). 117 - NAME: Represents the renaming action ('NAME'). 118 - TRACK: Represents the tracking action ('TRACK'). 119 - LOG: Represents the logging action ('LOG'). 120 - SUBTRACT: Represents the subtract action ('SUBTRACT'). 121 - ADD_FILE: Represents the action of adding a file ('ADD_FILE'). 122 - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE'). 123 - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER'). 124 - EXCHANGE: Represents the exchange action ('EXCHANGE'). 125 - REPORT: Represents the reporting action ('REPORT'). 126 - ZAKAT: Represents a Zakat related action ('ZAKAT'). 127 """ 128 CREATE = 'CREATE' 129 NAME = 'NAME' 130 TRACK = 'TRACK' 131 LOG = 'LOG' 132 SUBTRACT = 'SUBTRACT' 133 ADD_FILE = 'ADD_FILE' 134 REMOVE_FILE = 'REMOVE_FILE' 135 BOX_TRANSFER = 'BOX_TRANSFER' 136 EXCHANGE = 'EXCHANGE' 137 REPORT = 'REPORT' 138 ZAKAT = 'ZAKAT'
Enumeration representing various actions that can be performed.
Members:
- CREATE: Represents the creation action ('CREATE').
- NAME: Represents the renaming action ('NAME').
- TRACK: Represents the tracking action ('TRACK').
- LOG: Represents the logging action ('LOG').
- SUBTRACT: Represents the subtract action ('SUBTRACT').
- ADD_FILE: Represents the action of adding a file ('ADD_FILE').
- REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
- BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
- EXCHANGE: Represents the exchange action ('EXCHANGE').
- REPORT: Represents the reporting action ('REPORT').
- ZAKAT: Represents a Zakat related action ('ZAKAT').
874class JSONEncoder(json.JSONEncoder): 875 """ 876 Custom JSON encoder to handle specific object types. 877 878 This encoder overrides the default `default` method to serialize: 879 - `Action` and `MathOperation` enums as their member names. 880 - `decimal.Decimal` instances as floats. 881 882 Example: 883 ```bash 884 >>> json.dumps(Action.CREATE, cls=JSONEncoder) 885 'CREATE' 886 >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder) 887 '10.5' 888 ``` 889 """ 890 def default(self, o): 891 """ 892 Overrides the default `default` method to serialize specific object types. 893 894 Parameters: 895 - o: The object to serialize. 896 897 Returns: 898 - The serialized object. 899 """ 900 if isinstance(o, (Action, MathOperation)): 901 return o.name # Serialize as the enum member's name 902 if isinstance(o, decimal.Decimal): 903 return float(o) 904 if isinstance(o, Exception): 905 return str(o) 906 if isinstance(o, Vault) or isinstance(o, ImportReport): 907 return dataclasses.asdict(o) 908 return super().default(o)
Custom JSON encoder to handle specific object types.
This encoder overrides the default default
method to serialize:
Action
andMathOperation
enums as their member names.decimal.Decimal
instances as floats.
Example:
>>> json.dumps(Action.CREATE, cls=JSONEncoder)
'CREATE'
>>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
'10.5'
890 def default(self, o): 891 """ 892 Overrides the default `default` method to serialize specific object types. 893 894 Parameters: 895 - o: The object to serialize. 896 897 Returns: 898 - The serialized object. 899 """ 900 if isinstance(o, (Action, MathOperation)): 901 return o.name # Serialize as the enum member's name 902 if isinstance(o, decimal.Decimal): 903 return float(o) 904 if isinstance(o, Exception): 905 return str(o) 906 if isinstance(o, Vault) or isinstance(o, ImportReport): 907 return dataclasses.asdict(o) 908 return super().default(o)
Overrides the default default
method to serialize specific object types.
Parameters:
- o: The object to serialize.
Returns:
- The serialized object.
911class JSONDecoder(json.JSONDecoder): 912 """ 913 Custom JSON decoder to handle specific object types. 914 915 This decoder overrides the `object_hook` method to deserialize: 916 - Strings representing enum member names back to their respective enum values. 917 - Floats back to `decimal.Decimal` instances. 918 919 Example: 920 ```bash 921 >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder) 922 {'action': <Action.CREATE: 1>} 923 >>> json.loads('{"value": 10.5}', cls=JSONDecoder) 924 {'value': Decimal('10.5')} 925 ``` 926 """ 927 def object_hook(self, obj): 928 """ 929 Overrides the default `object_hook` method to deserialize specific object types. 930 931 Parameters: 932 - obj: The object to deserialize. 933 934 Returns: 935 - The deserialized object. 936 """ 937 if isinstance(obj, str) and obj in Action.__members__: 938 return Action[obj] 939 if isinstance(obj, str) and obj in MathOperation.__members__: 940 return MathOperation[obj] 941 if isinstance(obj, float): 942 return decimal.Decimal(str(obj)) 943 return obj
Custom JSON decoder to handle specific object types.
This decoder overrides the object_hook
method to deserialize:
- Strings representing enum member names back to their respective enum values.
- Floats back to
decimal.Decimal
instances.
Example:
>>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
{'action': <Action.CREATE: 1>}
>>> json.loads('{"value": 10.5}', cls=JSONDecoder)
{'value': Decimal('10.5')}
927 def object_hook(self, obj): 928 """ 929 Overrides the default `object_hook` method to deserialize specific object types. 930 931 Parameters: 932 - obj: The object to deserialize. 933 934 Returns: 935 - The deserialized object. 936 """ 937 if isinstance(obj, str) and obj in Action.__members__: 938 return Action[obj] 939 if isinstance(obj, str) and obj in MathOperation.__members__: 940 return MathOperation[obj] 941 if isinstance(obj, float): 942 return decimal.Decimal(str(obj)) 943 return obj
Overrides the default object_hook
method to deserialize specific object types.
Parameters:
- obj: The object to deserialize.
Returns:
- The deserialized object.
141@enum.unique 142class MathOperation(enum.Enum): 143 """ 144 Enumeration representing mathematical operations. 145 146 Members: 147 - ADDITION: Represents the addition operation ('ADDITION'). 148 - EQUAL: Represents the equality operation ('EQUAL'). 149 - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION'). 150 """ 151 ADDITION = 'ADDITION' 152 EQUAL = 'EQUAL' 153 SUBTRACTION = 'SUBTRACTION'
Enumeration representing mathematical operations.
Members:
- ADDITION: Represents the addition operation ('ADDITION').
- EQUAL: Represents the equality operation ('EQUAL').
- SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
87@enum.unique 88class WeekDay(enum.Enum): 89 """ 90 Enumeration representing the days of the week. 91 92 Members: 93 - MONDAY: Represents Monday (0). 94 - TUESDAY: Represents Tuesday (1). 95 - WEDNESDAY: Represents Wednesday (2). 96 - THURSDAY: Represents Thursday (3). 97 - FRIDAY: Represents Friday (4). 98 - SATURDAY: Represents Saturday (5). 99 - SUNDAY: Represents Sunday (6). 100 """ 101 MONDAY = 0 102 TUESDAY = 1 103 WEDNESDAY = 2 104 THURSDAY = 3 105 FRIDAY = 4 106 SATURDAY = 5 107 SUNDAY = 6
Enumeration representing the days of the week.
Members:
- MONDAY: Represents Monday (0).
- TUESDAY: Represents Tuesday (1).
- WEDNESDAY: Represents Wednesday (2).
- THURSDAY: Represents Thursday (3).
- FRIDAY: Represents Friday (4).
- SATURDAY: Represents Saturday (5).
- SUNDAY: Represents Sunday (6).
112def start_file_server(database_path: str, database_callback: Optional[callable] = None, csv_callback: Optional[callable] = None, 113 debug: bool = False) -> tuple: 114 """ 115 Starts a multi-purpose WSGI server to manage file interactions for a Zakat application. 116 117 This server facilitates the following functionalities: 118 119 1. GET `/{file_uuid}/get`: Download the database file specified by `database_path`. 120 2. GET `/{file_uuid}/upload`: Display an HTML form for uploading files. 121 3. POST `/{file_uuid}/upload`: Handle file uploads, distinguishing between: 122 - Database File (.db): Replaces the existing database with the uploaded one. 123 - CSV File (.csv): Imports data from the CSV into the existing database. 124 125 Parameters: 126 - database_path (str): The path to the camel database file. 127 - database_callback (callable, optional): A function to call after a successful database upload. 128 It receives the uploaded database path as its argument. 129 - csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path, 130 the database path, and the debug flag as its arguments. 131 - debug (bool, optional): If True, print debugging information. Defaults to False. 132 133 Returns: 134 - Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing: 135 - file_name (str): The name of the database file. 136 - download_url (str): The URL to download the database file. 137 - upload_url (str): The URL to access the file upload form. 138 - server_thread (threading.Thread): The thread running the server. 139 - shutdown_server (Callable[[], None]): A function to gracefully shut down the server. 140 141 Example: 142 ```python 143 _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db") 144 print(f"Download database: {download_url}") 145 print(f"Upload files: {upload_url}") 146 server_thread.start() 147 # ... later ... 148 shutdown_server() 149 ``` 150 """ 151 file_uuid = uuid.uuid4() 152 file_name = os.path.basename(database_path) 153 154 port = find_available_port() 155 download_url = f"http://localhost:{port}/{file_uuid}/get" 156 upload_url = f"http://localhost:{port}/{file_uuid}/upload" 157 158 # Upload directory 159 upload_directory = "./uploads" 160 os.makedirs(upload_directory, exist_ok=True) 161 162 # HTML templates 163 upload_form = f""" 164 <html lang="en"> 165 <head> 166 <title>Zakat File Server</title> 167 </head> 168 <body> 169 <h1>Zakat File Server</h1> 170 <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3> 171 <h3>Or upload a new file to restore a database or import `CSV` file:</h3> 172 <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data"> 173 <input type="file" name="file" required><br/> 174 <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required> 175 <label for="database">Database File</label><br/> 176 <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}"> 177 <label for="csv">CSV File</label><br/> 178 <input type="submit" value="Upload"><br/> 179 </form> 180 </body></html> 181 """ 182 183 # WSGI application 184 def wsgi_app(environ, start_response): 185 path = environ.get('PATH_INFO', '') 186 method = environ.get('REQUEST_METHOD', 'GET') 187 188 if path == f"/{file_uuid}/get" and method == 'GET': 189 # GET: Serve the existing file 190 try: 191 with open(database_path, "rb") as f: 192 file_content = f.read() 193 194 start_response('200 OK', [ 195 ('Content-type', 'application/octet-stream'), 196 ('Content-Disposition', f'attachment; filename="{file_name}"'), 197 ('Content-Length', str(len(file_content))) 198 ]) 199 return [file_content] 200 except FileNotFoundError: 201 start_response('404 Not Found', [('Content-type', 'text/plain')]) 202 return [b'File not found'] 203 204 elif path == f"/{file_uuid}/upload" and method == 'GET': 205 # GET: Serve the upload form 206 start_response('200 OK', [('Content-type', 'text/html')]) 207 return [upload_form.encode()] 208 209 elif path == f"/{file_uuid}/upload" and method == 'POST': 210 # POST: Handle file uploads 211 try: 212 # Get content length 213 content_length = int(environ.get('CONTENT_LENGTH', 0)) 214 215 # Get content type and boundary 216 content_type = environ.get('CONTENT_TYPE', '') 217 218 # Read the request body 219 request_body = environ['wsgi.input'].read(content_length) 220 221 # Create a file-like object from the request body 222 # request_body_file = io.BytesIO(request_body) 223 224 # Parse the multipart form data using WSGI approach 225 # First, detect the boundary from content_type 226 boundary = None 227 for part in content_type.split(';'): 228 part = part.strip() 229 if part.startswith('boundary='): 230 boundary = part[9:] 231 if boundary.startswith('"') and boundary.endswith('"'): 232 boundary = boundary[1:-1] 233 break 234 235 if not boundary: 236 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 237 return [b"Missing boundary in multipart form data"] 238 239 # Process multipart data 240 parts = request_body.split(f'--{boundary}'.encode()) 241 242 # Initialize variables to store form data 243 upload_type = None 244 # file_item = None 245 file_data = None 246 filename = None 247 248 # Process each part 249 for part in parts: 250 if not part.strip(): 251 continue 252 253 # Split header and body 254 try: 255 headers_raw, body = part.split(b'\r\n\r\n', 1) 256 headers_text = headers_raw.decode('utf-8') 257 except ValueError: 258 continue 259 260 # Parse headers 261 headers = {} 262 for header_line in headers_text.split('\r\n'): 263 if ':' in header_line: 264 name, value = header_line.split(':', 1) 265 headers[name.strip().lower()] = value.strip() 266 267 # Get content disposition 268 content_disposition = headers.get('content-disposition', '') 269 if not content_disposition.startswith('form-data'): 270 continue 271 272 # Extract field name 273 field_name = None 274 for item in content_disposition.split(';'): 275 item = item.strip() 276 if item.startswith('name='): 277 field_name = item[5:].strip('"\'') 278 break 279 280 if not field_name: 281 continue 282 283 # Handle upload_type field 284 if field_name == 'upload_type': 285 # Remove trailing data including the boundary 286 body_end = body.find(b'\r\n--') 287 if body_end >= 0: 288 body = body[:body_end] 289 upload_type = body.decode('utf-8').strip() 290 291 # Handle file field 292 elif field_name == 'file': 293 # Extract filename 294 for item in content_disposition.split(';'): 295 item = item.strip() 296 if item.startswith('filename='): 297 filename = item[9:].strip('"\'') 298 break 299 300 if filename: 301 # Remove trailing data including the boundary 302 body_end = body.find(b'\r\n--') 303 if body_end >= 0: 304 body = body[:body_end] 305 file_data = body 306 307 if debug: 308 print('upload_type', upload_type) 309 310 if debug: 311 print('upload_type:', upload_type) 312 print('filename:', filename) 313 314 if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]: 315 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 316 return [b"Invalid upload type"] 317 318 if not filename or not file_data: 319 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 320 return [b"Missing file data"] 321 322 if debug: 323 print(f'Uploaded filename: {filename}') 324 325 # Save the file 326 file_path = os.path.join(upload_directory, upload_type) 327 with open(file_path, 'wb') as f: 328 f.write(file_data) 329 330 # Process based on file type 331 if upload_type == FileType.Database.value: 332 try: 333 # Verify database file 334 if database_callback is not None: 335 database_callback(file_path) 336 337 # Copy database into the original path 338 shutil.copy2(file_path, database_path) 339 340 start_response('200 OK', [('Content-type', 'text/plain')]) 341 return [b"Database file uploaded successfully."] 342 except Exception as e: 343 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 344 return [str(e).encode()] 345 346 elif upload_type == FileType.CSV.value: 347 try: 348 if csv_callback is not None: 349 result = csv_callback(file_path, database_path, debug) 350 if debug: 351 print(f'CSV imported: {result}') 352 if len(result[2]) != 0: 353 start_response('200 OK', [('Content-type', 'application/json')]) 354 return [json.dumps(result).encode()] 355 356 start_response('200 OK', [('Content-type', 'text/plain')]) 357 return [b"CSV file uploaded successfully."] 358 except Exception as e: 359 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 360 return [str(e).encode()] 361 362 except Exception as e: 363 start_response('500 Internal Server Error', [('Content-type', 'text/plain')]) 364 return [f"Error processing upload: {str(e)}".encode()] 365 366 else: 367 # 404 for anything else 368 start_response('404 Not Found', [('Content-type', 'text/plain')]) 369 return [b'Not Found'] 370 371 # Create and start the server 372 httpd = make_server('localhost', port, wsgi_app) 373 server_thread = threading.Thread(target=httpd.serve_forever) 374 375 def shutdown_server(): 376 nonlocal httpd, server_thread 377 httpd.shutdown() 378 server_thread.join() # Wait for the thread to finish 379 380 return file_name, download_url, upload_url, server_thread, shutdown_server
Starts a multi-purpose WSGI server to manage file interactions for a Zakat application.
This server facilitates the following functionalities:
- GET
/{file_uuid}/get
: Download the database file specified bydatabase_path
. - GET
/{file_uuid}/upload
: Display an HTML form for uploading files. - POST
/{file_uuid}/upload
: Handle file uploads, distinguishing between:- Database File (.db): Replaces the existing database with the uploaded one.
- CSV File (.csv): Imports data from the CSV into the existing database.
Parameters:
- database_path (str): The path to the camel database file.
- database_callback (callable, optional): A function to call after a successful database upload. It receives the uploaded database path as its argument.
- csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path, the database path, and the debug flag as its arguments.
- debug (bool, optional): If True, print debugging information. Defaults to False.
Returns:
- Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
- file_name (str): The name of the database file.
- download_url (str): The URL to download the database file.
- upload_url (str): The URL to access the file upload form.
- server_thread (threading.Thread): The thread running the server.
- shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
Example:
_, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
print(f"Download database: {download_url}")
print(f"Upload files: {upload_url}")
server_thread.start()
# ... later ...
shutdown_server()
87def find_available_port() -> int: 88 """ 89 Finds and returns an available TCP port on the local machine. 90 91 This function utilizes a TCP server socket to bind to port 0, which 92 instructs the operating system to automatically assign an available 93 port. The assigned port is then extracted and returned. 94 95 Returns: 96 - int: The available TCP port number. 97 98 Raises: 99 - OSError: If an error occurs during the port binding process, such 100 as all ports being in use. 101 102 Example: 103 ```python 104 port = find_available_port() 105 print(f"Available port: {port}") 106 ``` 107 """ 108 with socketserver.TCPServer(("localhost", 0), None) as s: 109 return s.server_address[1]
Finds and returns an available TCP port on the local machine.
This function utilizes a TCP server socket to bind to port 0, which instructs the operating system to automatically assign an available port. The assigned port is then extracted and returned.
Returns:
- int: The available TCP port number.
Raises:
- OSError: If an error occurs during the port binding process, such as all ports being in use.
Example:
port = find_available_port()
print(f"Available port: {port}")
42@enum.unique 43class FileType(enum.Enum): 44 """ 45 Enumeration representing file types. 46 47 Members: 48 - Database: Represents a database file ('db'). 49 - CSV: Represents a CSV file ('csv'). 50 """ 51 Database = 'db' 52 CSV = 'csv'
Enumeration representing file types.
Members:
- Database: Represents a database file ('db').
- CSV: Represents a CSV file ('csv').
360@dataclasses.dataclass 361class StrictDataclass: 362 """A dataclass that prevents setting non-existent attributes.""" 363 def __setattr__(self, name: str, value: any) -> None: 364 _check_attribute(self, name, value)
A dataclass that prevents setting non-existent attributes.
367class ImmutableWithSelectiveFreeze: 368 """ 369 A base class for creating immutable objects with the ability to selectively 370 freeze specific fields. 371 372 Inheriting from this class will automatically make all fields defined in 373 dataclasses as frozen after initialization if their metadata contains 374 `"frozen": True`. Attempting to set a value to a frozen field after 375 initialization will raise a RuntimeError. 376 377 Example: 378 ```python 379 @dataclasses.dataclass 380 class MyObject(ImmutableWithSelectiveFreeze): 381 name: str 382 count: int = dataclasses.field(metadata={"frozen": True}) 383 description: str = "default" 384 385 obj = MyObject(name="Test", count=5) 386 print(obj.name) # Output: Test 387 print(obj.count) # Output: 5 388 obj.name = "New Name" # This will work 389 try: 390 obj.count = 10 # This will raise a RuntimeError 391 except RuntimeError as e: 392 print(e) # Output: Field 'count' is frozen! 393 print(obj.description) # Output: default 394 obj.description = "updated" # This will work 395 ``` 396 """ 397 # Implementation based on: https://discuss.python.org/t/dataclasses-freezing-specific-fields-should-be-possible/59968/2 398 def __post_init__(self): 399 """ 400 Initializes the object and freezes fields marked with `"frozen": True` 401 in their metadata. 402 """ 403 self.__set_fields_frozen(self) 404 405 @classmethod 406 def __set_fields_frozen(cls, self): 407 """ 408 Iterates through the dataclass fields and freezes those with the 409 `"frozen": True` metadata. 410 """ 411 flds = dataclasses.fields(cls) 412 for fld in flds: 413 if fld.metadata.get("frozen"): 414 field_name = fld.name 415 field_value = getattr(self, fld.name) 416 setattr(self, f"_{fld.name}", field_value) 417 418 def local_getter(self): 419 """Getter for the frozen field.""" 420 return getattr(self, f"_{field_name}") 421 422 def frozen(name): 423 """Creates a setter that raises a RuntimeError for frozen fields.""" 424 def local_setter(self, value): 425 raise RuntimeError(f"Field '{name}' is frozen!") 426 return local_setter 427 428 setattr(cls, field_name, property(local_getter, frozen(field_name)))
A base class for creating immutable objects with the ability to selectively freeze specific fields.
Inheriting from this class will automatically make all fields defined in
dataclasses as frozen after initialization if their metadata contains
"frozen": True
. Attempting to set a value to a frozen field after
initialization will raise a RuntimeError.
Example:
@dataclasses.dataclass
class MyObject(ImmutableWithSelectiveFreeze):
name: str
count: int = dataclasses.field(metadata={"frozen": True})
description: str = "default"
obj = MyObject(name="Test", count=5)
print(obj.name) # Output: Test
print(obj.count) # Output: 5
obj.name = "New Name" # This will work
try:
obj.count = 10 # This will raise a RuntimeError
except RuntimeError as e:
print(e) # Output: Field 'count' is frozen!
print(obj.description) # Output: default
obj.description = "updated" # This will work