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 Backup, 61 Collection, 62) 63 64from zakat.file_server import ( 65 start_file_server, 66 find_available_port, 67 FileType, 68) 69 70# Shortcuts 71time = Time.time 72time_to_datetime = Time.time_to_datetime 73tracker = ZakatTracker 74 75# Version information for the module 76__version__ = ZakatTracker.Version() 77__all__ = [ 78 "Time", 79 "time", 80 "time_to_datetime", 81 "tracker", 82 "SizeInfo", 83 "FileInfo", 84 "FileStats", 85 "TimeSummary", 86 "Transaction", 87 "DailyRecords", 88 "Timeline", 89 "ImportStatistics", 90 "CSVRecord", 91 "ImportReport", 92 "ZakatTracker", 93 "AccountID", 94 "AccountDetails", 95 "Timestamp", 96 "Box", 97 "Log", 98 "Account", 99 "Exchange", 100 "History", 101 "Vault", 102 "AccountPaymentPart", 103 "PaymentParts", 104 "SubtractAge", 105 "SubtractAges", 106 "SubtractReport", 107 "TransferTime", 108 "TransferTimes", 109 "TransferRecord", 110 "TransferReport", 111 "BoxPlan", 112 "ZakatSummary", 113 "ZakatReport", 114 "test", 115 "Action", 116 "JSONEncoder", 117 "JSONDecoder", 118 "MathOperation", 119 "WeekDay", 120 "start_file_server", 121 "find_available_port", 122 "FileType", 123 "StrictDataclass", 124 "ImmutableWithSelectiveFreeze", 125 "Backup", 126 "Collection", 127]
1126class Time: 1127 """ 1128 Utility class for generating and manipulating nanosecond-precision timestamps. 1129 1130 This class provides static methods for converting between datetime objects and 1131 nanosecond-precision timestamps, ensuring uniqueness and monotonicity. 1132 """ 1133 __last_time_ns = None 1134 __time_diff_ns = None 1135 1136 @staticmethod 1137 def minimum_time_diff_ns() -> tuple[int, int]: 1138 """ 1139 Calculates the minimum time difference between two consecutive calls to 1140 `Time._time()` in nanoseconds. 1141 1142 This method is used internally to determine the minimum granularity of 1143 time measurements within the system. 1144 1145 Returns: 1146 - tuple[int, int]: 1147 - The minimum time difference in nanoseconds. 1148 - The number of iterations required to measure the difference. 1149 """ 1150 i = 0 1151 x = y = Time._time() 1152 while x == y: 1153 y = Time._time() 1154 i += 1 1155 return y - x, i 1156 1157 @staticmethod 1158 def _time(now: Optional[datetime.datetime] = None) -> Timestamp: 1159 """ 1160 Internal method to generate a nanosecond-precision timestamp from a datetime object. 1161 1162 Parameters: 1163 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. 1164 If not provided, the current datetime is used. 1165 1166 Returns: 1167 - int: The timestamp in nanoseconds since the epoch (January 1, 1AD). 1168 """ 1169 if now is None: 1170 now = datetime.datetime.now() 1171 ns_in_day = (now - now.replace( 1172 hour=0, 1173 minute=0, 1174 second=0, 1175 microsecond=0, 1176 )).total_seconds() * 10 ** 9 1177 return Timestamp(int(now.toordinal() * 86_400_000_000_000 + ns_in_day)) 1178 1179 @staticmethod 1180 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 1181 """ 1182 Generates a unique, monotonically increasing timestamp based on the provided 1183 datetime object or the current datetime. 1184 1185 This method ensures that timestamps are unique even if called in rapid succession 1186 by introducing a small delay if necessary, based on the system's minimum 1187 time resolution. 1188 1189 Parameters: 1190 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 1191 1192 Returns: 1193 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 1194 """ 1195 new_time = Time._time(now) 1196 if Time.__last_time_ns is None: 1197 Time.__last_time_ns = new_time 1198 return new_time 1199 while new_time == Time.__last_time_ns: 1200 if Time.__time_diff_ns is None: 1201 diff, _ = Time.minimum_time_diff_ns() 1202 Time.__time_diff_ns = math.ceil(diff) 1203 time.sleep(Time.__time_diff_ns / 1_000_000_000) 1204 new_time = Time._time() 1205 Time.__last_time_ns = new_time 1206 return new_time 1207 1208 @staticmethod 1209 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 1210 """ 1211 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 1212 back to a datetime object. 1213 1214 Parameters: 1215 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 1216 1217 Returns: 1218 - datetime.datetime: The corresponding datetime object. 1219 """ 1220 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 1221 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 1222 return datetime.datetime.combine(d, datetime.time()) + t 1223 1224 @staticmethod 1225 def duration_from_nanoseconds(ns: int, 1226 show_zeros_in_spoken_time: bool = False, 1227 spoken_time_separator=',', 1228 millennia: str = 'Millennia', 1229 century: str = 'Century', 1230 years: str = 'Years', 1231 days: str = 'Days', 1232 hours: str = 'Hours', 1233 minutes: str = 'Minutes', 1234 seconds: str = 'Seconds', 1235 milli_seconds: str = 'MilliSeconds', 1236 micro_seconds: str = 'MicroSeconds', 1237 nano_seconds: str = 'NanoSeconds', 1238 ) -> tuple: 1239 """ 1240 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1241 Convert NanoSeconds to Human Readable Time Format. 1242 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1243 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1244 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1245 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1246 1247 INPUT : ms (AKA: MilliSeconds) 1248 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1249 OUTPUT Variables: time_lapsed, spoken_time 1250 1251 Example Input: duration_from_nanoseconds(ns) 1252 **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'** 1253 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') 1254 duration_from_nanoseconds(1234567890123456789012) 1255 """ 1256 us, ns = divmod(ns, 1000) 1257 ms, us = divmod(us, 1000) 1258 s, ms = divmod(ms, 1000) 1259 m, s = divmod(s, 60) 1260 h, m = divmod(m, 60) 1261 d, h = divmod(h, 24) 1262 y, d = divmod(d, 365) 1263 c, y = divmod(y, 100) 1264 n, c = divmod(c, 10) 1265 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}' 1266 spoken_time_part = [] 1267 if n > 0 or show_zeros_in_spoken_time: 1268 spoken_time_part.append(f'{n: 3d} {millennia}') 1269 if c > 0 or show_zeros_in_spoken_time: 1270 spoken_time_part.append(f'{c: 4d} {century}') 1271 if y > 0 or show_zeros_in_spoken_time: 1272 spoken_time_part.append(f'{y: 3d} {years}') 1273 if d > 0 or show_zeros_in_spoken_time: 1274 spoken_time_part.append(f'{d: 4d} {days}') 1275 if h > 0 or show_zeros_in_spoken_time: 1276 spoken_time_part.append(f'{h: 2d} {hours}') 1277 if m > 0 or show_zeros_in_spoken_time: 1278 spoken_time_part.append(f'{m: 2d} {minutes}') 1279 if s > 0 or show_zeros_in_spoken_time: 1280 spoken_time_part.append(f'{s: 2d} {seconds}') 1281 if ms > 0 or show_zeros_in_spoken_time: 1282 spoken_time_part.append(f'{ms: 3d} {milli_seconds}') 1283 if us > 0 or show_zeros_in_spoken_time: 1284 spoken_time_part.append(f'{us: 3d} {micro_seconds}') 1285 if ns > 0 or show_zeros_in_spoken_time: 1286 spoken_time_part.append(f'{ns: 3d} {nano_seconds}') 1287 return time_lapsed, spoken_time_separator.join(spoken_time_part) 1288 1289 @staticmethod 1290 def test(debug: bool = False): 1291 """ 1292 Performs unit tests to verify the correctness of the `Time` class methods. 1293 1294 This method checks the conversion between datetime objects and timestamps, 1295 ensuring accuracy and consistency across various date ranges. 1296 1297 Parameters: 1298 - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False. 1299 """ 1300 test_cases = [ 1301 datetime.datetime(1, 1, 1), 1302 datetime.datetime(1970, 1, 1), 1303 datetime.datetime(1969, 12, 31), 1304 datetime.datetime.now(), 1305 datetime.datetime(9999, 12, 31, 23, 59, 59), 1306 ] 1307 1308 for test_date in test_cases: 1309 timestamp = Time.time(test_date) 1310 converted = Time.time_to_datetime(timestamp) 1311 if debug: 1312 print(f'{timestamp} <=> {converted}') 1313 assert timestamp > 0 1314 assert test_date.year == converted.year 1315 assert test_date.month == converted.month 1316 assert test_date.day == converted.day 1317 assert test_date.hour == converted.hour 1318 assert test_date.minute == converted.minute 1319 assert test_date.second in [converted.second - 1, converted.second, converted.second + 1] 1320 1321 # sanity check - convert date since 1AD to 9999AD 1322 1323 for year in range(1, 10_000): 1324 ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f')) 1325 date = Time.time_to_datetime(ns) 1326 if debug: 1327 print(date, date.microsecond) 1328 assert ns > 0 1329 assert date.year == year 1330 assert date.month == 12 1331 assert date.day == 30 1332 assert date.hour == 18 1333 assert date.minute == 30 1334 assert date.second in [44, 45] 1335 #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.
1136 @staticmethod 1137 def minimum_time_diff_ns() -> tuple[int, int]: 1138 """ 1139 Calculates the minimum time difference between two consecutive calls to 1140 `Time._time()` in nanoseconds. 1141 1142 This method is used internally to determine the minimum granularity of 1143 time measurements within the system. 1144 1145 Returns: 1146 - tuple[int, int]: 1147 - The minimum time difference in nanoseconds. 1148 - The number of iterations required to measure the difference. 1149 """ 1150 i = 0 1151 x = y = Time._time() 1152 while x == y: 1153 y = Time._time() 1154 i += 1 1155 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.
1179 @staticmethod 1180 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 1181 """ 1182 Generates a unique, monotonically increasing timestamp based on the provided 1183 datetime object or the current datetime. 1184 1185 This method ensures that timestamps are unique even if called in rapid succession 1186 by introducing a small delay if necessary, based on the system's minimum 1187 time resolution. 1188 1189 Parameters: 1190 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 1191 1192 Returns: 1193 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 1194 """ 1195 new_time = Time._time(now) 1196 if Time.__last_time_ns is None: 1197 Time.__last_time_ns = new_time 1198 return new_time 1199 while new_time == Time.__last_time_ns: 1200 if Time.__time_diff_ns is None: 1201 diff, _ = Time.minimum_time_diff_ns() 1202 Time.__time_diff_ns = math.ceil(diff) 1203 time.sleep(Time.__time_diff_ns / 1_000_000_000) 1204 new_time = Time._time() 1205 Time.__last_time_ns = new_time 1206 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).
1208 @staticmethod 1209 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 1210 """ 1211 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 1212 back to a datetime object. 1213 1214 Parameters: 1215 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 1216 1217 Returns: 1218 - datetime.datetime: The corresponding datetime object. 1219 """ 1220 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 1221 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 1222 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.
1224 @staticmethod 1225 def duration_from_nanoseconds(ns: int, 1226 show_zeros_in_spoken_time: bool = False, 1227 spoken_time_separator=',', 1228 millennia: str = 'Millennia', 1229 century: str = 'Century', 1230 years: str = 'Years', 1231 days: str = 'Days', 1232 hours: str = 'Hours', 1233 minutes: str = 'Minutes', 1234 seconds: str = 'Seconds', 1235 milli_seconds: str = 'MilliSeconds', 1236 micro_seconds: str = 'MicroSeconds', 1237 nano_seconds: str = 'NanoSeconds', 1238 ) -> tuple: 1239 """ 1240 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1241 Convert NanoSeconds to Human Readable Time Format. 1242 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1243 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1244 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1245 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1246 1247 INPUT : ms (AKA: MilliSeconds) 1248 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1249 OUTPUT Variables: time_lapsed, spoken_time 1250 1251 Example Input: duration_from_nanoseconds(ns) 1252 **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'** 1253 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') 1254 duration_from_nanoseconds(1234567890123456789012) 1255 """ 1256 us, ns = divmod(ns, 1000) 1257 ms, us = divmod(us, 1000) 1258 s, ms = divmod(ms, 1000) 1259 m, s = divmod(s, 60) 1260 h, m = divmod(m, 60) 1261 d, h = divmod(h, 24) 1262 y, d = divmod(d, 365) 1263 c, y = divmod(y, 100) 1264 n, c = divmod(c, 10) 1265 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}' 1266 spoken_time_part = [] 1267 if n > 0 or show_zeros_in_spoken_time: 1268 spoken_time_part.append(f'{n: 3d} {millennia}') 1269 if c > 0 or show_zeros_in_spoken_time: 1270 spoken_time_part.append(f'{c: 4d} {century}') 1271 if y > 0 or show_zeros_in_spoken_time: 1272 spoken_time_part.append(f'{y: 3d} {years}') 1273 if d > 0 or show_zeros_in_spoken_time: 1274 spoken_time_part.append(f'{d: 4d} {days}') 1275 if h > 0 or show_zeros_in_spoken_time: 1276 spoken_time_part.append(f'{h: 2d} {hours}') 1277 if m > 0 or show_zeros_in_spoken_time: 1278 spoken_time_part.append(f'{m: 2d} {minutes}') 1279 if s > 0 or show_zeros_in_spoken_time: 1280 spoken_time_part.append(f'{s: 2d} {seconds}') 1281 if ms > 0 or show_zeros_in_spoken_time: 1282 spoken_time_part.append(f'{ms: 3d} {milli_seconds}') 1283 if us > 0 or show_zeros_in_spoken_time: 1284 spoken_time_part.append(f'{us: 3d} {micro_seconds}') 1285 if ns > 0 or show_zeros_in_spoken_time: 1286 spoken_time_part.append(f'{ns: 3d} {nano_seconds}') 1287 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)
1289 @staticmethod 1290 def test(debug: bool = False): 1291 """ 1292 Performs unit tests to verify the correctness of the `Time` class methods. 1293 1294 This method checks the conversion between datetime objects and timestamps, 1295 ensuring accuracy and consistency across various date ranges. 1296 1297 Parameters: 1298 - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False. 1299 """ 1300 test_cases = [ 1301 datetime.datetime(1, 1, 1), 1302 datetime.datetime(1970, 1, 1), 1303 datetime.datetime(1969, 12, 31), 1304 datetime.datetime.now(), 1305 datetime.datetime(9999, 12, 31, 23, 59, 59), 1306 ] 1307 1308 for test_date in test_cases: 1309 timestamp = Time.time(test_date) 1310 converted = Time.time_to_datetime(timestamp) 1311 if debug: 1312 print(f'{timestamp} <=> {converted}') 1313 assert timestamp > 0 1314 assert test_date.year == converted.year 1315 assert test_date.month == converted.month 1316 assert test_date.day == converted.day 1317 assert test_date.hour == converted.hour 1318 assert test_date.minute == converted.minute 1319 assert test_date.second in [converted.second - 1, converted.second, converted.second + 1] 1320 1321 # sanity check - convert date since 1AD to 9999AD 1322 1323 for year in range(1, 10_000): 1324 ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f')) 1325 date = Time.time_to_datetime(ns) 1326 if debug: 1327 print(date, date.microsecond) 1328 assert ns > 0 1329 assert date.year == year 1330 assert date.month == 12 1331 assert date.day == 30 1332 assert date.hour == 18 1333 assert date.minute == 30 1334 assert date.second in [44, 45] 1335 #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.
1179 @staticmethod 1180 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 1181 """ 1182 Generates a unique, monotonically increasing timestamp based on the provided 1183 datetime object or the current datetime. 1184 1185 This method ensures that timestamps are unique even if called in rapid succession 1186 by introducing a small delay if necessary, based on the system's minimum 1187 time resolution. 1188 1189 Parameters: 1190 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 1191 1192 Returns: 1193 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 1194 """ 1195 new_time = Time._time(now) 1196 if Time.__last_time_ns is None: 1197 Time.__last_time_ns = new_time 1198 return new_time 1199 while new_time == Time.__last_time_ns: 1200 if Time.__time_diff_ns is None: 1201 diff, _ = Time.minimum_time_diff_ns() 1202 Time.__time_diff_ns = math.ceil(diff) 1203 time.sleep(Time.__time_diff_ns / 1_000_000_000) 1204 new_time = Time._time() 1205 Time.__last_time_ns = new_time 1206 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).
1208 @staticmethod 1209 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 1210 """ 1211 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 1212 back to a datetime object. 1213 1214 Parameters: 1215 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 1216 1217 Returns: 1218 - datetime.datetime: The corresponding datetime object. 1219 """ 1220 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 1221 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 1222 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.
832@dataclasses.dataclass 833class SizeInfo(StrictDataclass): 834 """ 835 Represents size information in bytes and human-readable format. 836 837 Attributes: 838 - bytes (float): The size in bytes. 839 - human_readable (str): The human-readable representation of the size. 840 """ 841 bytes: float 842 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.
845@dataclasses.dataclass 846class FileInfo(StrictDataclass): 847 """ 848 Represents information about a file. 849 850 Attributes: 851 - type (str): The type of the file. 852 - path (str): The full path to the file. 853 - exists (bool): A boolean indicating whether the file exists. 854 - size (int): The size of the file in bytes. 855 - human_readable_size (str): The human-readable representation of the file size. 856 """ 857 type: str 858 path: str 859 exists: bool 860 size: int 861 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.
864@dataclasses.dataclass 865class FileStats(StrictDataclass): 866 """ 867 Represents statistics related to file storage. 868 869 Attributes: 870 - ram (:class:`SizeInfo`): Information about the RAM usage. 871 - database (:class:`SizeInfo`): Information about the database size. 872 """ 873 ram: SizeInfo 874 database: SizeInfo
Represents statistics related to file storage.
Attributes:
877@dataclasses.dataclass 878class TimeSummary(StrictDataclass): 879 """Summary of positive, negative, and total values over a period.""" 880 positive: int = 0 881 negative: int = 0 882 total: int = 0
Summary of positive, negative, and total values over a period.
885@dataclasses.dataclass 886class Transaction(StrictDataclass): 887 """Represents a single transaction record.""" 888 account: str 889 account_id: AccountID 890 log_ref: Timestamp 891 desc: str 892 file: dict[Timestamp, str] 893 value: int 894 time: Timestamp 895 transfer: bool
Represents a single transaction record.
898@dataclasses.dataclass 899class DailyRecords(TimeSummary, StrictDataclass): 900 """Represents the records for a single day, including a summary and a list of transactions.""" 901 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
904@dataclasses.dataclass 905class Timeline(StrictDataclass): 906 """Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries.""" 907 daily: dict[str, DailyRecords] = dataclasses.field(default_factory=dict) 908 weekly: dict[datetime.datetime, TimeSummary] = dataclasses.field(default_factory=dict) 909 monthly: dict[str, TimeSummary] = dataclasses.field(default_factory=dict) 910 yearly: dict[int, TimeSummary] = dataclasses.field(default_factory=dict)
Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries.
776@dataclasses.dataclass 777class ImportStatistics(StrictDataclass): 778 """ 779 Statistics summarizing the results of an import operation. 780 781 Attributes: 782 - created (int): The number of new records successfully created. 783 - found (int): The number of existing records found and potentially updated. 784 - bad (int): The number of records that failed to import due to errors. 785 """ 786 created: int 787 found: int 788 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.
791@dataclasses.dataclass 792class CSVRecord(StrictDataclass): 793 """ 794 Represents a single record read from a CSV file. 795 796 Attributes: 797 - index (int): The original row number of the record in the CSV file (0-based). 798 - account (str): The account identifier. 799 - desc (str): A description associated with the record. 800 - value (int): The numerical value of the record. 801 - date (str): The date associated with the record (format may vary). 802 - rate (float): A rate or factor associated with the record. 803 - reference (str): An optional reference string. 804 - hashed (str): A hashed representation of the record's content. 805 - error (str): An error message if there was an issue processing this record. 806 """ 807 index: int 808 account: str 809 desc: str 810 value: int 811 date: str 812 rate: float 813 reference: str 814 hashed: str 815 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.
818@dataclasses.dataclass 819class ImportReport(StrictDataclass): 820 """ 821 A report summarizing the outcome of an import operation. 822 823 Attributes: 824 - statistics (ImportStatistics): Statistical information about the import. 825 - bad (list[CSVRecord]): A list of CSV records that failed to import, 826 including any error messages. 827 """ 828 statistics: ImportStatistics 829 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.
1482class ZakatTracker: 1483 """ 1484 A class for tracking and calculating Zakat. 1485 1486 This class provides functionalities for recording transactions, calculating Zakat due, 1487 and managing account balances. It also offers features like importing transactions from 1488 CSV files, exporting data to JSON format, and saving/loading the tracker state. 1489 1490 The `ZakatTracker` class is designed to handle both positive and negative transactions, 1491 allowing for flexible tracking of financial activities related to Zakat. It also supports 1492 the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due 1493 based on the current silver price. 1494 1495 The class uses a json file as its database to persist the tracker state, 1496 ensuring data integrity across sessions. It also provides options for enabling or 1497 disabling history tracking, allowing users to choose their preferred level of detail. 1498 1499 In addition, the `ZakatTracker` class includes various helper methods like 1500 `time`, `time_to_datetime`, `lock`, `free`, `recall`, `save`, `load` 1501 and more. These methods provide additional functionalities and flexibility 1502 for interacting with and managing the Zakat tracker. 1503 1504 Attributes: 1505 - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage. 1506 - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat. 1507 - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price. 1508 - ZakatTracker.Version (function): The version of the ZakatTracker class. 1509 1510 Data Structure: 1511 1512 The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data, here below is just a demonstration: 1513 1514 __vault (dict): 1515 - account (dict): 1516 - {account_id} (dict): 1517 - balance (int): The current balance of the account. 1518 - name (str): The name of the account. 1519 - created (int): The creation time for the account. 1520 - box (dict): A dictionary storing transaction details. 1521 - {timestamp} (dict): 1522 - capital (int): The initial amount of the transaction. 1523 - rest (int): The remaining amount after Zakat deductions and withdrawal. 1524 - zakat (dict): 1525 - count (int): The number of times Zakat has been calculated for this transaction. 1526 - last (int): The timestamp of the last Zakat calculation. 1527 - total (int): The total Zakat deducted from this transaction. 1528 - count (int): The total number of transactions for the account. 1529 - log (dict): A dictionary storing transaction logs. 1530 - {timestamp} (dict): 1531 - value (int): The transaction amount (positive or negative). 1532 - desc (str): The description of the transaction. 1533 - ref (int): The box reference (positive or None). 1534 - file (dict): A dictionary storing file references associated with the transaction. 1535 - hide (bool): Indicates whether the account is hidden or not. 1536 - zakatable (bool): Indicates whether the account is subject to Zakat. 1537 - exchange (dict): 1538 - {account_id} (dict): 1539 - {timestamps} (dict): 1540 - rate (float): Exchange rate when compared to local currency. 1541 - description (str): The description of the exchange rate. 1542 - history (dict): 1543 - {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed. 1544 - {order_timestamp} (dict): 1545 - {action_dict} (dict): 1546 - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT). 1547 - account (str): The account reference associated with the action. 1548 - ref (int): The reference number of the transaction. 1549 - file (int): The reference number of the file (if applicable). 1550 - key (str): The key associated with the action (e.g., 'rest', 'total'). 1551 - value (int): The value associated with the action. 1552 - math (MathOperation): The mathematical operation performed (if applicable). 1553 - lock (int or None): The timestamp indicating the current lock status (None if not locked). 1554 - report (dict): 1555 - {timestamp} (tuple): A tuple storing Zakat report details. 1556 """ 1557 1558 @staticmethod 1559 def Version() -> str: 1560 """ 1561 Returns the current version of the software. 1562 1563 This function returns a string representing the current version of the software, 1564 including major, minor, and patch version numbers in the format 'X.Y.Z'. 1565 1566 Returns: 1567 - str: The current version of the software. 1568 """ 1569 version = '0.3.5' 1570 git_hash, unstaged_count, commit_count_since_last_tag = get_git_status() 1571 if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0): 1572 version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}" 1573 print(version) 1574 return version 1575 1576 @staticmethod 1577 def ZakatCut(x: float) -> float: 1578 """ 1579 Calculates the Zakat amount due on an asset. 1580 1581 This function calculates the zakat amount due on a given asset value over one lunar year. 1582 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 1583 that exceeds a certain threshold (Nisab). 1584 1585 Parameters: 1586 - x (float): The total value of the asset on which Zakat is to be calculated. 1587 1588 Returns: 1589 - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 1590 """ 1591 return 0.025 * x # Zakat Cut in one Lunar Year 1592 1593 @staticmethod 1594 def TimeCycle(days: int = 355) -> int: 1595 """ 1596 Calculates the approximate duration of a lunar year in nanoseconds. 1597 1598 This function calculates the approximate duration of a lunar year based on the given number of days. 1599 It converts the given number of days into nanoseconds for use in high-precision timing applications. 1600 1601 Parameters: 1602 - days (int, optional): The number of days in a lunar year. Defaults to 355, 1603 which is an approximation of the average length of a lunar year. 1604 1605 Returns: 1606 - int: The approximate duration of a lunar year in nanoseconds. 1607 """ 1608 return int(60 * 60 * 24 * days * 1e9) # Lunar Year in nanoseconds 1609 1610 @staticmethod 1611 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 1612 """ 1613 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 1614 1615 This function calculates the Nisab value, which is the minimum threshold of wealth, 1616 that makes an individual liable for paying Zakat. 1617 The Nisab value is determined by the equivalent value of a specific amount 1618 of gold or silver (currently 595 grams in silver) in the local currency. 1619 1620 Parameters: 1621 - gram_price (float): The price per gram of Nisab. 1622 - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver. 1623 1624 Returns: 1625 - float: The total value of Nisab based on the given price per gram. 1626 """ 1627 return gram_price * gram_quantity 1628 1629 @staticmethod 1630 def ext() -> str: 1631 """ 1632 Returns the file extension used by the ZakatTracker class. 1633 1634 Parameters: 1635 None 1636 1637 Returns: 1638 - str: The file extension used by the ZakatTracker class, which is 'json'. 1639 """ 1640 return 'json' 1641 1642 __base_path = pathlib.Path("") 1643 __vault_path = pathlib.Path("") 1644 __memory_mode = False 1645 __debug_output: list[any] = [] 1646 __vault: Vault 1647 1648 def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True): 1649 """ 1650 Initialize ZakatTracker with database path and history mode. 1651 1652 Parameters: 1653 - db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database. 1654 - history_mode (bool, optional): The mode for tracking history. Default is True. 1655 1656 Returns: 1657 None 1658 """ 1659 self.reset() 1660 self.__memory_mode = db_path == ':memory:' 1661 self.__history(history_mode) 1662 if not self.__memory_mode: 1663 self.path(f'{db_path}/db.{self.ext()}') 1664 1665 def memory_mode(self) -> bool: 1666 """ 1667 Check if the ZakatTracker is operating in memory mode. 1668 1669 Returns: 1670 - bool: True if the database is in memory, False otherwise. 1671 """ 1672 return self.__memory_mode 1673 1674 def path(self, path: Optional[str] = None) -> str: 1675 """ 1676 Set or get the path to the database file. 1677 1678 If no path is provided, the current path is returned. 1679 If a path is provided, it is set as the new path. 1680 The function also creates the necessary directories if the provided path is a file. 1681 1682 Parameters: 1683 - path (str, optional): The new path to the database file. If not provided, the current path is returned. 1684 1685 Returns: 1686 - str: The current or new path to the database file. 1687 """ 1688 if path is None: 1689 return str(self.__vault_path) 1690 self.__vault_path = pathlib.Path(path).resolve() 1691 base_path = pathlib.Path(path).resolve() 1692 if base_path.is_file() or base_path.suffix: 1693 base_path = base_path.parent 1694 base_path.mkdir(parents=True, exist_ok=True) 1695 self.__base_path = base_path 1696 return str(self.__vault_path) 1697 1698 def base_path(self, *args) -> str: 1699 """ 1700 Generate a base path by joining the provided arguments with the existing base path. 1701 1702 Parameters: 1703 - *args (str): Variable length argument list of strings to be joined with the base path. 1704 1705 Returns: 1706 - str: The generated base path. If no arguments are provided, the existing base path is returned. 1707 """ 1708 if not args: 1709 return str(self.__base_path) 1710 filtered_args = [] 1711 ignored_filename = None 1712 for arg in args: 1713 if pathlib.Path(arg).suffix: 1714 ignored_filename = arg 1715 else: 1716 filtered_args.append(arg) 1717 base_path = pathlib.Path(self.__base_path) 1718 full_path = base_path.joinpath(*filtered_args) 1719 full_path.mkdir(parents=True, exist_ok=True) 1720 if ignored_filename is not None: 1721 return full_path.resolve() / ignored_filename # Join with the ignored filename 1722 return str(full_path.resolve()) 1723 1724 @staticmethod 1725 def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int: 1726 """ 1727 Scales a numerical value by a specified power of 10, returning an integer. 1728 1729 This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and 1730 facilitate precise scaling operations, particularly useful in financial or scientific calculations. 1731 1732 Parameters: 1733 - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal. 1734 - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled 1735 by a factor of 100 (e.g., converts 1.23 to 123). 1736 1737 Returns: 1738 - The scaled value, rounded to the nearest integer. 1739 1740 Raises: 1741 - TypeError: If the input `x` is not a valid numeric type. 1742 1743 Examples: 1744 ```bash 1745 >>> ZakatTracker.scale(3.14159) 1746 314 1747 >>> ZakatTracker.scale(1234, decimal_places=3) 1748 1234000 1749 >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4) 1750 50 1751 ``` 1752 """ 1753 if not isinstance(x, (float, int, decimal.Decimal)): 1754 raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.') 1755 return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places)) 1756 1757 @staticmethod 1758 def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal: 1759 """ 1760 Unscales an integer by a power of 10. 1761 1762 Parameters: 1763 - x (int): The integer to unscale. 1764 - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float. 1765 - decimal_places (int, optional): The power of 10 to use. Defaults to 2. 1766 1767 Returns: 1768 - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type. 1769 1770 Raises: 1771 - TypeError: If the return_type is not float or decimal.Decimal. 1772 """ 1773 if return_type not in (float, decimal.Decimal): 1774 raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.') 1775 return round(return_type(x / (10 ** decimal_places)), decimal_places) 1776 1777 def reset(self) -> None: 1778 """ 1779 Reset the internal data structure to its initial state. 1780 1781 Parameters: 1782 None 1783 1784 Returns: 1785 None 1786 """ 1787 self.__vault = Vault() 1788 1789 def clean_history(self, lock: Optional[Timestamp] = None) -> int: 1790 """ 1791 Cleans up the empty history records of actions performed on the ZakatTracker instance. 1792 1793 Parameters: 1794 - lock (Timestamp, optional): The lock ID is used to clean up the empty history. 1795 If not provided, it cleans up the empty history records for all locks. 1796 1797 Returns: 1798 - int: The number of locks cleaned up. 1799 """ 1800 count = 0 1801 if lock in self.__vault.history: 1802 if len(self.__vault.history[lock]) <= 0: 1803 count += 1 1804 del self.__vault.history[lock] 1805 return count 1806 for key in self.__vault.history: 1807 if len(self.__vault.history[key]) <= 0: 1808 count += 1 1809 del self.__vault.history[key] 1810 return count 1811 1812 def __history(self, status: Optional[bool] = None) -> bool: 1813 """ 1814 Enable or disable history tracking. 1815 1816 Parameters: 1817 - status (bool, optional): The status of history tracking. Default is True. 1818 1819 Returns: 1820 None 1821 """ 1822 if status is not None: 1823 self.__history_mode = status 1824 return self.__history_mode 1825 1826 def __step(self, action: Optional[Action] = None, 1827 account: Optional[AccountID] = None, 1828 ref: Optional[Timestamp] = None, 1829 file: Optional[Timestamp] = None, 1830 value: Optional[any] = None, # !!! 1831 key: Optional[str] = None, 1832 math_operation: Optional[MathOperation] = None, 1833 lock_once: bool = True, 1834 debug: bool = False, 1835 ) -> Optional[Timestamp]: 1836 """ 1837 This method is responsible for recording the actions performed on the ZakatTracker. 1838 1839 Parameters: 1840 - action (Action, optional): The type of action performed. 1841 - account (AccountID, optional): The account reference on which the action was performed. 1842 - ref (Optional, optional): The reference number of the action. 1843 - file (Timestamp, optional): The file reference number of the action. 1844 - value (any, optional): The value associated with the action. 1845 - key (str, optional): The key associated with the action. 1846 - math_operation (MathOperation, optional): The mathematical operation performed during the action. 1847 - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True. 1848 - debug (bool, optional): If True, the function will print debug information. Default is False. 1849 1850 Returns: 1851 - Optional[Timestamp]: The lock time of the recorded action. If no lock was performed, it returns 0. 1852 """ 1853 if not self.__history(): 1854 return None 1855 no_lock = self.nolock() 1856 lock = self.__vault.lock 1857 if no_lock: 1858 lock = self.__vault.lock = Time.time() 1859 self.__vault.history[lock] = {} 1860 if action is None: 1861 if lock_once: 1862 assert no_lock, 'forbidden: lock called twice!!!' 1863 return lock 1864 if debug: 1865 print_stack() 1866 assert lock is not None 1867 assert lock > 0 1868 assert account is None or action != Action.REPORT 1869 self.__vault.history[lock][Time.time()] = History( 1870 action=action, 1871 account=account, 1872 ref=ref, 1873 file=file, 1874 key=key, 1875 value=value, 1876 math=math_operation, 1877 ) 1878 return lock 1879 1880 def nolock(self) -> bool: 1881 """ 1882 Check if the vault lock is currently not set. 1883 1884 Parameters: 1885 None 1886 1887 Returns: 1888 - bool: True if the vault lock is not set, False otherwise. 1889 """ 1890 return self.__vault.lock is None 1891 1892 def __lock(self) -> Optional[Timestamp]: 1893 """ 1894 Acquires a lock, potentially repeatedly, by calling the internal `_step` method. 1895 1896 This method specifically invokes the `_step` method with `lock_once` set to `False` 1897 indicating that the lock should be acquired even if it was previously acquired. 1898 This is useful for ensuring a lock is held throughout a critical section of code 1899 1900 Returns: 1901 - Optional[Timestamp]: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt. 1902 """ 1903 return self.__step(lock_once=False) 1904 1905 def lock(self) -> Optional[Timestamp]: 1906 """ 1907 Acquires a lock on the ZakatTracker instance. 1908 1909 Parameters: 1910 None 1911 1912 Returns: 1913 - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later. 1914 """ 1915 return self.__step() 1916 1917 def steps(self) -> dict: 1918 """ 1919 Returns a copy of the history of steps taken in the ZakatTracker. 1920 1921 The history is a dictionary where each key is a unique identifier for a step, 1922 and the corresponding value is a dictionary containing information about the step. 1923 1924 Parameters: 1925 None 1926 1927 Returns: 1928 - dict: A copy of the history of steps taken in the ZakatTracker. 1929 """ 1930 return { 1931 lock: { 1932 timestamp: dataclasses.asdict(history) 1933 for timestamp, history in steps.items() 1934 } 1935 for lock, steps in self.__vault.history.items() 1936 } 1937 1938 def free(self, lock: Timestamp, auto_save: bool = True) -> bool: 1939 """ 1940 Releases the lock on the database. 1941 1942 Parameters: 1943 - lock (Timestamp): The lock ID to be released. 1944 - auto_save (bool, optional): Whether to automatically save the database after releasing the lock. 1945 1946 Returns: 1947 - bool: True if the lock is successfully released and (optionally) saved, False otherwise. 1948 """ 1949 if lock == self.__vault.lock: 1950 self.clean_history(lock) 1951 self.__vault.lock = None 1952 if auto_save and not self.memory_mode(): 1953 return self.save(self.path()) 1954 return True 1955 return False 1956 1957 def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool: 1958 """ 1959 Revert the last operation. 1960 1961 Parameters: 1962 - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 1963 - lock (Timestamp, optional): An optional lock value to ensure the recall 1964 operation is performed on the expected history entry. If provided, 1965 it checks if the current lock and the most recent history key 1966 match the given lock value. Defaults to None. 1967 - debug (bool, optional): If True, the function will print debug information. Default is False. 1968 1969 Returns: 1970 - bool: True if the operation was successful, False otherwise. 1971 """ 1972 if not self.nolock() or len(self.__vault.history) == 0: 1973 return False 1974 if len(self.__vault.history) <= 0: 1975 return False 1976 ref = sorted(self.__vault.history.keys())[-1] 1977 if debug: 1978 print('recall', ref) 1979 memory = sorted(self.__vault.history[ref], reverse=True) 1980 if debug: 1981 print(type(memory), 'memory', memory) 1982 if lock is not None: 1983 assert self.__vault.lock == lock, "Invalid current lock" 1984 assert ref == lock, "Invalid last lock" 1985 assert self.__history(), "History mode should be enabled, found off!!!" 1986 sub_positive_log_negative = 0 1987 for i in memory: 1988 x = self.__vault.history[ref][i] 1989 if debug: 1990 print(type(x), x) 1991 if x.action != Action.REPORT: 1992 assert x.account is not None 1993 if x.action != Action.EXCHANGE: 1994 assert self.account_exists(x.account) 1995 match x.action: 1996 case Action.CREATE: 1997 if debug: 1998 print('account', self.__vault.account[x.account]) 1999 assert len(self.__vault.account[x.account].box) == 0 2000 assert len(self.__vault.account[x.account].log) == 0 2001 assert self.__vault.account[x.account].balance == 0 2002 assert self.__vault.account[x.account].count == 0 2003 assert self.__vault.account[x.account].name == '' 2004 if dry: 2005 continue 2006 del self.__vault.account[x.account] 2007 2008 case Action.NAME: 2009 assert x.value is not None 2010 if dry: 2011 continue 2012 self.__vault.account[x.account].name = x.value 2013 2014 case Action.TRACK: 2015 assert x.value is not None 2016 assert x.ref is not None 2017 if dry: 2018 continue 2019 self.__vault.account[x.account].balance -= x.value 2020 self.__vault.account[x.account].count -= 1 2021 del self.__vault.account[x.account].box[x.ref] 2022 2023 case Action.LOG: 2024 assert x.ref in self.__vault.account[x.account].log 2025 assert x.value is not None 2026 if dry: 2027 continue 2028 if sub_positive_log_negative == -x.value: 2029 self.__vault.account[x.account].count -= 1 2030 sub_positive_log_negative = 0 2031 box_ref = self.__vault.account[x.account].log[x.ref].ref 2032 if not box_ref is None: 2033 assert self.box_exists(x.account, box_ref) 2034 box_value = self.__vault.account[x.account].log[x.ref].value 2035 assert box_value < 0 2036 2037 try: 2038 self.__vault.account[x.account].box[box_ref].rest += -box_value 2039 except TypeError: 2040 self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value) 2041 2042 try: 2043 self.__vault.account[x.account].balance += -box_value 2044 except TypeError: 2045 self.__vault.account[x.account].balance += decimal.Decimal(-box_value) 2046 2047 self.__vault.account[x.account].count -= 1 2048 del self.__vault.account[x.account].log[x.ref] 2049 2050 case Action.SUBTRACT: 2051 assert x.ref in self.__vault.account[x.account].box 2052 assert x.value is not None 2053 if dry: 2054 continue 2055 self.__vault.account[x.account].box[x.ref].rest += x.value 2056 self.__vault.account[x.account].balance += x.value 2057 sub_positive_log_negative = x.value 2058 2059 case Action.ADD_FILE: 2060 assert x.ref in self.__vault.account[x.account].log 2061 assert x.file is not None 2062 assert dry or x.file in self.__vault.account[x.account].log[x.ref].file 2063 if dry: 2064 continue 2065 del self.__vault.account[x.account].log[x.ref].file[x.file] 2066 2067 case Action.REMOVE_FILE: 2068 assert x.ref in self.__vault.account[x.account].log 2069 assert x.file is not None 2070 assert x.value is not None 2071 if dry: 2072 continue 2073 self.__vault.account[x.account].log[x.ref].file[x.file] = x.value 2074 2075 case Action.BOX_TRANSFER: 2076 assert x.ref in self.__vault.account[x.account].box 2077 assert x.value is not None 2078 if dry: 2079 continue 2080 self.__vault.account[x.account].box[x.ref].rest -= x.value 2081 2082 case Action.EXCHANGE: 2083 assert x.account in self.__vault.exchange 2084 assert x.ref in self.__vault.exchange[x.account] 2085 if dry: 2086 continue 2087 del self.__vault.exchange[x.account][x.ref] 2088 2089 case Action.REPORT: 2090 assert x.ref in self.__vault.report 2091 if dry: 2092 continue 2093 del self.__vault.report[x.ref] 2094 2095 case Action.ZAKAT: 2096 assert x.ref in self.__vault.account[x.account].box 2097 assert x.key is not None 2098 assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) 2099 if dry: 2100 continue 2101 match x.math: 2102 case MathOperation.ADDITION: 2103 setattr( 2104 self.__vault.account[x.account].box[x.ref].zakat, 2105 x.key, 2106 getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value, 2107 ) 2108 case MathOperation.EQUAL: 2109 setattr( 2110 self.__vault.account[x.account].box[x.ref].zakat, 2111 x.key, 2112 x.value, 2113 ) 2114 case MathOperation.SUBTRACTION: 2115 setattr( 2116 self.__vault.account[x.account].box[x.ref], 2117 x.key, 2118 getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value, 2119 ) 2120 2121 if not dry: 2122 del self.__vault.history[ref] 2123 return True 2124 2125 def vault(self) -> dict: 2126 """ 2127 Returns a copy of the internal vault dictionary. 2128 2129 This method is used to retrieve the current state of the ZakatTracker object. 2130 It provides a snapshot of the internal data structure, allowing for further 2131 processing or analysis. 2132 2133 Parameters: 2134 None 2135 2136 Returns: 2137 - dict: A copy of the internal vault dictionary. 2138 """ 2139 return dataclasses.asdict(self.__vault) 2140 2141 @staticmethod 2142 def stats_init() -> FileStats: 2143 """ 2144 Initialize and return the initial file statistics. 2145 2146 Returns: 2147 - FileStats: A :class:`FileStats` instance with initial values 2148 of 0 bytes for both RAM and database. 2149 """ 2150 return FileStats( 2151 database=SizeInfo(0, '0'), 2152 ram=SizeInfo(0, '0'), 2153 ) 2154 2155 def stats(self, ignore_ram: bool = True) -> FileStats: 2156 """ 2157 Calculates and returns statistics about the object's data storage. 2158 2159 This method determines the size of the database file on disk and the 2160 size of the data currently held in RAM (likely within a dictionary). 2161 Both sizes are reported in bytes and in a human-readable format 2162 (e.g., KB, MB). 2163 2164 Parameters: 2165 - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True 2166 2167 Returns: 2168 - FileStats: A dataclass containing the following statistics: 2169 2170 * 'database': A tuple with two elements: 2171 - The database file size in bytes (float). 2172 - The database file size in human-readable format (str). 2173 * 'ram': A tuple with two elements: 2174 - The RAM usage (dictionary size) in bytes (float). 2175 - The RAM usage in human-readable format (str). 2176 2177 Example: 2178 ```bash 2179 >>> x = ZakatTracker() 2180 >>> stats = x.stats() 2181 >>> print(stats.database) 2182 SizeInfo(bytes=256000, human_readable='250.0 KB') 2183 >>> print(stats.ram) 2184 SizeInfo(bytes=12345, human_readable='12.1 KB') 2185 ``` 2186 """ 2187 ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault()) 2188 file_size = os.path.getsize(self.path()) 2189 return FileStats( 2190 database=SizeInfo(file_size, self.human_readable_size(file_size)), 2191 ram=SizeInfo(ram_size, self.human_readable_size(ram_size)), 2192 ) 2193 2194 def files(self) -> list[FileInfo]: 2195 """ 2196 Retrieves information about files associated with this class. 2197 2198 This class method provides a standardized way to gather details about 2199 files used by the class for storage, snapshots, and CSV imports. 2200 2201 Parameters: 2202 None 2203 2204 Returns: 2205 - list[FileInfo]: A list of dataclass, each containing information 2206 about a specific file: 2207 2208 * type (str): The type of file ('database', 'snapshot', 'import_csv'). 2209 * path (str): The full file path. 2210 * exists (bool): Whether the file exists on the filesystem. 2211 * size (int): The file size in bytes (0 if the file doesn't exist). 2212 * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB'). 2213 """ 2214 result = [] 2215 for file_type, path in { 2216 'database': self.path(), 2217 'snapshot': self.snapshot_cache_path(), 2218 'import_csv': self.import_csv_cache_path(), 2219 }.items(): 2220 exists = os.path.exists(path) 2221 size = os.path.getsize(path) if exists else 0 2222 human_readable_size = self.human_readable_size(size) if exists else '0' 2223 result.append(FileInfo( 2224 type=file_type, 2225 path=path, 2226 exists=exists, 2227 size=size, 2228 human_readable_size=human_readable_size, 2229 )) 2230 return result 2231 2232 def account_exists(self, account: AccountID) -> bool: 2233 """ 2234 Check if the given account exists in the vault. 2235 2236 Parameters: 2237 - account (AccountID): The account reference to check. 2238 2239 Returns: 2240 - bool: True if the account exists, False otherwise. 2241 """ 2242 account = AccountID(account) 2243 return account in self.__vault.account 2244 2245 def box_size(self, account: AccountID) -> int: 2246 """ 2247 Calculate the size of the box for a specific account. 2248 2249 Parameters: 2250 - account (AccountID): The account reference for which the box size needs to be calculated. 2251 2252 Returns: 2253 - int: The size of the box for the given account. If the account does not exist, -1 is returned. 2254 """ 2255 if self.account_exists(account): 2256 return len(self.__vault.account[account].box) 2257 return -1 2258 2259 def log_size(self, account: AccountID) -> int: 2260 """ 2261 Get the size of the log for a specific account. 2262 2263 Parameters: 2264 - account (AccountID): The account reference for which the log size needs to be calculated. 2265 2266 Returns: 2267 - int: The size of the log for the given account. If the account does not exist, -1 is returned. 2268 """ 2269 if self.account_exists(account): 2270 return len(self.__vault.account[account].log) 2271 return -1 2272 2273 @staticmethod 2274 def hash_data(data: bytes, algorithm: str = 'blake2b') -> str: 2275 """ 2276 Calculates the hash of given byte data using the specified algorithm. 2277 2278 Parameters: 2279 - data (bytes): The byte data to hash. 2280 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2281 2282 Returns: 2283 - str: The hexadecimal representation of the data's hash. 2284 """ 2285 hash_obj = hashlib.new(algorithm) 2286 hash_obj.update(data) 2287 return hash_obj.hexdigest() 2288 2289 @staticmethod 2290 def hash_file(file_path: str, algorithm: str = 'blake2b') -> str: 2291 """ 2292 Calculates the hash of a file using the specified algorithm. 2293 2294 Parameters: 2295 - file_path (str): The path to the file. 2296 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2297 2298 Returns: 2299 - str: The hexadecimal representation of the file's hash. 2300 """ 2301 hash_obj = hashlib.new(algorithm) # Create the hash object 2302 with open(file_path, 'rb') as file: # Open file in binary mode for reading 2303 for chunk in iter(lambda: file.read(4096), b''): # Read file in chunks 2304 hash_obj.update(chunk) 2305 return hash_obj.hexdigest() # Return the hash as a hexadecimal string 2306 2307 def snapshot_cache_path(self): 2308 """ 2309 Generate the path for the cache file used to store snapshots. 2310 2311 The cache file is a json file that stores the timestamps of the snapshots. 2312 The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'. 2313 2314 Parameters: 2315 None 2316 2317 Returns: 2318 - str: The path to the cache file. 2319 """ 2320 path = str(self.path()) 2321 ext = self.ext() 2322 ext_len = len(ext) 2323 if path.endswith(f'.{ext}'): 2324 path = path[:-ext_len - 1] 2325 _, filename = os.path.split(path + f'.snapshots.{ext}') 2326 return self.base_path(filename) 2327 2328 def snapshot(self) -> bool: 2329 """ 2330 This function creates a snapshot of the current database state. 2331 2332 The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. 2333 If a snapshot with the same hash exists, the function returns True without creating a new snapshot. 2334 If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state 2335 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. 2336 2337 Parameters: 2338 None 2339 2340 Returns: 2341 - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails. 2342 """ 2343 current_hash = self.hash_file(self.path()) 2344 cache: dict[str, int] = {} # hash: time_ns 2345 try: 2346 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2347 cache = json.load(stream, cls=JSONDecoder) 2348 except: 2349 pass 2350 if current_hash in cache: 2351 return True 2352 ref = time.time_ns() 2353 cache[current_hash] = ref 2354 if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')): 2355 return False 2356 with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream: 2357 stream.write(json.dumps(cache, cls=JSONEncoder)) 2358 return True 2359 2360 def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \ 2361 -> dict[int, tuple[str, str, bool]]: 2362 """ 2363 Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status. 2364 2365 Parameters: 2366 - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True. 2367 - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False. 2368 2369 Returns: 2370 - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, 2371 and the values are tuples containing the snapshot's hash, path, and existence status. 2372 """ 2373 cache: dict[str, int] = {} # hash: time_ns 2374 try: 2375 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2376 cache = json.load(stream, cls=JSONDecoder) 2377 except: 2378 pass 2379 if not cache: 2380 return {} 2381 result: dict[int, tuple[str, str, bool]] = {} # time_ns: (hash, path, exists) 2382 for hash_file, ref in cache.items(): 2383 path = self.base_path('snapshots', f'{ref}.{self.ext()}') 2384 exists = os.path.exists(path) 2385 valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True 2386 if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists): 2387 continue 2388 if exists or not hide_missing: 2389 result[ref] = (hash_file, path, exists) 2390 return result 2391 2392 def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool: 2393 """ 2394 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 2395 2396 Parameters: 2397 - account (AccountID): The account reference for which to check the existence of the reference. 2398 - ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 2399 - ref (Timestamp): The reference (transaction) number to check for existence. 2400 2401 Returns: 2402 - bool: True if the reference exists for the given account and reference type, False otherwise. 2403 """ 2404 account = AccountID(account) 2405 if account in self.__vault.account: 2406 return ref in getattr(self.__vault.account[account], ref_type) 2407 return False 2408 2409 def box_exists(self, account: AccountID, ref: Timestamp) -> bool: 2410 """ 2411 Check if a specific box (transaction) exists in the vault for a given account and reference. 2412 2413 Parameters: 2414 - account (AccountID): The account reference for which to check the existence of the box. 2415 - ref (Timestamp): The reference (transaction) number to check for existence. 2416 2417 Returns: 2418 - bool: True if the box exists for the given account and reference, False otherwise. 2419 """ 2420 return self.ref_exists(account, 'box', ref) 2421 2422 def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'), 2423 created_time_ns: Optional[Timestamp] = None, 2424 debug: bool = False) -> Optional[Timestamp]: 2425 """ 2426 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. 2427 2428 Parameters: 2429 - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0. 2430 - desc (str, optional): The description of the transaction. Default is an empty string. 2431 - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'. 2432 - 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. 2433 - debug (bool, optional): Whether to print debug information. Default is False. 2434 2435 Returns: 2436 - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD). 2437 2438 Raises: 2439 - ValueError: The created_time_ns should be greater than zero. 2440 - ValueError: The log transaction happened again in the same nanosecond time. 2441 - ValueError: The box transaction happened again in the same nanosecond time. 2442 """ 2443 return self.__track( 2444 unscaled_value=unscaled_value, 2445 desc=desc, 2446 account=account, 2447 logging=True, 2448 created_time_ns=created_time_ns, 2449 debug=debug, 2450 ) 2451 2452 def __track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'), 2453 logging: bool = True, 2454 created_time_ns: Optional[Timestamp] = None, 2455 debug: bool = False) -> Optional[Timestamp]: 2456 """ 2457 Internal function to track a transaction. 2458 2459 This function handles the core logic for tracking a transaction, including account creation, logging, and box creation. 2460 2461 Parameters: 2462 - unscaled_value (float | int | decimal.Decimal, optional): The monetary value of the transaction. Defaults to 0. 2463 - desc (str, optional): A description of the transaction. Defaults to an empty string. 2464 - account (AccountID, optional): The reference of the account to track the transaction for. Defaults to '1'. 2465 - logging (bool, optional): Enables transaction logging. Defaults to True. 2466 - 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. 2467 - debug (bool, optional): Enables debug printing. Defaults to False. 2468 2469 Returns: 2470 - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since the epoch. 2471 2472 Raises: 2473 - ValueError: If `created_time_ns` is not greater than zero. 2474 - ValueError: If a box transaction already exists for the given `account` and `created_time_ns`. 2475 """ 2476 if debug: 2477 print('track', f'unscaled_value={unscaled_value}, debug={debug}') 2478 account = AccountID(account) 2479 if created_time_ns is None: 2480 created_time_ns = Time.time() 2481 if created_time_ns <= 0: 2482 raise ValueError('The created should be greater than zero.') 2483 no_lock = self.nolock() 2484 lock = self.__lock() 2485 if not self.account_exists(account): 2486 if debug: 2487 print(f'account {account} created') 2488 self.__vault.account[account] = Account( 2489 balance=0, 2490 created=created_time_ns, 2491 ) 2492 self.__step(Action.CREATE, account) 2493 if unscaled_value == 0: 2494 if no_lock: 2495 assert lock is not None 2496 self.free(lock) 2497 return None 2498 value = self.scale(unscaled_value) 2499 if logging: 2500 self.__log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 2501 if debug: 2502 print('create-box', created_time_ns) 2503 if self.box_exists(account, created_time_ns): 2504 raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).') 2505 if debug: 2506 print('created-box', created_time_ns) 2507 self.__vault.account[account].box[created_time_ns] = Box( 2508 capital=value, 2509 rest=value, 2510 zakat=BoxZakat(0, 0, 0), 2511 ) 2512 self.__step(Action.TRACK, account, ref=created_time_ns, value=value) 2513 if no_lock: 2514 assert lock is not None 2515 self.free(lock) 2516 return created_time_ns 2517 2518 def log_exists(self, account: AccountID, ref: Timestamp) -> bool: 2519 """ 2520 Checks if a specific transaction log entry exists for a given account. 2521 2522 Parameters: 2523 - account (AccountID): The account reference associated with the transaction log. 2524 - ref (Timestamp): The reference to the transaction log entry. 2525 2526 Returns: 2527 - bool: True if the transaction log entry exists, False otherwise. 2528 """ 2529 return self.ref_exists(account, 'log', ref) 2530 2531 def __log(self, value: int, desc: str = '', account: AccountID = AccountID('1'), 2532 created_time_ns: Optional[Timestamp] = None, 2533 ref: Optional[Timestamp] = None, 2534 debug: bool = False) -> Timestamp: 2535 """ 2536 Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details. 2537 It also creates a step in the history of the transaction. 2538 2539 Parameters: 2540 - value (int): The value of the transaction. 2541 - desc (str, optional): The description of the transaction. 2542 - account (AccountID, optional): The account reference to log the transaction into. Default is '1'. 2543 - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 2544 If not provided, it will be generated. 2545 - ref (Timestamp, optional): The reference of the object. 2546 - debug (bool, optional): Whether to print debug information. Default is False. 2547 2548 Returns: 2549 - Timestamp: The timestamp of the logged transaction. 2550 2551 Raises: 2552 - ValueError: The created_time_ns should be greater than zero. 2553 - ValueError: The log transaction happened again in the same nanosecond time. 2554 """ 2555 if debug: 2556 print('_log', f'debug={debug}') 2557 account = AccountID(account) 2558 if created_time_ns is None: 2559 created_time_ns = Time.time() 2560 if created_time_ns <= 0: 2561 raise ValueError('The created should be greater than zero.') 2562 try: 2563 self.__vault.account[account].balance += value 2564 except TypeError: 2565 self.__vault.account[account].balance += decimal.Decimal(value) 2566 self.__vault.account[account].count += 1 2567 if debug: 2568 print('create-log', created_time_ns) 2569 if self.log_exists(account, created_time_ns): 2570 raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).') 2571 if debug: 2572 print('created-log', created_time_ns) 2573 self.__vault.account[account].log[created_time_ns] = Log( 2574 value=value, 2575 desc=desc, 2576 ref=ref, 2577 file={}, 2578 ) 2579 self.__step(Action.LOG, account, ref=created_time_ns, value=value) 2580 return created_time_ns 2581 2582 def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None, 2583 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange: 2584 """ 2585 This method is used to record or retrieve exchange rates for a specific account. 2586 2587 Parameters: 2588 - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved. 2589 - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 2590 - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 2591 - description (str, optional): A description of the exchange rate. 2592 - debug (bool, optional): Whether to print debug information. Default is False. 2593 2594 Returns: 2595 - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 2596 it returns a dictionary with default values for the rate and description. 2597 2598 Raises: 2599 - ValueError: The created should be greater than zero. 2600 """ 2601 if debug: 2602 print('exchange', f'debug={debug}') 2603 account = AccountID(account) 2604 if created_time_ns is None: 2605 created_time_ns = Time.time() 2606 if created_time_ns <= 0: 2607 raise ValueError('The created should be greater than zero.') 2608 if rate is not None: 2609 if rate <= 0: 2610 return Exchange() 2611 if account not in self.__vault.exchange: 2612 self.__vault.exchange[account] = {} 2613 if len(self.__vault.exchange[account]) == 0 and rate <= 1: 2614 return Exchange(time=created_time_ns, rate=1) 2615 no_lock = self.nolock() 2616 lock = self.__lock() 2617 self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description) 2618 self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate) 2619 if no_lock: 2620 assert lock is not None 2621 self.free(lock) 2622 if debug: 2623 print('exchange-created-1', 2624 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2625 2626 if account in self.__vault.exchange: 2627 valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns] 2628 if valid_rates: 2629 latest_rate = max(valid_rates, key=lambda x: x[0]) 2630 if debug: 2631 print('exchange-read-1', 2632 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}', 2633 'latest_rate', latest_rate) 2634 result = latest_rate[1] 2635 result.time = latest_rate[0] 2636 return result # إرجاع قاموس يحتوي على المعدل والوصف 2637 if debug: 2638 print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2639 return Exchange(time=created_time_ns, rate=1, description=None) # إرجاع القيمة الافتراضية مع وصف فارغ 2640 2641 @staticmethod 2642 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 2643 """ 2644 This function calculates the exchanged amount of a currency. 2645 2646 Parameters: 2647 - x (float): The original amount of the currency. 2648 - x_rate (float): The exchange rate of the original currency. 2649 - y_rate (float): The exchange rate of the target currency. 2650 2651 Returns: 2652 - float: The exchanged amount of the target currency. 2653 """ 2654 return (x * x_rate) / y_rate 2655 2656 def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]: 2657 """ 2658 Retrieve the recorded exchange rates for all accounts. 2659 2660 Parameters: 2661 None 2662 2663 Returns: 2664 - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. 2665 The keys are account references or numbers, and the values are dictionaries containing the exchange rates. 2666 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 2667 """ 2668 return self.__vault.exchange.copy() 2669 2670 def accounts(self) -> dict[AccountID, AccountDetails]: 2671 """ 2672 Returns a dictionary containing account references as keys and their respective account details as values. 2673 2674 Parameters: 2675 None 2676 2677 Returns: 2678 - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details. 2679 """ 2680 return { 2681 account_id: AccountDetails( 2682 account_id=account_id, 2683 account_name=self.__vault.account[account_id].name, 2684 balance=self.__vault.account[account_id].balance, 2685 ) 2686 for account_id in self.__vault.account 2687 } 2688 2689 def boxes(self, account: AccountID) -> dict[Timestamp, Box]: 2690 """ 2691 Retrieve the boxes (transactions) associated with a specific account. 2692 2693 Parameters: 2694 - account (AccountID): The account reference for which to retrieve the boxes. 2695 2696 Returns: 2697 - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account. 2698 If the account does not exist, an empty dictionary is returned. 2699 """ 2700 if self.account_exists(account): 2701 return self.__vault.account[account].box 2702 return {} 2703 2704 def logs(self, account: AccountID) -> dict[Timestamp, Log]: 2705 """ 2706 Retrieve the logs (transactions) associated with a specific account. 2707 2708 Parameters: 2709 - account (AccountID): The account reference for which to retrieve the logs. 2710 2711 Returns: 2712 - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. 2713 If the account does not exist, an empty dictionary is returned. 2714 """ 2715 if self.account_exists(account): 2716 return self.__vault.account[account].log 2717 return {} 2718 2719 def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline: 2720 """ 2721 Aggregates transaction logs into a structured timeline. 2722 2723 This method retrieves transaction logs from all accounts and organizes them 2724 into daily, weekly, monthly, and yearly summaries. Each level of the 2725 timeline includes a `TimeSummary` object with the total positive, negative, 2726 and overall values for that period. The daily level also includes a list 2727 of individual `Transaction` records. 2728 2729 Parameters: 2730 - weekday (WeekDay, optional): The day of the week to use as the anchor 2731 for weekly summaries. Defaults to WeekDay.FRIDAY. 2732 - debug (bool, optional): If True, prints intermediate debug information 2733 during processing. Defaults to False. 2734 2735 Returns: 2736 - Timeline: An object containing the aggregated transaction data, organized 2737 into daily, weekly, monthly, and yearly summaries. The 'daily' 2738 attribute is a dictionary where keys are dates (YYYY-MM-DD) and 2739 values are `DailyRecords` objects. The 'weekly' attribute is a 2740 dictionary where keys are the starting datetime of the week and 2741 values are `TimeSummary` objects. The 'monthly' attribute is a 2742 dictionary where keys are year-month strings (YYYY-MM) and values 2743 are `TimeSummary` objects. The 'yearly' attribute is a dictionary 2744 where keys are years (YYYY) and values are `TimeSummary` objects. 2745 2746 Example: 2747 ```bash 2748 >>> from zakat import tracker 2749 >>> ledger = tracker(':memory:') 2750 >>> account1_id = ledger.create_account('account1') 2751 >>> account2_id = ledger.create_account('account2') 2752 >>> ledger.subtract(51, 'desc', account1_id) 2753 >>> ref = ledger.track(100, 'desc', account2_id) 2754 >>> ledger.add_file(account2_id, ref, 'file_0') 2755 >>> ledger.add_file(account2_id, ref, 'file_1') 2756 >>> ledger.add_file(account2_id, ref, 'file_2') 2757 >>> ledger.timeline() 2758 Timeline( 2759 daily={ 2760 "2025-04-06": DailyRecords( 2761 positive=10000, 2762 negative=5100, 2763 total=4900, 2764 rows=[ 2765 Transaction( 2766 account="account2", 2767 account_id="63879638114290122752", 2768 desc="desc2", 2769 file={ 2770 63879638220705865728: "file_0", 2771 63879638223391350784: "file_1", 2772 63879638225766047744: "file_2", 2773 }, 2774 value=10000, 2775 time=63879638181936513024, 2776 transfer=False, 2777 ), 2778 Transaction( 2779 account="account1", 2780 account_id="63879638104007106560", 2781 desc="desc", 2782 file={}, 2783 value=-5100, 2784 time=63879638149199421440, 2785 transfer=False, 2786 ), 2787 ], 2788 ) 2789 }, 2790 weekly={ 2791 datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary( 2792 positive=10000, negative=0, total=10000 2793 ), 2794 datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary( 2795 positive=0, negative=5100, total=-5100 2796 ), 2797 }, 2798 monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)}, 2799 yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)}, 2800 ) 2801 ``` 2802 """ 2803 logs: dict[Timestamp, list[Transaction]] = {} 2804 for account_id in self.accounts(): 2805 for log_ref, log in self.logs(account_id).items(): 2806 if log_ref not in logs: 2807 logs[log_ref] = [] 2808 logs[log_ref].append(Transaction( 2809 account=self.name(account_id), 2810 account_id=account_id, 2811 log_ref=log_ref, 2812 desc=log.desc, 2813 file=log.file, 2814 value=log.value, 2815 time=log_ref, 2816 transfer=False, 2817 )) 2818 if debug: 2819 print('logs', logs) 2820 y = Timeline() 2821 for i in sorted(logs, reverse=True): 2822 dt = Time.time_to_datetime(i) 2823 daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}' 2824 weekly = dt - datetime.timedelta(days=weekday.value) 2825 monthly = f'{dt.year}-{dt.month:02d}' 2826 yearly = dt.year 2827 # daily 2828 if daily not in y.daily: 2829 y.daily[daily] = DailyRecords() 2830 transfer = len(logs[i]) > 1 2831 if debug: 2832 print('logs[i]', logs[i]) 2833 for z in logs[i]: 2834 if debug: 2835 print('z', z) 2836 # daily 2837 value = z.value 2838 if value > 0: 2839 y.daily[daily].positive += value 2840 else: 2841 y.daily[daily].negative += -value 2842 y.daily[daily].total += value 2843 z.transfer = transfer 2844 y.daily[daily].rows.append(z) 2845 # weekly 2846 if weekly not in y.weekly: 2847 y.weekly[weekly] = TimeSummary() 2848 if value > 0: 2849 y.weekly[weekly].positive += value 2850 else: 2851 y.weekly[weekly].negative += -value 2852 y.weekly[weekly].total += value 2853 # monthly 2854 if monthly not in y.monthly: 2855 y.monthly[monthly] = TimeSummary() 2856 if value > 0: 2857 y.monthly[monthly].positive += value 2858 else: 2859 y.monthly[monthly].negative += -value 2860 y.monthly[monthly].total += value 2861 # yearly 2862 if yearly not in y.yearly: 2863 y.yearly[yearly] = TimeSummary() 2864 if value > 0: 2865 y.yearly[yearly].positive += value 2866 else: 2867 y.yearly[yearly].negative += -value 2868 y.yearly[yearly].total += value 2869 if debug: 2870 print('y', y) 2871 return y 2872 2873 def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp: 2874 """ 2875 Adds a file reference to a specific transaction log entry in the vault. 2876 2877 Parameters: 2878 - account (AccountID): The account reference associated with the transaction log. 2879 - ref (Timestamp): The reference to the transaction log entry. 2880 - path (str): The path of the file to be added. 2881 2882 Returns: 2883 - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 2884 """ 2885 if self.account_exists(account): 2886 if ref in self.__vault.account[account].log: 2887 no_lock = self.nolock() 2888 lock = self.__lock() 2889 file_ref = Time.time() 2890 self.__vault.account[account].log[ref].file[file_ref] = path 2891 self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref) 2892 if no_lock: 2893 assert lock is not None 2894 self.free(lock) 2895 return file_ref 2896 return Timestamp(0) 2897 2898 def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool: 2899 """ 2900 Removes a file reference from a specific transaction log entry in the vault. 2901 2902 Parameters: 2903 - account (AccountID): The account reference associated with the transaction log. 2904 - ref (Timestamp): The reference to the transaction log entry. 2905 - file_ref (Timestamp): The reference of the file to be removed. 2906 2907 Returns: 2908 - bool: True if the file reference is successfully removed, False otherwise. 2909 """ 2910 if self.account_exists(account): 2911 if ref in self.__vault.account[account].log: 2912 if file_ref in self.__vault.account[account].log[ref].file: 2913 no_lock = self.nolock() 2914 lock = self.__lock() 2915 x = self.__vault.account[account].log[ref].file[file_ref] 2916 del self.__vault.account[account].log[ref].file[file_ref] 2917 self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 2918 if no_lock: 2919 assert lock is not None 2920 self.free(lock) 2921 return True 2922 return False 2923 2924 def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int: 2925 """ 2926 Calculate and return the balance of a specific account. 2927 2928 Parameters: 2929 - account (AccountID, optional): The account reference. Default is '1'. 2930 - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 2931 2932 Returns: 2933 - int: The balance of the account. 2934 2935 Notes: 2936 - If cached is True, the function returns the cached balance. 2937 - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 2938 """ 2939 account = AccountID(account) 2940 if cached: 2941 return self.__vault.account[account].balance 2942 x = 0 2943 return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1] 2944 2945 def hide(self, account: AccountID, status: Optional[bool] = None) -> bool: 2946 """ 2947 Check or set the hide status of a specific account. 2948 2949 Parameters: 2950 - account (AccountID): The account reference. 2951 - status (bool, optional): The new hide status. If not provided, the function will return the current status. 2952 2953 Returns: 2954 - bool: The current or updated hide status of the account. 2955 2956 Raises: 2957 None 2958 2959 Example: 2960 ```bash 2961 >>> tracker = ZakatTracker() 2962 >>> ref = tracker.track(51, 'desc', 'account1') 2963 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 2964 False 2965 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 2966 True 2967 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 2968 True 2969 >>> tracker.hide('account1', False) 2970 False 2971 ``` 2972 """ 2973 if self.account_exists(account): 2974 if status is None: 2975 return self.__vault.account[account].hide 2976 self.__vault.account[account].hide = status 2977 return status 2978 return False 2979 2980 def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]: 2981 """ 2982 Retrieves an AccountDetails object for the first account matching the given name. 2983 2984 This method searches for accounts with names that contain the provided 'name' 2985 (case-insensitive substring matching). If a match is found, it returns an 2986 AccountDetails object containing the account's ID, name and balance. If no matching 2987 account is found, it returns None. 2988 2989 Parameters: 2990 - name: The name (or partial name) of the account to retrieve. 2991 - exact: If True, performs a case-insensitive exact match. 2992 If False, performs a case-insensitive substring search. 2993 Defaults to True. 2994 2995 Returns: 2996 - AccountDetails: An AccountDetails object representing the found account, or None if no 2997 matching account exists. 2998 """ 2999 for account_name, account_id in self.names(name).items(): 3000 if not exact or account_name.lower() == name.lower(): 3001 return AccountDetails( 3002 account_id=account_id, 3003 account_name=account_name, 3004 balance=self.__vault.account[account_id].balance, 3005 ) 3006 return None 3007 3008 def create_account(self, name: str) -> AccountID: 3009 """ 3010 Creates a new account with the given name and returns its unique ID. 3011 3012 This method: 3013 1. Checks if an account with the same name (case-insensitive) already exists. 3014 2. Generates a unique `AccountID` based on the current time. 3015 3. Tracks the account creation internally. 3016 4. Sets the account's name. 3017 5. Verifies that the name was set correctly. 3018 3019 Parameters: 3020 - name: The name of the new account. 3021 3022 Returns: 3023 - AccountID: The unique `AccountID` of the newly created account. 3024 3025 Raises: 3026 - AssertionError: Empty account name is forbidden. 3027 - AssertionError: Account name in number is forbidden. 3028 - AssertionError: If an account with the same name already exists (case-insensitive). 3029 - AssertionError: If the provided name does not match the name set for the account. 3030 """ 3031 assert name.strip(), 'empty account name is forbidden' 3032 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' 3033 account_ref = self.account(name, exact=True) 3034 # check if account not exists 3035 assert account_ref is None, f'account name({name}) already used' 3036 # create new account 3037 account_id = AccountID(Time.time()) 3038 self.__track(0, '', account_id) 3039 new_name = self.name( 3040 account=account_id, 3041 new_name=name, 3042 ) 3043 assert name == new_name 3044 return account_id 3045 3046 def names(self, keyword: str = '') -> dict[str, AccountID]: 3047 """ 3048 Retrieves a dictionary of account IDs and names, optionally filtered by a keyword. 3049 3050 Parameters: 3051 - keyword: An optional string to filter account names. If provided, only accounts whose 3052 names contain the keyword (case-insensitive) will be included in the result. 3053 Defaults to an empty string, which returns all accounts. 3054 3055 Returns: 3056 - A dictionary where keys are account names and values are AccountIDs. The dictionary 3057 contains only accounts that match the provided keyword (if any). 3058 """ 3059 return { 3060 account.name: account_id 3061 for account_id, account in self.__vault.account.items() 3062 if keyword.lower() in account.name.lower() 3063 } 3064 3065 def name(self, account: AccountID, new_name: Optional[str] = None) -> str: 3066 """ 3067 Retrieves or sets the name of an account. 3068 3069 Parameters: 3070 - account: The AccountID of the account. 3071 - new_name: The new name to set for the account. If None, the current name is retrieved. 3072 3073 Returns: 3074 - The current name of the account if `new_name` is None, or the `new_name` if it is set. 3075 3076 Note: Returns an empty string if the account does not exist. 3077 """ 3078 if self.account_exists(account): 3079 if new_name is None: 3080 return self.__vault.account[account].name 3081 assert new_name != '' 3082 no_lock = self.nolock() 3083 lock = self.__lock() 3084 self.__step(Action.NAME, account, value=self.__vault.account[account].name) 3085 self.__vault.account[account].name = new_name 3086 if no_lock: 3087 assert lock is not None 3088 self.free(lock) 3089 return new_name 3090 return '' 3091 3092 def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool: 3093 """ 3094 Check or set the zakatable status of a specific account. 3095 3096 Parameters: 3097 - account (AccountID): The account reference. 3098 - status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 3099 3100 Returns: 3101 - bool: The current or updated zakatable status of the account. 3102 3103 Raises: 3104 None 3105 3106 Example: 3107 ```bash 3108 >>> tracker = ZakatTracker() 3109 >>> ref = tracker.track(51, 'desc', 'account1') 3110 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 3111 True 3112 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 3113 True 3114 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 3115 True 3116 >>> tracker.zakatable('account1', False) 3117 False 3118 ``` 3119 """ 3120 if self.account_exists(account): 3121 if status is None: 3122 return self.__vault.account[account].zakatable 3123 self.__vault.account[account].zakatable = status 3124 return status 3125 return False 3126 3127 def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'), 3128 created_time_ns: Optional[Timestamp] = None, 3129 debug: bool = False) \ 3130 -> SubtractReport: 3131 """ 3132 Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, 3133 the remaining amount will be transferred to a new transaction with a negative value. 3134 3135 Parameters: 3136 - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted. 3137 - desc (str, optional): A description for the transaction. Defaults to an empty string. 3138 - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'. 3139 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 3140 If not provided, the current timestamp will be used. 3141 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 3142 3143 Returns: 3144 - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 3145 3146 Raises: 3147 - ValueError: The unscaled_value should be greater than zero. 3148 - ValueError: The created_time_ns should be greater than zero. 3149 - ValueError: The box transaction happened again in the same nanosecond time. 3150 - ValueError: The log transaction happened again in the same nanosecond time. 3151 """ 3152 if debug: 3153 print('sub', f'debug={debug}') 3154 account = AccountID(account) 3155 if unscaled_value <= 0: 3156 raise ValueError('The unscaled_value should be greater than zero.') 3157 if created_time_ns is None: 3158 created_time_ns = Time.time() 3159 if created_time_ns <= 0: 3160 raise ValueError('The created should be greater than zero.') 3161 no_lock = self.nolock() 3162 lock = self.__lock() 3163 self.__track(0, '', account) 3164 value = self.scale(unscaled_value) 3165 self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 3166 ids = sorted(self.__vault.account[account].box.keys()) 3167 limit = len(ids) + 1 3168 target = value 3169 if debug: 3170 print('ids', ids) 3171 ages = SubtractAges() 3172 for i in range(-1, -limit, -1): 3173 if target == 0: 3174 break 3175 j = ids[i] 3176 if debug: 3177 print('i', i, 'j', j) 3178 rest = self.__vault.account[account].box[j].rest 3179 if rest >= target: 3180 self.__vault.account[account].box[j].rest -= target 3181 self.__step(Action.SUBTRACT, account, ref=j, value=target) 3182 ages.append(SubtractAge(box_ref=j, total=target)) 3183 target = 0 3184 break 3185 elif target > rest > 0: 3186 chunk = rest 3187 target -= chunk 3188 self.__vault.account[account].box[j].rest = 0 3189 self.__step(Action.SUBTRACT, account, ref=j, value=chunk) 3190 ages.append(SubtractAge(box_ref=j, total=chunk)) 3191 if target > 0: 3192 self.__track( 3193 unscaled_value=self.unscale(-target), 3194 desc=desc, 3195 account=account, 3196 logging=False, 3197 created_time_ns=created_time_ns, 3198 ) 3199 ages.append(SubtractAge(box_ref=created_time_ns, total=target)) 3200 if no_lock: 3201 assert lock is not None 3202 self.free(lock) 3203 return SubtractReport( 3204 log_ref=created_time_ns, 3205 ages=ages, 3206 ) 3207 3208 def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '', 3209 created_time_ns: Optional[Timestamp] = None, 3210 debug: bool = False) -> Optional[TransferReport]: 3211 """ 3212 Transfers a specified value from one account to another. 3213 3214 Parameters: 3215 - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred. 3216 - from_account (AccountID): The account reference from which the value will be transferred. 3217 - to_account (AccountID): The account reference to which the value will be transferred. 3218 - desc (str, optional): A description for the transaction. Defaults to an empty string. 3219 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used. 3220 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 3221 3222 Returns: 3223 - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer. 3224 3225 Raises: 3226 - ValueError: Transfer to the same account is forbidden. 3227 - ValueError: The created_time_ns should be greater than zero. 3228 - ValueError: The box transaction happened again in the same nanosecond time. 3229 - ValueError: The log transaction happened again in the same nanosecond time. 3230 """ 3231 if debug: 3232 print('transfer', f'debug={debug}') 3233 from_account = AccountID(from_account) 3234 to_account = AccountID(to_account) 3235 if from_account == to_account: 3236 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 3237 if unscaled_amount <= 0: 3238 return None 3239 if created_time_ns is None: 3240 created_time_ns = Time.time() 3241 if created_time_ns <= 0: 3242 raise ValueError('The created should be greater than zero.') 3243 no_lock = self.nolock() 3244 lock = self.__lock() 3245 subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug) 3246 source_exchange = self.exchange(from_account, created_time_ns) 3247 target_exchange = self.exchange(to_account, created_time_ns) 3248 3249 if debug: 3250 print('ages', subtract_report.ages) 3251 3252 transfer_report = TransferReport() 3253 for subtract in subtract_report.ages: 3254 times = TransferTimes() 3255 age = subtract.box_ref 3256 value = subtract.total 3257 assert source_exchange.rate is not None 3258 assert target_exchange.rate is not None 3259 target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate)) 3260 if debug: 3261 print('target_amount', target_amount) 3262 # Perform the transfer 3263 if self.box_exists(to_account, age): 3264 if debug: 3265 print('box_exists', age) 3266 capital = self.__vault.account[to_account].box[age].capital 3267 rest = self.__vault.account[to_account].box[age].rest 3268 if debug: 3269 print( 3270 f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3271 selected_age = age 3272 if rest + target_amount > capital: 3273 self.__vault.account[to_account].box[age].capital += target_amount 3274 selected_age = Time.time() 3275 self.__vault.account[to_account].box[age].rest += target_amount 3276 self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 3277 y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 3278 created_time_ns=None, ref=None, debug=debug) 3279 times.append(TransferTime(box_ref=age, log_ref=y)) 3280 continue 3281 if debug: 3282 print( 3283 f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3284 box_ref = self.__track( 3285 unscaled_value=self.unscale(int(target_amount)), 3286 desc=desc, 3287 account=to_account, 3288 logging=True, 3289 created_time_ns=age, 3290 debug=debug, 3291 ) 3292 transfer_report.append(TransferRecord( 3293 box_ref=box_ref, 3294 times=times, 3295 )) 3296 if no_lock: 3297 assert lock is not None 3298 self.free(lock) 3299 return transfer_report 3300 3301 def check(self, 3302 silver_gram_price: float, 3303 unscaled_nisab: Optional[float | int | decimal.Decimal] = None, 3304 debug: bool = False, 3305 created_time_ns: Optional[Timestamp] = None, 3306 cycle: Optional[float] = None) -> ZakatReport: 3307 """ 3308 Check the eligibility for Zakat based on the given parameters. 3309 3310 Parameters: 3311 - silver_gram_price (float): The price of a gram of silver. 3312 - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. 3313 If not provided, it will be calculated based on the silver_gram_price. 3314 - debug (bool, optional): Flag to enable debug mode. 3315 - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time(). 3316 - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 3317 3318 Returns: 3319 - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, 3320 a list of brief statistics, and a dictionary containing the Zakat plan. 3321 """ 3322 if debug: 3323 print('check', f'debug={debug}') 3324 before_parameters = { 3325 "silver_gram_price": silver_gram_price, 3326 "unscaled_nisab": unscaled_nisab, 3327 "debug": debug, 3328 "created_time_ns": created_time_ns, 3329 "cycle": cycle, 3330 } 3331 if created_time_ns is None: 3332 created_time_ns = Time.time() 3333 if cycle is None: 3334 cycle = ZakatTracker.TimeCycle() 3335 if unscaled_nisab is None: 3336 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 3337 nisab = self.scale(unscaled_nisab) 3338 plan: dict[AccountID, list[BoxPlan]] = {} 3339 summary = ZakatSummary() 3340 below_nisab = 0 3341 valid = False 3342 after_parameters = { 3343 "silver_gram_price": silver_gram_price, 3344 "unscaled_nisab": unscaled_nisab, 3345 "debug": debug, 3346 "created_time_ns": created_time_ns, 3347 "cycle": cycle, 3348 } 3349 if debug: 3350 print('exchanges', self.exchanges()) 3351 for x in self.__vault.account: 3352 if not self.zakatable(x): 3353 continue 3354 _box = self.__vault.account[x].box 3355 _log = self.__vault.account[x].log 3356 limit = len(_box) + 1 3357 ids = sorted(self.__vault.account[x].box.keys()) 3358 for i in range(-1, -limit, -1): 3359 j = ids[i] 3360 rest = float(_box[j].rest) 3361 if rest <= 0: 3362 continue 3363 exchange = self.exchange(x, created_time_ns=Time.time()) 3364 assert exchange.rate is not None 3365 rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1) 3366 summary.num_wealth_items += 1 3367 summary.total_wealth += rest 3368 epoch = (created_time_ns - j) / cycle 3369 if debug: 3370 print(f'Epoch: {epoch}', _box[j]) 3371 if _box[j].zakat.last > 0: 3372 epoch = (created_time_ns - _box[j].zakat.last) / cycle 3373 if debug: 3374 print(f'Epoch: {epoch}') 3375 epoch = math.floor(epoch) 3376 if debug: 3377 print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch) 3378 if epoch == 0: 3379 continue 3380 if debug: 3381 print('Epoch - PASSED') 3382 summary.num_zakatable_items += 1 3383 summary.total_zakatable_amount += rest 3384 is_nisab = rest >= nisab 3385 total = 0 3386 if is_nisab: 3387 for _ in range(epoch): 3388 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 3389 valid = total > 0 3390 elif rest > 0: 3391 below_nisab += rest 3392 total = ZakatTracker.ZakatCut(float(rest)) 3393 if total > 0: 3394 if x not in plan: 3395 plan[x] = [] 3396 summary.total_zakat_due += total 3397 plan[x].append(BoxPlan( 3398 below_nisab=not is_nisab, 3399 total=total, 3400 count=epoch, 3401 ref=j, 3402 box=_box[j], 3403 log=_log[j], 3404 exchange=exchange, 3405 )) 3406 valid = valid or below_nisab >= nisab 3407 if debug: 3408 print(f'below_nisab({below_nisab}) >= nisab({nisab})') 3409 report = ZakatReport( 3410 created=Time.time(), 3411 valid=valid, 3412 summary=summary, 3413 plan=plan, 3414 parameters={ 3415 'before': before_parameters, 3416 'after': after_parameters, 3417 }, 3418 ) 3419 self.__vault.cache.zakat = report if valid else None 3420 return report 3421 3422 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts: 3423 """ 3424 Build payment parts for the Zakat distribution. 3425 3426 Parameters: 3427 - scaled_demand (int): The total demand for payment in local currency. 3428 - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True. 3429 3430 Returns: 3431 - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: 3432 { 3433 'account': { 3434 'account_id': {'balance': float, 'rate': float, 'part': float}, 3435 ... 3436 }, 3437 'exceed': bool, 3438 'demand': int, 3439 'total': float, 3440 } 3441 """ 3442 total = 0.0 3443 parts = PaymentParts( 3444 account={}, 3445 exceed=False, 3446 demand=int(round(scaled_demand)), 3447 total=0, 3448 ) 3449 for x, y in self.accounts().items(): 3450 if positive_only and y.balance <= 0: 3451 continue 3452 total += float(y.balance) 3453 exchange = self.exchange(x) 3454 parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0) 3455 parts.total = total 3456 return parts 3457 3458 @staticmethod 3459 def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int: 3460 """ 3461 Checks the validity of payment parts. 3462 3463 Parameters: 3464 - parts (dict[str, PaymentParts): A dictionary containing payment parts information. 3465 - debug (bool, optional): Flag to enable debug mode. 3466 3467 Returns: 3468 - int: Returns 0 if the payment parts are valid, otherwise returns the error code. 3469 3470 Error Codes: 3471 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 3472 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3473 3: 'part' value in parts['account'][x] is less than 0. 3474 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 3475 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 3476 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 3477 """ 3478 if debug: 3479 print('check_payment_parts', f'debug={debug}') 3480 # for i in ['demand', 'account', 'total', 'exceed']: 3481 # if i not in parts: 3482 # return 1 3483 exceed = parts.exceed 3484 # for j in ['balance', 'rate', 'part']: 3485 # if j not in parts.account[x]: 3486 # return 2 3487 for x in parts.account: 3488 if parts.account[x].part < 0: 3489 return 3 3490 if not exceed and parts.account[x].balance <= 0: 3491 return 4 3492 demand = parts.demand 3493 z = 0.0 3494 for _, y in parts.account.items(): 3495 if not exceed and y.part > y.balance: 3496 return 5 3497 z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0) 3498 z = round(z, 2) 3499 demand = round(demand, 2) 3500 if debug: 3501 print('check_payment_parts', f'z = {z}, demand = {demand}') 3502 print('check_payment_parts', type(z), type(demand)) 3503 print('check_payment_parts', z != demand) 3504 print('check_payment_parts', str(z) != str(demand)) 3505 if z != demand and str(z) != str(demand): 3506 return 6 3507 return 0 3508 3509 def zakat(self, report: ZakatReport, 3510 parts: Optional[PaymentParts] = None, debug: bool = False) -> bool: 3511 """ 3512 Perform Zakat calculation based on the given report and optional parts. 3513 3514 Parameters: 3515 - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan. 3516 - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat. 3517 - debug (bool, optional): A flag indicating whether to print debug information. 3518 3519 Returns: 3520 - bool: True if the zakat calculation is successful, False otherwise. 3521 3522 Raises: 3523 - AssertionError: Bad Zakat report, call `check` first then call `zakat`. 3524 """ 3525 if debug: 3526 print('zakat', f'debug={debug}') 3527 if not report.valid: 3528 return report.valid 3529 assert report.plan 3530 parts_exist = parts is not None 3531 if parts_exist: 3532 if self.check_payment_parts(parts, debug=debug) != 0: 3533 return False 3534 if debug: 3535 print('######### zakat #######') 3536 print('parts_exist', parts_exist) 3537 assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`" 3538 no_lock = self.nolock() 3539 lock = self.__lock() 3540 report_time = Time.time() 3541 self.__vault.report[report_time] = report 3542 self.__step(Action.REPORT, ref=report_time) 3543 created_time_ns = Time.time() 3544 for x in report.plan: 3545 target_exchange = self.exchange(x) 3546 if debug: 3547 print(report.plan[x]) 3548 print('-------------') 3549 print(self.__vault.account[x].box) 3550 if debug: 3551 print('plan[x]', report.plan[x]) 3552 for plan in report.plan[x]: 3553 j = plan.ref 3554 if debug: 3555 print('j', j) 3556 assert j 3557 self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last, 3558 key='last', 3559 math_operation=MathOperation.EQUAL) 3560 self.__vault.account[x].box[j].zakat.last = created_time_ns 3561 assert target_exchange.rate is not None 3562 amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate)) 3563 self.__vault.account[x].box[j].zakat.total += amount 3564 self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 3565 math_operation=MathOperation.ADDITION) 3566 self.__vault.account[x].box[j].zakat.count += plan.count 3567 self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count', 3568 math_operation=MathOperation.ADDITION) 3569 if not parts_exist: 3570 try: 3571 self.__vault.account[x].box[j].rest -= amount 3572 except TypeError: 3573 self.__vault.account[x].box[j].rest -= decimal.Decimal(amount) 3574 # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 3575 # math_operation=MathOperation.SUBTRACTION) 3576 self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug) 3577 if parts_exist: 3578 for account, part in parts.account.items(): 3579 if part.part == 0: 3580 continue 3581 if debug: 3582 print('zakat-part', account, part.rate) 3583 target_exchange = self.exchange(account) 3584 assert target_exchange.rate is not None 3585 amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate) 3586 unscaled_amount = self.unscale(int(amount)) 3587 if unscaled_amount <= 0: 3588 if debug: 3589 print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.") 3590 continue 3591 self.subtract( 3592 unscaled_value=unscaled_amount, 3593 desc='zakat-part-دفعة-زكاة', 3594 account=account, 3595 debug=debug, 3596 ) 3597 if no_lock: 3598 assert lock is not None 3599 self.free(lock) 3600 self.__vault.cache.zakat = None 3601 return True 3602 3603 @staticmethod 3604 def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]: 3605 """Splits a string at the last occurrence of a given symbol. 3606 3607 Parameters: 3608 - data (str): The input string. 3609 - symbol (str): The symbol to split at. 3610 3611 Returns: 3612 - tuple[str, str]: A tuple containing two strings, the part before the last symbol and 3613 the part after the last symbol. If the symbol is not found, returns (data, ""). 3614 """ 3615 last_symbol_index = data.rfind(symbol) 3616 3617 if last_symbol_index != -1: 3618 before_symbol = data[:last_symbol_index] 3619 after_symbol = data[last_symbol_index + len(symbol):] 3620 return before_symbol, after_symbol 3621 return data, "" 3622 3623 def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool: 3624 """ 3625 Saves the ZakatTracker's current state to a json file. 3626 3627 This method serializes the internal data (`__vault`). 3628 3629 Parameters: 3630 - path (str, optional): File path for saving. Defaults to a predefined location. 3631 - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True. 3632 3633 Returns: 3634 - bool: True if the save operation is successful, False otherwise. 3635 """ 3636 if path is None: 3637 path = self.path() 3638 # first save in tmp file 3639 temp = f'{path}.tmp' 3640 try: 3641 with open(temp, 'w', encoding='utf-8') as stream: 3642 data = json.dumps(self.__vault, cls=JSONEncoder) 3643 stream.write(data) 3644 if hash_required: 3645 hashed = self.hash_data(data.encode()) 3646 stream.write(f'//{hashed}') 3647 # then move tmp file to original location 3648 shutil.move(temp, path) 3649 return True 3650 except (IOError, OSError) as e: 3651 print(f'Error saving file: {e}') 3652 if os.path.exists(temp): 3653 os.remove(temp) 3654 return False 3655 3656 @staticmethod 3657 def load_vault_from_json(json_string: str) -> Vault: 3658 """Loads a Vault dataclass from a JSON string.""" 3659 data = json.loads(json_string) 3660 3661 vault = Vault() 3662 3663 # Load Accounts 3664 for account_reference, account_data in data.get("account", {}).items(): 3665 account_reference = AccountID(account_reference) 3666 box_data = account_data.get('box', {}) 3667 box = { 3668 Timestamp(ts): Box( 3669 capital=box_data[str(ts)]["capital"], 3670 rest=box_data[str(ts)]["rest"], 3671 zakat=BoxZakat(**box_data[str(ts)]["zakat"]), 3672 ) 3673 for ts in box_data 3674 } 3675 3676 log_data = account_data.get('log', {}) 3677 log = {Timestamp(ts): Log( 3678 value=log_data[str(ts)]['value'], 3679 desc=log_data[str(ts)]['desc'], 3680 ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None, 3681 file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()}, 3682 ) for ts in log_data} 3683 3684 vault.account[account_reference] = Account( 3685 balance=account_data["balance"], 3686 created=Timestamp(account_data["created"]), 3687 name=account_data.get("name", ""), 3688 box=box, 3689 count=account_data.get("count", 0), 3690 log=log, 3691 hide=account_data.get("hide", False), 3692 zakatable=account_data.get("zakatable", True), 3693 ) 3694 3695 # Load Exchanges 3696 for account_reference, exchange_data in data.get("exchange", {}).items(): 3697 account_reference = AccountID(account_reference) 3698 vault.exchange[account_reference] = {} 3699 for timestamp, exchange_details in exchange_data.items(): 3700 vault.exchange[account_reference][Timestamp(timestamp)] = Exchange( 3701 rate=exchange_details.get("rate"), 3702 description=exchange_details.get("description"), 3703 time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None, 3704 ) 3705 3706 # Load History 3707 for timestamp, history_dict in data.get("history", {}).items(): 3708 vault.history[Timestamp(timestamp)] = {} 3709 for history_key, history_data in history_dict.items(): 3710 vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History( 3711 action=Action(history_data["action"]), 3712 account=AccountID(history_data["account"]) if history_data.get("account") is not None else None, 3713 ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None, 3714 file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None, 3715 key=history_data.get("key"), 3716 value=history_data.get("value"), 3717 math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None, 3718 ) 3719 3720 # Load Lock 3721 vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None 3722 3723 # Load Report 3724 for timestamp, report_data in data.get("report", {}).items(): 3725 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3726 for account_reference, box_plans in report_data.get("plan", {}).items(): 3727 account_reference = AccountID(account_reference) 3728 zakat_plan[account_reference] = [] 3729 for box_plan_data in box_plans: 3730 zakat_plan[account_reference].append(BoxPlan( 3731 box=Box( 3732 capital=box_plan_data["box"]["capital"], 3733 rest=box_plan_data["box"]["rest"], 3734 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3735 ), 3736 log=Log(**box_plan_data["log"]), 3737 exchange=Exchange(**box_plan_data["exchange"]), 3738 below_nisab=box_plan_data["below_nisab"], 3739 total=box_plan_data["total"], 3740 count=box_plan_data["count"], 3741 ref=Timestamp(box_plan_data["ref"]), 3742 )) 3743 3744 vault.report[Timestamp(timestamp)] = ZakatReport( 3745 created=report_data["created"], 3746 valid=report_data["valid"], 3747 summary=ZakatSummary(**report_data["summary"]), 3748 plan=zakat_plan, 3749 parameters=report_data["parameters"], 3750 ) 3751 3752 # Load Cache 3753 vault.cache = Cache() 3754 cache_data = data.get("cache", {}) 3755 if "zakat" in cache_data: 3756 cache_zakat_data = cache_data.get("zakat", {}) 3757 if cache_zakat_data: 3758 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3759 for account_reference, box_plans in cache_zakat_data.get("plan", {}).items(): 3760 account_reference = AccountID(account_reference) 3761 zakat_plan[account_reference] = [] 3762 for box_plan_data in box_plans: 3763 zakat_plan[account_reference].append(BoxPlan( 3764 box=Box( 3765 capital=box_plan_data["box"]["capital"], 3766 rest=box_plan_data["box"]["rest"], 3767 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3768 ), 3769 log=Log(**box_plan_data["log"]), 3770 exchange=Exchange(**box_plan_data["exchange"]), 3771 below_nisab=box_plan_data["below_nisab"], 3772 total=box_plan_data["total"], 3773 count=box_plan_data["count"], 3774 ref=Timestamp(box_plan_data["ref"]), 3775 )) 3776 3777 vault.cache.zakat = ZakatReport( 3778 created=cache_zakat_data["created"], 3779 valid=cache_zakat_data["valid"], 3780 summary=ZakatSummary(**cache_zakat_data["summary"]), 3781 plan=zakat_plan, 3782 parameters=cache_zakat_data["parameters"], 3783 ) 3784 3785 return vault 3786 3787 def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool: 3788 """ 3789 Load the current state of the ZakatTracker object from a json file. 3790 3791 Parameters: 3792 - path (str, optional): The path where the json file is located. If not provided, it will use the default path. 3793 - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True. 3794 - debug (bool, optional): Flag to enable debug mode. 3795 3796 Returns: 3797 - bool: True if the load operation is successful, False otherwise. 3798 """ 3799 if path is None: 3800 path = self.path() 3801 try: 3802 if os.path.exists(path): 3803 with open(path, 'r', encoding='utf-8') as stream: 3804 file = stream.read() 3805 data, hashed = self.split_at_last_symbol(file, '//') 3806 if hash_required: 3807 assert hashed 3808 if debug: 3809 print('[debug-load]', hashed) 3810 new_hash = self.hash_data(data.encode()) 3811 if debug: 3812 print('[debug-load]', new_hash) 3813 assert hashed == new_hash, "Hash verification failed. File may be corrupted." 3814 self.__vault = self.load_vault_from_json(data) 3815 return True 3816 else: 3817 print(f'File not found: {path}') 3818 return False 3819 except (IOError, OSError) as e: 3820 print(f'Error loading file: {e}') 3821 return False 3822 3823 @experimental 3824 def import_csv_cache_path(self): 3825 """ 3826 Generates the cache file path for imported CSV data. 3827 3828 This function constructs the file path where cached data from CSV imports 3829 will be stored. The cache file is a json file (.json extension) appended 3830 to the base path of the object. 3831 3832 Parameters: 3833 None 3834 3835 Returns: 3836 - str: The full path to the import CSV cache file. 3837 3838 Example: 3839 ```bash 3840 >>> obj = ZakatTracker('/data/reports') 3841 >>> obj.import_csv_cache_path() 3842 '/data/reports.import_csv.json' 3843 ``` 3844 """ 3845 path = str(self.path()) 3846 ext = self.ext() 3847 ext_len = len(ext) 3848 if path.endswith(f'.{ext}'): 3849 path = path[:-ext_len - 1] 3850 _, filename = os.path.split(path + f'.import_csv.{ext}') 3851 return self.base_path(filename) 3852 3853 @staticmethod 3854 @experimental 3855 def get_transaction_csv_headers() -> list[str]: 3856 """ 3857 Returns a list of strings representing the headers for a transaction CSV file. 3858 3859 The headers include: 3860 - account: The account associated with the transaction. 3861 - desc: A description of the transaction. 3862 - value: The monetary value of the transaction. 3863 - date: The date of the transaction. 3864 - rate: The applicable rate (if any) for the transaction. 3865 - reference: An optional reference number or identifier for the transaction. 3866 3867 Returns: 3868 - list[str]: A list containing the CSV header strings. 3869 """ 3870 return [ 3871 "account", 3872 "desc", 3873 "value", 3874 "date", 3875 "rate", 3876 "reference", 3877 ] 3878 3879 @experimental 3880 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport: 3881 """ 3882 The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system. 3883 3884 Parameters: 3885 - path (str, optional): The path to the CSV file. Default is 'file.csv'. 3886 - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0. 3887 - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','. 3888 - debug (bool, optional): A flag indicating whether to print debug information. 3889 3890 Returns: 3891 - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, 3892 and a dictionary of bad transactions. 3893 3894 Notes: 3895 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3896 are appropriate for the currency pairs involved in the conversions. 3897 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3898 to 1.0 or the previous rate for that account. 3899 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3900 transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and 3901 `zakat` operations. 3902 3903 Example: 3904 The CSV file should have the following format, rate and reference are optionals per transaction: 3905 account, desc, value, date, rate, reference 3906 For example: 3907 safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554 3908 """ 3909 if debug: 3910 print('import_csv', f'debug={debug}') 3911 cache: list[int] = [] 3912 try: 3913 if not self.memory_mode(): 3914 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3915 cache = json.load(stream) 3916 except Exception as e: 3917 if debug: 3918 print(e) 3919 date_formats = [ 3920 '%Y-%m-%d %H:%M:%S.%f', 3921 '%Y-%m-%dT%H:%M:%S.%f', 3922 '%Y-%m-%dT%H%M%S.%f', 3923 '%Y-%m-%d', 3924 ] 3925 statistics = ImportStatistics(0, 0, 0) 3926 data: dict[int, list[CSVRecord]] = {} 3927 with open(path, newline='', encoding='utf-8') as f: 3928 i = 0 3929 for row in csv.reader(f, delimiter=delimiter): 3930 if debug: 3931 print(f"csv_row({i})", row, type(row)) 3932 if row == self.get_transaction_csv_headers(): 3933 continue 3934 i += 1 3935 hashed = hash(tuple(row)) 3936 if hashed in cache: 3937 statistics.found += 1 3938 continue 3939 account = row[0] 3940 desc = row[1] 3941 value = float(row[2]) 3942 rate = 1.0 3943 reference = '' 3944 if row[4:5]: # Empty list if index is out of range 3945 rate = float(row[4]) 3946 if row[5:6]: 3947 reference = row[5] 3948 date: int = 0 3949 for time_format in date_formats: 3950 try: 3951 date_str = row[3] 3952 if "." not in date_str: 3953 date_str += ".000000" 3954 date = Time.time(datetime.datetime.strptime(date_str, time_format)) 3955 break 3956 except Exception as e: 3957 if debug: 3958 print(e) 3959 record = CSVRecord( 3960 index=i, 3961 account=account, 3962 desc=desc, 3963 value=value, 3964 date=date, 3965 rate=rate, 3966 reference=reference, 3967 hashed=hashed, 3968 error='', 3969 ) 3970 if date <= 0: 3971 record.error = 'invalid date' 3972 statistics.bad += 1 3973 if value == 0: 3974 record.error = 'invalid value' 3975 statistics.bad += 1 3976 continue 3977 if date not in data: 3978 data[date] = [] 3979 data[date].append(record) 3980 3981 if debug: 3982 print('import_csv', len(data)) 3983 3984 if statistics.bad > 0: 3985 return ImportReport( 3986 statistics=statistics, 3987 bad=[ 3988 item 3989 for sublist in data.values() 3990 for item in sublist 3991 if item.error 3992 ], 3993 ) 3994 3995 no_lock = self.nolock() 3996 lock = self.__lock() 3997 names = self.names() 3998 3999 # sync accounts 4000 if debug: 4001 print('before-names', names, len(names)) 4002 for date, rows in sorted(data.items()): 4003 new_rows: list[CSVRecord] = [] 4004 for row in rows: 4005 if row.account not in names: 4006 account_id = self.create_account(row.account) 4007 names[row.account] = account_id 4008 account_id = names[row.account] 4009 assert account_id 4010 row.account = account_id 4011 new_rows.append(row) 4012 assert new_rows 4013 assert date in data 4014 data[date] = new_rows 4015 if debug: 4016 print('after-names', names, len(names)) 4017 assert names == self.names() 4018 4019 # do ops 4020 for date, rows in sorted(data.items()): 4021 try: 4022 def process(x: CSVRecord): 4023 x.value = self.unscale( 4024 x.value, 4025 decimal_places=scale_decimal_places, 4026 ) if scale_decimal_places > 0 else x.value 4027 if x.rate > 0: 4028 self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate) 4029 if x.value > 0: 4030 self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 4031 elif x.value < 0: 4032 self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 4033 return x.hashed 4034 len_rows = len(rows) 4035 # If records are found at the same time with different accounts in the same amount 4036 # (one positive and the other negative), this indicates it is a transfer. 4037 if len_rows > 2 or len_rows == 1: 4038 i = 0 4039 for row in rows: 4040 row.date += i 4041 i += 1 4042 hashed = process(row) 4043 assert hashed not in cache 4044 cache.append(hashed) 4045 statistics.created += 1 4046 continue 4047 x1 = rows[0] 4048 x2 = rows[1] 4049 if x1.account == x2.account: 4050 continue 4051 # raise Exception(f'invalid transfer') 4052 # not transfer - same time - normal ops 4053 if abs(x1.value) != abs(x2.value) and x1.date == x2.date: 4054 rows[1].date += 1 4055 for row in rows: 4056 hashed = process(row) 4057 assert hashed not in cache 4058 cache.append(hashed) 4059 statistics.created += 1 4060 continue 4061 if x1.rate > 0: 4062 self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate) 4063 if x2.rate > 0: 4064 self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate) 4065 x1.value = self.unscale( 4066 x1.value, 4067 decimal_places=scale_decimal_places, 4068 ) if scale_decimal_places > 0 else x1.value 4069 x2.value = self.unscale( 4070 x2.value, 4071 decimal_places=scale_decimal_places, 4072 ) if scale_decimal_places > 0 else x2.value 4073 # just transfer 4074 values = { 4075 x1.value: x1.account, 4076 x2.value: x2.account, 4077 } 4078 if debug: 4079 print('values', values) 4080 if len(values) <= 1: 4081 continue 4082 self.transfer( 4083 unscaled_amount=abs(x1.value), 4084 from_account=values[min(values.keys())], 4085 to_account=values[max(values.keys())], 4086 desc=x1.desc, 4087 created_time_ns=x1.date, 4088 ) 4089 except Exception as e: 4090 for row in rows: 4091 row.error = str(e) 4092 break 4093 if not self.memory_mode(): 4094 with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream: 4095 stream.write(json.dumps(cache)) 4096 if no_lock: 4097 assert lock is not None 4098 self.free(lock) 4099 report = ImportReport( 4100 statistics=statistics, 4101 bad=[ 4102 item 4103 for sublist in data.values() 4104 for item in sublist 4105 if item.error 4106 ], 4107 ) 4108 if debug: 4109 debug_path = f'{self.import_csv_cache_path()}.debug.json' 4110 with open(debug_path, 'w', encoding='utf-8') as file: 4111 json.dump(report, file, indent=4, cls=JSONEncoder) 4112 print(f'generated debug report @ `{debug_path}`...') 4113 return report 4114 4115 ######## 4116 # TESTS # 4117 ####### 4118 4119 @staticmethod 4120 def human_readable_size(size: float, decimal_places: int = 2) -> str: 4121 """ 4122 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 4123 4124 This function iterates through progressively larger units of information 4125 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 4126 range that can be expressed with a reasonable number before the unit. 4127 4128 Parameters: 4129 - size (float): The size in bytes to convert. 4130 - decimal_places (int, optional): The number of decimal places to display 4131 in the result. Defaults to 2. 4132 4133 Returns: 4134 - str: A string representation of the size in a human-readable format, 4135 rounded to the specified number of decimal places. For example: 4136 - '1.50 KB' (1536 bytes) 4137 - '23.00 MB' (24117248 bytes) 4138 - '1.23 GB' (1325899906 bytes) 4139 """ 4140 if type(size) not in (float, int): 4141 raise TypeError('size must be a float or integer') 4142 if type(decimal_places) != int: 4143 raise TypeError('decimal_places must be an integer') 4144 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 4145 if size < 1024.0: 4146 break 4147 size /= 1024.0 4148 return f'{size:.{decimal_places}f} {unit}' 4149 4150 @staticmethod 4151 def get_dict_size(obj: dict, seen: Optional[set] = None) -> float: 4152 """ 4153 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 4154 4155 This function traverses the dictionary structure, accounting for the size of keys, values, 4156 and any nested objects. It handles various data types commonly found in dictionaries 4157 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 4158 of circular references. 4159 4160 Parameters: 4161 - obj (dict): The dictionary whose size is to be calculated. 4162 - seen (set, optional): A set used internally to track visited objects 4163 and avoid circular references. Defaults to None. 4164 4165 Returns: 4166 - float: An approximate size of the dictionary and its contents in bytes. 4167 4168 Notes: 4169 - This function is a method of the `ZakatTracker` class and is likely used to 4170 estimate the memory footprint of data structures relevant to Zakat calculations. 4171 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 4172 not account for all memory overhead depending on the Python implementation. 4173 - Circular references are handled to prevent infinite recursion. 4174 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 4175 - String sizes are estimated based on character length and encoding. 4176 """ 4177 size = 0 4178 if seen is None: 4179 seen = set() 4180 4181 obj_id = id(obj) 4182 if obj_id in seen: 4183 return 0 4184 4185 seen.add(obj_id) 4186 size += sys.getsizeof(obj) 4187 4188 if isinstance(obj, dict): 4189 for k, v in obj.items(): 4190 size += ZakatTracker.get_dict_size(k, seen) 4191 size += ZakatTracker.get_dict_size(v, seen) 4192 elif isinstance(obj, (list, tuple, set, frozenset)): 4193 for item in obj: 4194 size += ZakatTracker.get_dict_size(item, seen) 4195 elif isinstance(obj, (int, float, complex)): # Handle numbers 4196 pass # Basic numbers have a fixed size, so nothing to add here 4197 elif isinstance(obj, str): # Handle strings 4198 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 4199 return size 4200 4201 @staticmethod 4202 def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp: # افتراض أن الشهر هو يونيو والسنة 2024 4203 """ 4204 Convert a specific day, month, and year into a timestamp. 4205 4206 Parameters: 4207 - day (int): The day of the month. 4208 - month (int, optional): The month of the year. Default is 6 (June). 4209 - year (int, optional): The year. Default is 2024. 4210 4211 Returns: 4212 - Timestamp: The timestamp representing the given day, month, and year. 4213 4214 Note: 4215 - This method assumes the default month and year if not provided. 4216 """ 4217 return Time.time(datetime.datetime(year, month, day)) 4218 4219 @staticmethod 4220 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 4221 """ 4222 Generate a random date between two given dates. 4223 4224 Parameters: 4225 - start_date (datetime.datetime): The start date from which to generate a random date. 4226 - end_date (datetime.datetime): The end date until which to generate a random date. 4227 4228 Returns: 4229 - datetime.datetime: A random date between the start_date and end_date. 4230 """ 4231 time_between_dates = end_date - start_date 4232 days_between_dates = time_between_dates.days 4233 random_number_of_days = random.randrange(days_between_dates) 4234 return start_date + datetime.timedelta(days=random_number_of_days) 4235 4236 @staticmethod 4237 def generate_random_csv_file(path: str = 'data.csv', 4238 count: int = 1_000, 4239 with_rate: bool = False, 4240 delimiter: str = ',', 4241 debug: bool = False) -> int: 4242 """ 4243 Generate a random CSV file with specified parameters. 4244 The function generates a CSV file at the specified path with the given count of rows. 4245 Each row contains a randomly generated account, description, value, and date. 4246 The value is randomly generated between 1000 and 100000, 4247 and the date is randomly generated between 1950-01-01 and 2023-12-31. 4248 If the row number is not divisible by 13, the value is multiplied by -1. 4249 4250 Parameters: 4251 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 4252 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 4253 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 4254 - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','. 4255 - debug (bool, optional): A flag indicating whether to print debug information. 4256 4257 Returns: 4258 - int: number of generated records. 4259 """ 4260 if debug: 4261 print('generate_random_csv_file', f'debug={debug}') 4262 i = 0 4263 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 4264 writer = csv.writer(csvfile, delimiter=delimiter) 4265 writer.writerow(ZakatTracker.get_transaction_csv_headers()) 4266 for i in range(count): 4267 account = f'acc-{random.randint(1, count)}' 4268 desc = f'Some text {random.randint(1, count)}' 4269 value = random.randint(1000, 100000) 4270 date = ZakatTracker.generate_random_date( 4271 datetime.datetime(1000, 1, 1), 4272 datetime.datetime(2023, 12, 31), 4273 ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S') 4274 if not i % 13 == 0: 4275 value *= -1 4276 row = [account, desc, value, date] 4277 if with_rate: 4278 rate = random.randint(1, 100) * 0.12 4279 if debug: 4280 print('before-append', row) 4281 row.append(rate) 4282 if debug: 4283 print('after-append', row) 4284 if i % 2 == 1: 4285 row += (Time.time(),) 4286 writer.writerow(row) 4287 i = i + 1 4288 return i 4289 4290 @staticmethod 4291 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 4292 """ 4293 Creates a list of random integers whose sum does not exceed the specified maximum. 4294 4295 Parameters: 4296 - max_sum (int): The maximum allowed sum of the list elements. 4297 - min_value (int, optional): The minimum possible value for an element (inclusive). 4298 - max_value (int, optional): The maximum possible value for an element (inclusive). 4299 4300 Returns: 4301 - A list of random integers. 4302 """ 4303 result = [] 4304 current_sum = 0 4305 4306 while current_sum < max_sum: 4307 # Calculate the remaining space for the next element 4308 remaining_sum = max_sum - current_sum 4309 # Determine the maximum possible value for the next element 4310 next_max_value = min(remaining_sum, max_value) 4311 # Generate a random element within the allowed range 4312 next_element = random.randint(min_value, next_max_value) 4313 result.append(next_element) 4314 current_sum += next_element 4315 4316 return result 4317 4318 def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]: 4319 """ 4320 Compresses a folder into a .tar.lzma archive. 4321 4322 The archive is named following a specific format: 4323 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. This format 4324 is crucial for the `restore` function, so avoid renaming the files. 4325 4326 Parameters: 4327 - folder_path (str): The path to the folder to be compressed. 4328 - output_directory (str, optional): The directory to save the compressed file. 4329 Defaults to "compressed". 4330 - debug (bool, optional): Whether to print debug information. Default is False. 4331 4332 Returns: 4333 - Optional[Backup]: A Backup object containing the path to the created archive 4334 and its SHA1 hash on success, None on failure. 4335 """ 4336 try: 4337 os.makedirs(output_directory, exist_ok=True) 4338 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4339 4340 # Create a temporary tar archive in memory to calculate the hash 4341 tar_buffer = io.BytesIO() 4342 with tarfile.open(fileobj=tar_buffer, mode="w") as tar: 4343 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4344 tar_buffer.seek(0) 4345 folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4346 output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma" 4347 output_path = os.path.join(output_directory, output_filename) 4348 4349 # Compress the folder to the final .tar.lzma file 4350 with lzma.open(output_path, "wb") as lzma_file: 4351 tar_buffer.seek(0) # Reset the buffer 4352 with tarfile.open(fileobj=lzma_file, mode="w") as tar: 4353 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4354 4355 if debug: 4356 print(f"Folder '{folder_path}' has been compressed to '{output_path}'") 4357 return Backup( 4358 path=output_path, 4359 hash=folder_hash, 4360 ) 4361 except Exception as e: 4362 print(f"Error during compression: {e}") 4363 return None 4364 4365 def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool: 4366 """ 4367 Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash. 4368 4369 The SHA1 hash is extracted from the archive's filename, which must follow 4370 the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. 4371 This format is essential for successful restoration. 4372 4373 Parameters: 4374 - tar_lzma_path (str): The path to the .tar.lzma file. 4375 - output_folder_path (str, optional): The directory to extract the contents to. 4376 Defaults to "uncompressed". 4377 - debug (bool, optional): Whether to print debug information. Default is False. 4378 4379 Returns: 4380 - bool: True if the restoration was successful and the hash matches, False otherwise. 4381 """ 4382 try: 4383 output_folder_path = pathlib.Path(output_folder_path).resolve() 4384 os.makedirs(output_folder_path, exist_ok=True) 4385 filename = os.path.basename(tar_lzma_path) 4386 re_match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename) 4387 if not re_match: 4388 if debug: 4389 print(f"Error: Invalid filename format: '{filename}'") 4390 return False 4391 4392 expected_hash_from_filename = re_match.group(3) 4393 4394 with lzma.open(tar_lzma_path, "rb") as lzma_file: 4395 tar_buffer = io.BytesIO(lzma_file.read()) # Read the entire decompressed tar into memory 4396 with tarfile.open(fileobj=tar_buffer, mode="r") as tar: 4397 tar.extractall(output_folder_path) 4398 tar_buffer.seek(0) # Reset buffer to calculate hash 4399 extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4400 4401 new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path)) 4402 assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found." 4403 if extracted_hash == expected_hash_from_filename: 4404 if debug: 4405 print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.") 4406 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4407 old_path = os.path.dirname(self.path()) 4408 tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now) 4409 if debug: 4410 print('[xxx] - old_path:', old_path) 4411 print('[xxx] - tmp_path:', tmp_path) 4412 print('[xxx] - new_path:', new_path) 4413 try: 4414 shutil.move(old_path, tmp_path) 4415 shutil.move(new_path, old_path) 4416 assert self.load() 4417 shutil.rmtree(tmp_path) 4418 return True 4419 except Exception as e: 4420 print(f"Error applying the restored files: {e}") 4421 shutil.move(tmp_path, old_path) 4422 return False 4423 else: 4424 if debug: 4425 print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}") 4426 # Optionally remove the extracted folder if the hash doesn't match 4427 # shutil.rmtree(output_folder_path, ignore_errors=True) 4428 return False 4429 4430 except Exception as e: 4431 print(f"Error during uncompression or hash check: {e}") 4432 return False 4433 4434 def _test_core(self, restore: bool = False, debug: bool = False): 4435 4436 random.seed(1234567890) 4437 4438 # sanity check - core 4439 4440 assert sorted([6, 0, 9, 3], reverse=False) == [0, 3, 6, 9] 4441 assert sorted([6, 0, 9, 3], reverse=True) == [9, 6, 3, 0] 4442 assert sorted( 4443 {6: '6', 0: '0', 9: '9', 3: '3'}.items(), 4444 reverse=False, 4445 ) == [(0, '0'), (3, '3'), (6, '6'), (9, '9')] 4446 assert sorted( 4447 {6: '6', 0: '0', 9: '9', 3: '3'}.items(), 4448 reverse=True, 4449 ) == [(9, '9'), (6, '6'), (3, '3'), (0, '0')] 4450 assert sorted( 4451 {'6': 6, '0': 0, '9': 9, '3': 3}.items(), 4452 reverse=False, 4453 ) == [('0', 0), ('3', 3), ('6', 6), ('9', 9)] 4454 assert sorted( 4455 {'6': 6, '0': 0, '9': 9, '3': 3}.items(), 4456 reverse=True, 4457 ) == [('9', 9), ('6', 6), ('3', 3), ('0', 0)] 4458 4459 Collection.test_paginate(debug) 4460 Timestamp.test() 4461 AccountID.test(debug) 4462 Time.test(debug) 4463 4464 # test to prevents setting non-existent attributes 4465 4466 for cls in [ 4467 StrictDataclass, 4468 BoxZakat, 4469 Box, 4470 Log, 4471 Account, 4472 Exchange, 4473 History, 4474 BoxPlan, 4475 ZakatSummary, 4476 ZakatReport, 4477 Vault, 4478 AccountPaymentPart, 4479 PaymentParts, 4480 SubtractAge, 4481 SubtractAges, 4482 SubtractReport, 4483 TransferTime, 4484 TransferTimes, 4485 TransferRecord, 4486 ImportStatistics, 4487 CSVRecord, 4488 ImportReport, 4489 SizeInfo, 4490 FileInfo, 4491 FileStats, 4492 TimeSummary, 4493 Transaction, 4494 DailyRecords, 4495 Timeline, 4496 ]: 4497 failed = False 4498 try: 4499 x = cls() 4500 x.x = 123 4501 except: 4502 failed = True 4503 assert failed 4504 4505 # sanity check - random forward time 4506 4507 xlist = [] 4508 limit = 1000 4509 for _ in range(limit): 4510 y = Time.time() 4511 z = '-' 4512 if y not in xlist: 4513 xlist.append(y) 4514 else: 4515 z = 'x' 4516 if debug: 4517 print(z, y) 4518 xx = len(xlist) 4519 if debug: 4520 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 4521 assert limit == xx 4522 4523 # test ZakatTracker.split_at_last_symbol 4524 4525 test_cases = [ 4526 ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")), 4527 ("No symbol here.", '$', ("No symbol here.", "")), 4528 ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")), 4529 ("Here is a symbol%", '%', ("Here is a symbol", "")), 4530 ("@only a symbol", '@', ("", "only a symbol")), 4531 ("", '#', ("", "")), 4532 ("test/test/test.txt", '/', ("test/test", "test.txt")), 4533 ("abc#def#ghi", "#", ("abc#def", "ghi")), 4534 ("abc", "#", ("abc", "")), 4535 ("//https://test", '//', ("//https:", "test")), 4536 ] 4537 4538 for data, symbol, expected in test_cases: 4539 result = ZakatTracker.split_at_last_symbol(data, symbol) 4540 assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}" 4541 4542 # human_readable_size 4543 4544 assert ZakatTracker.human_readable_size(0) == '0.00 B' 4545 assert ZakatTracker.human_readable_size(512) == '512.00 B' 4546 assert ZakatTracker.human_readable_size(1023) == '1023.00 B' 4547 4548 assert ZakatTracker.human_readable_size(1024) == '1.00 KB' 4549 assert ZakatTracker.human_readable_size(2048) == '2.00 KB' 4550 assert ZakatTracker.human_readable_size(5120) == '5.00 KB' 4551 4552 assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB' 4553 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB' 4554 4555 assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB' 4556 assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB' 4557 assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB' 4558 4559 assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB' 4560 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB' 4561 assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB' 4562 4563 try: 4564 # noinspection PyTypeChecker 4565 ZakatTracker.human_readable_size('not a number') 4566 assert False, 'Expected TypeError for invalid input' 4567 except TypeError: 4568 pass 4569 4570 try: 4571 # noinspection PyTypeChecker 4572 ZakatTracker.human_readable_size(1024, decimal_places='not an int') 4573 assert False, 'Expected TypeError for invalid decimal_places' 4574 except TypeError: 4575 pass 4576 4577 # get_dict_size 4578 assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch' 4579 assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary' 4580 4581 # number scale 4582 error = 0 4583 total = 0 4584 for sign in ['', '-']: 4585 for max_i, max_j, decimal_places in [ 4586 (101, 101, 2), # fiat currency minimum unit took 2 decimal places 4587 (1, 1_000, 8), # cryptocurrency like Satoshi in Bitcoin took 8 decimal places 4588 (1, 1_000, 18) # cryptocurrency like Wei in Ethereum took 18 decimal places 4589 ]: 4590 for return_type in ( 4591 float, 4592 decimal.Decimal, 4593 ): 4594 for i in range(max_i): 4595 for j in range(max_j): 4596 total += 1 4597 num_str = f'{sign}{i}.{j:0{decimal_places}d}' 4598 num = return_type(num_str) 4599 scaled = self.scale(num, decimal_places=decimal_places) 4600 unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places) 4601 if debug: 4602 print( 4603 f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}') 4604 if unscaled != num: 4605 if debug: 4606 print('***** SCALE ERROR *****') 4607 error += 1 4608 if debug: 4609 print(f'total: {total}, error({error}): {100 * error / total}%') 4610 assert error == 0 4611 4612 # test lock 4613 4614 assert self.nolock() 4615 assert self.__history() is True 4616 lock = self.lock() 4617 assert lock is not None 4618 assert lock > 0 4619 failed = False 4620 try: 4621 self.lock() 4622 except: 4623 failed = True 4624 assert failed 4625 assert self.free(lock) 4626 assert not self.free(lock) 4627 4628 wallet_account_id = self.create_account('wallet') 4629 4630 table = { 4631 AccountID('1'): [ 4632 (0, 10, 1000, 1000, 1000, 1, 1), 4633 (0, 20, 3000, 3000, 3000, 2, 2), 4634 (0, 30, 6000, 6000, 6000, 3, 3), 4635 (1, 15, 4500, 4500, 4500, 3, 4), 4636 (1, 50, -500, -500, -500, 4, 5), 4637 (1, 100, -10500, -10500, -10500, 5, 6), 4638 ], 4639 wallet_account_id: [ 4640 (1, 90, -9000, -9000, -9000, 1, 1), 4641 (0, 100, 1000, 1000, 1000, 2, 2), 4642 (1, 190, -18000, -18000, -18000, 3, 3), 4643 (0, 1000, 82000, 82000, 82000, 4, 4), 4644 ], 4645 } 4646 for x in table: 4647 for y in table[x]: 4648 lock = self.lock() 4649 if y[0] == 0: 4650 ref = self.track( 4651 unscaled_value=y[1], 4652 desc='test-add', 4653 account=x, 4654 created_time_ns=Time.time(), 4655 debug=debug, 4656 ) 4657 else: 4658 report = self.subtract( 4659 unscaled_value=y[1], 4660 desc='test-sub', 4661 account=x, 4662 created_time_ns=Time.time(), 4663 ) 4664 ref = report.log_ref 4665 if debug: 4666 print('_sub', z, Time.time()) 4667 assert ref != 0 4668 assert len(self.__vault.account[x].log[ref].file) == 0 4669 for i in range(3): 4670 file_ref = self.add_file(x, ref, 'file_' + str(i)) 4671 assert file_ref != 0 4672 if debug: 4673 print('ref', ref, 'file', file_ref) 4674 assert len(self.__vault.account[x].log[ref].file) == i + 1 4675 assert file_ref in self.__vault.account[x].log[ref].file 4676 file_ref = self.add_file(x, ref, 'file_' + str(3)) 4677 assert self.remove_file(x, ref, file_ref) 4678 timeline = self.timeline(debug=debug) 4679 if debug: 4680 print('timeline', timeline) 4681 assert timeline.daily 4682 assert timeline.weekly 4683 assert timeline.monthly 4684 assert timeline.yearly 4685 z = self.balance(x) 4686 if debug: 4687 print('debug-0', z, y) 4688 assert z == y[2] 4689 z = self.balance(x, False) 4690 if debug: 4691 print('debug-1', z, y[3]) 4692 assert z == y[3] 4693 o = self.__vault.account[x].log 4694 z = 0 4695 for i in o: 4696 z += o[i].value 4697 if debug: 4698 print('debug-2', z, type(z)) 4699 print('debug-2', y[4], type(y[4])) 4700 assert z == y[4] 4701 if debug: 4702 print('debug-2 - PASSED') 4703 assert self.box_size(x) == y[5] 4704 assert self.log_size(x) == y[6] 4705 assert not self.nolock() 4706 assert lock is not None 4707 self.free(lock) 4708 assert self.nolock() 4709 assert self.boxes(x) != {} 4710 assert self.logs(x) != {} 4711 4712 assert not self.hide(x) 4713 assert self.hide(x, False) is False 4714 assert self.hide(x) is False 4715 assert self.hide(x, True) 4716 assert self.hide(x) 4717 4718 assert self.zakatable(x) 4719 assert self.zakatable(x, False) is False 4720 assert self.zakatable(x) is False 4721 assert self.zakatable(x, True) 4722 assert self.zakatable(x) 4723 4724 if restore is True: 4725 # invalid restore point 4726 for lock in [0, time.time_ns(), Time.time()]: 4727 failed = False 4728 try: 4729 self.recall(dry=True, lock=lock) 4730 except: 4731 failed = True 4732 assert failed 4733 count = len(self.__vault.history) 4734 if debug: 4735 print('history-count', count) 4736 assert count == 12 4737 # try mode 4738 for _ in range(count): 4739 assert self.recall(dry=True, debug=debug) 4740 count = len(self.__vault.history) 4741 if debug: 4742 print('history-count', count) 4743 assert count == 12 4744 _accounts = list(table.keys()) 4745 accounts_limit = len(_accounts) + 1 4746 for i in range(-1, -accounts_limit, -1): 4747 account = _accounts[i] 4748 if debug: 4749 print(account, len(table[account])) 4750 transaction_limit = len(table[account]) + 1 4751 for j in range(-1, -transaction_limit, -1): 4752 row = table[account][j] 4753 if debug: 4754 print(row, self.balance(account), self.balance(account, False)) 4755 assert self.balance(account) == self.balance(account, False) 4756 assert self.balance(account) == row[2] 4757 assert self.recall(dry=False, debug=debug) 4758 assert self.recall(dry=False, debug=debug) 4759 assert self.recall(dry=False, debug=debug) 4760 assert not self.recall(dry=False, debug=debug) 4761 count = len(self.__vault.history) 4762 if debug: 4763 print('history-count', count) 4764 assert count == 0 4765 self.reset() 4766 4767 def _test_storage(self, account_id: Optional[AccountID] = None, debug: bool = False): 4768 old_vault = dataclasses.replace(self.__vault) 4769 old_vault_deep = copy.deepcopy(self.__vault) 4770 old_vault_dict = dataclasses.asdict(self.__vault) 4771 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 4772 if os.path.exists(_path): 4773 os.remove(_path) 4774 for hashed in [False, True]: 4775 self.save(hash_required=hashed) 4776 assert os.path.getsize(_path) > 0 4777 self.reset() 4778 assert self.recall(dry=False, debug=debug) is False 4779 for hash_required in [False, True]: 4780 if debug: 4781 print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4782 self.load(hash_required=hashed and hash_required) 4783 if debug: 4784 print('[debug]', type(self.__vault)) 4785 assert self.__vault.account is not None 4786 assert old_vault == self.__vault 4787 assert old_vault_deep == self.__vault 4788 assert old_vault_dict == dataclasses.asdict(self.__vault) 4789 if account_id is not None: 4790 # corrupt the data 4791 log_ref = None 4792 tmp_file_ref = Time.time() 4793 for k in self.__vault.account[account_id].log: 4794 log_ref = k 4795 self.__vault.account[account_id].log[k].file[tmp_file_ref] = 'HACKED' 4796 break 4797 assert old_vault != self.__vault 4798 assert old_vault_deep != self.__vault 4799 assert old_vault_dict != dataclasses.asdict(self.__vault) 4800 # fix the data 4801 del self.__vault.account[account_id].log[log_ref].file[tmp_file_ref] 4802 assert old_vault == self.__vault 4803 assert old_vault_deep == self.__vault 4804 assert old_vault_dict == dataclasses.asdict(self.__vault) 4805 if hashed: 4806 continue 4807 failed = False 4808 try: 4809 hash_required = True 4810 if debug: 4811 print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4812 self.load(hash_required=True) 4813 except: 4814 failed = True 4815 assert failed 4816 4817 compressed_dir = "test_compressed" 4818 extracted_dir = "test_extracted" 4819 4820 old_vault = dataclasses.replace(self.__vault) 4821 old_vault_deep = copy.deepcopy(self.__vault) 4822 old_vault_dict = dataclasses.asdict(self.__vault) 4823 4824 # Test backup 4825 backup = self.backup(os.path.dirname(self.path()), compressed_dir, debug=debug) 4826 assert backup.path is not None, "Backup should create a file." 4827 assert backup.hash is not None, "Backup should return a hash." 4828 assert os.path.exists(backup.path), f"Backup file not found at {backup.path}" 4829 assert backup.path.startswith(os.path.join(compressed_dir, f"zakatdb_v{self.Version()}")), "Backup filename should start with zakatdb_v<version>" 4830 assert backup.hash in backup.path, "Backup filename should contain the hash." 4831 4832 # Test restore 4833 restore_successful = self.restore(backup.path, extracted_dir, debug=debug) 4834 assert restore_successful, "Restore should be successful when hash matches." 4835 4836 assert old_vault == self.__vault 4837 assert old_vault_deep == self.__vault 4838 assert old_vault_dict == dataclasses.asdict(self.__vault) 4839 4840 # Test restore with incorrect filename format 4841 invalid_backup_path = os.path.join(compressed_dir, "invalid_name.tar.lzma") 4842 with open(invalid_backup_path, "w") as f: 4843 f.write("") # Create an empty file 4844 restore_failed_format = self.restore(invalid_backup_path, "temp_extract", debug=debug) 4845 assert not restore_failed_format, "Restore should fail with incorrect filename format." 4846 if os.path.exists("temp_extract"): 4847 shutil.rmtree("temp_extract") 4848 4849 assert old_vault == self.__vault 4850 assert old_vault_deep == self.__vault 4851 assert old_vault_dict == dataclasses.asdict(self.__vault) 4852 4853 # Clean up test files and directories 4854 if not debug: 4855 if os.path.exists(compressed_dir): 4856 shutil.rmtree(compressed_dir) 4857 if os.path.exists(extracted_dir): 4858 shutil.rmtree(extracted_dir) 4859 4860 def _test_timeline(self, debug: bool = False): 4861 self.reset() 4862 debug=False 4863 account_pocket_id = self.create_account('pocket') 4864 account_bank_id = self.create_account('bank') 4865 start_date = datetime.datetime(2025, 1, 1) 4866 end_date = datetime.datetime(2025, 12, 31) 4867 delta = datetime.timedelta(days=1) 4868 4869 current_date = start_date 4870 i = 1 4871 lock = self.lock() 4872 balance = { 4873 account_pocket_id: 0, 4874 account_bank_id: 0, 4875 } 4876 while current_date <= end_date: 4877 if debug: 4878 print(current_date) 4879 t = Time.time(current_date) 4880 y = i 4881 for j in range(1, 4): 4882 if debug: 4883 print(j, i) 4884 self.track( 4885 i, 4886 f"{i}++", 4887 account=account_pocket_id, 4888 created_time_ns=Timestamp(t+j), 4889 debug=debug, 4890 ) 4891 balance[account_pocket_id] += i 4892 i += 1 4893 self.subtract( 4894 y, 4895 f"{i}--", 4896 account=account_pocket_id, 4897 created_time_ns=Timestamp(t+4), 4898 debug=debug, 4899 ) 4900 balance[account_pocket_id] -= y 4901 self.transfer( 4902 i, 4903 from_account=account_pocket_id, 4904 to_account=account_bank_id, 4905 desc=f"={i}=", 4906 created_time_ns=Timestamp(t+5), 4907 debug=debug, 4908 ) 4909 balance[account_pocket_id] -= i 4910 balance[account_bank_id] += i 4911 assert self.balance(account_pocket_id) == self.scale(balance[account_pocket_id]) 4912 assert self.balance(account_pocket_id, cached=False) == self.scale(balance[account_pocket_id]) 4913 assert self.balance(account_bank_id) == self.scale(balance[account_bank_id]) 4914 assert self.balance(account_bank_id, cached=False) == self.scale(balance[account_bank_id]) 4915 current_date += delta 4916 assert lock 4917 self.free(lock) 4918 timeline = self.timeline(debug=debug) 4919 4920 current_date = start_date 4921 while current_date <= end_date: 4922 date_key = current_date.strftime("%Y-%m-%d") 4923 assert date_key in timeline.daily 4924 assert len(timeline.daily[date_key].rows) == 7 4925 current_date += delta 4926 assert len(timeline.weekly) == 365 4927 assert len(timeline.monthly) == 12 4928 assert len(timeline.yearly) == 1 4929 self.reset() 4930 4931 def test(self, debug: bool = False) -> bool: 4932 if debug: 4933 print('test', f'debug={debug}') 4934 try: 4935 4936 self._test_timeline(debug) 4937 self._test_core(True, debug) 4938 self._test_core(False, debug) 4939 4940 # test_names 4941 self.reset() 4942 x = "test_names" 4943 failed = False 4944 try: 4945 assert self.name(x) == '' 4946 except: 4947 failed = True 4948 assert failed 4949 assert self.names() == {} 4950 failed = False 4951 try: 4952 assert self.name(x, 'qwe') == '' 4953 except: 4954 failed = True 4955 assert failed 4956 account_id0 = self.create_account(x) 4957 assert isinstance(account_id0, AccountID) 4958 assert int(account_id0) > 0 4959 assert self.name(account_id0) == x 4960 assert self.name(account_id0, 'qwe') == 'qwe' 4961 if debug: 4962 print(self.names(keyword='qwe')) 4963 assert self.names(keyword='asd') == {} 4964 assert self.names(keyword='qwe') == {'qwe': account_id0} 4965 4966 # test_create_account 4967 account_name = "test_account" 4968 assert self.names(keyword=account_name) == {} 4969 account_id = self.create_account(account_name) 4970 assert isinstance(account_id, AccountID) 4971 assert int(account_id) > 0 4972 assert account_id in self.__vault.account 4973 assert self.name(account_id) == account_name 4974 assert self.names(keyword=account_name) == {account_name: account_id} 4975 4976 failed = False 4977 try: 4978 self.create_account(account_name) 4979 except: 4980 failed = True 4981 assert failed 4982 4983 # bad are names is forbidden 4984 4985 for bad_name in [ 4986 None, 4987 '', 4988 Time.time(), 4989 -Time.time(), 4990 f'{Time.time()}', 4991 f'{-Time.time()}', 4992 0.0, 4993 '0.0', 4994 ' ', 4995 ]: 4996 failed = False 4997 try: 4998 self.create_account(bad_name) 4999 except: 5000 failed = True 5001 assert failed 5002 5003 # rename account 5004 assert self.name(account_id) == account_name 5005 assert self.name(account_id, 'asd') == 'asd' 5006 assert self.name(account_id) == 'asd' 5007 # use old and not used name 5008 account_id2 = self.create_account(account_name) 5009 assert int(account_id2) > 0 5010 assert account_id != account_id2 5011 assert self.name(account_id2) == account_name 5012 assert self.names(keyword=account_name) == {account_name: account_id2} 5013 5014 assert self.__history() 5015 count = len(self.__vault.history) 5016 if debug: 5017 print('history-count', count) 5018 assert count == 8 5019 5020 assert self.recall(dry=False, debug=debug) 5021 assert self.name(account_id2) == '' 5022 assert self.account_exists(account_id2) 5023 assert self.recall(dry=False, debug=debug) 5024 assert not self.account_exists(account_id2) 5025 assert self.recall(dry=False, debug=debug) 5026 assert self.name(account_id) == account_name 5027 assert self.recall(dry=False, debug=debug) 5028 assert self.account_exists(account_id) 5029 assert self.recall(dry=False, debug=debug) 5030 assert not self.account_exists(account_id) 5031 assert self.names(keyword='qwe') == {'qwe': account_id0} 5032 assert self.recall(dry=False, debug=debug) 5033 assert self.names(keyword='qwe') == {} 5034 assert self.name(account_id0) == x 5035 assert self.recall(dry=False, debug=debug) 5036 assert self.name(account_id0) == '' 5037 assert self.account_exists(account_id0) 5038 assert self.recall(dry=False, debug=debug) 5039 assert not self.account_exists(account_id0) 5040 assert not self.recall(dry=False, debug=debug) 5041 5042 # Not allowed for duplicate transactions in the same account and time 5043 5044 created = Time.time() 5045 same_account_id = self.create_account('same') 5046 self.track(100, 'test-1', same_account_id, True, created) 5047 failed = False 5048 try: 5049 self.track(50, 'test-1', same_account_id, True, created) 5050 except: 5051 failed = True 5052 assert failed is True 5053 5054 self.reset() 5055 5056 # Same account transfer 5057 for x in [1, 'a', True, 1.8, None]: 5058 failed = False 5059 try: 5060 self.transfer(1, x, x, 'same-account', debug=debug) 5061 except: 5062 failed = True 5063 assert failed is True 5064 5065 # Always preserve box age during transfer 5066 5067 series: list[tuple[int, int]] = [ 5068 (30, 4), 5069 (60, 3), 5070 (90, 2), 5071 ] 5072 case = { 5073 3000: { 5074 'series': series, 5075 'rest': 15000, 5076 }, 5077 6000: { 5078 'series': series, 5079 'rest': 12000, 5080 }, 5081 9000: { 5082 'series': series, 5083 'rest': 9000, 5084 }, 5085 18000: { 5086 'series': series, 5087 'rest': 0, 5088 }, 5089 27000: { 5090 'series': series, 5091 'rest': -9000, 5092 }, 5093 36000: { 5094 'series': series, 5095 'rest': -18000, 5096 }, 5097 } 5098 5099 selected_time = Time.time() - ZakatTracker.TimeCycle() 5100 ages_account_id = self.create_account('ages') 5101 future_account_id = self.create_account('future') 5102 5103 for total in case: 5104 if debug: 5105 print('--------------------------------------------------------') 5106 print(f'case[{total}]', case[total]) 5107 for x in case[total]['series']: 5108 self.track( 5109 unscaled_value=x[0], 5110 desc=f'test-{x} ages', 5111 account=ages_account_id, 5112 created_time_ns=selected_time * x[1], 5113 ) 5114 5115 unscaled_total = self.unscale(total) 5116 if debug: 5117 print('unscaled_total', unscaled_total) 5118 refs = self.transfer( 5119 unscaled_amount=unscaled_total, 5120 from_account=ages_account_id, 5121 to_account=future_account_id, 5122 desc='Zakat Movement', 5123 debug=debug, 5124 ) 5125 5126 if debug: 5127 print('refs', refs) 5128 5129 ages_cache_balance = self.balance(ages_account_id) 5130 ages_fresh_balance = self.balance(ages_account_id, False) 5131 rest = case[total]['rest'] 5132 if debug: 5133 print('source', ages_cache_balance, ages_fresh_balance, rest) 5134 assert ages_cache_balance == rest 5135 assert ages_fresh_balance == rest 5136 5137 future_cache_balance = self.balance(future_account_id) 5138 future_fresh_balance = self.balance(future_account_id, False) 5139 if debug: 5140 print('target', future_cache_balance, future_fresh_balance, total) 5141 print('refs', refs) 5142 assert future_cache_balance == total 5143 assert future_fresh_balance == total 5144 5145 # TODO: check boxes times for `ages` should equal box times in `future` 5146 for ref in self.__vault.account[ages_account_id].box: 5147 ages_capital = self.__vault.account[ages_account_id].box[ref].capital 5148 ages_rest = self.__vault.account[ages_account_id].box[ref].rest 5149 future_capital = 0 5150 if ref in self.__vault.account[future_account_id].box: 5151 future_capital = self.__vault.account[future_account_id].box[ref].capital 5152 future_rest = 0 5153 if ref in self.__vault.account[future_account_id].box: 5154 future_rest = self.__vault.account[future_account_id].box[ref].rest 5155 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 5156 if debug: 5157 print('================================================================') 5158 print('ages', ages_capital, ages_rest) 5159 print('future', future_capital, future_rest) 5160 if ages_rest == 0: 5161 assert ages_capital == future_capital 5162 elif ages_rest < 0: 5163 assert -ages_capital == future_capital 5164 elif ages_rest > 0: 5165 assert ages_capital == ages_rest + future_capital 5166 self.reset() 5167 assert len(self.__vault.history) == 0 5168 5169 assert self.__history() 5170 assert self.__history(False) is False 5171 assert self.__history() is False 5172 assert self.__history(True) 5173 assert self.__history() 5174 if debug: 5175 print('####################################################################') 5176 5177 wallet_account_id = self.create_account('wallet') 5178 safe_account_id = self.create_account('safe') 5179 bank_account_id = self.create_account('bank') 5180 transaction = [ 5181 ( 5182 20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1, 5183 2000, 2000, 2000, 1, 1, 5184 ), 5185 ( 5186 750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2, 5187 75000, 75000, 75000, 1, 1, 5188 ), 5189 ( 5190 600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2, 5191 60000, 60000, 60000, 1, 1, 5192 ), 5193 ] 5194 for z in transaction: 5195 lock = self.lock() 5196 x = z[1] 5197 y = z[2] 5198 self.transfer( 5199 unscaled_amount=z[0], 5200 from_account=x, 5201 to_account=y, 5202 desc='test-transfer', 5203 debug=debug, 5204 ) 5205 zz = self.balance(x) 5206 if debug: 5207 print(zz, z) 5208 assert zz == z[3] 5209 xx = self.accounts()[x] 5210 assert xx.balance == z[3] 5211 assert self.balance(x, False) == z[4] 5212 assert xx.balance == z[4] 5213 5214 s = 0 5215 log = self.__vault.account[x].log 5216 for i in log: 5217 s += log[i].value 5218 if debug: 5219 print('s', s, 'z[5]', z[5]) 5220 assert s == z[5] 5221 5222 assert self.box_size(x) == z[6] 5223 assert self.log_size(x) == z[7] 5224 5225 yy = self.accounts()[y] 5226 assert self.balance(y) == z[8] 5227 assert yy.balance == z[8] 5228 assert self.balance(y, False) == z[9] 5229 assert yy.balance == z[9] 5230 5231 s = 0 5232 log = self.__vault.account[y].log 5233 for i in log: 5234 s += log[i].value 5235 assert s == z[10] 5236 5237 assert self.box_size(y) == z[11] 5238 assert self.log_size(y) == z[12] 5239 assert lock is not None 5240 assert self.free(lock) 5241 5242 assert self.nolock() 5243 history_count = len(self.__vault.history) 5244 transaction_count = len(transaction) 5245 if debug: 5246 print('history-count', history_count, transaction_count) 5247 assert history_count == transaction_count * 3 5248 assert not self.free(Time.time()) 5249 assert self.free(self.lock()) 5250 assert self.nolock() 5251 assert len(self.__vault.history) == transaction_count * 3 5252 5253 # recall 5254 5255 assert self.nolock() 5256 for i in range(transaction_count * 3, 0, -1): 5257 assert len(self.__vault.history) == i 5258 assert self.recall(dry=False, debug=debug) is True 5259 assert len(self.__vault.history) == 0 5260 assert self.recall(dry=False, debug=debug) is False 5261 assert len(self.__vault.history) == 0 5262 5263 # exchange 5264 5265 cash_account_id = self.create_account('cash') 5266 self.exchange(cash_account_id, 25, 3.75, '2024-06-25') 5267 self.exchange(cash_account_id, 22, 3.73, '2024-06-22') 5268 self.exchange(cash_account_id, 15, 3.69, '2024-06-15') 5269 self.exchange(cash_account_id, 10, 3.66) 5270 5271 assert self.nolock() 5272 5273 bank_account_id = self.create_account('bank') 5274 for i in range(1, 30): 5275 exchange = self.exchange(cash_account_id, i) 5276 rate, description, created = exchange.rate, exchange.description, exchange.time 5277 if debug: 5278 print(i, rate, description, created) 5279 assert created 5280 if i < 10: 5281 assert rate == 1 5282 assert description is None 5283 elif i == 10: 5284 assert rate == 3.66 5285 assert description is None 5286 elif i < 15: 5287 assert rate == 3.66 5288 assert description is None 5289 elif i == 15: 5290 assert rate == 3.69 5291 assert description is not None 5292 elif i < 22: 5293 assert rate == 3.69 5294 assert description is not None 5295 elif i == 22: 5296 assert rate == 3.73 5297 assert description is not None 5298 elif i >= 25: 5299 assert rate == 3.75 5300 assert description is not None 5301 exchange = self.exchange(bank_account_id, i) 5302 rate, description, created = exchange.rate, exchange.description, exchange.time 5303 if debug: 5304 print(i, rate, description, created) 5305 assert created 5306 assert rate == 1 5307 assert description is None 5308 5309 assert len(self.__vault.exchange) == 1 5310 assert len(self.exchanges()) == 1 5311 self.__vault.exchange.clear() 5312 assert len(self.__vault.exchange) == 0 5313 assert len(self.exchanges()) == 0 5314 self.reset() 5315 5316 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 5317 cash_account_id = self.create_account('cash') 5318 self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 5319 self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 5320 self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 5321 self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66) 5322 5323 assert self.nolock() 5324 5325 test_account_id = self.create_account('test') 5326 for i in [x * 0.12 for x in range(-15, 21)]: 5327 if i <= 0: 5328 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange() 5329 else: 5330 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange() 5331 5332 assert self.nolock() 5333 5334 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 5335 bank_account_id = self.create_account('bank') 5336 for i in range(1, 31): 5337 timestamp_ns = ZakatTracker.day_to_time(i) 5338 exchange = self.exchange(cash_account_id, timestamp_ns) 5339 rate, description, created = exchange.rate, exchange.description, exchange.time 5340 if debug: 5341 print(i, rate, description, created) 5342 assert created 5343 if i < 10: 5344 assert rate == 1 5345 assert description is None 5346 elif i == 10: 5347 assert rate == 3.66 5348 assert description is None 5349 elif i < 15: 5350 assert rate == 3.66 5351 assert description is None 5352 elif i == 15: 5353 assert rate == 3.69 5354 assert description is not None 5355 elif i < 22: 5356 assert rate == 3.69 5357 assert description is not None 5358 elif i == 22: 5359 assert rate == 3.73 5360 assert description is not None 5361 elif i >= 25: 5362 assert rate == 3.75 5363 assert description is not None 5364 exchange = self.exchange(bank_account_id, i) 5365 rate, description, created = exchange.rate, exchange.description, exchange.time 5366 if debug: 5367 print(i, rate, description, created) 5368 assert created 5369 assert rate == 1 5370 assert description is None 5371 5372 assert self.nolock() 5373 if debug: 5374 print(self.__vault.history, len(self.__vault.history)) 5375 for _ in range(len(self.__vault.history)): 5376 assert self.recall(dry=False, debug=debug) 5377 assert not self.recall(dry=False, debug=debug) 5378 5379 self.reset() 5380 5381 # test transfer between accounts with different exchange rate 5382 5383 a_SAR = self.create_account('Bank (SAR)') 5384 b_USD = self.create_account('Bank (USD)') 5385 c_SAR = self.create_account('Safe (SAR)') 5386 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 5387 for case in [ 5388 (0, a_SAR, 'SAR Gift', 1000, 100000), 5389 (1, a_SAR, 1), 5390 (0, b_USD, 'USD Gift', 500, 50000), 5391 (1, b_USD, 1), 5392 (2, b_USD, 3.75), 5393 (1, b_USD, 3.75), 5394 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 5395 (0, c_SAR, 'Salary', 750, 75000), 5396 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 5397 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 5398 ]: 5399 if debug: 5400 print('case', case) 5401 match (case[0]): 5402 case 0: # track 5403 _, account, desc, x, balance = case 5404 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 5405 5406 cached_value = self.balance(account, cached=True) 5407 fresh_value = self.balance(account, cached=False) 5408 if debug: 5409 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 5410 assert cached_value == balance 5411 assert fresh_value == balance 5412 case 1: # check-exchange 5413 _, account, expected_rate = case 5414 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5415 if debug: 5416 print('t-exchange', t_exchange) 5417 assert t_exchange.rate == expected_rate 5418 case 2: # do-exchange 5419 _, account, rate = case 5420 self.exchange(account, rate=rate, debug=debug) 5421 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5422 if debug: 5423 print('b-exchange', b_exchange) 5424 assert b_exchange.rate == rate 5425 case 3: # transfer 5426 _, x, a, b, desc, a_balance, b_balance = case 5427 self.transfer(x, a, b, desc, debug=debug) 5428 5429 cached_value = self.balance(a, cached=True) 5430 fresh_value = self.balance(a, cached=False) 5431 if debug: 5432 print( 5433 'account', a, 5434 'cached_value', cached_value, 5435 'fresh_value', fresh_value, 5436 'a_balance', a_balance, 5437 ) 5438 assert cached_value == a_balance 5439 assert fresh_value == a_balance 5440 5441 cached_value = self.balance(b, cached=True) 5442 fresh_value = self.balance(b, cached=False) 5443 if debug: 5444 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 5445 assert cached_value == b_balance 5446 assert fresh_value == b_balance 5447 5448 # Transfer all in many chunks randomly from B to A 5449 a_SAR_balance = 137125 5450 b_USD_balance = 50100 5451 b_USD_exchange = self.exchange(b_USD) 5452 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 5453 if debug: 5454 print('amounts', amounts) 5455 i = 0 5456 for x in amounts: 5457 if debug: 5458 print(f'{i} - transfer-with-exchange({x})') 5459 self.transfer( 5460 unscaled_amount=self.unscale(x), 5461 from_account=b_USD, 5462 to_account=a_SAR, 5463 desc=f'{x} USD -> SAR', 5464 debug=debug, 5465 ) 5466 5467 b_USD_balance -= x 5468 cached_value = self.balance(b_USD, cached=True) 5469 fresh_value = self.balance(b_USD, cached=False) 5470 if debug: 5471 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5472 b_USD_balance) 5473 assert cached_value == b_USD_balance 5474 assert fresh_value == b_USD_balance 5475 5476 a_SAR_balance += int(x * b_USD_exchange.rate) 5477 cached_value = self.balance(a_SAR, cached=True) 5478 fresh_value = self.balance(a_SAR, cached=False) 5479 if debug: 5480 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5481 a_SAR_balance, 'rate', b_USD_exchange.rate) 5482 assert cached_value == a_SAR_balance 5483 assert fresh_value == a_SAR_balance 5484 i += 1 5485 5486 # Transfer all in many chunks randomly from C to A 5487 c_SAR_balance = 37500 5488 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 5489 if debug: 5490 print('amounts', amounts) 5491 i = 0 5492 for x in amounts: 5493 if debug: 5494 print(f'{i} - transfer-with-exchange({x})') 5495 self.transfer( 5496 unscaled_amount=self.unscale(x), 5497 from_account=c_SAR, 5498 to_account=a_SAR, 5499 desc=f'{x} SAR -> a_SAR', 5500 debug=debug, 5501 ) 5502 5503 c_SAR_balance -= x 5504 cached_value = self.balance(c_SAR, cached=True) 5505 fresh_value = self.balance(c_SAR, cached=False) 5506 if debug: 5507 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5508 c_SAR_balance) 5509 assert cached_value == c_SAR_balance 5510 assert fresh_value == c_SAR_balance 5511 5512 a_SAR_balance += x 5513 cached_value = self.balance(a_SAR, cached=True) 5514 fresh_value = self.balance(a_SAR, cached=False) 5515 if debug: 5516 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5517 a_SAR_balance) 5518 assert cached_value == a_SAR_balance 5519 assert fresh_value == a_SAR_balance 5520 i += 1 5521 5522 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 5523 5524 # check & zakat with exchange rates for many cycles 5525 5526 lock = None 5527 safe_account_id = self.create_account('safe') 5528 cave_account_id = self.create_account('cave') 5529 for rate, values in { 5530 1: { 5531 'in': [1000, 2000, 10000], 5532 'exchanged': [100000, 200000, 1000000], 5533 'out': [2500, 5000, 73140], 5534 }, 5535 3.75: { 5536 'in': [200, 1000, 5000], 5537 'exchanged': [75000, 375000, 1875000], 5538 'out': [1875, 9375, 137138], 5539 }, 5540 }.items(): 5541 a, b, c = values['in'] 5542 m, n, o = values['exchanged'] 5543 x, y, z = values['out'] 5544 if debug: 5545 print('rate', rate, 'values', values) 5546 for case in [ 5547 (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5548 {safe_account_id: {0: {'below_nisab': x}}}, 5549 ], False, m), 5550 (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5551 {safe_account_id: {0: {'count': 1, 'total': y}}}, 5552 ], True, n), 5553 (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [ 5554 {cave_account_id: {0: {'count': 3, 'total': z}}}, 5555 ], True, o), 5556 ]: 5557 if debug: 5558 print(f'############# check(rate: {rate}) #############') 5559 print('case', case) 5560 self.reset() 5561 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 5562 self.track( 5563 unscaled_value=case[0], 5564 desc='test-check', 5565 account=case[1], 5566 created_time_ns=case[2], 5567 ) 5568 assert self.snapshot() 5569 5570 # assert self.nolock() 5571 # history_size = len(self.__vault.history) 5572 # print('history_size', history_size) 5573 # assert history_size == 2 5574 lock = self.lock() 5575 assert lock 5576 assert not self.nolock() 5577 assert self.__vault.cache.zakat is None 5578 report = self.check(2.17, None, debug) 5579 if debug: 5580 print('[report]', report) 5581 assert case[4] == report.valid 5582 assert case[5] == report.summary.total_wealth 5583 assert case[5] == report.summary.total_zakatable_amount 5584 if report.valid: 5585 assert self.__vault.cache.zakat is not None 5586 assert report.plan 5587 assert self.zakat(report, debug=debug) 5588 assert self.__vault.cache.zakat is None 5589 if debug: 5590 pp().pprint(self.__vault) 5591 self._test_storage(debug=debug) 5592 5593 for x in report.plan: 5594 assert case[1] == x 5595 if report.plan[x][0].below_nisab: 5596 if debug: 5597 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 5598 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 5599 else: 5600 if debug: 5601 print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total']) 5602 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 5603 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 5604 assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total'] 5605 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 5606 assert report.plan[x][0].count == case[3][0][x][0]['count'] 5607 else: 5608 assert self.__vault.cache.zakat is None 5609 result = self.zakat(report, debug=debug) 5610 if debug: 5611 print('zakat-result', result, case[4]) 5612 assert result == case[4] 5613 report = self.check(2.17, None, debug) 5614 assert report.valid is False 5615 self._test_storage(account_id=cave_account_id, debug=debug) 5616 5617 # recall after zakat 5618 5619 history_size = len(self.__vault.history) 5620 if debug: 5621 print('history_size', history_size) 5622 assert history_size == 3 5623 assert not self.nolock() 5624 assert self.recall(dry=False, debug=debug) is False 5625 self.free(lock) 5626 assert self.nolock() 5627 5628 for i in range(3, 0, -1): 5629 history_size = len(self.__vault.history) 5630 if debug: 5631 print('history_size', history_size) 5632 assert history_size == i 5633 assert self.recall(dry=False, debug=debug) is True 5634 5635 assert self.nolock() 5636 assert self.recall(dry=False, debug=debug) is False 5637 5638 history_size = len(self.__vault.history) 5639 if debug: 5640 print('history_size', history_size) 5641 assert history_size == 0 5642 5643 account_size = len(self.__vault.account) 5644 if debug: 5645 print('account_size', account_size) 5646 assert account_size == 0 5647 5648 report_size = len(self.__vault.report) 5649 if debug: 5650 print('report_size', report_size) 5651 assert report_size == 0 5652 5653 assert self.nolock() 5654 5655 # csv 5656 5657 csv_count = 1000 5658 5659 for with_rate, path in { 5660 False: 'test-import_csv-no-exchange', 5661 True: 'test-import_csv-with-exchange', 5662 }.items(): 5663 5664 if debug: 5665 print('test_import_csv', with_rate, path) 5666 5667 csv_path = path + '.csv' 5668 if os.path.exists(csv_path): 5669 os.remove(csv_path) 5670 c = self.generate_random_csv_file( 5671 path=csv_path, 5672 count=csv_count, 5673 with_rate=with_rate, 5674 debug=debug, 5675 ) 5676 if debug: 5677 print('generate_random_csv_file', c) 5678 assert c == csv_count 5679 assert os.path.getsize(csv_path) > 0 5680 cache_path = self.import_csv_cache_path() 5681 if os.path.exists(cache_path): 5682 os.remove(cache_path) 5683 self.reset() 5684 lock = self.lock() 5685 import_report = self.import_csv(csv_path, debug=debug) 5686 bad_count = len(import_report.bad) 5687 if debug: 5688 print(f'csv-imported: {import_report.statistics} = count({csv_count})') 5689 print('bad', import_report.bad) 5690 assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count 5691 assert import_report.statistics.created == csv_count 5692 assert bad_count == 0 5693 assert bad_count == import_report.statistics.bad 5694 tmp_size = os.path.getsize(cache_path) 5695 assert tmp_size > 0 5696 5697 import_report_2 = self.import_csv(csv_path, debug=debug) 5698 bad_2_count = len(import_report_2.bad) 5699 if debug: 5700 print(f'csv-imported: {import_report_2}') 5701 print('bad', import_report_2.bad) 5702 assert tmp_size == os.path.getsize(cache_path) 5703 assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count 5704 assert import_report.statistics.created == import_report_2.statistics.found 5705 assert bad_count == bad_2_count 5706 assert import_report_2.statistics.found == csv_count 5707 assert bad_2_count == 0 5708 assert bad_2_count == import_report_2.statistics.bad 5709 assert import_report_2.statistics.created == 0 5710 5711 # payment parts 5712 5713 positive_parts = self.build_payment_parts(100, positive_only=True) 5714 assert self.check_payment_parts(positive_parts) != 0 5715 assert self.check_payment_parts(positive_parts) != 0 5716 all_parts = self.build_payment_parts(300, positive_only=False) 5717 assert self.check_payment_parts(all_parts) != 0 5718 assert self.check_payment_parts(all_parts) != 0 5719 if debug: 5720 pp().pprint(positive_parts) 5721 pp().pprint(all_parts) 5722 # dynamic discount 5723 suite = [] 5724 count = 3 5725 for exceed in [False, True]: 5726 case = [] 5727 for part in [positive_parts, all_parts]: 5728 #part = parts.copy() 5729 demand = part.demand 5730 if debug: 5731 print(demand, part.total) 5732 i = 0 5733 z = demand / count 5734 cp = PaymentParts( 5735 demand=demand, 5736 exceed=exceed, 5737 total=part.total, 5738 ) 5739 j = '' 5740 for x, y in part.account.items(): 5741 x_exchange = self.exchange(x) 5742 zz = self.exchange_calc(z, 1, x_exchange.rate) 5743 if exceed and zz <= demand: 5744 i += 1 5745 y.part = zz 5746 if debug: 5747 print(exceed, y) 5748 cp.account[x] = y 5749 case.append(y) 5750 elif not exceed and y.balance >= zz: 5751 i += 1 5752 y.part = zz 5753 if debug: 5754 print(exceed, y) 5755 cp.account[x] = y 5756 case.append(y) 5757 j = x 5758 if i >= count: 5759 break 5760 if debug: 5761 print('[debug]', j) 5762 print('[debug]', cp.account[j]) 5763 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 5764 suite.append(cp) 5765 if debug: 5766 print('suite', len(suite)) 5767 for case in suite: 5768 if debug: 5769 print('case', case) 5770 result = self.check_payment_parts(case) 5771 if debug: 5772 print('check_payment_parts', result, f'exceed: {exceed}') 5773 assert result == 0 5774 5775 assert self.__vault.cache.zakat is None 5776 report = self.check(2.17, None, debug) 5777 if debug: 5778 print('valid', report.valid) 5779 zakat_result = self.zakat(report, parts=case, debug=debug) 5780 if debug: 5781 print('zakat-result', zakat_result) 5782 assert report.valid == zakat_result 5783 # test verified zakat report is required 5784 if zakat_result: 5785 assert self.__vault.cache.zakat is None 5786 failed = False 5787 try: 5788 self.zakat(report, parts=case, debug=debug) 5789 except: 5790 failed = True 5791 assert failed 5792 5793 assert self.free(lock) 5794 5795 assert self.save(path + f'.{self.ext()}') 5796 assert self.save(f'1000-transactions-test.{self.ext()}') 5797 return True 5798 except Exception as e: 5799 if self.__debug_output: 5800 pp().pprint(self.__vault) 5801 print('============================================================================') 5802 pp().pprint(self.__debug_output) 5803 assert self.save(f'test-snapshot.{self.ext()}') 5804 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.
1648 def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True): 1649 """ 1650 Initialize ZakatTracker with database path and history mode. 1651 1652 Parameters: 1653 - db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database. 1654 - history_mode (bool, optional): The mode for tracking history. Default is True. 1655 1656 Returns: 1657 None 1658 """ 1659 self.reset() 1660 self.__memory_mode = db_path == ':memory:' 1661 self.__history(history_mode) 1662 if not self.__memory_mode: 1663 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
1558 @staticmethod 1559 def Version() -> str: 1560 """ 1561 Returns the current version of the software. 1562 1563 This function returns a string representing the current version of the software, 1564 including major, minor, and patch version numbers in the format 'X.Y.Z'. 1565 1566 Returns: 1567 - str: The current version of the software. 1568 """ 1569 version = '0.3.5' 1570 git_hash, unstaged_count, commit_count_since_last_tag = get_git_status() 1571 if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0): 1572 version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}" 1573 print(version) 1574 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.
1576 @staticmethod 1577 def ZakatCut(x: float) -> float: 1578 """ 1579 Calculates the Zakat amount due on an asset. 1580 1581 This function calculates the zakat amount due on a given asset value over one lunar year. 1582 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 1583 that exceeds a certain threshold (Nisab). 1584 1585 Parameters: 1586 - x (float): The total value of the asset on which Zakat is to be calculated. 1587 1588 Returns: 1589 - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 1590 """ 1591 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.
1593 @staticmethod 1594 def TimeCycle(days: int = 355) -> int: 1595 """ 1596 Calculates the approximate duration of a lunar year in nanoseconds. 1597 1598 This function calculates the approximate duration of a lunar year based on the given number of days. 1599 It converts the given number of days into nanoseconds for use in high-precision timing applications. 1600 1601 Parameters: 1602 - days (int, optional): The number of days in a lunar year. Defaults to 355, 1603 which is an approximation of the average length of a lunar year. 1604 1605 Returns: 1606 - int: The approximate duration of a lunar year in nanoseconds. 1607 """ 1608 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.
1610 @staticmethod 1611 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 1612 """ 1613 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 1614 1615 This function calculates the Nisab value, which is the minimum threshold of wealth, 1616 that makes an individual liable for paying Zakat. 1617 The Nisab value is determined by the equivalent value of a specific amount 1618 of gold or silver (currently 595 grams in silver) in the local currency. 1619 1620 Parameters: 1621 - gram_price (float): The price per gram of Nisab. 1622 - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver. 1623 1624 Returns: 1625 - float: The total value of Nisab based on the given price per gram. 1626 """ 1627 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.
1629 @staticmethod 1630 def ext() -> str: 1631 """ 1632 Returns the file extension used by the ZakatTracker class. 1633 1634 Parameters: 1635 None 1636 1637 Returns: 1638 - str: The file extension used by the ZakatTracker class, which is 'json'. 1639 """ 1640 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'.
1665 def memory_mode(self) -> bool: 1666 """ 1667 Check if the ZakatTracker is operating in memory mode. 1668 1669 Returns: 1670 - bool: True if the database is in memory, False otherwise. 1671 """ 1672 return self.__memory_mode
Check if the ZakatTracker is operating in memory mode.
Returns:
- bool: True if the database is in memory, False otherwise.
1674 def path(self, path: Optional[str] = None) -> str: 1675 """ 1676 Set or get the path to the database file. 1677 1678 If no path is provided, the current path is returned. 1679 If a path is provided, it is set as the new path. 1680 The function also creates the necessary directories if the provided path is a file. 1681 1682 Parameters: 1683 - path (str, optional): The new path to the database file. If not provided, the current path is returned. 1684 1685 Returns: 1686 - str: The current or new path to the database file. 1687 """ 1688 if path is None: 1689 return str(self.__vault_path) 1690 self.__vault_path = pathlib.Path(path).resolve() 1691 base_path = pathlib.Path(path).resolve() 1692 if base_path.is_file() or base_path.suffix: 1693 base_path = base_path.parent 1694 base_path.mkdir(parents=True, exist_ok=True) 1695 self.__base_path = base_path 1696 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.
1698 def base_path(self, *args) -> str: 1699 """ 1700 Generate a base path by joining the provided arguments with the existing base path. 1701 1702 Parameters: 1703 - *args (str): Variable length argument list of strings to be joined with the base path. 1704 1705 Returns: 1706 - str: The generated base path. If no arguments are provided, the existing base path is returned. 1707 """ 1708 if not args: 1709 return str(self.__base_path) 1710 filtered_args = [] 1711 ignored_filename = None 1712 for arg in args: 1713 if pathlib.Path(arg).suffix: 1714 ignored_filename = arg 1715 else: 1716 filtered_args.append(arg) 1717 base_path = pathlib.Path(self.__base_path) 1718 full_path = base_path.joinpath(*filtered_args) 1719 full_path.mkdir(parents=True, exist_ok=True) 1720 if ignored_filename is not None: 1721 return full_path.resolve() / ignored_filename # Join with the ignored filename 1722 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.
1724 @staticmethod 1725 def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int: 1726 """ 1727 Scales a numerical value by a specified power of 10, returning an integer. 1728 1729 This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and 1730 facilitate precise scaling operations, particularly useful in financial or scientific calculations. 1731 1732 Parameters: 1733 - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal. 1734 - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled 1735 by a factor of 100 (e.g., converts 1.23 to 123). 1736 1737 Returns: 1738 - The scaled value, rounded to the nearest integer. 1739 1740 Raises: 1741 - TypeError: If the input `x` is not a valid numeric type. 1742 1743 Examples: 1744 ```bash 1745 >>> ZakatTracker.scale(3.14159) 1746 314 1747 >>> ZakatTracker.scale(1234, decimal_places=3) 1748 1234000 1749 >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4) 1750 50 1751 ``` 1752 """ 1753 if not isinstance(x, (float, int, decimal.Decimal)): 1754 raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.') 1755 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
1757 @staticmethod 1758 def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal: 1759 """ 1760 Unscales an integer by a power of 10. 1761 1762 Parameters: 1763 - x (int): The integer to unscale. 1764 - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float. 1765 - decimal_places (int, optional): The power of 10 to use. Defaults to 2. 1766 1767 Returns: 1768 - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type. 1769 1770 Raises: 1771 - TypeError: If the return_type is not float or decimal.Decimal. 1772 """ 1773 if return_type not in (float, decimal.Decimal): 1774 raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.') 1775 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.
1777 def reset(self) -> None: 1778 """ 1779 Reset the internal data structure to its initial state. 1780 1781 Parameters: 1782 None 1783 1784 Returns: 1785 None 1786 """ 1787 self.__vault = Vault()
Reset the internal data structure to its initial state.
Parameters: None
Returns: None
1789 def clean_history(self, lock: Optional[Timestamp] = None) -> int: 1790 """ 1791 Cleans up the empty history records of actions performed on the ZakatTracker instance. 1792 1793 Parameters: 1794 - lock (Timestamp, optional): The lock ID is used to clean up the empty history. 1795 If not provided, it cleans up the empty history records for all locks. 1796 1797 Returns: 1798 - int: The number of locks cleaned up. 1799 """ 1800 count = 0 1801 if lock in self.__vault.history: 1802 if len(self.__vault.history[lock]) <= 0: 1803 count += 1 1804 del self.__vault.history[lock] 1805 return count 1806 for key in self.__vault.history: 1807 if len(self.__vault.history[key]) <= 0: 1808 count += 1 1809 del self.__vault.history[key] 1810 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.
1880 def nolock(self) -> bool: 1881 """ 1882 Check if the vault lock is currently not set. 1883 1884 Parameters: 1885 None 1886 1887 Returns: 1888 - bool: True if the vault lock is not set, False otherwise. 1889 """ 1890 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.
1905 def lock(self) -> Optional[Timestamp]: 1906 """ 1907 Acquires a lock on the ZakatTracker instance. 1908 1909 Parameters: 1910 None 1911 1912 Returns: 1913 - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later. 1914 """ 1915 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.
1917 def steps(self) -> dict: 1918 """ 1919 Returns a copy of the history of steps taken in the ZakatTracker. 1920 1921 The history is a dictionary where each key is a unique identifier for a step, 1922 and the corresponding value is a dictionary containing information about the step. 1923 1924 Parameters: 1925 None 1926 1927 Returns: 1928 - dict: A copy of the history of steps taken in the ZakatTracker. 1929 """ 1930 return { 1931 lock: { 1932 timestamp: dataclasses.asdict(history) 1933 for timestamp, history in steps.items() 1934 } 1935 for lock, steps in self.__vault.history.items() 1936 }
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.
1938 def free(self, lock: Timestamp, auto_save: bool = True) -> bool: 1939 """ 1940 Releases the lock on the database. 1941 1942 Parameters: 1943 - lock (Timestamp): The lock ID to be released. 1944 - auto_save (bool, optional): Whether to automatically save the database after releasing the lock. 1945 1946 Returns: 1947 - bool: True if the lock is successfully released and (optionally) saved, False otherwise. 1948 """ 1949 if lock == self.__vault.lock: 1950 self.clean_history(lock) 1951 self.__vault.lock = None 1952 if auto_save and not self.memory_mode(): 1953 return self.save(self.path()) 1954 return True 1955 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.
1957 def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool: 1958 """ 1959 Revert the last operation. 1960 1961 Parameters: 1962 - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 1963 - lock (Timestamp, optional): An optional lock value to ensure the recall 1964 operation is performed on the expected history entry. If provided, 1965 it checks if the current lock and the most recent history key 1966 match the given lock value. Defaults to None. 1967 - debug (bool, optional): If True, the function will print debug information. Default is False. 1968 1969 Returns: 1970 - bool: True if the operation was successful, False otherwise. 1971 """ 1972 if not self.nolock() or len(self.__vault.history) == 0: 1973 return False 1974 if len(self.__vault.history) <= 0: 1975 return False 1976 ref = sorted(self.__vault.history.keys())[-1] 1977 if debug: 1978 print('recall', ref) 1979 memory = sorted(self.__vault.history[ref], reverse=True) 1980 if debug: 1981 print(type(memory), 'memory', memory) 1982 if lock is not None: 1983 assert self.__vault.lock == lock, "Invalid current lock" 1984 assert ref == lock, "Invalid last lock" 1985 assert self.__history(), "History mode should be enabled, found off!!!" 1986 sub_positive_log_negative = 0 1987 for i in memory: 1988 x = self.__vault.history[ref][i] 1989 if debug: 1990 print(type(x), x) 1991 if x.action != Action.REPORT: 1992 assert x.account is not None 1993 if x.action != Action.EXCHANGE: 1994 assert self.account_exists(x.account) 1995 match x.action: 1996 case Action.CREATE: 1997 if debug: 1998 print('account', self.__vault.account[x.account]) 1999 assert len(self.__vault.account[x.account].box) == 0 2000 assert len(self.__vault.account[x.account].log) == 0 2001 assert self.__vault.account[x.account].balance == 0 2002 assert self.__vault.account[x.account].count == 0 2003 assert self.__vault.account[x.account].name == '' 2004 if dry: 2005 continue 2006 del self.__vault.account[x.account] 2007 2008 case Action.NAME: 2009 assert x.value is not None 2010 if dry: 2011 continue 2012 self.__vault.account[x.account].name = x.value 2013 2014 case Action.TRACK: 2015 assert x.value is not None 2016 assert x.ref is not None 2017 if dry: 2018 continue 2019 self.__vault.account[x.account].balance -= x.value 2020 self.__vault.account[x.account].count -= 1 2021 del self.__vault.account[x.account].box[x.ref] 2022 2023 case Action.LOG: 2024 assert x.ref in self.__vault.account[x.account].log 2025 assert x.value is not None 2026 if dry: 2027 continue 2028 if sub_positive_log_negative == -x.value: 2029 self.__vault.account[x.account].count -= 1 2030 sub_positive_log_negative = 0 2031 box_ref = self.__vault.account[x.account].log[x.ref].ref 2032 if not box_ref is None: 2033 assert self.box_exists(x.account, box_ref) 2034 box_value = self.__vault.account[x.account].log[x.ref].value 2035 assert box_value < 0 2036 2037 try: 2038 self.__vault.account[x.account].box[box_ref].rest += -box_value 2039 except TypeError: 2040 self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value) 2041 2042 try: 2043 self.__vault.account[x.account].balance += -box_value 2044 except TypeError: 2045 self.__vault.account[x.account].balance += decimal.Decimal(-box_value) 2046 2047 self.__vault.account[x.account].count -= 1 2048 del self.__vault.account[x.account].log[x.ref] 2049 2050 case Action.SUBTRACT: 2051 assert x.ref in self.__vault.account[x.account].box 2052 assert x.value is not None 2053 if dry: 2054 continue 2055 self.__vault.account[x.account].box[x.ref].rest += x.value 2056 self.__vault.account[x.account].balance += x.value 2057 sub_positive_log_negative = x.value 2058 2059 case Action.ADD_FILE: 2060 assert x.ref in self.__vault.account[x.account].log 2061 assert x.file is not None 2062 assert dry or x.file in self.__vault.account[x.account].log[x.ref].file 2063 if dry: 2064 continue 2065 del self.__vault.account[x.account].log[x.ref].file[x.file] 2066 2067 case Action.REMOVE_FILE: 2068 assert x.ref in self.__vault.account[x.account].log 2069 assert x.file is not None 2070 assert x.value is not None 2071 if dry: 2072 continue 2073 self.__vault.account[x.account].log[x.ref].file[x.file] = x.value 2074 2075 case Action.BOX_TRANSFER: 2076 assert x.ref in self.__vault.account[x.account].box 2077 assert x.value is not None 2078 if dry: 2079 continue 2080 self.__vault.account[x.account].box[x.ref].rest -= x.value 2081 2082 case Action.EXCHANGE: 2083 assert x.account in self.__vault.exchange 2084 assert x.ref in self.__vault.exchange[x.account] 2085 if dry: 2086 continue 2087 del self.__vault.exchange[x.account][x.ref] 2088 2089 case Action.REPORT: 2090 assert x.ref in self.__vault.report 2091 if dry: 2092 continue 2093 del self.__vault.report[x.ref] 2094 2095 case Action.ZAKAT: 2096 assert x.ref in self.__vault.account[x.account].box 2097 assert x.key is not None 2098 assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) 2099 if dry: 2100 continue 2101 match x.math: 2102 case MathOperation.ADDITION: 2103 setattr( 2104 self.__vault.account[x.account].box[x.ref].zakat, 2105 x.key, 2106 getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value, 2107 ) 2108 case MathOperation.EQUAL: 2109 setattr( 2110 self.__vault.account[x.account].box[x.ref].zakat, 2111 x.key, 2112 x.value, 2113 ) 2114 case MathOperation.SUBTRACTION: 2115 setattr( 2116 self.__vault.account[x.account].box[x.ref], 2117 x.key, 2118 getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value, 2119 ) 2120 2121 if not dry: 2122 del self.__vault.history[ref] 2123 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.
2125 def vault(self) -> dict: 2126 """ 2127 Returns a copy of the internal vault dictionary. 2128 2129 This method is used to retrieve the current state of the ZakatTracker object. 2130 It provides a snapshot of the internal data structure, allowing for further 2131 processing or analysis. 2132 2133 Parameters: 2134 None 2135 2136 Returns: 2137 - dict: A copy of the internal vault dictionary. 2138 """ 2139 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.
2141 @staticmethod 2142 def stats_init() -> FileStats: 2143 """ 2144 Initialize and return the initial file statistics. 2145 2146 Returns: 2147 - FileStats: A :class:`FileStats` instance with initial values 2148 of 0 bytes for both RAM and database. 2149 """ 2150 return FileStats( 2151 database=SizeInfo(0, '0'), 2152 ram=SizeInfo(0, '0'), 2153 )
Initialize and return the initial file statistics.
Returns:
- FileStats: A
FileStats
instance with initial values of 0 bytes for both RAM and database.
2155 def stats(self, ignore_ram: bool = True) -> FileStats: 2156 """ 2157 Calculates and returns statistics about the object's data storage. 2158 2159 This method determines the size of the database file on disk and the 2160 size of the data currently held in RAM (likely within a dictionary). 2161 Both sizes are reported in bytes and in a human-readable format 2162 (e.g., KB, MB). 2163 2164 Parameters: 2165 - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True 2166 2167 Returns: 2168 - FileStats: A dataclass containing the following statistics: 2169 2170 * 'database': A tuple with two elements: 2171 - The database file size in bytes (float). 2172 - The database file size in human-readable format (str). 2173 * 'ram': A tuple with two elements: 2174 - The RAM usage (dictionary size) in bytes (float). 2175 - The RAM usage in human-readable format (str). 2176 2177 Example: 2178 ```bash 2179 >>> x = ZakatTracker() 2180 >>> stats = x.stats() 2181 >>> print(stats.database) 2182 SizeInfo(bytes=256000, human_readable='250.0 KB') 2183 >>> print(stats.ram) 2184 SizeInfo(bytes=12345, human_readable='12.1 KB') 2185 ``` 2186 """ 2187 ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault()) 2188 file_size = os.path.getsize(self.path()) 2189 return FileStats( 2190 database=SizeInfo(file_size, self.human_readable_size(file_size)), 2191 ram=SizeInfo(ram_size, self.human_readable_size(ram_size)), 2192 )
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')
2194 def files(self) -> list[FileInfo]: 2195 """ 2196 Retrieves information about files associated with this class. 2197 2198 This class method provides a standardized way to gather details about 2199 files used by the class for storage, snapshots, and CSV imports. 2200 2201 Parameters: 2202 None 2203 2204 Returns: 2205 - list[FileInfo]: A list of dataclass, each containing information 2206 about a specific file: 2207 2208 * type (str): The type of file ('database', 'snapshot', 'import_csv'). 2209 * path (str): The full file path. 2210 * exists (bool): Whether the file exists on the filesystem. 2211 * size (int): The file size in bytes (0 if the file doesn't exist). 2212 * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB'). 2213 """ 2214 result = [] 2215 for file_type, path in { 2216 'database': self.path(), 2217 'snapshot': self.snapshot_cache_path(), 2218 'import_csv': self.import_csv_cache_path(), 2219 }.items(): 2220 exists = os.path.exists(path) 2221 size = os.path.getsize(path) if exists else 0 2222 human_readable_size = self.human_readable_size(size) if exists else '0' 2223 result.append(FileInfo( 2224 type=file_type, 2225 path=path, 2226 exists=exists, 2227 size=size, 2228 human_readable_size=human_readable_size, 2229 )) 2230 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').
2232 def account_exists(self, account: AccountID) -> bool: 2233 """ 2234 Check if the given account exists in the vault. 2235 2236 Parameters: 2237 - account (AccountID): The account reference to check. 2238 2239 Returns: 2240 - bool: True if the account exists, False otherwise. 2241 """ 2242 account = AccountID(account) 2243 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.
2245 def box_size(self, account: AccountID) -> int: 2246 """ 2247 Calculate the size of the box for a specific account. 2248 2249 Parameters: 2250 - account (AccountID): The account reference for which the box size needs to be calculated. 2251 2252 Returns: 2253 - int: The size of the box for the given account. If the account does not exist, -1 is returned. 2254 """ 2255 if self.account_exists(account): 2256 return len(self.__vault.account[account].box) 2257 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.
2259 def log_size(self, account: AccountID) -> int: 2260 """ 2261 Get the size of the log for a specific account. 2262 2263 Parameters: 2264 - account (AccountID): The account reference for which the log size needs to be calculated. 2265 2266 Returns: 2267 - int: The size of the log for the given account. If the account does not exist, -1 is returned. 2268 """ 2269 if self.account_exists(account): 2270 return len(self.__vault.account[account].log) 2271 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.
2273 @staticmethod 2274 def hash_data(data: bytes, algorithm: str = 'blake2b') -> str: 2275 """ 2276 Calculates the hash of given byte data using the specified algorithm. 2277 2278 Parameters: 2279 - data (bytes): The byte data to hash. 2280 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2281 2282 Returns: 2283 - str: The hexadecimal representation of the data's hash. 2284 """ 2285 hash_obj = hashlib.new(algorithm) 2286 hash_obj.update(data) 2287 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.
2289 @staticmethod 2290 def hash_file(file_path: str, algorithm: str = 'blake2b') -> str: 2291 """ 2292 Calculates the hash of a file using the specified algorithm. 2293 2294 Parameters: 2295 - file_path (str): The path to the file. 2296 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 2297 2298 Returns: 2299 - str: The hexadecimal representation of the file's hash. 2300 """ 2301 hash_obj = hashlib.new(algorithm) # Create the hash object 2302 with open(file_path, 'rb') as file: # Open file in binary mode for reading 2303 for chunk in iter(lambda: file.read(4096), b''): # Read file in chunks 2304 hash_obj.update(chunk) 2305 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.
2307 def snapshot_cache_path(self): 2308 """ 2309 Generate the path for the cache file used to store snapshots. 2310 2311 The cache file is a json file that stores the timestamps of the snapshots. 2312 The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'. 2313 2314 Parameters: 2315 None 2316 2317 Returns: 2318 - str: The path to the cache file. 2319 """ 2320 path = str(self.path()) 2321 ext = self.ext() 2322 ext_len = len(ext) 2323 if path.endswith(f'.{ext}'): 2324 path = path[:-ext_len - 1] 2325 _, filename = os.path.split(path + f'.snapshots.{ext}') 2326 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.
2328 def snapshot(self) -> bool: 2329 """ 2330 This function creates a snapshot of the current database state. 2331 2332 The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. 2333 If a snapshot with the same hash exists, the function returns True without creating a new snapshot. 2334 If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state 2335 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. 2336 2337 Parameters: 2338 None 2339 2340 Returns: 2341 - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails. 2342 """ 2343 current_hash = self.hash_file(self.path()) 2344 cache: dict[str, int] = {} # hash: time_ns 2345 try: 2346 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2347 cache = json.load(stream, cls=JSONDecoder) 2348 except: 2349 pass 2350 if current_hash in cache: 2351 return True 2352 ref = time.time_ns() 2353 cache[current_hash] = ref 2354 if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')): 2355 return False 2356 with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream: 2357 stream.write(json.dumps(cache, cls=JSONEncoder)) 2358 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.
2360 def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \ 2361 -> dict[int, tuple[str, str, bool]]: 2362 """ 2363 Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status. 2364 2365 Parameters: 2366 - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True. 2367 - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False. 2368 2369 Returns: 2370 - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, 2371 and the values are tuples containing the snapshot's hash, path, and existence status. 2372 """ 2373 cache: dict[str, int] = {} # hash: time_ns 2374 try: 2375 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 2376 cache = json.load(stream, cls=JSONDecoder) 2377 except: 2378 pass 2379 if not cache: 2380 return {} 2381 result: dict[int, tuple[str, str, bool]] = {} # time_ns: (hash, path, exists) 2382 for hash_file, ref in cache.items(): 2383 path = self.base_path('snapshots', f'{ref}.{self.ext()}') 2384 exists = os.path.exists(path) 2385 valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True 2386 if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists): 2387 continue 2388 if exists or not hide_missing: 2389 result[ref] = (hash_file, path, exists) 2390 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.
2392 def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool: 2393 """ 2394 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 2395 2396 Parameters: 2397 - account (AccountID): The account reference for which to check the existence of the reference. 2398 - ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 2399 - ref (Timestamp): The reference (transaction) number to check for existence. 2400 2401 Returns: 2402 - bool: True if the reference exists for the given account and reference type, False otherwise. 2403 """ 2404 account = AccountID(account) 2405 if account in self.__vault.account: 2406 return ref in getattr(self.__vault.account[account], ref_type) 2407 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.
2409 def box_exists(self, account: AccountID, ref: Timestamp) -> bool: 2410 """ 2411 Check if a specific box (transaction) exists in the vault for a given account and reference. 2412 2413 Parameters: 2414 - account (AccountID): The account reference for which to check the existence of the box. 2415 - ref (Timestamp): The reference (transaction) number to check for existence. 2416 2417 Returns: 2418 - bool: True if the box exists for the given account and reference, False otherwise. 2419 """ 2420 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.
2422 def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'), 2423 created_time_ns: Optional[Timestamp] = None, 2424 debug: bool = False) -> Optional[Timestamp]: 2425 """ 2426 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. 2427 2428 Parameters: 2429 - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0. 2430 - desc (str, optional): The description of the transaction. Default is an empty string. 2431 - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'. 2432 - 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. 2433 - debug (bool, optional): Whether to print debug information. Default is False. 2434 2435 Returns: 2436 - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD). 2437 2438 Raises: 2439 - ValueError: The created_time_ns should be greater than zero. 2440 - ValueError: The log transaction happened again in the same nanosecond time. 2441 - ValueError: The box transaction happened again in the same nanosecond time. 2442 """ 2443 return self.__track( 2444 unscaled_value=unscaled_value, 2445 desc=desc, 2446 account=account, 2447 logging=True, 2448 created_time_ns=created_time_ns, 2449 debug=debug, 2450 )
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.
2518 def log_exists(self, account: AccountID, ref: Timestamp) -> bool: 2519 """ 2520 Checks if a specific transaction log entry exists for a given account. 2521 2522 Parameters: 2523 - account (AccountID): The account reference associated with the transaction log. 2524 - ref (Timestamp): The reference to the transaction log entry. 2525 2526 Returns: 2527 - bool: True if the transaction log entry exists, False otherwise. 2528 """ 2529 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.
2582 def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None, 2583 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange: 2584 """ 2585 This method is used to record or retrieve exchange rates for a specific account. 2586 2587 Parameters: 2588 - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved. 2589 - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 2590 - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 2591 - description (str, optional): A description of the exchange rate. 2592 - debug (bool, optional): Whether to print debug information. Default is False. 2593 2594 Returns: 2595 - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 2596 it returns a dictionary with default values for the rate and description. 2597 2598 Raises: 2599 - ValueError: The created should be greater than zero. 2600 """ 2601 if debug: 2602 print('exchange', f'debug={debug}') 2603 account = AccountID(account) 2604 if created_time_ns is None: 2605 created_time_ns = Time.time() 2606 if created_time_ns <= 0: 2607 raise ValueError('The created should be greater than zero.') 2608 if rate is not None: 2609 if rate <= 0: 2610 return Exchange() 2611 if account not in self.__vault.exchange: 2612 self.__vault.exchange[account] = {} 2613 if len(self.__vault.exchange[account]) == 0 and rate <= 1: 2614 return Exchange(time=created_time_ns, rate=1) 2615 no_lock = self.nolock() 2616 lock = self.__lock() 2617 self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description) 2618 self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate) 2619 if no_lock: 2620 assert lock is not None 2621 self.free(lock) 2622 if debug: 2623 print('exchange-created-1', 2624 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2625 2626 if account in self.__vault.exchange: 2627 valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns] 2628 if valid_rates: 2629 latest_rate = max(valid_rates, key=lambda x: x[0]) 2630 if debug: 2631 print('exchange-read-1', 2632 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}', 2633 'latest_rate', latest_rate) 2634 result = latest_rate[1] 2635 result.time = latest_rate[0] 2636 return result # إرجاع قاموس يحتوي على المعدل والوصف 2637 if debug: 2638 print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2639 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.
2641 @staticmethod 2642 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 2643 """ 2644 This function calculates the exchanged amount of a currency. 2645 2646 Parameters: 2647 - x (float): The original amount of the currency. 2648 - x_rate (float): The exchange rate of the original currency. 2649 - y_rate (float): The exchange rate of the target currency. 2650 2651 Returns: 2652 - float: The exchanged amount of the target currency. 2653 """ 2654 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.
2656 def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]: 2657 """ 2658 Retrieve the recorded exchange rates for all accounts. 2659 2660 Parameters: 2661 None 2662 2663 Returns: 2664 - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. 2665 The keys are account references or numbers, and the values are dictionaries containing the exchange rates. 2666 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 2667 """ 2668 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.
2670 def accounts(self) -> dict[AccountID, AccountDetails]: 2671 """ 2672 Returns a dictionary containing account references as keys and their respective account details as values. 2673 2674 Parameters: 2675 None 2676 2677 Returns: 2678 - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details. 2679 """ 2680 return { 2681 account_id: AccountDetails( 2682 account_id=account_id, 2683 account_name=self.__vault.account[account_id].name, 2684 balance=self.__vault.account[account_id].balance, 2685 ) 2686 for account_id in self.__vault.account 2687 }
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.
2689 def boxes(self, account: AccountID) -> dict[Timestamp, Box]: 2690 """ 2691 Retrieve the boxes (transactions) associated with a specific account. 2692 2693 Parameters: 2694 - account (AccountID): The account reference for which to retrieve the boxes. 2695 2696 Returns: 2697 - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account. 2698 If the account does not exist, an empty dictionary is returned. 2699 """ 2700 if self.account_exists(account): 2701 return self.__vault.account[account].box 2702 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.
2704 def logs(self, account: AccountID) -> dict[Timestamp, Log]: 2705 """ 2706 Retrieve the logs (transactions) associated with a specific account. 2707 2708 Parameters: 2709 - account (AccountID): The account reference for which to retrieve the logs. 2710 2711 Returns: 2712 - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. 2713 If the account does not exist, an empty dictionary is returned. 2714 """ 2715 if self.account_exists(account): 2716 return self.__vault.account[account].log 2717 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.
2719 def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline: 2720 """ 2721 Aggregates transaction logs into a structured timeline. 2722 2723 This method retrieves transaction logs from all accounts and organizes them 2724 into daily, weekly, monthly, and yearly summaries. Each level of the 2725 timeline includes a `TimeSummary` object with the total positive, negative, 2726 and overall values for that period. The daily level also includes a list 2727 of individual `Transaction` records. 2728 2729 Parameters: 2730 - weekday (WeekDay, optional): The day of the week to use as the anchor 2731 for weekly summaries. Defaults to WeekDay.FRIDAY. 2732 - debug (bool, optional): If True, prints intermediate debug information 2733 during processing. Defaults to False. 2734 2735 Returns: 2736 - Timeline: An object containing the aggregated transaction data, organized 2737 into daily, weekly, monthly, and yearly summaries. The 'daily' 2738 attribute is a dictionary where keys are dates (YYYY-MM-DD) and 2739 values are `DailyRecords` objects. The 'weekly' attribute is a 2740 dictionary where keys are the starting datetime of the week and 2741 values are `TimeSummary` objects. The 'monthly' attribute is a 2742 dictionary where keys are year-month strings (YYYY-MM) and values 2743 are `TimeSummary` objects. The 'yearly' attribute is a dictionary 2744 where keys are years (YYYY) and values are `TimeSummary` objects. 2745 2746 Example: 2747 ```bash 2748 >>> from zakat import tracker 2749 >>> ledger = tracker(':memory:') 2750 >>> account1_id = ledger.create_account('account1') 2751 >>> account2_id = ledger.create_account('account2') 2752 >>> ledger.subtract(51, 'desc', account1_id) 2753 >>> ref = ledger.track(100, 'desc', account2_id) 2754 >>> ledger.add_file(account2_id, ref, 'file_0') 2755 >>> ledger.add_file(account2_id, ref, 'file_1') 2756 >>> ledger.add_file(account2_id, ref, 'file_2') 2757 >>> ledger.timeline() 2758 Timeline( 2759 daily={ 2760 "2025-04-06": DailyRecords( 2761 positive=10000, 2762 negative=5100, 2763 total=4900, 2764 rows=[ 2765 Transaction( 2766 account="account2", 2767 account_id="63879638114290122752", 2768 desc="desc2", 2769 file={ 2770 63879638220705865728: "file_0", 2771 63879638223391350784: "file_1", 2772 63879638225766047744: "file_2", 2773 }, 2774 value=10000, 2775 time=63879638181936513024, 2776 transfer=False, 2777 ), 2778 Transaction( 2779 account="account1", 2780 account_id="63879638104007106560", 2781 desc="desc", 2782 file={}, 2783 value=-5100, 2784 time=63879638149199421440, 2785 transfer=False, 2786 ), 2787 ], 2788 ) 2789 }, 2790 weekly={ 2791 datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary( 2792 positive=10000, negative=0, total=10000 2793 ), 2794 datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary( 2795 positive=0, negative=5100, total=-5100 2796 ), 2797 }, 2798 monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)}, 2799 yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)}, 2800 ) 2801 ``` 2802 """ 2803 logs: dict[Timestamp, list[Transaction]] = {} 2804 for account_id in self.accounts(): 2805 for log_ref, log in self.logs(account_id).items(): 2806 if log_ref not in logs: 2807 logs[log_ref] = [] 2808 logs[log_ref].append(Transaction( 2809 account=self.name(account_id), 2810 account_id=account_id, 2811 log_ref=log_ref, 2812 desc=log.desc, 2813 file=log.file, 2814 value=log.value, 2815 time=log_ref, 2816 transfer=False, 2817 )) 2818 if debug: 2819 print('logs', logs) 2820 y = Timeline() 2821 for i in sorted(logs, reverse=True): 2822 dt = Time.time_to_datetime(i) 2823 daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}' 2824 weekly = dt - datetime.timedelta(days=weekday.value) 2825 monthly = f'{dt.year}-{dt.month:02d}' 2826 yearly = dt.year 2827 # daily 2828 if daily not in y.daily: 2829 y.daily[daily] = DailyRecords() 2830 transfer = len(logs[i]) > 1 2831 if debug: 2832 print('logs[i]', logs[i]) 2833 for z in logs[i]: 2834 if debug: 2835 print('z', z) 2836 # daily 2837 value = z.value 2838 if value > 0: 2839 y.daily[daily].positive += value 2840 else: 2841 y.daily[daily].negative += -value 2842 y.daily[daily].total += value 2843 z.transfer = transfer 2844 y.daily[daily].rows.append(z) 2845 # weekly 2846 if weekly not in y.weekly: 2847 y.weekly[weekly] = TimeSummary() 2848 if value > 0: 2849 y.weekly[weekly].positive += value 2850 else: 2851 y.weekly[weekly].negative += -value 2852 y.weekly[weekly].total += value 2853 # monthly 2854 if monthly not in y.monthly: 2855 y.monthly[monthly] = TimeSummary() 2856 if value > 0: 2857 y.monthly[monthly].positive += value 2858 else: 2859 y.monthly[monthly].negative += -value 2860 y.monthly[monthly].total += value 2861 # yearly 2862 if yearly not in y.yearly: 2863 y.yearly[yearly] = TimeSummary() 2864 if value > 0: 2865 y.yearly[yearly].positive += value 2866 else: 2867 y.yearly[yearly].negative += -value 2868 y.yearly[yearly].total += value 2869 if debug: 2870 print('y', y) 2871 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)},
)
2873 def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp: 2874 """ 2875 Adds a file reference to a specific transaction log entry in the vault. 2876 2877 Parameters: 2878 - account (AccountID): The account reference associated with the transaction log. 2879 - ref (Timestamp): The reference to the transaction log entry. 2880 - path (str): The path of the file to be added. 2881 2882 Returns: 2883 - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 2884 """ 2885 if self.account_exists(account): 2886 if ref in self.__vault.account[account].log: 2887 no_lock = self.nolock() 2888 lock = self.__lock() 2889 file_ref = Time.time() 2890 self.__vault.account[account].log[ref].file[file_ref] = path 2891 self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref) 2892 if no_lock: 2893 assert lock is not None 2894 self.free(lock) 2895 return file_ref 2896 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.
2898 def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool: 2899 """ 2900 Removes a file reference from a specific transaction log entry in the vault. 2901 2902 Parameters: 2903 - account (AccountID): The account reference associated with the transaction log. 2904 - ref (Timestamp): The reference to the transaction log entry. 2905 - file_ref (Timestamp): The reference of the file to be removed. 2906 2907 Returns: 2908 - bool: True if the file reference is successfully removed, False otherwise. 2909 """ 2910 if self.account_exists(account): 2911 if ref in self.__vault.account[account].log: 2912 if file_ref in self.__vault.account[account].log[ref].file: 2913 no_lock = self.nolock() 2914 lock = self.__lock() 2915 x = self.__vault.account[account].log[ref].file[file_ref] 2916 del self.__vault.account[account].log[ref].file[file_ref] 2917 self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 2918 if no_lock: 2919 assert lock is not None 2920 self.free(lock) 2921 return True 2922 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.
2924 def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int: 2925 """ 2926 Calculate and return the balance of a specific account. 2927 2928 Parameters: 2929 - account (AccountID, optional): The account reference. Default is '1'. 2930 - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 2931 2932 Returns: 2933 - int: The balance of the account. 2934 2935 Notes: 2936 - If cached is True, the function returns the cached balance. 2937 - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 2938 """ 2939 account = AccountID(account) 2940 if cached: 2941 return self.__vault.account[account].balance 2942 x = 0 2943 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.
2945 def hide(self, account: AccountID, status: Optional[bool] = None) -> bool: 2946 """ 2947 Check or set the hide status of a specific account. 2948 2949 Parameters: 2950 - account (AccountID): The account reference. 2951 - status (bool, optional): The new hide status. If not provided, the function will return the current status. 2952 2953 Returns: 2954 - bool: The current or updated hide status of the account. 2955 2956 Raises: 2957 None 2958 2959 Example: 2960 ```bash 2961 >>> tracker = ZakatTracker() 2962 >>> ref = tracker.track(51, 'desc', 'account1') 2963 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 2964 False 2965 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 2966 True 2967 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 2968 True 2969 >>> tracker.hide('account1', False) 2970 False 2971 ``` 2972 """ 2973 if self.account_exists(account): 2974 if status is None: 2975 return self.__vault.account[account].hide 2976 self.__vault.account[account].hide = status 2977 return status 2978 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
2980 def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]: 2981 """ 2982 Retrieves an AccountDetails object for the first account matching the given name. 2983 2984 This method searches for accounts with names that contain the provided 'name' 2985 (case-insensitive substring matching). If a match is found, it returns an 2986 AccountDetails object containing the account's ID, name and balance. If no matching 2987 account is found, it returns None. 2988 2989 Parameters: 2990 - name: The name (or partial name) of the account to retrieve. 2991 - exact: If True, performs a case-insensitive exact match. 2992 If False, performs a case-insensitive substring search. 2993 Defaults to True. 2994 2995 Returns: 2996 - AccountDetails: An AccountDetails object representing the found account, or None if no 2997 matching account exists. 2998 """ 2999 for account_name, account_id in self.names(name).items(): 3000 if not exact or account_name.lower() == name.lower(): 3001 return AccountDetails( 3002 account_id=account_id, 3003 account_name=account_name, 3004 balance=self.__vault.account[account_id].balance, 3005 ) 3006 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.
3008 def create_account(self, name: str) -> AccountID: 3009 """ 3010 Creates a new account with the given name and returns its unique ID. 3011 3012 This method: 3013 1. Checks if an account with the same name (case-insensitive) already exists. 3014 2. Generates a unique `AccountID` based on the current time. 3015 3. Tracks the account creation internally. 3016 4. Sets the account's name. 3017 5. Verifies that the name was set correctly. 3018 3019 Parameters: 3020 - name: The name of the new account. 3021 3022 Returns: 3023 - AccountID: The unique `AccountID` of the newly created account. 3024 3025 Raises: 3026 - AssertionError: Empty account name is forbidden. 3027 - AssertionError: Account name in number is forbidden. 3028 - AssertionError: If an account with the same name already exists (case-insensitive). 3029 - AssertionError: If the provided name does not match the name set for the account. 3030 """ 3031 assert name.strip(), 'empty account name is forbidden' 3032 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' 3033 account_ref = self.account(name, exact=True) 3034 # check if account not exists 3035 assert account_ref is None, f'account name({name}) already used' 3036 # create new account 3037 account_id = AccountID(Time.time()) 3038 self.__track(0, '', account_id) 3039 new_name = self.name( 3040 account=account_id, 3041 new_name=name, 3042 ) 3043 assert name == new_name 3044 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.
3046 def names(self, keyword: str = '') -> dict[str, AccountID]: 3047 """ 3048 Retrieves a dictionary of account IDs and names, optionally filtered by a keyword. 3049 3050 Parameters: 3051 - keyword: An optional string to filter account names. If provided, only accounts whose 3052 names contain the keyword (case-insensitive) will be included in the result. 3053 Defaults to an empty string, which returns all accounts. 3054 3055 Returns: 3056 - A dictionary where keys are account names and values are AccountIDs. The dictionary 3057 contains only accounts that match the provided keyword (if any). 3058 """ 3059 return { 3060 account.name: account_id 3061 for account_id, account in self.__vault.account.items() 3062 if keyword.lower() in account.name.lower() 3063 }
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).
3065 def name(self, account: AccountID, new_name: Optional[str] = None) -> str: 3066 """ 3067 Retrieves or sets the name of an account. 3068 3069 Parameters: 3070 - account: The AccountID of the account. 3071 - new_name: The new name to set for the account. If None, the current name is retrieved. 3072 3073 Returns: 3074 - The current name of the account if `new_name` is None, or the `new_name` if it is set. 3075 3076 Note: Returns an empty string if the account does not exist. 3077 """ 3078 if self.account_exists(account): 3079 if new_name is None: 3080 return self.__vault.account[account].name 3081 assert new_name != '' 3082 no_lock = self.nolock() 3083 lock = self.__lock() 3084 self.__step(Action.NAME, account, value=self.__vault.account[account].name) 3085 self.__vault.account[account].name = new_name 3086 if no_lock: 3087 assert lock is not None 3088 self.free(lock) 3089 return new_name 3090 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.
3092 def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool: 3093 """ 3094 Check or set the zakatable status of a specific account. 3095 3096 Parameters: 3097 - account (AccountID): The account reference. 3098 - status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 3099 3100 Returns: 3101 - bool: The current or updated zakatable status of the account. 3102 3103 Raises: 3104 None 3105 3106 Example: 3107 ```bash 3108 >>> tracker = ZakatTracker() 3109 >>> ref = tracker.track(51, 'desc', 'account1') 3110 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 3111 True 3112 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 3113 True 3114 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 3115 True 3116 >>> tracker.zakatable('account1', False) 3117 False 3118 ``` 3119 """ 3120 if self.account_exists(account): 3121 if status is None: 3122 return self.__vault.account[account].zakatable 3123 self.__vault.account[account].zakatable = status 3124 return status 3125 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
3127 def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'), 3128 created_time_ns: Optional[Timestamp] = None, 3129 debug: bool = False) \ 3130 -> SubtractReport: 3131 """ 3132 Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, 3133 the remaining amount will be transferred to a new transaction with a negative value. 3134 3135 Parameters: 3136 - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted. 3137 - desc (str, optional): A description for the transaction. Defaults to an empty string. 3138 - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'. 3139 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 3140 If not provided, the current timestamp will be used. 3141 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 3142 3143 Returns: 3144 - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 3145 3146 Raises: 3147 - ValueError: The unscaled_value should be greater than zero. 3148 - ValueError: The created_time_ns should be greater than zero. 3149 - ValueError: The box transaction happened again in the same nanosecond time. 3150 - ValueError: The log transaction happened again in the same nanosecond time. 3151 """ 3152 if debug: 3153 print('sub', f'debug={debug}') 3154 account = AccountID(account) 3155 if unscaled_value <= 0: 3156 raise ValueError('The unscaled_value should be greater than zero.') 3157 if created_time_ns is None: 3158 created_time_ns = Time.time() 3159 if created_time_ns <= 0: 3160 raise ValueError('The created should be greater than zero.') 3161 no_lock = self.nolock() 3162 lock = self.__lock() 3163 self.__track(0, '', account) 3164 value = self.scale(unscaled_value) 3165 self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 3166 ids = sorted(self.__vault.account[account].box.keys()) 3167 limit = len(ids) + 1 3168 target = value 3169 if debug: 3170 print('ids', ids) 3171 ages = SubtractAges() 3172 for i in range(-1, -limit, -1): 3173 if target == 0: 3174 break 3175 j = ids[i] 3176 if debug: 3177 print('i', i, 'j', j) 3178 rest = self.__vault.account[account].box[j].rest 3179 if rest >= target: 3180 self.__vault.account[account].box[j].rest -= target 3181 self.__step(Action.SUBTRACT, account, ref=j, value=target) 3182 ages.append(SubtractAge(box_ref=j, total=target)) 3183 target = 0 3184 break 3185 elif target > rest > 0: 3186 chunk = rest 3187 target -= chunk 3188 self.__vault.account[account].box[j].rest = 0 3189 self.__step(Action.SUBTRACT, account, ref=j, value=chunk) 3190 ages.append(SubtractAge(box_ref=j, total=chunk)) 3191 if target > 0: 3192 self.__track( 3193 unscaled_value=self.unscale(-target), 3194 desc=desc, 3195 account=account, 3196 logging=False, 3197 created_time_ns=created_time_ns, 3198 ) 3199 ages.append(SubtractAge(box_ref=created_time_ns, total=target)) 3200 if no_lock: 3201 assert lock is not None 3202 self.free(lock) 3203 return SubtractReport( 3204 log_ref=created_time_ns, 3205 ages=ages, 3206 )
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.
3208 def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '', 3209 created_time_ns: Optional[Timestamp] = None, 3210 debug: bool = False) -> Optional[TransferReport]: 3211 """ 3212 Transfers a specified value from one account to another. 3213 3214 Parameters: 3215 - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred. 3216 - from_account (AccountID): The account reference from which the value will be transferred. 3217 - to_account (AccountID): The account reference to which the value will be transferred. 3218 - desc (str, optional): A description for the transaction. Defaults to an empty string. 3219 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used. 3220 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 3221 3222 Returns: 3223 - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer. 3224 3225 Raises: 3226 - ValueError: Transfer to the same account is forbidden. 3227 - ValueError: The created_time_ns should be greater than zero. 3228 - ValueError: The box transaction happened again in the same nanosecond time. 3229 - ValueError: The log transaction happened again in the same nanosecond time. 3230 """ 3231 if debug: 3232 print('transfer', f'debug={debug}') 3233 from_account = AccountID(from_account) 3234 to_account = AccountID(to_account) 3235 if from_account == to_account: 3236 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 3237 if unscaled_amount <= 0: 3238 return None 3239 if created_time_ns is None: 3240 created_time_ns = Time.time() 3241 if created_time_ns <= 0: 3242 raise ValueError('The created should be greater than zero.') 3243 no_lock = self.nolock() 3244 lock = self.__lock() 3245 subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug) 3246 source_exchange = self.exchange(from_account, created_time_ns) 3247 target_exchange = self.exchange(to_account, created_time_ns) 3248 3249 if debug: 3250 print('ages', subtract_report.ages) 3251 3252 transfer_report = TransferReport() 3253 for subtract in subtract_report.ages: 3254 times = TransferTimes() 3255 age = subtract.box_ref 3256 value = subtract.total 3257 assert source_exchange.rate is not None 3258 assert target_exchange.rate is not None 3259 target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate)) 3260 if debug: 3261 print('target_amount', target_amount) 3262 # Perform the transfer 3263 if self.box_exists(to_account, age): 3264 if debug: 3265 print('box_exists', age) 3266 capital = self.__vault.account[to_account].box[age].capital 3267 rest = self.__vault.account[to_account].box[age].rest 3268 if debug: 3269 print( 3270 f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3271 selected_age = age 3272 if rest + target_amount > capital: 3273 self.__vault.account[to_account].box[age].capital += target_amount 3274 selected_age = Time.time() 3275 self.__vault.account[to_account].box[age].rest += target_amount 3276 self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 3277 y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 3278 created_time_ns=None, ref=None, debug=debug) 3279 times.append(TransferTime(box_ref=age, log_ref=y)) 3280 continue 3281 if debug: 3282 print( 3283 f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 3284 box_ref = self.__track( 3285 unscaled_value=self.unscale(int(target_amount)), 3286 desc=desc, 3287 account=to_account, 3288 logging=True, 3289 created_time_ns=age, 3290 debug=debug, 3291 ) 3292 transfer_report.append(TransferRecord( 3293 box_ref=box_ref, 3294 times=times, 3295 )) 3296 if no_lock: 3297 assert lock is not None 3298 self.free(lock) 3299 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.
3301 def check(self, 3302 silver_gram_price: float, 3303 unscaled_nisab: Optional[float | int | decimal.Decimal] = None, 3304 debug: bool = False, 3305 created_time_ns: Optional[Timestamp] = None, 3306 cycle: Optional[float] = None) -> ZakatReport: 3307 """ 3308 Check the eligibility for Zakat based on the given parameters. 3309 3310 Parameters: 3311 - silver_gram_price (float): The price of a gram of silver. 3312 - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. 3313 If not provided, it will be calculated based on the silver_gram_price. 3314 - debug (bool, optional): Flag to enable debug mode. 3315 - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time(). 3316 - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 3317 3318 Returns: 3319 - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, 3320 a list of brief statistics, and a dictionary containing the Zakat plan. 3321 """ 3322 if debug: 3323 print('check', f'debug={debug}') 3324 before_parameters = { 3325 "silver_gram_price": silver_gram_price, 3326 "unscaled_nisab": unscaled_nisab, 3327 "debug": debug, 3328 "created_time_ns": created_time_ns, 3329 "cycle": cycle, 3330 } 3331 if created_time_ns is None: 3332 created_time_ns = Time.time() 3333 if cycle is None: 3334 cycle = ZakatTracker.TimeCycle() 3335 if unscaled_nisab is None: 3336 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 3337 nisab = self.scale(unscaled_nisab) 3338 plan: dict[AccountID, list[BoxPlan]] = {} 3339 summary = ZakatSummary() 3340 below_nisab = 0 3341 valid = False 3342 after_parameters = { 3343 "silver_gram_price": silver_gram_price, 3344 "unscaled_nisab": unscaled_nisab, 3345 "debug": debug, 3346 "created_time_ns": created_time_ns, 3347 "cycle": cycle, 3348 } 3349 if debug: 3350 print('exchanges', self.exchanges()) 3351 for x in self.__vault.account: 3352 if not self.zakatable(x): 3353 continue 3354 _box = self.__vault.account[x].box 3355 _log = self.__vault.account[x].log 3356 limit = len(_box) + 1 3357 ids = sorted(self.__vault.account[x].box.keys()) 3358 for i in range(-1, -limit, -1): 3359 j = ids[i] 3360 rest = float(_box[j].rest) 3361 if rest <= 0: 3362 continue 3363 exchange = self.exchange(x, created_time_ns=Time.time()) 3364 assert exchange.rate is not None 3365 rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1) 3366 summary.num_wealth_items += 1 3367 summary.total_wealth += rest 3368 epoch = (created_time_ns - j) / cycle 3369 if debug: 3370 print(f'Epoch: {epoch}', _box[j]) 3371 if _box[j].zakat.last > 0: 3372 epoch = (created_time_ns - _box[j].zakat.last) / cycle 3373 if debug: 3374 print(f'Epoch: {epoch}') 3375 epoch = math.floor(epoch) 3376 if debug: 3377 print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch) 3378 if epoch == 0: 3379 continue 3380 if debug: 3381 print('Epoch - PASSED') 3382 summary.num_zakatable_items += 1 3383 summary.total_zakatable_amount += rest 3384 is_nisab = rest >= nisab 3385 total = 0 3386 if is_nisab: 3387 for _ in range(epoch): 3388 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 3389 valid = total > 0 3390 elif rest > 0: 3391 below_nisab += rest 3392 total = ZakatTracker.ZakatCut(float(rest)) 3393 if total > 0: 3394 if x not in plan: 3395 plan[x] = [] 3396 summary.total_zakat_due += total 3397 plan[x].append(BoxPlan( 3398 below_nisab=not is_nisab, 3399 total=total, 3400 count=epoch, 3401 ref=j, 3402 box=_box[j], 3403 log=_log[j], 3404 exchange=exchange, 3405 )) 3406 valid = valid or below_nisab >= nisab 3407 if debug: 3408 print(f'below_nisab({below_nisab}) >= nisab({nisab})') 3409 report = ZakatReport( 3410 created=Time.time(), 3411 valid=valid, 3412 summary=summary, 3413 plan=plan, 3414 parameters={ 3415 'before': before_parameters, 3416 'after': after_parameters, 3417 }, 3418 ) 3419 self.__vault.cache.zakat = report if valid else None 3420 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.
3422 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts: 3423 """ 3424 Build payment parts for the Zakat distribution. 3425 3426 Parameters: 3427 - scaled_demand (int): The total demand for payment in local currency. 3428 - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True. 3429 3430 Returns: 3431 - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: 3432 { 3433 'account': { 3434 'account_id': {'balance': float, 'rate': float, 'part': float}, 3435 ... 3436 }, 3437 'exceed': bool, 3438 'demand': int, 3439 'total': float, 3440 } 3441 """ 3442 total = 0.0 3443 parts = PaymentParts( 3444 account={}, 3445 exceed=False, 3446 demand=int(round(scaled_demand)), 3447 total=0, 3448 ) 3449 for x, y in self.accounts().items(): 3450 if positive_only and y.balance <= 0: 3451 continue 3452 total += float(y.balance) 3453 exchange = self.exchange(x) 3454 parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0) 3455 parts.total = total 3456 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, }
3458 @staticmethod 3459 def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int: 3460 """ 3461 Checks the validity of payment parts. 3462 3463 Parameters: 3464 - parts (dict[str, PaymentParts): A dictionary containing payment parts information. 3465 - debug (bool, optional): Flag to enable debug mode. 3466 3467 Returns: 3468 - int: Returns 0 if the payment parts are valid, otherwise returns the error code. 3469 3470 Error Codes: 3471 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 3472 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3473 3: 'part' value in parts['account'][x] is less than 0. 3474 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 3475 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 3476 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 3477 """ 3478 if debug: 3479 print('check_payment_parts', f'debug={debug}') 3480 # for i in ['demand', 'account', 'total', 'exceed']: 3481 # if i not in parts: 3482 # return 1 3483 exceed = parts.exceed 3484 # for j in ['balance', 'rate', 'part']: 3485 # if j not in parts.account[x]: 3486 # return 2 3487 for x in parts.account: 3488 if parts.account[x].part < 0: 3489 return 3 3490 if not exceed and parts.account[x].balance <= 0: 3491 return 4 3492 demand = parts.demand 3493 z = 0.0 3494 for _, y in parts.account.items(): 3495 if not exceed and y.part > y.balance: 3496 return 5 3497 z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0) 3498 z = round(z, 2) 3499 demand = round(demand, 2) 3500 if debug: 3501 print('check_payment_parts', f'z = {z}, demand = {demand}') 3502 print('check_payment_parts', type(z), type(demand)) 3503 print('check_payment_parts', z != demand) 3504 print('check_payment_parts', str(z) != str(demand)) 3505 if z != demand and str(z) != str(demand): 3506 return 6 3507 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.
3509 def zakat(self, report: ZakatReport, 3510 parts: Optional[PaymentParts] = None, debug: bool = False) -> bool: 3511 """ 3512 Perform Zakat calculation based on the given report and optional parts. 3513 3514 Parameters: 3515 - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan. 3516 - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat. 3517 - debug (bool, optional): A flag indicating whether to print debug information. 3518 3519 Returns: 3520 - bool: True if the zakat calculation is successful, False otherwise. 3521 3522 Raises: 3523 - AssertionError: Bad Zakat report, call `check` first then call `zakat`. 3524 """ 3525 if debug: 3526 print('zakat', f'debug={debug}') 3527 if not report.valid: 3528 return report.valid 3529 assert report.plan 3530 parts_exist = parts is not None 3531 if parts_exist: 3532 if self.check_payment_parts(parts, debug=debug) != 0: 3533 return False 3534 if debug: 3535 print('######### zakat #######') 3536 print('parts_exist', parts_exist) 3537 assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`" 3538 no_lock = self.nolock() 3539 lock = self.__lock() 3540 report_time = Time.time() 3541 self.__vault.report[report_time] = report 3542 self.__step(Action.REPORT, ref=report_time) 3543 created_time_ns = Time.time() 3544 for x in report.plan: 3545 target_exchange = self.exchange(x) 3546 if debug: 3547 print(report.plan[x]) 3548 print('-------------') 3549 print(self.__vault.account[x].box) 3550 if debug: 3551 print('plan[x]', report.plan[x]) 3552 for plan in report.plan[x]: 3553 j = plan.ref 3554 if debug: 3555 print('j', j) 3556 assert j 3557 self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last, 3558 key='last', 3559 math_operation=MathOperation.EQUAL) 3560 self.__vault.account[x].box[j].zakat.last = created_time_ns 3561 assert target_exchange.rate is not None 3562 amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate)) 3563 self.__vault.account[x].box[j].zakat.total += amount 3564 self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 3565 math_operation=MathOperation.ADDITION) 3566 self.__vault.account[x].box[j].zakat.count += plan.count 3567 self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count', 3568 math_operation=MathOperation.ADDITION) 3569 if not parts_exist: 3570 try: 3571 self.__vault.account[x].box[j].rest -= amount 3572 except TypeError: 3573 self.__vault.account[x].box[j].rest -= decimal.Decimal(amount) 3574 # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 3575 # math_operation=MathOperation.SUBTRACTION) 3576 self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug) 3577 if parts_exist: 3578 for account, part in parts.account.items(): 3579 if part.part == 0: 3580 continue 3581 if debug: 3582 print('zakat-part', account, part.rate) 3583 target_exchange = self.exchange(account) 3584 assert target_exchange.rate is not None 3585 amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate) 3586 unscaled_amount = self.unscale(int(amount)) 3587 if unscaled_amount <= 0: 3588 if debug: 3589 print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.") 3590 continue 3591 self.subtract( 3592 unscaled_value=unscaled_amount, 3593 desc='zakat-part-دفعة-زكاة', 3594 account=account, 3595 debug=debug, 3596 ) 3597 if no_lock: 3598 assert lock is not None 3599 self.free(lock) 3600 self.__vault.cache.zakat = None 3601 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:
3603 @staticmethod 3604 def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]: 3605 """Splits a string at the last occurrence of a given symbol. 3606 3607 Parameters: 3608 - data (str): The input string. 3609 - symbol (str): The symbol to split at. 3610 3611 Returns: 3612 - tuple[str, str]: A tuple containing two strings, the part before the last symbol and 3613 the part after the last symbol. If the symbol is not found, returns (data, ""). 3614 """ 3615 last_symbol_index = data.rfind(symbol) 3616 3617 if last_symbol_index != -1: 3618 before_symbol = data[:last_symbol_index] 3619 after_symbol = data[last_symbol_index + len(symbol):] 3620 return before_symbol, after_symbol 3621 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, "").
3623 def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool: 3624 """ 3625 Saves the ZakatTracker's current state to a json file. 3626 3627 This method serializes the internal data (`__vault`). 3628 3629 Parameters: 3630 - path (str, optional): File path for saving. Defaults to a predefined location. 3631 - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True. 3632 3633 Returns: 3634 - bool: True if the save operation is successful, False otherwise. 3635 """ 3636 if path is None: 3637 path = self.path() 3638 # first save in tmp file 3639 temp = f'{path}.tmp' 3640 try: 3641 with open(temp, 'w', encoding='utf-8') as stream: 3642 data = json.dumps(self.__vault, cls=JSONEncoder) 3643 stream.write(data) 3644 if hash_required: 3645 hashed = self.hash_data(data.encode()) 3646 stream.write(f'//{hashed}') 3647 # then move tmp file to original location 3648 shutil.move(temp, path) 3649 return True 3650 except (IOError, OSError) as e: 3651 print(f'Error saving file: {e}') 3652 if os.path.exists(temp): 3653 os.remove(temp) 3654 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.
3656 @staticmethod 3657 def load_vault_from_json(json_string: str) -> Vault: 3658 """Loads a Vault dataclass from a JSON string.""" 3659 data = json.loads(json_string) 3660 3661 vault = Vault() 3662 3663 # Load Accounts 3664 for account_reference, account_data in data.get("account", {}).items(): 3665 account_reference = AccountID(account_reference) 3666 box_data = account_data.get('box', {}) 3667 box = { 3668 Timestamp(ts): Box( 3669 capital=box_data[str(ts)]["capital"], 3670 rest=box_data[str(ts)]["rest"], 3671 zakat=BoxZakat(**box_data[str(ts)]["zakat"]), 3672 ) 3673 for ts in box_data 3674 } 3675 3676 log_data = account_data.get('log', {}) 3677 log = {Timestamp(ts): Log( 3678 value=log_data[str(ts)]['value'], 3679 desc=log_data[str(ts)]['desc'], 3680 ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None, 3681 file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()}, 3682 ) for ts in log_data} 3683 3684 vault.account[account_reference] = Account( 3685 balance=account_data["balance"], 3686 created=Timestamp(account_data["created"]), 3687 name=account_data.get("name", ""), 3688 box=box, 3689 count=account_data.get("count", 0), 3690 log=log, 3691 hide=account_data.get("hide", False), 3692 zakatable=account_data.get("zakatable", True), 3693 ) 3694 3695 # Load Exchanges 3696 for account_reference, exchange_data in data.get("exchange", {}).items(): 3697 account_reference = AccountID(account_reference) 3698 vault.exchange[account_reference] = {} 3699 for timestamp, exchange_details in exchange_data.items(): 3700 vault.exchange[account_reference][Timestamp(timestamp)] = Exchange( 3701 rate=exchange_details.get("rate"), 3702 description=exchange_details.get("description"), 3703 time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None, 3704 ) 3705 3706 # Load History 3707 for timestamp, history_dict in data.get("history", {}).items(): 3708 vault.history[Timestamp(timestamp)] = {} 3709 for history_key, history_data in history_dict.items(): 3710 vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History( 3711 action=Action(history_data["action"]), 3712 account=AccountID(history_data["account"]) if history_data.get("account") is not None else None, 3713 ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None, 3714 file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None, 3715 key=history_data.get("key"), 3716 value=history_data.get("value"), 3717 math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None, 3718 ) 3719 3720 # Load Lock 3721 vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None 3722 3723 # Load Report 3724 for timestamp, report_data in data.get("report", {}).items(): 3725 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3726 for account_reference, box_plans in report_data.get("plan", {}).items(): 3727 account_reference = AccountID(account_reference) 3728 zakat_plan[account_reference] = [] 3729 for box_plan_data in box_plans: 3730 zakat_plan[account_reference].append(BoxPlan( 3731 box=Box( 3732 capital=box_plan_data["box"]["capital"], 3733 rest=box_plan_data["box"]["rest"], 3734 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3735 ), 3736 log=Log(**box_plan_data["log"]), 3737 exchange=Exchange(**box_plan_data["exchange"]), 3738 below_nisab=box_plan_data["below_nisab"], 3739 total=box_plan_data["total"], 3740 count=box_plan_data["count"], 3741 ref=Timestamp(box_plan_data["ref"]), 3742 )) 3743 3744 vault.report[Timestamp(timestamp)] = ZakatReport( 3745 created=report_data["created"], 3746 valid=report_data["valid"], 3747 summary=ZakatSummary(**report_data["summary"]), 3748 plan=zakat_plan, 3749 parameters=report_data["parameters"], 3750 ) 3751 3752 # Load Cache 3753 vault.cache = Cache() 3754 cache_data = data.get("cache", {}) 3755 if "zakat" in cache_data: 3756 cache_zakat_data = cache_data.get("zakat", {}) 3757 if cache_zakat_data: 3758 zakat_plan: dict[AccountID, list[BoxPlan]] = {} 3759 for account_reference, box_plans in cache_zakat_data.get("plan", {}).items(): 3760 account_reference = AccountID(account_reference) 3761 zakat_plan[account_reference] = [] 3762 for box_plan_data in box_plans: 3763 zakat_plan[account_reference].append(BoxPlan( 3764 box=Box( 3765 capital=box_plan_data["box"]["capital"], 3766 rest=box_plan_data["box"]["rest"], 3767 zakat=BoxZakat(**box_plan_data["box"]["zakat"]), 3768 ), 3769 log=Log(**box_plan_data["log"]), 3770 exchange=Exchange(**box_plan_data["exchange"]), 3771 below_nisab=box_plan_data["below_nisab"], 3772 total=box_plan_data["total"], 3773 count=box_plan_data["count"], 3774 ref=Timestamp(box_plan_data["ref"]), 3775 )) 3776 3777 vault.cache.zakat = ZakatReport( 3778 created=cache_zakat_data["created"], 3779 valid=cache_zakat_data["valid"], 3780 summary=ZakatSummary(**cache_zakat_data["summary"]), 3781 plan=zakat_plan, 3782 parameters=cache_zakat_data["parameters"], 3783 ) 3784 3785 return vault
Loads a Vault dataclass from a JSON string.
3787 def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool: 3788 """ 3789 Load the current state of the ZakatTracker object from a json file. 3790 3791 Parameters: 3792 - path (str, optional): The path where the json file is located. If not provided, it will use the default path. 3793 - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True. 3794 - debug (bool, optional): Flag to enable debug mode. 3795 3796 Returns: 3797 - bool: True if the load operation is successful, False otherwise. 3798 """ 3799 if path is None: 3800 path = self.path() 3801 try: 3802 if os.path.exists(path): 3803 with open(path, 'r', encoding='utf-8') as stream: 3804 file = stream.read() 3805 data, hashed = self.split_at_last_symbol(file, '//') 3806 if hash_required: 3807 assert hashed 3808 if debug: 3809 print('[debug-load]', hashed) 3810 new_hash = self.hash_data(data.encode()) 3811 if debug: 3812 print('[debug-load]', new_hash) 3813 assert hashed == new_hash, "Hash verification failed. File may be corrupted." 3814 self.__vault = self.load_vault_from_json(data) 3815 return True 3816 else: 3817 print(f'File not found: {path}') 3818 return False 3819 except (IOError, OSError) as e: 3820 print(f'Error loading file: {e}') 3821 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.
3823 @experimental 3824 def import_csv_cache_path(self): 3825 """ 3826 Generates the cache file path for imported CSV data. 3827 3828 This function constructs the file path where cached data from CSV imports 3829 will be stored. The cache file is a json file (.json extension) appended 3830 to the base path of the object. 3831 3832 Parameters: 3833 None 3834 3835 Returns: 3836 - str: The full path to the import CSV cache file. 3837 3838 Example: 3839 ```bash 3840 >>> obj = ZakatTracker('/data/reports') 3841 >>> obj.import_csv_cache_path() 3842 '/data/reports.import_csv.json' 3843 ``` 3844 """ 3845 path = str(self.path()) 3846 ext = self.ext() 3847 ext_len = len(ext) 3848 if path.endswith(f'.{ext}'): 3849 path = path[:-ext_len - 1] 3850 _, filename = os.path.split(path + f'.import_csv.{ext}') 3851 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'
3853 @staticmethod 3854 @experimental 3855 def get_transaction_csv_headers() -> list[str]: 3856 """ 3857 Returns a list of strings representing the headers for a transaction CSV file. 3858 3859 The headers include: 3860 - account: The account associated with the transaction. 3861 - desc: A description of the transaction. 3862 - value: The monetary value of the transaction. 3863 - date: The date of the transaction. 3864 - rate: The applicable rate (if any) for the transaction. 3865 - reference: An optional reference number or identifier for the transaction. 3866 3867 Returns: 3868 - list[str]: A list containing the CSV header strings. 3869 """ 3870 return [ 3871 "account", 3872 "desc", 3873 "value", 3874 "date", 3875 "rate", 3876 "reference", 3877 ]
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.
3879 @experimental 3880 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport: 3881 """ 3882 The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system. 3883 3884 Parameters: 3885 - path (str, optional): The path to the CSV file. Default is 'file.csv'. 3886 - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0. 3887 - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','. 3888 - debug (bool, optional): A flag indicating whether to print debug information. 3889 3890 Returns: 3891 - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, 3892 and a dictionary of bad transactions. 3893 3894 Notes: 3895 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3896 are appropriate for the currency pairs involved in the conversions. 3897 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3898 to 1.0 or the previous rate for that account. 3899 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3900 transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and 3901 `zakat` operations. 3902 3903 Example: 3904 The CSV file should have the following format, rate and reference are optionals per transaction: 3905 account, desc, value, date, rate, reference 3906 For example: 3907 safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554 3908 """ 3909 if debug: 3910 print('import_csv', f'debug={debug}') 3911 cache: list[int] = [] 3912 try: 3913 if not self.memory_mode(): 3914 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3915 cache = json.load(stream) 3916 except Exception as e: 3917 if debug: 3918 print(e) 3919 date_formats = [ 3920 '%Y-%m-%d %H:%M:%S.%f', 3921 '%Y-%m-%dT%H:%M:%S.%f', 3922 '%Y-%m-%dT%H%M%S.%f', 3923 '%Y-%m-%d', 3924 ] 3925 statistics = ImportStatistics(0, 0, 0) 3926 data: dict[int, list[CSVRecord]] = {} 3927 with open(path, newline='', encoding='utf-8') as f: 3928 i = 0 3929 for row in csv.reader(f, delimiter=delimiter): 3930 if debug: 3931 print(f"csv_row({i})", row, type(row)) 3932 if row == self.get_transaction_csv_headers(): 3933 continue 3934 i += 1 3935 hashed = hash(tuple(row)) 3936 if hashed in cache: 3937 statistics.found += 1 3938 continue 3939 account = row[0] 3940 desc = row[1] 3941 value = float(row[2]) 3942 rate = 1.0 3943 reference = '' 3944 if row[4:5]: # Empty list if index is out of range 3945 rate = float(row[4]) 3946 if row[5:6]: 3947 reference = row[5] 3948 date: int = 0 3949 for time_format in date_formats: 3950 try: 3951 date_str = row[3] 3952 if "." not in date_str: 3953 date_str += ".000000" 3954 date = Time.time(datetime.datetime.strptime(date_str, time_format)) 3955 break 3956 except Exception as e: 3957 if debug: 3958 print(e) 3959 record = CSVRecord( 3960 index=i, 3961 account=account, 3962 desc=desc, 3963 value=value, 3964 date=date, 3965 rate=rate, 3966 reference=reference, 3967 hashed=hashed, 3968 error='', 3969 ) 3970 if date <= 0: 3971 record.error = 'invalid date' 3972 statistics.bad += 1 3973 if value == 0: 3974 record.error = 'invalid value' 3975 statistics.bad += 1 3976 continue 3977 if date not in data: 3978 data[date] = [] 3979 data[date].append(record) 3980 3981 if debug: 3982 print('import_csv', len(data)) 3983 3984 if statistics.bad > 0: 3985 return ImportReport( 3986 statistics=statistics, 3987 bad=[ 3988 item 3989 for sublist in data.values() 3990 for item in sublist 3991 if item.error 3992 ], 3993 ) 3994 3995 no_lock = self.nolock() 3996 lock = self.__lock() 3997 names = self.names() 3998 3999 # sync accounts 4000 if debug: 4001 print('before-names', names, len(names)) 4002 for date, rows in sorted(data.items()): 4003 new_rows: list[CSVRecord] = [] 4004 for row in rows: 4005 if row.account not in names: 4006 account_id = self.create_account(row.account) 4007 names[row.account] = account_id 4008 account_id = names[row.account] 4009 assert account_id 4010 row.account = account_id 4011 new_rows.append(row) 4012 assert new_rows 4013 assert date in data 4014 data[date] = new_rows 4015 if debug: 4016 print('after-names', names, len(names)) 4017 assert names == self.names() 4018 4019 # do ops 4020 for date, rows in sorted(data.items()): 4021 try: 4022 def process(x: CSVRecord): 4023 x.value = self.unscale( 4024 x.value, 4025 decimal_places=scale_decimal_places, 4026 ) if scale_decimal_places > 0 else x.value 4027 if x.rate > 0: 4028 self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate) 4029 if x.value > 0: 4030 self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 4031 elif x.value < 0: 4032 self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 4033 return x.hashed 4034 len_rows = len(rows) 4035 # If records are found at the same time with different accounts in the same amount 4036 # (one positive and the other negative), this indicates it is a transfer. 4037 if len_rows > 2 or len_rows == 1: 4038 i = 0 4039 for row in rows: 4040 row.date += i 4041 i += 1 4042 hashed = process(row) 4043 assert hashed not in cache 4044 cache.append(hashed) 4045 statistics.created += 1 4046 continue 4047 x1 = rows[0] 4048 x2 = rows[1] 4049 if x1.account == x2.account: 4050 continue 4051 # raise Exception(f'invalid transfer') 4052 # not transfer - same time - normal ops 4053 if abs(x1.value) != abs(x2.value) and x1.date == x2.date: 4054 rows[1].date += 1 4055 for row in rows: 4056 hashed = process(row) 4057 assert hashed not in cache 4058 cache.append(hashed) 4059 statistics.created += 1 4060 continue 4061 if x1.rate > 0: 4062 self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate) 4063 if x2.rate > 0: 4064 self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate) 4065 x1.value = self.unscale( 4066 x1.value, 4067 decimal_places=scale_decimal_places, 4068 ) if scale_decimal_places > 0 else x1.value 4069 x2.value = self.unscale( 4070 x2.value, 4071 decimal_places=scale_decimal_places, 4072 ) if scale_decimal_places > 0 else x2.value 4073 # just transfer 4074 values = { 4075 x1.value: x1.account, 4076 x2.value: x2.account, 4077 } 4078 if debug: 4079 print('values', values) 4080 if len(values) <= 1: 4081 continue 4082 self.transfer( 4083 unscaled_amount=abs(x1.value), 4084 from_account=values[min(values.keys())], 4085 to_account=values[max(values.keys())], 4086 desc=x1.desc, 4087 created_time_ns=x1.date, 4088 ) 4089 except Exception as e: 4090 for row in rows: 4091 row.error = str(e) 4092 break 4093 if not self.memory_mode(): 4094 with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream: 4095 stream.write(json.dumps(cache)) 4096 if no_lock: 4097 assert lock is not None 4098 self.free(lock) 4099 report = ImportReport( 4100 statistics=statistics, 4101 bad=[ 4102 item 4103 for sublist in data.values() 4104 for item in sublist 4105 if item.error 4106 ], 4107 ) 4108 if debug: 4109 debug_path = f'{self.import_csv_cache_path()}.debug.json' 4110 with open(debug_path, 'w', encoding='utf-8') as file: 4111 json.dump(report, file, indent=4, cls=JSONEncoder) 4112 print(f'generated debug report @ `{debug_path}`...') 4113 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.
- delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
- 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
4119 @staticmethod 4120 def human_readable_size(size: float, decimal_places: int = 2) -> str: 4121 """ 4122 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 4123 4124 This function iterates through progressively larger units of information 4125 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 4126 range that can be expressed with a reasonable number before the unit. 4127 4128 Parameters: 4129 - size (float): The size in bytes to convert. 4130 - decimal_places (int, optional): The number of decimal places to display 4131 in the result. Defaults to 2. 4132 4133 Returns: 4134 - str: A string representation of the size in a human-readable format, 4135 rounded to the specified number of decimal places. For example: 4136 - '1.50 KB' (1536 bytes) 4137 - '23.00 MB' (24117248 bytes) 4138 - '1.23 GB' (1325899906 bytes) 4139 """ 4140 if type(size) not in (float, int): 4141 raise TypeError('size must be a float or integer') 4142 if type(decimal_places) != int: 4143 raise TypeError('decimal_places must be an integer') 4144 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 4145 if size < 1024.0: 4146 break 4147 size /= 1024.0 4148 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)
4150 @staticmethod 4151 def get_dict_size(obj: dict, seen: Optional[set] = None) -> float: 4152 """ 4153 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 4154 4155 This function traverses the dictionary structure, accounting for the size of keys, values, 4156 and any nested objects. It handles various data types commonly found in dictionaries 4157 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 4158 of circular references. 4159 4160 Parameters: 4161 - obj (dict): The dictionary whose size is to be calculated. 4162 - seen (set, optional): A set used internally to track visited objects 4163 and avoid circular references. Defaults to None. 4164 4165 Returns: 4166 - float: An approximate size of the dictionary and its contents in bytes. 4167 4168 Notes: 4169 - This function is a method of the `ZakatTracker` class and is likely used to 4170 estimate the memory footprint of data structures relevant to Zakat calculations. 4171 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 4172 not account for all memory overhead depending on the Python implementation. 4173 - Circular references are handled to prevent infinite recursion. 4174 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 4175 - String sizes are estimated based on character length and encoding. 4176 """ 4177 size = 0 4178 if seen is None: 4179 seen = set() 4180 4181 obj_id = id(obj) 4182 if obj_id in seen: 4183 return 0 4184 4185 seen.add(obj_id) 4186 size += sys.getsizeof(obj) 4187 4188 if isinstance(obj, dict): 4189 for k, v in obj.items(): 4190 size += ZakatTracker.get_dict_size(k, seen) 4191 size += ZakatTracker.get_dict_size(v, seen) 4192 elif isinstance(obj, (list, tuple, set, frozenset)): 4193 for item in obj: 4194 size += ZakatTracker.get_dict_size(item, seen) 4195 elif isinstance(obj, (int, float, complex)): # Handle numbers 4196 pass # Basic numbers have a fixed size, so nothing to add here 4197 elif isinstance(obj, str): # Handle strings 4198 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 4199 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.
4201 @staticmethod 4202 def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp: # افتراض أن الشهر هو يونيو والسنة 2024 4203 """ 4204 Convert a specific day, month, and year into a timestamp. 4205 4206 Parameters: 4207 - day (int): The day of the month. 4208 - month (int, optional): The month of the year. Default is 6 (June). 4209 - year (int, optional): The year. Default is 2024. 4210 4211 Returns: 4212 - Timestamp: The timestamp representing the given day, month, and year. 4213 4214 Note: 4215 - This method assumes the default month and year if not provided. 4216 """ 4217 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.
4219 @staticmethod 4220 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 4221 """ 4222 Generate a random date between two given dates. 4223 4224 Parameters: 4225 - start_date (datetime.datetime): The start date from which to generate a random date. 4226 - end_date (datetime.datetime): The end date until which to generate a random date. 4227 4228 Returns: 4229 - datetime.datetime: A random date between the start_date and end_date. 4230 """ 4231 time_between_dates = end_date - start_date 4232 days_between_dates = time_between_dates.days 4233 random_number_of_days = random.randrange(days_between_dates) 4234 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.
4236 @staticmethod 4237 def generate_random_csv_file(path: str = 'data.csv', 4238 count: int = 1_000, 4239 with_rate: bool = False, 4240 delimiter: str = ',', 4241 debug: bool = False) -> int: 4242 """ 4243 Generate a random CSV file with specified parameters. 4244 The function generates a CSV file at the specified path with the given count of rows. 4245 Each row contains a randomly generated account, description, value, and date. 4246 The value is randomly generated between 1000 and 100000, 4247 and the date is randomly generated between 1950-01-01 and 2023-12-31. 4248 If the row number is not divisible by 13, the value is multiplied by -1. 4249 4250 Parameters: 4251 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 4252 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 4253 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 4254 - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','. 4255 - debug (bool, optional): A flag indicating whether to print debug information. 4256 4257 Returns: 4258 - int: number of generated records. 4259 """ 4260 if debug: 4261 print('generate_random_csv_file', f'debug={debug}') 4262 i = 0 4263 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 4264 writer = csv.writer(csvfile, delimiter=delimiter) 4265 writer.writerow(ZakatTracker.get_transaction_csv_headers()) 4266 for i in range(count): 4267 account = f'acc-{random.randint(1, count)}' 4268 desc = f'Some text {random.randint(1, count)}' 4269 value = random.randint(1000, 100000) 4270 date = ZakatTracker.generate_random_date( 4271 datetime.datetime(1000, 1, 1), 4272 datetime.datetime(2023, 12, 31), 4273 ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S') 4274 if not i % 13 == 0: 4275 value *= -1 4276 row = [account, desc, value, date] 4277 if with_rate: 4278 rate = random.randint(1, 100) * 0.12 4279 if debug: 4280 print('before-append', row) 4281 row.append(rate) 4282 if debug: 4283 print('after-append', row) 4284 if i % 2 == 1: 4285 row += (Time.time(),) 4286 writer.writerow(row) 4287 i = i + 1 4288 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.
- delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
- debug (bool, optional): A flag indicating whether to print debug information.
Returns:
- int: number of generated records.
4290 @staticmethod 4291 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 4292 """ 4293 Creates a list of random integers whose sum does not exceed the specified maximum. 4294 4295 Parameters: 4296 - max_sum (int): The maximum allowed sum of the list elements. 4297 - min_value (int, optional): The minimum possible value for an element (inclusive). 4298 - max_value (int, optional): The maximum possible value for an element (inclusive). 4299 4300 Returns: 4301 - A list of random integers. 4302 """ 4303 result = [] 4304 current_sum = 0 4305 4306 while current_sum < max_sum: 4307 # Calculate the remaining space for the next element 4308 remaining_sum = max_sum - current_sum 4309 # Determine the maximum possible value for the next element 4310 next_max_value = min(remaining_sum, max_value) 4311 # Generate a random element within the allowed range 4312 next_element = random.randint(min_value, next_max_value) 4313 result.append(next_element) 4314 current_sum += next_element 4315 4316 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.
4318 def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]: 4319 """ 4320 Compresses a folder into a .tar.lzma archive. 4321 4322 The archive is named following a specific format: 4323 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. This format 4324 is crucial for the `restore` function, so avoid renaming the files. 4325 4326 Parameters: 4327 - folder_path (str): The path to the folder to be compressed. 4328 - output_directory (str, optional): The directory to save the compressed file. 4329 Defaults to "compressed". 4330 - debug (bool, optional): Whether to print debug information. Default is False. 4331 4332 Returns: 4333 - Optional[Backup]: A Backup object containing the path to the created archive 4334 and its SHA1 hash on success, None on failure. 4335 """ 4336 try: 4337 os.makedirs(output_directory, exist_ok=True) 4338 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4339 4340 # Create a temporary tar archive in memory to calculate the hash 4341 tar_buffer = io.BytesIO() 4342 with tarfile.open(fileobj=tar_buffer, mode="w") as tar: 4343 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4344 tar_buffer.seek(0) 4345 folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4346 output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma" 4347 output_path = os.path.join(output_directory, output_filename) 4348 4349 # Compress the folder to the final .tar.lzma file 4350 with lzma.open(output_path, "wb") as lzma_file: 4351 tar_buffer.seek(0) # Reset the buffer 4352 with tarfile.open(fileobj=lzma_file, mode="w") as tar: 4353 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4354 4355 if debug: 4356 print(f"Folder '{folder_path}' has been compressed to '{output_path}'") 4357 return Backup( 4358 path=output_path, 4359 hash=folder_hash, 4360 ) 4361 except Exception as e: 4362 print(f"Error during compression: {e}") 4363 return None
Compresses a folder into a .tar.lzma archive.
The archive is named following a specific format:
'zakatdb_vrestore
function, so avoid renaming the files.
Parameters:
- folder_path (str): The path to the folder to be compressed.
- output_directory (str, optional): The directory to save the compressed file. Defaults to "compressed".
- debug (bool, optional): Whether to print debug information. Default is False.
Returns:
- Optional[Backup]: A Backup object containing the path to the created archive and its SHA1 hash on success, None on failure.
4365 def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool: 4366 """ 4367 Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash. 4368 4369 The SHA1 hash is extracted from the archive's filename, which must follow 4370 the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. 4371 This format is essential for successful restoration. 4372 4373 Parameters: 4374 - tar_lzma_path (str): The path to the .tar.lzma file. 4375 - output_folder_path (str, optional): The directory to extract the contents to. 4376 Defaults to "uncompressed". 4377 - debug (bool, optional): Whether to print debug information. Default is False. 4378 4379 Returns: 4380 - bool: True if the restoration was successful and the hash matches, False otherwise. 4381 """ 4382 try: 4383 output_folder_path = pathlib.Path(output_folder_path).resolve() 4384 os.makedirs(output_folder_path, exist_ok=True) 4385 filename = os.path.basename(tar_lzma_path) 4386 re_match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename) 4387 if not re_match: 4388 if debug: 4389 print(f"Error: Invalid filename format: '{filename}'") 4390 return False 4391 4392 expected_hash_from_filename = re_match.group(3) 4393 4394 with lzma.open(tar_lzma_path, "rb") as lzma_file: 4395 tar_buffer = io.BytesIO(lzma_file.read()) # Read the entire decompressed tar into memory 4396 with tarfile.open(fileobj=tar_buffer, mode="r") as tar: 4397 tar.extractall(output_folder_path) 4398 tar_buffer.seek(0) # Reset buffer to calculate hash 4399 extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4400 4401 new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path)) 4402 assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found." 4403 if extracted_hash == expected_hash_from_filename: 4404 if debug: 4405 print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.") 4406 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4407 old_path = os.path.dirname(self.path()) 4408 tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now) 4409 if debug: 4410 print('[xxx] - old_path:', old_path) 4411 print('[xxx] - tmp_path:', tmp_path) 4412 print('[xxx] - new_path:', new_path) 4413 try: 4414 shutil.move(old_path, tmp_path) 4415 shutil.move(new_path, old_path) 4416 assert self.load() 4417 shutil.rmtree(tmp_path) 4418 return True 4419 except Exception as e: 4420 print(f"Error applying the restored files: {e}") 4421 shutil.move(tmp_path, old_path) 4422 return False 4423 else: 4424 if debug: 4425 print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}") 4426 # Optionally remove the extracted folder if the hash doesn't match 4427 # shutil.rmtree(output_folder_path, ignore_errors=True) 4428 return False 4429 4430 except Exception as e: 4431 print(f"Error during uncompression or hash check: {e}") 4432 return False
Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.
The SHA1 hash is extracted from the archive's filename, which must follow
the format: 'zakatdb_v
Parameters:
- tar_lzma_path (str): The path to the .tar.lzma file.
- output_folder_path (str, optional): The directory to extract the contents to. Defaults to "uncompressed".
- debug (bool, optional): Whether to print debug information. Default is False.
Returns:
- bool: True if the restoration was successful and the hash matches, False otherwise.
4931 def test(self, debug: bool = False) -> bool: 4932 if debug: 4933 print('test', f'debug={debug}') 4934 try: 4935 4936 self._test_timeline(debug) 4937 self._test_core(True, debug) 4938 self._test_core(False, debug) 4939 4940 # test_names 4941 self.reset() 4942 x = "test_names" 4943 failed = False 4944 try: 4945 assert self.name(x) == '' 4946 except: 4947 failed = True 4948 assert failed 4949 assert self.names() == {} 4950 failed = False 4951 try: 4952 assert self.name(x, 'qwe') == '' 4953 except: 4954 failed = True 4955 assert failed 4956 account_id0 = self.create_account(x) 4957 assert isinstance(account_id0, AccountID) 4958 assert int(account_id0) > 0 4959 assert self.name(account_id0) == x 4960 assert self.name(account_id0, 'qwe') == 'qwe' 4961 if debug: 4962 print(self.names(keyword='qwe')) 4963 assert self.names(keyword='asd') == {} 4964 assert self.names(keyword='qwe') == {'qwe': account_id0} 4965 4966 # test_create_account 4967 account_name = "test_account" 4968 assert self.names(keyword=account_name) == {} 4969 account_id = self.create_account(account_name) 4970 assert isinstance(account_id, AccountID) 4971 assert int(account_id) > 0 4972 assert account_id in self.__vault.account 4973 assert self.name(account_id) == account_name 4974 assert self.names(keyword=account_name) == {account_name: account_id} 4975 4976 failed = False 4977 try: 4978 self.create_account(account_name) 4979 except: 4980 failed = True 4981 assert failed 4982 4983 # bad are names is forbidden 4984 4985 for bad_name in [ 4986 None, 4987 '', 4988 Time.time(), 4989 -Time.time(), 4990 f'{Time.time()}', 4991 f'{-Time.time()}', 4992 0.0, 4993 '0.0', 4994 ' ', 4995 ]: 4996 failed = False 4997 try: 4998 self.create_account(bad_name) 4999 except: 5000 failed = True 5001 assert failed 5002 5003 # rename account 5004 assert self.name(account_id) == account_name 5005 assert self.name(account_id, 'asd') == 'asd' 5006 assert self.name(account_id) == 'asd' 5007 # use old and not used name 5008 account_id2 = self.create_account(account_name) 5009 assert int(account_id2) > 0 5010 assert account_id != account_id2 5011 assert self.name(account_id2) == account_name 5012 assert self.names(keyword=account_name) == {account_name: account_id2} 5013 5014 assert self.__history() 5015 count = len(self.__vault.history) 5016 if debug: 5017 print('history-count', count) 5018 assert count == 8 5019 5020 assert self.recall(dry=False, debug=debug) 5021 assert self.name(account_id2) == '' 5022 assert self.account_exists(account_id2) 5023 assert self.recall(dry=False, debug=debug) 5024 assert not self.account_exists(account_id2) 5025 assert self.recall(dry=False, debug=debug) 5026 assert self.name(account_id) == account_name 5027 assert self.recall(dry=False, debug=debug) 5028 assert self.account_exists(account_id) 5029 assert self.recall(dry=False, debug=debug) 5030 assert not self.account_exists(account_id) 5031 assert self.names(keyword='qwe') == {'qwe': account_id0} 5032 assert self.recall(dry=False, debug=debug) 5033 assert self.names(keyword='qwe') == {} 5034 assert self.name(account_id0) == x 5035 assert self.recall(dry=False, debug=debug) 5036 assert self.name(account_id0) == '' 5037 assert self.account_exists(account_id0) 5038 assert self.recall(dry=False, debug=debug) 5039 assert not self.account_exists(account_id0) 5040 assert not self.recall(dry=False, debug=debug) 5041 5042 # Not allowed for duplicate transactions in the same account and time 5043 5044 created = Time.time() 5045 same_account_id = self.create_account('same') 5046 self.track(100, 'test-1', same_account_id, True, created) 5047 failed = False 5048 try: 5049 self.track(50, 'test-1', same_account_id, True, created) 5050 except: 5051 failed = True 5052 assert failed is True 5053 5054 self.reset() 5055 5056 # Same account transfer 5057 for x in [1, 'a', True, 1.8, None]: 5058 failed = False 5059 try: 5060 self.transfer(1, x, x, 'same-account', debug=debug) 5061 except: 5062 failed = True 5063 assert failed is True 5064 5065 # Always preserve box age during transfer 5066 5067 series: list[tuple[int, int]] = [ 5068 (30, 4), 5069 (60, 3), 5070 (90, 2), 5071 ] 5072 case = { 5073 3000: { 5074 'series': series, 5075 'rest': 15000, 5076 }, 5077 6000: { 5078 'series': series, 5079 'rest': 12000, 5080 }, 5081 9000: { 5082 'series': series, 5083 'rest': 9000, 5084 }, 5085 18000: { 5086 'series': series, 5087 'rest': 0, 5088 }, 5089 27000: { 5090 'series': series, 5091 'rest': -9000, 5092 }, 5093 36000: { 5094 'series': series, 5095 'rest': -18000, 5096 }, 5097 } 5098 5099 selected_time = Time.time() - ZakatTracker.TimeCycle() 5100 ages_account_id = self.create_account('ages') 5101 future_account_id = self.create_account('future') 5102 5103 for total in case: 5104 if debug: 5105 print('--------------------------------------------------------') 5106 print(f'case[{total}]', case[total]) 5107 for x in case[total]['series']: 5108 self.track( 5109 unscaled_value=x[0], 5110 desc=f'test-{x} ages', 5111 account=ages_account_id, 5112 created_time_ns=selected_time * x[1], 5113 ) 5114 5115 unscaled_total = self.unscale(total) 5116 if debug: 5117 print('unscaled_total', unscaled_total) 5118 refs = self.transfer( 5119 unscaled_amount=unscaled_total, 5120 from_account=ages_account_id, 5121 to_account=future_account_id, 5122 desc='Zakat Movement', 5123 debug=debug, 5124 ) 5125 5126 if debug: 5127 print('refs', refs) 5128 5129 ages_cache_balance = self.balance(ages_account_id) 5130 ages_fresh_balance = self.balance(ages_account_id, False) 5131 rest = case[total]['rest'] 5132 if debug: 5133 print('source', ages_cache_balance, ages_fresh_balance, rest) 5134 assert ages_cache_balance == rest 5135 assert ages_fresh_balance == rest 5136 5137 future_cache_balance = self.balance(future_account_id) 5138 future_fresh_balance = self.balance(future_account_id, False) 5139 if debug: 5140 print('target', future_cache_balance, future_fresh_balance, total) 5141 print('refs', refs) 5142 assert future_cache_balance == total 5143 assert future_fresh_balance == total 5144 5145 # TODO: check boxes times for `ages` should equal box times in `future` 5146 for ref in self.__vault.account[ages_account_id].box: 5147 ages_capital = self.__vault.account[ages_account_id].box[ref].capital 5148 ages_rest = self.__vault.account[ages_account_id].box[ref].rest 5149 future_capital = 0 5150 if ref in self.__vault.account[future_account_id].box: 5151 future_capital = self.__vault.account[future_account_id].box[ref].capital 5152 future_rest = 0 5153 if ref in self.__vault.account[future_account_id].box: 5154 future_rest = self.__vault.account[future_account_id].box[ref].rest 5155 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 5156 if debug: 5157 print('================================================================') 5158 print('ages', ages_capital, ages_rest) 5159 print('future', future_capital, future_rest) 5160 if ages_rest == 0: 5161 assert ages_capital == future_capital 5162 elif ages_rest < 0: 5163 assert -ages_capital == future_capital 5164 elif ages_rest > 0: 5165 assert ages_capital == ages_rest + future_capital 5166 self.reset() 5167 assert len(self.__vault.history) == 0 5168 5169 assert self.__history() 5170 assert self.__history(False) is False 5171 assert self.__history() is False 5172 assert self.__history(True) 5173 assert self.__history() 5174 if debug: 5175 print('####################################################################') 5176 5177 wallet_account_id = self.create_account('wallet') 5178 safe_account_id = self.create_account('safe') 5179 bank_account_id = self.create_account('bank') 5180 transaction = [ 5181 ( 5182 20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1, 5183 2000, 2000, 2000, 1, 1, 5184 ), 5185 ( 5186 750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2, 5187 75000, 75000, 75000, 1, 1, 5188 ), 5189 ( 5190 600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2, 5191 60000, 60000, 60000, 1, 1, 5192 ), 5193 ] 5194 for z in transaction: 5195 lock = self.lock() 5196 x = z[1] 5197 y = z[2] 5198 self.transfer( 5199 unscaled_amount=z[0], 5200 from_account=x, 5201 to_account=y, 5202 desc='test-transfer', 5203 debug=debug, 5204 ) 5205 zz = self.balance(x) 5206 if debug: 5207 print(zz, z) 5208 assert zz == z[3] 5209 xx = self.accounts()[x] 5210 assert xx.balance == z[3] 5211 assert self.balance(x, False) == z[4] 5212 assert xx.balance == z[4] 5213 5214 s = 0 5215 log = self.__vault.account[x].log 5216 for i in log: 5217 s += log[i].value 5218 if debug: 5219 print('s', s, 'z[5]', z[5]) 5220 assert s == z[5] 5221 5222 assert self.box_size(x) == z[6] 5223 assert self.log_size(x) == z[7] 5224 5225 yy = self.accounts()[y] 5226 assert self.balance(y) == z[8] 5227 assert yy.balance == z[8] 5228 assert self.balance(y, False) == z[9] 5229 assert yy.balance == z[9] 5230 5231 s = 0 5232 log = self.__vault.account[y].log 5233 for i in log: 5234 s += log[i].value 5235 assert s == z[10] 5236 5237 assert self.box_size(y) == z[11] 5238 assert self.log_size(y) == z[12] 5239 assert lock is not None 5240 assert self.free(lock) 5241 5242 assert self.nolock() 5243 history_count = len(self.__vault.history) 5244 transaction_count = len(transaction) 5245 if debug: 5246 print('history-count', history_count, transaction_count) 5247 assert history_count == transaction_count * 3 5248 assert not self.free(Time.time()) 5249 assert self.free(self.lock()) 5250 assert self.nolock() 5251 assert len(self.__vault.history) == transaction_count * 3 5252 5253 # recall 5254 5255 assert self.nolock() 5256 for i in range(transaction_count * 3, 0, -1): 5257 assert len(self.__vault.history) == i 5258 assert self.recall(dry=False, debug=debug) is True 5259 assert len(self.__vault.history) == 0 5260 assert self.recall(dry=False, debug=debug) is False 5261 assert len(self.__vault.history) == 0 5262 5263 # exchange 5264 5265 cash_account_id = self.create_account('cash') 5266 self.exchange(cash_account_id, 25, 3.75, '2024-06-25') 5267 self.exchange(cash_account_id, 22, 3.73, '2024-06-22') 5268 self.exchange(cash_account_id, 15, 3.69, '2024-06-15') 5269 self.exchange(cash_account_id, 10, 3.66) 5270 5271 assert self.nolock() 5272 5273 bank_account_id = self.create_account('bank') 5274 for i in range(1, 30): 5275 exchange = self.exchange(cash_account_id, i) 5276 rate, description, created = exchange.rate, exchange.description, exchange.time 5277 if debug: 5278 print(i, rate, description, created) 5279 assert created 5280 if i < 10: 5281 assert rate == 1 5282 assert description is None 5283 elif i == 10: 5284 assert rate == 3.66 5285 assert description is None 5286 elif i < 15: 5287 assert rate == 3.66 5288 assert description is None 5289 elif i == 15: 5290 assert rate == 3.69 5291 assert description is not None 5292 elif i < 22: 5293 assert rate == 3.69 5294 assert description is not None 5295 elif i == 22: 5296 assert rate == 3.73 5297 assert description is not None 5298 elif i >= 25: 5299 assert rate == 3.75 5300 assert description is not None 5301 exchange = self.exchange(bank_account_id, i) 5302 rate, description, created = exchange.rate, exchange.description, exchange.time 5303 if debug: 5304 print(i, rate, description, created) 5305 assert created 5306 assert rate == 1 5307 assert description is None 5308 5309 assert len(self.__vault.exchange) == 1 5310 assert len(self.exchanges()) == 1 5311 self.__vault.exchange.clear() 5312 assert len(self.__vault.exchange) == 0 5313 assert len(self.exchanges()) == 0 5314 self.reset() 5315 5316 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 5317 cash_account_id = self.create_account('cash') 5318 self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 5319 self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 5320 self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 5321 self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66) 5322 5323 assert self.nolock() 5324 5325 test_account_id = self.create_account('test') 5326 for i in [x * 0.12 for x in range(-15, 21)]: 5327 if i <= 0: 5328 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange() 5329 else: 5330 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange() 5331 5332 assert self.nolock() 5333 5334 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 5335 bank_account_id = self.create_account('bank') 5336 for i in range(1, 31): 5337 timestamp_ns = ZakatTracker.day_to_time(i) 5338 exchange = self.exchange(cash_account_id, timestamp_ns) 5339 rate, description, created = exchange.rate, exchange.description, exchange.time 5340 if debug: 5341 print(i, rate, description, created) 5342 assert created 5343 if i < 10: 5344 assert rate == 1 5345 assert description is None 5346 elif i == 10: 5347 assert rate == 3.66 5348 assert description is None 5349 elif i < 15: 5350 assert rate == 3.66 5351 assert description is None 5352 elif i == 15: 5353 assert rate == 3.69 5354 assert description is not None 5355 elif i < 22: 5356 assert rate == 3.69 5357 assert description is not None 5358 elif i == 22: 5359 assert rate == 3.73 5360 assert description is not None 5361 elif i >= 25: 5362 assert rate == 3.75 5363 assert description is not None 5364 exchange = self.exchange(bank_account_id, i) 5365 rate, description, created = exchange.rate, exchange.description, exchange.time 5366 if debug: 5367 print(i, rate, description, created) 5368 assert created 5369 assert rate == 1 5370 assert description is None 5371 5372 assert self.nolock() 5373 if debug: 5374 print(self.__vault.history, len(self.__vault.history)) 5375 for _ in range(len(self.__vault.history)): 5376 assert self.recall(dry=False, debug=debug) 5377 assert not self.recall(dry=False, debug=debug) 5378 5379 self.reset() 5380 5381 # test transfer between accounts with different exchange rate 5382 5383 a_SAR = self.create_account('Bank (SAR)') 5384 b_USD = self.create_account('Bank (USD)') 5385 c_SAR = self.create_account('Safe (SAR)') 5386 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 5387 for case in [ 5388 (0, a_SAR, 'SAR Gift', 1000, 100000), 5389 (1, a_SAR, 1), 5390 (0, b_USD, 'USD Gift', 500, 50000), 5391 (1, b_USD, 1), 5392 (2, b_USD, 3.75), 5393 (1, b_USD, 3.75), 5394 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 5395 (0, c_SAR, 'Salary', 750, 75000), 5396 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 5397 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 5398 ]: 5399 if debug: 5400 print('case', case) 5401 match (case[0]): 5402 case 0: # track 5403 _, account, desc, x, balance = case 5404 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 5405 5406 cached_value = self.balance(account, cached=True) 5407 fresh_value = self.balance(account, cached=False) 5408 if debug: 5409 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 5410 assert cached_value == balance 5411 assert fresh_value == balance 5412 case 1: # check-exchange 5413 _, account, expected_rate = case 5414 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5415 if debug: 5416 print('t-exchange', t_exchange) 5417 assert t_exchange.rate == expected_rate 5418 case 2: # do-exchange 5419 _, account, rate = case 5420 self.exchange(account, rate=rate, debug=debug) 5421 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5422 if debug: 5423 print('b-exchange', b_exchange) 5424 assert b_exchange.rate == rate 5425 case 3: # transfer 5426 _, x, a, b, desc, a_balance, b_balance = case 5427 self.transfer(x, a, b, desc, debug=debug) 5428 5429 cached_value = self.balance(a, cached=True) 5430 fresh_value = self.balance(a, cached=False) 5431 if debug: 5432 print( 5433 'account', a, 5434 'cached_value', cached_value, 5435 'fresh_value', fresh_value, 5436 'a_balance', a_balance, 5437 ) 5438 assert cached_value == a_balance 5439 assert fresh_value == a_balance 5440 5441 cached_value = self.balance(b, cached=True) 5442 fresh_value = self.balance(b, cached=False) 5443 if debug: 5444 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 5445 assert cached_value == b_balance 5446 assert fresh_value == b_balance 5447 5448 # Transfer all in many chunks randomly from B to A 5449 a_SAR_balance = 137125 5450 b_USD_balance = 50100 5451 b_USD_exchange = self.exchange(b_USD) 5452 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 5453 if debug: 5454 print('amounts', amounts) 5455 i = 0 5456 for x in amounts: 5457 if debug: 5458 print(f'{i} - transfer-with-exchange({x})') 5459 self.transfer( 5460 unscaled_amount=self.unscale(x), 5461 from_account=b_USD, 5462 to_account=a_SAR, 5463 desc=f'{x} USD -> SAR', 5464 debug=debug, 5465 ) 5466 5467 b_USD_balance -= x 5468 cached_value = self.balance(b_USD, cached=True) 5469 fresh_value = self.balance(b_USD, cached=False) 5470 if debug: 5471 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5472 b_USD_balance) 5473 assert cached_value == b_USD_balance 5474 assert fresh_value == b_USD_balance 5475 5476 a_SAR_balance += int(x * b_USD_exchange.rate) 5477 cached_value = self.balance(a_SAR, cached=True) 5478 fresh_value = self.balance(a_SAR, cached=False) 5479 if debug: 5480 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5481 a_SAR_balance, 'rate', b_USD_exchange.rate) 5482 assert cached_value == a_SAR_balance 5483 assert fresh_value == a_SAR_balance 5484 i += 1 5485 5486 # Transfer all in many chunks randomly from C to A 5487 c_SAR_balance = 37500 5488 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 5489 if debug: 5490 print('amounts', amounts) 5491 i = 0 5492 for x in amounts: 5493 if debug: 5494 print(f'{i} - transfer-with-exchange({x})') 5495 self.transfer( 5496 unscaled_amount=self.unscale(x), 5497 from_account=c_SAR, 5498 to_account=a_SAR, 5499 desc=f'{x} SAR -> a_SAR', 5500 debug=debug, 5501 ) 5502 5503 c_SAR_balance -= x 5504 cached_value = self.balance(c_SAR, cached=True) 5505 fresh_value = self.balance(c_SAR, cached=False) 5506 if debug: 5507 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5508 c_SAR_balance) 5509 assert cached_value == c_SAR_balance 5510 assert fresh_value == c_SAR_balance 5511 5512 a_SAR_balance += x 5513 cached_value = self.balance(a_SAR, cached=True) 5514 fresh_value = self.balance(a_SAR, cached=False) 5515 if debug: 5516 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5517 a_SAR_balance) 5518 assert cached_value == a_SAR_balance 5519 assert fresh_value == a_SAR_balance 5520 i += 1 5521 5522 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 5523 5524 # check & zakat with exchange rates for many cycles 5525 5526 lock = None 5527 safe_account_id = self.create_account('safe') 5528 cave_account_id = self.create_account('cave') 5529 for rate, values in { 5530 1: { 5531 'in': [1000, 2000, 10000], 5532 'exchanged': [100000, 200000, 1000000], 5533 'out': [2500, 5000, 73140], 5534 }, 5535 3.75: { 5536 'in': [200, 1000, 5000], 5537 'exchanged': [75000, 375000, 1875000], 5538 'out': [1875, 9375, 137138], 5539 }, 5540 }.items(): 5541 a, b, c = values['in'] 5542 m, n, o = values['exchanged'] 5543 x, y, z = values['out'] 5544 if debug: 5545 print('rate', rate, 'values', values) 5546 for case in [ 5547 (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5548 {safe_account_id: {0: {'below_nisab': x}}}, 5549 ], False, m), 5550 (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5551 {safe_account_id: {0: {'count': 1, 'total': y}}}, 5552 ], True, n), 5553 (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [ 5554 {cave_account_id: {0: {'count': 3, 'total': z}}}, 5555 ], True, o), 5556 ]: 5557 if debug: 5558 print(f'############# check(rate: {rate}) #############') 5559 print('case', case) 5560 self.reset() 5561 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 5562 self.track( 5563 unscaled_value=case[0], 5564 desc='test-check', 5565 account=case[1], 5566 created_time_ns=case[2], 5567 ) 5568 assert self.snapshot() 5569 5570 # assert self.nolock() 5571 # history_size = len(self.__vault.history) 5572 # print('history_size', history_size) 5573 # assert history_size == 2 5574 lock = self.lock() 5575 assert lock 5576 assert not self.nolock() 5577 assert self.__vault.cache.zakat is None 5578 report = self.check(2.17, None, debug) 5579 if debug: 5580 print('[report]', report) 5581 assert case[4] == report.valid 5582 assert case[5] == report.summary.total_wealth 5583 assert case[5] == report.summary.total_zakatable_amount 5584 if report.valid: 5585 assert self.__vault.cache.zakat is not None 5586 assert report.plan 5587 assert self.zakat(report, debug=debug) 5588 assert self.__vault.cache.zakat is None 5589 if debug: 5590 pp().pprint(self.__vault) 5591 self._test_storage(debug=debug) 5592 5593 for x in report.plan: 5594 assert case[1] == x 5595 if report.plan[x][0].below_nisab: 5596 if debug: 5597 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 5598 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 5599 else: 5600 if debug: 5601 print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total']) 5602 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 5603 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 5604 assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total'] 5605 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 5606 assert report.plan[x][0].count == case[3][0][x][0]['count'] 5607 else: 5608 assert self.__vault.cache.zakat is None 5609 result = self.zakat(report, debug=debug) 5610 if debug: 5611 print('zakat-result', result, case[4]) 5612 assert result == case[4] 5613 report = self.check(2.17, None, debug) 5614 assert report.valid is False 5615 self._test_storage(account_id=cave_account_id, debug=debug) 5616 5617 # recall after zakat 5618 5619 history_size = len(self.__vault.history) 5620 if debug: 5621 print('history_size', history_size) 5622 assert history_size == 3 5623 assert not self.nolock() 5624 assert self.recall(dry=False, debug=debug) is False 5625 self.free(lock) 5626 assert self.nolock() 5627 5628 for i in range(3, 0, -1): 5629 history_size = len(self.__vault.history) 5630 if debug: 5631 print('history_size', history_size) 5632 assert history_size == i 5633 assert self.recall(dry=False, debug=debug) is True 5634 5635 assert self.nolock() 5636 assert self.recall(dry=False, debug=debug) is False 5637 5638 history_size = len(self.__vault.history) 5639 if debug: 5640 print('history_size', history_size) 5641 assert history_size == 0 5642 5643 account_size = len(self.__vault.account) 5644 if debug: 5645 print('account_size', account_size) 5646 assert account_size == 0 5647 5648 report_size = len(self.__vault.report) 5649 if debug: 5650 print('report_size', report_size) 5651 assert report_size == 0 5652 5653 assert self.nolock() 5654 5655 # csv 5656 5657 csv_count = 1000 5658 5659 for with_rate, path in { 5660 False: 'test-import_csv-no-exchange', 5661 True: 'test-import_csv-with-exchange', 5662 }.items(): 5663 5664 if debug: 5665 print('test_import_csv', with_rate, path) 5666 5667 csv_path = path + '.csv' 5668 if os.path.exists(csv_path): 5669 os.remove(csv_path) 5670 c = self.generate_random_csv_file( 5671 path=csv_path, 5672 count=csv_count, 5673 with_rate=with_rate, 5674 debug=debug, 5675 ) 5676 if debug: 5677 print('generate_random_csv_file', c) 5678 assert c == csv_count 5679 assert os.path.getsize(csv_path) > 0 5680 cache_path = self.import_csv_cache_path() 5681 if os.path.exists(cache_path): 5682 os.remove(cache_path) 5683 self.reset() 5684 lock = self.lock() 5685 import_report = self.import_csv(csv_path, debug=debug) 5686 bad_count = len(import_report.bad) 5687 if debug: 5688 print(f'csv-imported: {import_report.statistics} = count({csv_count})') 5689 print('bad', import_report.bad) 5690 assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count 5691 assert import_report.statistics.created == csv_count 5692 assert bad_count == 0 5693 assert bad_count == import_report.statistics.bad 5694 tmp_size = os.path.getsize(cache_path) 5695 assert tmp_size > 0 5696 5697 import_report_2 = self.import_csv(csv_path, debug=debug) 5698 bad_2_count = len(import_report_2.bad) 5699 if debug: 5700 print(f'csv-imported: {import_report_2}') 5701 print('bad', import_report_2.bad) 5702 assert tmp_size == os.path.getsize(cache_path) 5703 assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count 5704 assert import_report.statistics.created == import_report_2.statistics.found 5705 assert bad_count == bad_2_count 5706 assert import_report_2.statistics.found == csv_count 5707 assert bad_2_count == 0 5708 assert bad_2_count == import_report_2.statistics.bad 5709 assert import_report_2.statistics.created == 0 5710 5711 # payment parts 5712 5713 positive_parts = self.build_payment_parts(100, positive_only=True) 5714 assert self.check_payment_parts(positive_parts) != 0 5715 assert self.check_payment_parts(positive_parts) != 0 5716 all_parts = self.build_payment_parts(300, positive_only=False) 5717 assert self.check_payment_parts(all_parts) != 0 5718 assert self.check_payment_parts(all_parts) != 0 5719 if debug: 5720 pp().pprint(positive_parts) 5721 pp().pprint(all_parts) 5722 # dynamic discount 5723 suite = [] 5724 count = 3 5725 for exceed in [False, True]: 5726 case = [] 5727 for part in [positive_parts, all_parts]: 5728 #part = parts.copy() 5729 demand = part.demand 5730 if debug: 5731 print(demand, part.total) 5732 i = 0 5733 z = demand / count 5734 cp = PaymentParts( 5735 demand=demand, 5736 exceed=exceed, 5737 total=part.total, 5738 ) 5739 j = '' 5740 for x, y in part.account.items(): 5741 x_exchange = self.exchange(x) 5742 zz = self.exchange_calc(z, 1, x_exchange.rate) 5743 if exceed and zz <= demand: 5744 i += 1 5745 y.part = zz 5746 if debug: 5747 print(exceed, y) 5748 cp.account[x] = y 5749 case.append(y) 5750 elif not exceed and y.balance >= zz: 5751 i += 1 5752 y.part = zz 5753 if debug: 5754 print(exceed, y) 5755 cp.account[x] = y 5756 case.append(y) 5757 j = x 5758 if i >= count: 5759 break 5760 if debug: 5761 print('[debug]', j) 5762 print('[debug]', cp.account[j]) 5763 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 5764 suite.append(cp) 5765 if debug: 5766 print('suite', len(suite)) 5767 for case in suite: 5768 if debug: 5769 print('case', case) 5770 result = self.check_payment_parts(case) 5771 if debug: 5772 print('check_payment_parts', result, f'exceed: {exceed}') 5773 assert result == 0 5774 5775 assert self.__vault.cache.zakat is None 5776 report = self.check(2.17, None, debug) 5777 if debug: 5778 print('valid', report.valid) 5779 zakat_result = self.zakat(report, parts=case, debug=debug) 5780 if debug: 5781 print('zakat-result', zakat_result) 5782 assert report.valid == zakat_result 5783 # test verified zakat report is required 5784 if zakat_result: 5785 assert self.__vault.cache.zakat is None 5786 failed = False 5787 try: 5788 self.zakat(report, parts=case, debug=debug) 5789 except: 5790 failed = True 5791 assert failed 5792 5793 assert self.free(lock) 5794 5795 assert self.save(path + f'.{self.ext()}') 5796 assert self.save(f'1000-transactions-test.{self.ext()}') 5797 return True 5798 except Exception as e: 5799 if self.__debug_output: 5800 pp().pprint(self.__vault) 5801 print('============================================================================') 5802 pp().pprint(self.__debug_output) 5803 assert self.save(f'test-snapshot.{self.ext()}') 5804 raise e
274class AccountID(str): 275 """ 276 A class representing an Account ID, which is a string that must be a positive integer greater than zero. 277 Inherits from str, so it behaves like a string. 278 """ 279 280 def __new__(cls, value): 281 """ 282 Creates a new AccountID instance. 283 284 Parameters: 285 - value (str): The string value to be used as the AccountID. 286 287 Raises: 288 - ValueError: If the provided value is not a valid AccountID. 289 290 Returns: 291 - AccountID: A new AccountID instance. 292 """ 293 if isinstance(value, Timestamp): 294 value = str(value) # convert timestamp to string 295 if not cls.is_valid_account_id(value): 296 raise ValueError(f"Invalid AccountID: '{value}'") 297 return super().__new__(cls, value) 298 299 @staticmethod 300 def is_valid_account_id(s: str) -> bool: 301 """ 302 Checks if a string is a valid AccountID (positive integer greater than zero). 303 304 Parameters: 305 - s (str): The string to check. 306 307 Returns: 308 - bool: True if the string is a valid AccountID, False otherwise. 309 """ 310 if not s: 311 return False 312 313 try: 314 if s[0] == '0': 315 return False 316 if s.startswith('-'): 317 return False 318 if not s.isdigit(): 319 return False 320 except: 321 pass 322 323 try: 324 num = int(s) 325 return num > 0 326 except ValueError: 327 return False 328 329 @classmethod 330 def test(cls, debug: bool = False): 331 """ 332 Runs tests for the AccountID class to ensure it behaves correctly. 333 334 This method tests various valid and invalid input strings to verify that: 335 - Valid AccountIDs are created successfully. 336 - Invalid AccountIDs raise ValueError exceptions. 337 """ 338 test_data = { 339 "123": True, 340 "0": False, 341 "01": False, 342 "-1": False, 343 "abc": False, 344 "12.3": False, 345 "": False, 346 "9999999999999999999999999999999999999": True, 347 "1": True, 348 "10": True, 349 "000000000000000001": False, 350 " ": False, 351 "1 ": False, 352 " 1": False, 353 "1.0": False, 354 Timestamp(12345): True, # Test timestamp input 355 } 356 357 for input_value, expected_output in test_data.items(): 358 if expected_output: 359 try: 360 account_id = cls(input_value) 361 if debug: 362 print(f'"{str(account_id)}", "{input_value}"') 363 if isinstance(input_value, Timestamp): 364 input_value = str(input_value) 365 assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'" 366 except ValueError as e: 367 assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}" 368 else: 369 try: 370 cls(input_value) 371 assert False, f"Expected ValueError for invalid input: '{input_value}'" 372 except ValueError as e: 373 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.
280 def __new__(cls, value): 281 """ 282 Creates a new AccountID instance. 283 284 Parameters: 285 - value (str): The string value to be used as the AccountID. 286 287 Raises: 288 - ValueError: If the provided value is not a valid AccountID. 289 290 Returns: 291 - AccountID: A new AccountID instance. 292 """ 293 if isinstance(value, Timestamp): 294 value = str(value) # convert timestamp to string 295 if not cls.is_valid_account_id(value): 296 raise ValueError(f"Invalid AccountID: '{value}'") 297 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.
299 @staticmethod 300 def is_valid_account_id(s: str) -> bool: 301 """ 302 Checks if a string is a valid AccountID (positive integer greater than zero). 303 304 Parameters: 305 - s (str): The string to check. 306 307 Returns: 308 - bool: True if the string is a valid AccountID, False otherwise. 309 """ 310 if not s: 311 return False 312 313 try: 314 if s[0] == '0': 315 return False 316 if s.startswith('-'): 317 return False 318 if not s.isdigit(): 319 return False 320 except: 321 pass 322 323 try: 324 num = int(s) 325 return num > 0 326 except ValueError: 327 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.
329 @classmethod 330 def test(cls, debug: bool = False): 331 """ 332 Runs tests for the AccountID class to ensure it behaves correctly. 333 334 This method tests various valid and invalid input strings to verify that: 335 - Valid AccountIDs are created successfully. 336 - Invalid AccountIDs raise ValueError exceptions. 337 """ 338 test_data = { 339 "123": True, 340 "0": False, 341 "01": False, 342 "-1": False, 343 "abc": False, 344 "12.3": False, 345 "": False, 346 "9999999999999999999999999999999999999": True, 347 "1": True, 348 "10": True, 349 "000000000000000001": False, 350 " ": False, 351 "1 ": False, 352 " 1": False, 353 "1.0": False, 354 Timestamp(12345): True, # Test timestamp input 355 } 356 357 for input_value, expected_output in test_data.items(): 358 if expected_output: 359 try: 360 account_id = cls(input_value) 361 if debug: 362 print(f'"{str(account_id)}", "{input_value}"') 363 if isinstance(input_value, Timestamp): 364 input_value = str(input_value) 365 assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'" 366 except ValueError as e: 367 assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}" 368 else: 369 try: 370 cls(input_value) 371 assert False, f"Expected ValueError for invalid input: '{input_value}'" 372 except ValueError as e: 373 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.
376@dataclasses.dataclass 377class AccountDetails: 378 """ 379 Details of an account. 380 381 Attributes: 382 - account_id: The unique identifier (ID) of the account. 383 - account_name: Human-readable name of the account. 384 - balance: The current cached balance of the account. 385 """ 386 account_id: AccountID 387 account_name: str 388 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.
212class Timestamp(int): 213 """Represents a timestamp as an integer, which must be greater than zero.""" 214 215 def __new__(cls, value): 216 """ 217 Creates a new Timestamp instance. 218 219 Parameters: 220 - value (int or str): The integer value to be used as the timestamp. 221 222 Raises: 223 - TypeError: If the provided value is not an integer or a string representing an integer. 224 - ValueError: If the provided value is not greater than zero. 225 226 Returns: 227 - Timestamp: A new Timestamp instance. 228 """ 229 if isinstance(value, str): 230 try: 231 value = int(value) 232 except ValueError: 233 raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.") 234 if not isinstance(value, int): 235 raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.") 236 237 if value <= 0: 238 raise ValueError("Timestamp value must be greater than zero.") 239 240 return super().__new__(cls, value) 241 242 @classmethod 243 def test(cls): 244 """ 245 Runs tests for the Timestamp class to ensure it behaves correctly. 246 """ 247 test_data = { 248 123: True, 249 "123": True, 250 0: False, 251 "0": False, 252 -1: False, 253 "-1": False, 254 "abc": False, 255 1: True, 256 "1": True, 257 } 258 259 for input_value, expected_output in test_data.items(): 260 if expected_output: 261 try: 262 timestamp = cls(input_value) 263 assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'" 264 except (TypeError, ValueError) as e: 265 assert False, f"Unexpected error for valid input: '{input_value}': {e}" 266 else: 267 try: 268 cls(input_value) 269 assert False, f"Expected error for invalid input: '{input_value}'" 270 except (TypeError, ValueError): 271 pass # Expected exception
Represents a timestamp as an integer, which must be greater than zero.
215 def __new__(cls, value): 216 """ 217 Creates a new Timestamp instance. 218 219 Parameters: 220 - value (int or str): The integer value to be used as the timestamp. 221 222 Raises: 223 - TypeError: If the provided value is not an integer or a string representing an integer. 224 - ValueError: If the provided value is not greater than zero. 225 226 Returns: 227 - Timestamp: A new Timestamp instance. 228 """ 229 if isinstance(value, str): 230 try: 231 value = int(value) 232 except ValueError: 233 raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.") 234 if not isinstance(value, int): 235 raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.") 236 237 if value <= 0: 238 raise ValueError("Timestamp value must be greater than zero.") 239 240 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.
242 @classmethod 243 def test(cls): 244 """ 245 Runs tests for the Timestamp class to ensure it behaves correctly. 246 """ 247 test_data = { 248 123: True, 249 "123": True, 250 0: False, 251 "0": False, 252 -1: False, 253 "-1": False, 254 "abc": False, 255 1: True, 256 "1": True, 257 } 258 259 for input_value, expected_output in test_data.items(): 260 if expected_output: 261 try: 262 timestamp = cls(input_value) 263 assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'" 264 except (TypeError, ValueError) as e: 265 assert False, f"Unexpected error for valid input: '{input_value}': {e}" 266 else: 267 try: 268 cls(input_value) 269 assert False, f"Expected error for invalid input: '{input_value}'" 270 except (TypeError, ValueError): 271 pass # Expected exception
Runs tests for the Timestamp class to ensure it behaves correctly.
484@dataclasses.dataclass 485class Box( 486 StrictDataclass, 487 # ImmutableWithSelectiveFreeze, 488 ): 489 """ 490 Represents a financial box with capital, remaining value, and zakat details. 491 492 Attributes: 493 - capital (int): The initial capital value of the box. 494 - rest (int): The current remaining value within the box. 495 - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box. 496 """ 497 capital: int #= dataclasses.field(metadata={"frozen": True}) 498 rest: int 499 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.
502@dataclasses.dataclass 503class Log(StrictDataclass): 504 """ 505 Represents a log entry for an account. 506 507 Attributes: 508 - value: The value of the log entry. 509 - desc: A description of the log entry. 510 - ref: An optional timestamp reference. 511 - file: A dictionary mapping timestamps to file paths. 512 """ 513 value: int 514 desc: str 515 ref: Optional[Timestamp] 516 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.
519@dataclasses.dataclass 520class Account(StrictDataclass): 521 """ 522 Represents a financial account. 523 524 Attributes: 525 - balance: The current balance of the account. 526 - created: The timestamp when the account was created. 527 - name: The name of the account. 528 - box: A dictionary mapping timestamps to Box objects. 529 - count: A counter for logs, initialized to 0. 530 - log: A dictionary mapping timestamps to Log objects. 531 - hide: A boolean indicating whether the account is hidden. 532 - zakatable: A boolean indicating whether the account is subject to zakat. 533 """ 534 balance: int 535 created: Timestamp 536 name: str = '' 537 box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict) 538 count: int = dataclasses.field(default_factory=factory_value(0)) 539 log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict) 540 hide: bool = dataclasses.field(default_factory=factory_value(False)) 541 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.
544@dataclasses.dataclass 545class Exchange(StrictDataclass): 546 """ 547 Represents an exchange rate and related information. 548 549 Attributes: 550 - rate: The exchange rate (optional). 551 - description: A description of the exchange (optional). 552 - time: The timestamp of the exchange (optional). 553 """ 554 rate: Optional[float] = None 555 description: Optional[str] = None 556 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).
559@dataclasses.dataclass 560class History(StrictDataclass): 561 """ 562 Represents a history entry for an account action. 563 564 Attributes: 565 - action: The action performed. 566 - account: The ID of the account (optional). 567 - ref: An optional timestamp reference. 568 - file: An optional timestamp for a file. 569 - key: An optional key. 570 - value: An optional value. 571 - math: An optional math operation. 572 """ 573 action: Action 574 account: Optional[AccountID] 575 ref: Optional[Timestamp] 576 file: Optional[Timestamp] 577 key: Optional[str] 578 value: Optional[any] # !!! 579 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.
654@dataclasses.dataclass 655class Vault(StrictDataclass): 656 """ 657 Represents a vault containing accounts, exchanges, and history. 658 659 Attributes: 660 - account: A dictionary mapping account IDs to Account objects. 661 - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects. 662 - history: A dictionary mapping timestamps to dictionaries of History objects. 663 - lock: An optional timestamp for a lock. 664 - report: A dictionary mapping timestamps to tuples. 665 - cache: A Cache object containing cached Zakat-related data. 666 """ 667 account: dict[AccountID, Account] = dataclasses.field(default_factory=dict) 668 exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict) 669 history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict) 670 lock: Optional[Timestamp] = None 671 report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict) 672 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.
675@dataclasses.dataclass 676class AccountPaymentPart(StrictDataclass): 677 """ 678 Represents a payment part for an account. 679 680 Attributes: 681 - balance: The balance of the payment part. 682 - rate: The rate of the payment part. 683 - part: The part of the payment. 684 """ 685 balance: float 686 rate: float 687 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.
690@dataclasses.dataclass 691class PaymentParts(StrictDataclass): 692 """ 693 Represents payment parts for multiple accounts. 694 695 Attributes: 696 - exceed: A boolean indicating whether the payment exceeds a limit. 697 - demand: The demand for payment. 698 - total: The total payment. 699 - account: A dictionary mapping account references to AccountPaymentPart objects. 700 """ 701 exceed: bool 702 demand: int 703 total: float 704 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.
707@dataclasses.dataclass 708class SubtractAge(StrictDataclass): 709 """ 710 Represents an age subtraction. 711 712 Attributes: 713 - box_ref: The timestamp reference for the box. 714 - total: The total amount to subtract. 715 """ 716 box_ref: Timestamp 717 total: int
Represents an age subtraction.
Attributes:
- box_ref: The timestamp reference for the box.
- total: The total amount to subtract.
720@dataclasses.dataclass 721class SubtractAges(StrictDataclass, list[SubtractAge]): 722 """A list of SubtractAge objects.""" 723 pass
A list of SubtractAge objects.
726@dataclasses.dataclass 727class SubtractReport(StrictDataclass): 728 """ 729 Represents a report of age subtractions. 730 731 Attributes: 732 - log_ref: The timestamp reference for the log. 733 - ages: A list of SubtractAge objects. 734 """ 735 log_ref: Timestamp 736 ages: SubtractAges
Represents a report of age subtractions.
Attributes:
- log_ref: The timestamp reference for the log.
- ages: A list of SubtractAge objects.
739@dataclasses.dataclass 740class TransferTime(StrictDataclass): 741 """ 742 Represents a transfer time. 743 744 Attributes: 745 - box_ref: The timestamp reference for the box. 746 - log_ref: The timestamp reference for the log. 747 """ 748 box_ref: Timestamp 749 log_ref: Timestamp
Represents a transfer time.
Attributes:
- box_ref: The timestamp reference for the box.
- log_ref: The timestamp reference for the log.
752@dataclasses.dataclass 753class TransferTimes(StrictDataclass, list[TransferTime]): 754 """A list of TransferTime objects.""" 755 pass
A list of TransferTime objects.
758@dataclasses.dataclass 759class TransferRecord(StrictDataclass): 760 """ 761 Represents a transfer record. 762 763 Attributes: 764 - box_ref: The timestamp reference for the box. 765 - times: A list of TransferTime objects. 766 """ 767 box_ref: Timestamp 768 times: TransferTimes
Represents a transfer record.
Attributes:
- box_ref: The timestamp reference for the box.
- times: A list of TransferTime objects.
771class TransferReport(StrictDataclass, list[TransferRecord]): 772 """A list of TransferRecord objects.""" 773 pass
A list of TransferRecord objects.
582@dataclasses.dataclass 583class BoxPlan(StrictDataclass): 584 """ 585 Represents a plan for a box. 586 587 Attributes: 588 - box: The Box object. 589 - log: The Log object. 590 - exchange: The Exchange object. 591 - below_nisab: A boolean indicating whether the value is below nisab. 592 - total: The total value. 593 - count: The count. 594 - ref: The timestamp reference for related Box & Log. 595 """ 596 box: Box 597 log: Log 598 exchange: Exchange 599 below_nisab: bool 600 total: float 601 count: int 602 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.
605@dataclasses.dataclass 606class ZakatSummary(StrictDataclass): 607 """ 608 Summarizes key financial figures for a Zakat calculation. 609 610 Attributes: 611 - total_wealth (int): The total wealth collected from all rest of transactions. 612 - num_wealth_items (int): The number of individual transactions contributing to the total wealth. 613 - num_zakatable_items (int): The number of transactions subject to Zakat. 614 - total_zakatable_amount (int): The total value of all transactions subject to Zakat. 615 - total_zakat_due (int): The calculated amount of Zakat payable. 616 """ 617 total_wealth: int = 0 618 num_wealth_items: int = 0 619 num_zakatable_items: int = 0 620 total_zakatable_amount: int = 0 621 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.
624@dataclasses.dataclass 625class ZakatReport(StrictDataclass): 626 """ 627 Represents a Zakat report containing the calculation summary, plan, and parameters. 628 629 Attributes: 630 - created: The timestamp when the report was created. 631 - valid: A boolean indicating whether the Zakat is available. 632 - summary: The ZakatSummary object. 633 - plan: A dictionary mapping account IDs to lists of BoxPlan objects. 634 - parameters: A dictionary holding the input parameters used during the Zakat calculation. 635 """ 636 created: Timestamp 637 valid: bool 638 summary: ZakatSummary 639 plan: dict[AccountID, list[BoxPlan]] 640 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.
5807def test(path: Optional[str] = None, debug: bool = False): 5808 """ 5809 Executes a test suite for the ZakatTracker. 5810 5811 This function initializes a ZakatTracker instance, optionally using a specified 5812 database path or a temporary directory. It then runs the test suite and, if debug 5813 mode is enabled, prints detailed test results and execution time. 5814 5815 Parameters: 5816 - path (str, optional): The path to the ZakatTracker database. If None, a 5817 temporary directory is created. Defaults to None. 5818 - debug (bool, optional): Enables debug mode, which prints detailed test 5819 results and execution time. Defaults to False. 5820 5821 Returns: 5822 None. The function asserts the result of the ZakatTracker's test suite. 5823 5824 Raises: 5825 - AssertionError: If the ZakatTracker's test suite fails. 5826 5827 Examples: 5828 - `test()` Runs tests using a temporary database. 5829 - `test(debug=True)` Runs the test suite in debug mode with a temporary directory. 5830 - `test(path="/path/to/my/db")` Runs tests using a specified database path. 5831 - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path. 5832 """ 5833 no_path = path is None 5834 if no_path: 5835 path = tempfile.mkdtemp() 5836 print(f"Random database path {path}") 5837 if os.path.exists(path): 5838 shutil.rmtree(path) 5839 assert ZakatTracker(':memory:').memory_mode() 5840 ledger = ZakatTracker( 5841 db_path=path, 5842 history_mode=True, 5843 ) 5844 start = time.time_ns() 5845 assert not ledger.memory_mode() 5846 assert ledger.test(debug=debug) 5847 if no_path and os.path.exists(path): 5848 shutil.rmtree(path) 5849 if debug: 5850 print('#########################') 5851 print('######## TEST DONE ########') 5852 print('#########################') 5853 print(Time.duration_from_nanoseconds(time.time_ns() - start)) 5854 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.
148@enum.unique 149class Action(enum.Enum): 150 """ 151 Enumeration representing various actions that can be performed. 152 153 Members: 154 - CREATE: Represents the creation action ('CREATE'). 155 - NAME: Represents the renaming action ('NAME'). 156 - TRACK: Represents the tracking action ('TRACK'). 157 - LOG: Represents the logging action ('LOG'). 158 - SUBTRACT: Represents the subtract action ('SUBTRACT'). 159 - ADD_FILE: Represents the action of adding a file ('ADD_FILE'). 160 - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE'). 161 - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER'). 162 - EXCHANGE: Represents the exchange action ('EXCHANGE'). 163 - REPORT: Represents the reporting action ('REPORT'). 164 - ZAKAT: Represents a Zakat related action ('ZAKAT'). 165 """ 166 CREATE = 'CREATE' 167 NAME = 'NAME' 168 TRACK = 'TRACK' 169 LOG = 'LOG' 170 SUBTRACT = 'SUBTRACT' 171 ADD_FILE = 'ADD_FILE' 172 REMOVE_FILE = 'REMOVE_FILE' 173 BOX_TRANSFER = 'BOX_TRANSFER' 174 EXCHANGE = 'EXCHANGE' 175 REPORT = 'REPORT' 176 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').
926class JSONEncoder(json.JSONEncoder): 927 """ 928 Custom JSON encoder to handle specific object types. 929 930 This encoder overrides the default `default` method to serialize: 931 - `Action` and `MathOperation` enums as their member names. 932 - `decimal.Decimal` instances as floats. 933 934 Example: 935 ```bash 936 >>> json.dumps(Action.CREATE, cls=JSONEncoder) 937 'CREATE' 938 >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder) 939 '10.5' 940 ``` 941 """ 942 def default(self, o): 943 """ 944 Overrides the default `default` method to serialize specific object types. 945 946 Parameters: 947 - o: The object to serialize. 948 949 Returns: 950 - The serialized object. 951 """ 952 if isinstance(o, (Action, MathOperation)): 953 return o.name # Serialize as the enum member's name 954 if isinstance(o, decimal.Decimal): 955 return float(o) 956 if isinstance(o, Exception): 957 return str(o) 958 if isinstance(o, Vault) or isinstance(o, ImportReport): 959 return dataclasses.asdict(o) 960 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'
942 def default(self, o): 943 """ 944 Overrides the default `default` method to serialize specific object types. 945 946 Parameters: 947 - o: The object to serialize. 948 949 Returns: 950 - The serialized object. 951 """ 952 if isinstance(o, (Action, MathOperation)): 953 return o.name # Serialize as the enum member's name 954 if isinstance(o, decimal.Decimal): 955 return float(o) 956 if isinstance(o, Exception): 957 return str(o) 958 if isinstance(o, Vault) or isinstance(o, ImportReport): 959 return dataclasses.asdict(o) 960 return super().default(o)
Overrides the default default
method to serialize specific object types.
Parameters:
- o: The object to serialize.
Returns:
- The serialized object.
963class JSONDecoder(json.JSONDecoder): 964 """ 965 Custom JSON decoder to handle specific object types. 966 967 This decoder overrides the `object_hook` method to deserialize: 968 - Strings representing enum member names back to their respective enum values. 969 - Floats back to `decimal.Decimal` instances. 970 971 Example: 972 ```bash 973 >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder) 974 {'action': <Action.CREATE: 1>} 975 >>> json.loads('{"value": 10.5}', cls=JSONDecoder) 976 {'value': Decimal('10.5')} 977 ``` 978 """ 979 def object_hook(self, obj): 980 """ 981 Overrides the default `object_hook` method to deserialize specific object types. 982 983 Parameters: 984 - obj: The object to deserialize. 985 986 Returns: 987 - The deserialized object. 988 """ 989 if isinstance(obj, str) and obj in Action.__members__: 990 return Action[obj] 991 if isinstance(obj, str) and obj in MathOperation.__members__: 992 return MathOperation[obj] 993 if isinstance(obj, float): 994 return decimal.Decimal(str(obj)) 995 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')}
979 def object_hook(self, obj): 980 """ 981 Overrides the default `object_hook` method to deserialize specific object types. 982 983 Parameters: 984 - obj: The object to deserialize. 985 986 Returns: 987 - The deserialized object. 988 """ 989 if isinstance(obj, str) and obj in Action.__members__: 990 return Action[obj] 991 if isinstance(obj, str) and obj in MathOperation.__members__: 992 return MathOperation[obj] 993 if isinstance(obj, float): 994 return decimal.Decimal(str(obj)) 995 return obj
Overrides the default object_hook
method to deserialize specific object types.
Parameters:
- obj: The object to deserialize.
Returns:
- The deserialized object.
179@enum.unique 180class MathOperation(enum.Enum): 181 """ 182 Enumeration representing mathematical operations. 183 184 Members: 185 - ADDITION: Represents the addition operation ('ADDITION'). 186 - EQUAL: Represents the equality operation ('EQUAL'). 187 - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION'). 188 """ 189 ADDITION = 'ADDITION' 190 EQUAL = 'EQUAL' 191 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').
125@enum.unique 126class WeekDay(enum.Enum): 127 """ 128 Enumeration representing the days of the week. 129 130 Members: 131 - MONDAY: Represents Monday (0). 132 - TUESDAY: Represents Tuesday (1). 133 - WEDNESDAY: Represents Wednesday (2). 134 - THURSDAY: Represents Thursday (3). 135 - FRIDAY: Represents Friday (4). 136 - SATURDAY: Represents Saturday (5). 137 - SUNDAY: Represents Sunday (6). 138 """ 139 MONDAY = 0 140 TUESDAY = 1 141 WEDNESDAY = 2 142 THURSDAY = 3 143 FRIDAY = 4 144 SATURDAY = 5 145 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').
398@dataclasses.dataclass 399class StrictDataclass: 400 """A dataclass that prevents setting non-existent attributes.""" 401 def __setattr__(self, name: str, value: any) -> None: 402 _check_attribute(self, name, value)
A dataclass that prevents setting non-existent attributes.
405class ImmutableWithSelectiveFreeze: 406 """ 407 A base class for creating immutable objects with the ability to selectively 408 freeze specific fields. 409 410 Inheriting from this class will automatically make all fields defined in 411 dataclasses as frozen after initialization if their metadata contains 412 `"frozen": True`. Attempting to set a value to a frozen field after 413 initialization will raise a RuntimeError. 414 415 Example: 416 ```python 417 @dataclasses.dataclass 418 class MyObject(ImmutableWithSelectiveFreeze): 419 name: str 420 count: int = dataclasses.field(metadata={"frozen": True}) 421 description: str = "default" 422 423 obj = MyObject(name="Test", count=5) 424 print(obj.name) # Output: Test 425 print(obj.count) # Output: 5 426 obj.name = "New Name" # This will work 427 try: 428 obj.count = 10 # This will raise a RuntimeError 429 except RuntimeError as e: 430 print(e) # Output: Field 'count' is frozen! 431 print(obj.description) # Output: default 432 obj.description = "updated" # This will work 433 ``` 434 """ 435 # Implementation based on: https://discuss.python.org/t/dataclasses-freezing-specific-fields-should-be-possible/59968/2 436 def __post_init__(self): 437 """ 438 Initializes the object and freezes fields marked with `"frozen": True` 439 in their metadata. 440 """ 441 self.__set_fields_frozen(self) 442 443 @classmethod 444 def __set_fields_frozen(cls, self): 445 """ 446 Iterates through the dataclass fields and freezes those with the 447 `"frozen": True` metadata. 448 """ 449 flds = dataclasses.fields(cls) 450 for fld in flds: 451 if fld.metadata.get("frozen"): 452 field_name = fld.name 453 field_value = getattr(self, fld.name) 454 setattr(self, f"_{fld.name}", field_value) 455 456 def local_getter(self): 457 """Getter for the frozen field.""" 458 return getattr(self, f"_{field_name}") 459 460 def frozen(name): 461 """Creates a setter that raises a RuntimeError for frozen fields.""" 462 def local_setter(self, value): 463 raise RuntimeError(f"Field '{name}' is frozen!") 464 return local_setter 465 466 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
913@dataclasses.dataclass 914class Backup: 915 """ 916 Represents a backup of a file. 917 918 Attributes: 919 - path (str): The path to the back-up file. 920 - hash (str): The hash (SHA1) of the backed-up data for integrity verification. 921 """ 922 path: str 923 hash: str
Represents a backup of a file.
Attributes:
- path (str): The path to the back-up file.
- hash (str): The hash (SHA1) of the backed-up data for integrity verification.
1338class Collection: 1339 @staticmethod 1340 def paginate(items: list | dict, page_size: int, page_number: int, debug: bool = False) -> tuple[list[tuple], int, int]: 1341 """ 1342 Paginate a list or dictionary of items into pages. 1343 1344 This function divides the input `items` into smaller, equally sized pages. 1345 It handles both lists and dictionaries as input, and ensures the requested 1346 page number is within a valid range. For lists, it adds the item number 1347 to each item in the result 1348 1349 Parameters: 1350 - items (list | dict): The list or dictionary of items to paginate. 1351 If a dictionary is provided, the keys are used to determine the order of items. 1352 - page_size (int): The maximum number of items to include in each page. 1353 Must be a positive integer. 1354 - page_number (int): The desired page number (1-based index). 1355 The function will adjust this value to be within the valid range of pages. 1356 - debug (bool, optional): If True, the function will print debug information. 1357 Default is False. 1358 1359 Returns: 1360 - tuple[list[tuple], int, int]: A tuple containing three elements: 1361 - A list of items for the requested page. If the input `items` 1362 is a list, each item in the output list is a tuple where the 1363 first element is the item number (1-based index within the 1364 original list), and the subsequent elements are the original 1365 item's data. If `items` is a dictionary, the output list 1366 contains the keys of the dictionary for the selected page. 1367 - The total number of pages (int). 1368 - The total number of items (int) in the original `items`. 1369 """ 1370 total_items = len(items) 1371 total_pages = (total_items + page_size - 1) // page_size # Calculate total pages 1372 page_number = max(1, min(page_number, total_pages)) # Clamp page number to valid range 1373 1374 start_index = (page_number - 1) * page_size 1375 end_index = start_index + page_size 1376 page_items = [] 1377 if type(items) is dict: 1378 page_items = list(items)[start_index:end_index] 1379 else: 1380 for i, item in enumerate(items[start_index:end_index]): 1381 item_number = start_index + i + 1 # Calculate item number 1382 if debug: 1383 print(item_number, item) 1384 if type(item) is not tuple: 1385 item = (item,) 1386 page_items.append((item_number,) + item) # Include item number with item 1387 1388 return page_items, total_pages, total_items 1389 1390 @staticmethod 1391 def test_paginate(debug: bool = False): 1392 """ 1393 Test the paginate function with various inputs. 1394 1395 Parameters: 1396 - debug (bool, optional): If True, the function will print debug information. Default is False. 1397 """ 1398 if debug: 1399 print("Running tests for Collection.paginate()") 1400 1401 # Test case 1: List of integers 1402 items1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 1403 page_size1 = 3 1404 page_number1 = 2 1405 expected_output1 = ([(4, 4,),(5, 5,), (6, 6,)], 4, 10) 1406 result1 = Collection.paginate(items1, page_size1, page_number1, debug=debug) 1407 if debug: 1408 print('items:', items1) 1409 print('result:', result1) 1410 print('expected:', expected_output1) 1411 assert result1 == expected_output1, f"Test Case 1 Failed: {result1} != {expected_output1}" 1412 if debug: 1413 print("Test Case 1 Passed") 1414 1415 # Test case 2: List of strings 1416 items2 = ["a", "b", "c", "d", "e"] 1417 page_size2 = 2 1418 page_number2 = 3 1419 expected_output2 = ([(5, 'e',)], 3, 5) 1420 result2 = Collection.paginate(items2, page_size2, page_number2) 1421 if debug: 1422 print('items:', items2) 1423 print('result:', result2) 1424 print('expected:', expected_output2) 1425 assert result2 == expected_output2, f"Test Case 2 Failed: {result2} != {expected_output2}" 1426 if debug: 1427 print("Test Case 2 Passed") 1428 1429 # Test case 3: Dictionary 1430 items3 = {"a": 1, "b": 2, "c": 3, "d": 4} 1431 page_size3 = 2 1432 page_number3 = 1 1433 expected_output3 = (["a", "b"], 2, 4) 1434 result3 = Collection.paginate(items3, page_size3, page_number3) 1435 if debug: 1436 print('items:', items3) 1437 print('result:', result3) 1438 print('expected:', expected_output3) 1439 assert result3 == expected_output3, f"Test Case 3 Failed: {result3} != {expected_output3}" 1440 if debug: 1441 print("Test Case 3 Passed") 1442 1443 # Test case 4: Empty list 1444 items4 = [] 1445 page_size4 = 2 1446 page_number4 = 1 1447 expected_output4 = ([], 0, 0) 1448 result4 = Collection.paginate(items4, page_size4, page_number4) 1449 if debug: 1450 print('items:', items4) 1451 print('result:', result4) 1452 print('expected:', expected_output4) 1453 assert result4 == expected_output4, f"Test Case 4 Failed: {result4} != {expected_output4}" 1454 if debug: 1455 print("Test Case 4 Passed") 1456 1457 # Test case 5: page_number out of range 1458 items5 = [1, 2, 3, 4, 5] 1459 page_size5 = 2 1460 page_number5 = 100 1461 expected_output5 = ([(5, 5,)], 3, 5) 1462 result5 = Collection.paginate(items5, page_size5, page_number5) 1463 if debug: 1464 print('items:', items5) 1465 print('result:', result5) 1466 print('expected:', expected_output5) 1467 assert result5 == expected_output5, f"Test Case 5 Failed: {result5} != {expected_output5}" 1468 if debug: 1469 print("Test Case 5 Passed") 1470 print("All test cases passed!")
1339 @staticmethod 1340 def paginate(items: list | dict, page_size: int, page_number: int, debug: bool = False) -> tuple[list[tuple], int, int]: 1341 """ 1342 Paginate a list or dictionary of items into pages. 1343 1344 This function divides the input `items` into smaller, equally sized pages. 1345 It handles both lists and dictionaries as input, and ensures the requested 1346 page number is within a valid range. For lists, it adds the item number 1347 to each item in the result 1348 1349 Parameters: 1350 - items (list | dict): The list or dictionary of items to paginate. 1351 If a dictionary is provided, the keys are used to determine the order of items. 1352 - page_size (int): The maximum number of items to include in each page. 1353 Must be a positive integer. 1354 - page_number (int): The desired page number (1-based index). 1355 The function will adjust this value to be within the valid range of pages. 1356 - debug (bool, optional): If True, the function will print debug information. 1357 Default is False. 1358 1359 Returns: 1360 - tuple[list[tuple], int, int]: A tuple containing three elements: 1361 - A list of items for the requested page. If the input `items` 1362 is a list, each item in the output list is a tuple where the 1363 first element is the item number (1-based index within the 1364 original list), and the subsequent elements are the original 1365 item's data. If `items` is a dictionary, the output list 1366 contains the keys of the dictionary for the selected page. 1367 - The total number of pages (int). 1368 - The total number of items (int) in the original `items`. 1369 """ 1370 total_items = len(items) 1371 total_pages = (total_items + page_size - 1) // page_size # Calculate total pages 1372 page_number = max(1, min(page_number, total_pages)) # Clamp page number to valid range 1373 1374 start_index = (page_number - 1) * page_size 1375 end_index = start_index + page_size 1376 page_items = [] 1377 if type(items) is dict: 1378 page_items = list(items)[start_index:end_index] 1379 else: 1380 for i, item in enumerate(items[start_index:end_index]): 1381 item_number = start_index + i + 1 # Calculate item number 1382 if debug: 1383 print(item_number, item) 1384 if type(item) is not tuple: 1385 item = (item,) 1386 page_items.append((item_number,) + item) # Include item number with item 1387 1388 return page_items, total_pages, total_items
Paginate a list or dictionary of items into pages.
This function divides the input items
into smaller, equally sized pages.
It handles both lists and dictionaries as input, and ensures the requested
page number is within a valid range. For lists, it adds the item number
to each item in the result
Parameters:
- items (list | dict): The list or dictionary of items to paginate. If a dictionary is provided, the keys are used to determine the order of items.
- page_size (int): The maximum number of items to include in each page. Must be a positive integer.
- page_number (int): The desired page number (1-based index). The function will adjust this value to be within the valid range of pages.
- debug (bool, optional): If True, the function will print debug information. Default is False.
Returns:
- tuple[list[tuple], int, int]: A tuple containing three elements:
- A list of items for the requested page. If the input
items
is a list, each item in the output list is a tuple where the first element is the item number (1-based index within the original list), and the subsequent elements are the original item's data. Ifitems
is a dictionary, the output list contains the keys of the dictionary for the selected page. - The total number of pages (int).
- The total number of items (int) in the original
items
.
- A list of items for the requested page. If the input
1390 @staticmethod 1391 def test_paginate(debug: bool = False): 1392 """ 1393 Test the paginate function with various inputs. 1394 1395 Parameters: 1396 - debug (bool, optional): If True, the function will print debug information. Default is False. 1397 """ 1398 if debug: 1399 print("Running tests for Collection.paginate()") 1400 1401 # Test case 1: List of integers 1402 items1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 1403 page_size1 = 3 1404 page_number1 = 2 1405 expected_output1 = ([(4, 4,),(5, 5,), (6, 6,)], 4, 10) 1406 result1 = Collection.paginate(items1, page_size1, page_number1, debug=debug) 1407 if debug: 1408 print('items:', items1) 1409 print('result:', result1) 1410 print('expected:', expected_output1) 1411 assert result1 == expected_output1, f"Test Case 1 Failed: {result1} != {expected_output1}" 1412 if debug: 1413 print("Test Case 1 Passed") 1414 1415 # Test case 2: List of strings 1416 items2 = ["a", "b", "c", "d", "e"] 1417 page_size2 = 2 1418 page_number2 = 3 1419 expected_output2 = ([(5, 'e',)], 3, 5) 1420 result2 = Collection.paginate(items2, page_size2, page_number2) 1421 if debug: 1422 print('items:', items2) 1423 print('result:', result2) 1424 print('expected:', expected_output2) 1425 assert result2 == expected_output2, f"Test Case 2 Failed: {result2} != {expected_output2}" 1426 if debug: 1427 print("Test Case 2 Passed") 1428 1429 # Test case 3: Dictionary 1430 items3 = {"a": 1, "b": 2, "c": 3, "d": 4} 1431 page_size3 = 2 1432 page_number3 = 1 1433 expected_output3 = (["a", "b"], 2, 4) 1434 result3 = Collection.paginate(items3, page_size3, page_number3) 1435 if debug: 1436 print('items:', items3) 1437 print('result:', result3) 1438 print('expected:', expected_output3) 1439 assert result3 == expected_output3, f"Test Case 3 Failed: {result3} != {expected_output3}" 1440 if debug: 1441 print("Test Case 3 Passed") 1442 1443 # Test case 4: Empty list 1444 items4 = [] 1445 page_size4 = 2 1446 page_number4 = 1 1447 expected_output4 = ([], 0, 0) 1448 result4 = Collection.paginate(items4, page_size4, page_number4) 1449 if debug: 1450 print('items:', items4) 1451 print('result:', result4) 1452 print('expected:', expected_output4) 1453 assert result4 == expected_output4, f"Test Case 4 Failed: {result4} != {expected_output4}" 1454 if debug: 1455 print("Test Case 4 Passed") 1456 1457 # Test case 5: page_number out of range 1458 items5 = [1, 2, 3, 4, 5] 1459 page_size5 = 2 1460 page_number5 = 100 1461 expected_output5 = ([(5, 5,)], 3, 5) 1462 result5 = Collection.paginate(items5, page_size5, page_number5) 1463 if debug: 1464 print('items:', items5) 1465 print('result:', result5) 1466 print('expected:', expected_output5) 1467 assert result5 == expected_output5, f"Test Case 5 Failed: {result5} != {expected_output5}" 1468 if debug: 1469 print("Test Case 5 Passed") 1470 print("All test cases passed!")
Test the paginate function with various inputs.
Parameters:
- debug (bool, optional): If True, the function will print debug information. Default is False.