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