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.4' 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, 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 - debug (bool, optional): A flag indicating whether to print debug information. 3724 3725 Returns: 3726 - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, 3727 and a dictionary of bad transactions. 3728 3729 Notes: 3730 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3731 are appropriate for the currency pairs involved in the conversions. 3732 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3733 to 1.0 or the previous rate for that account. 3734 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3735 transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and 3736 `zakat` operations. 3737 3738 Example: 3739 The CSV file should have the following format, rate and reference are optionals per transaction: 3740 account, desc, value, date, rate, reference 3741 For example: 3742 safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554 3743 """ 3744 if debug: 3745 print('import_csv', f'debug={debug}') 3746 cache: list[int] = [] 3747 try: 3748 if not self.memory_mode(): 3749 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3750 cache = json.load(stream) 3751 except Exception as e: 3752 if debug: 3753 print(e) 3754 date_formats = [ 3755 '%Y-%m-%d %H:%M:%S.%f', 3756 '%Y-%m-%dT%H:%M:%S.%f', 3757 '%Y-%m-%dT%H%M%S.%f', 3758 '%Y-%m-%d', 3759 ] 3760 statistics = ImportStatistics(0, 0, 0) 3761 data: dict[int, list[CSVRecord]] = {} 3762 with open(path, newline='', encoding='utf-8') as f: 3763 i = 0 3764 for row in csv.reader(f, delimiter=','): 3765 if debug: 3766 print(f"csv_row({i})", row, type(row)) 3767 if row == self.get_transaction_csv_headers(): 3768 continue 3769 i += 1 3770 hashed = hash(tuple(row)) 3771 if hashed in cache: 3772 statistics.found += 1 3773 continue 3774 account = row[0] 3775 desc = row[1] 3776 value = float(row[2]) 3777 rate = 1.0 3778 reference = '' 3779 if row[4:5]: # Empty list if index is out of range 3780 rate = float(row[4]) 3781 if row[5:6]: 3782 reference = row[5] 3783 date: int = 0 3784 for time_format in date_formats: 3785 try: 3786 date_str = row[3] 3787 if "." not in date_str: 3788 date_str += ".000000" 3789 date = Time.time(datetime.datetime.strptime(date_str, time_format)) 3790 break 3791 except Exception as e: 3792 if debug: 3793 print(e) 3794 record = CSVRecord( 3795 index=i, 3796 account=account, 3797 desc=desc, 3798 value=value, 3799 date=date, 3800 rate=rate, 3801 reference=reference, 3802 hashed=hashed, 3803 error='', 3804 ) 3805 if date <= 0: 3806 record.error = 'invalid date' 3807 statistics.bad += 1 3808 if value == 0: 3809 record.error = 'invalid value' 3810 statistics.bad += 1 3811 continue 3812 if date not in data: 3813 data[date] = [] 3814 data[date].append(record) 3815 3816 if debug: 3817 print('import_csv', len(data)) 3818 3819 if statistics.bad > 0: 3820 return ImportReport( 3821 statistics=statistics, 3822 bad=[ 3823 item 3824 for sublist in data.values() 3825 for item in sublist 3826 if item.error 3827 ], 3828 ) 3829 3830 no_lock = self.nolock() 3831 lock = self.__lock() 3832 names = self.names() 3833 3834 # sync accounts 3835 if debug: 3836 print('before-names', names, len(names)) 3837 for date, rows in sorted(data.items()): 3838 new_rows: list[CSVRecord] = [] 3839 for row in rows: 3840 if row.account not in names: 3841 account_id = self.create_account(row.account) 3842 names[row.account] = account_id 3843 account_id = names[row.account] 3844 assert account_id 3845 row.account = account_id 3846 new_rows.append(row) 3847 assert new_rows 3848 assert date in data 3849 data[date] = new_rows 3850 if debug: 3851 print('after-names', names, len(names)) 3852 assert names == self.names() 3853 3854 # do ops 3855 for date, rows in sorted(data.items()): 3856 try: 3857 def process(x: CSVRecord): 3858 x.value = self.unscale( 3859 x.value, 3860 decimal_places=scale_decimal_places, 3861 ) if scale_decimal_places > 0 else x.value 3862 if x.rate > 0: 3863 self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate) 3864 if x.value > 0: 3865 self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3866 elif x.value < 0: 3867 self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3868 return x.hashed 3869 len_rows = len(rows) 3870 # If records are found at the same time with different accounts in the same amount 3871 # (one positive and the other negative), this indicates it is a transfer. 3872 if len_rows > 2 or len_rows == 1: 3873 for row in rows: 3874 hashed = process(row) 3875 assert hashed not in cache 3876 cache.append(hashed) 3877 statistics.created += 1 3878 continue 3879 x1 = rows[0] 3880 x2 = rows[1] 3881 if x1.account == x2.account: 3882 continue 3883 # raise Exception(f'invalid transfer') 3884 # not transfer - same time - normal ops 3885 if abs(x1.value) != abs(x2.value) and x1.date == x2.date: 3886 rows[1].date += 1 3887 for row in rows: 3888 hashed = process(row) 3889 assert hashed not in cache 3890 cache.append(hashed) 3891 statistics.created += 1 3892 continue 3893 if x1.rate > 0: 3894 self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate) 3895 if x2.rate > 0: 3896 self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate) 3897 x1.value = self.unscale( 3898 x1.value, 3899 decimal_places=scale_decimal_places, 3900 ) if scale_decimal_places > 0 else x1.value 3901 x2.value = self.unscale( 3902 x2.value, 3903 decimal_places=scale_decimal_places, 3904 ) if scale_decimal_places > 0 else x2.value 3905 # just transfer 3906 values = { 3907 x1.value: x1.account, 3908 x2.value: x2.account, 3909 } 3910 if debug: 3911 print('values', values) 3912 if len(values) <= 1: 3913 continue 3914 self.transfer( 3915 unscaled_amount=abs(x1.value), 3916 from_account=values[min(values.keys())], 3917 to_account=values[max(values.keys())], 3918 desc=x1.desc, 3919 created_time_ns=x1.date, 3920 ) 3921 except Exception as e: 3922 for row in rows: 3923 _tuple = tuple() 3924 for field in row: 3925 _tuple += (field,) 3926 _tuple += (e,) 3927 bad[i] = _tuple 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', count: int = 1_000, with_rate: bool = False, 4074 debug: bool = False) -> int: 4075 """ 4076 Generate a random CSV file with specified parameters. 4077 The function generates a CSV file at the specified path with the given count of rows. 4078 Each row contains a randomly generated account, description, value, and date. 4079 The value is randomly generated between 1000 and 100000, 4080 and the date is randomly generated between 1950-01-01 and 2023-12-31. 4081 If the row number is not divisible by 13, the value is multiplied by -1. 4082 4083 Parameters: 4084 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 4085 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 4086 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 4087 - debug (bool, optional): A flag indicating whether to print debug information. 4088 4089 Returns: 4090 - int: number of generated records. 4091 """ 4092 if debug: 4093 print('generate_random_csv_file', f'debug={debug}') 4094 i = 0 4095 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 4096 writer = csv.writer(csvfile) 4097 writer.writerow(ZakatTracker.get_transaction_csv_headers()) 4098 for i in range(count): 4099 account = f'acc-{random.randint(1, count)}' 4100 desc = f'Some text {random.randint(1, count)}' 4101 value = random.randint(1000, 100000) 4102 date = ZakatTracker.generate_random_date( 4103 datetime.datetime(1000, 1, 1), 4104 datetime.datetime(2023, 12, 31), 4105 ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S') 4106 if not i % 13 == 0: 4107 value *= -1 4108 row = [account, desc, value, date] 4109 if with_rate: 4110 rate = random.randint(1, 100) * 0.12 4111 if debug: 4112 print('before-append', row) 4113 row.append(rate) 4114 if debug: 4115 print('after-append', row) 4116 if i % 2 == 1: 4117 row += (Time.time(),) 4118 writer.writerow(row) 4119 i = i + 1 4120 return i 4121 4122 @staticmethod 4123 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 4124 """ 4125 Creates a list of random integers whose sum does not exceed the specified maximum. 4126 4127 Parameters: 4128 - max_sum (int): The maximum allowed sum of the list elements. 4129 - min_value (int, optional): The minimum possible value for an element (inclusive). 4130 - max_value (int, optional): The maximum possible value for an element (inclusive). 4131 4132 Returns: 4133 - A list of random integers. 4134 """ 4135 result = [] 4136 current_sum = 0 4137 4138 while current_sum < max_sum: 4139 # Calculate the remaining space for the next element 4140 remaining_sum = max_sum - current_sum 4141 # Determine the maximum possible value for the next element 4142 next_max_value = min(remaining_sum, max_value) 4143 # Generate a random element within the allowed range 4144 next_element = random.randint(min_value, next_max_value) 4145 result.append(next_element) 4146 current_sum += next_element 4147 4148 return result 4149 4150 def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]: 4151 """ 4152 Compresses a folder into a .tar.lzma archive. 4153 4154 The archive is named following a specific format: 4155 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. This format 4156 is crucial for the `restore` function, so avoid renaming the files. 4157 4158 Parameters: 4159 - folder_path (str): The path to the folder to be compressed. 4160 - output_directory (str, optional): The directory to save the compressed file. 4161 Defaults to "compressed". 4162 - debug (bool, optional): Whether to print debug information. Default is False. 4163 4164 Returns: 4165 - Optional[Backup]: A Backup object containing the path to the created archive 4166 and its SHA1 hash on success, None on failure. 4167 """ 4168 try: 4169 os.makedirs(output_directory, exist_ok=True) 4170 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4171 4172 # Create a temporary tar archive in memory to calculate the hash 4173 tar_buffer = io.BytesIO() 4174 with tarfile.open(fileobj=tar_buffer, mode="w") as tar: 4175 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4176 tar_buffer.seek(0) 4177 folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4178 output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma" 4179 output_path = os.path.join(output_directory, output_filename) 4180 4181 # Compress the folder to the final .tar.lzma file 4182 with lzma.open(output_path, "wb") as lzma_file: 4183 tar_buffer.seek(0) # Reset the buffer 4184 with tarfile.open(fileobj=lzma_file, mode="w") as tar: 4185 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4186 4187 if debug: 4188 print(f"Folder '{folder_path}' has been compressed to '{output_path}'") 4189 return Backup( 4190 path=output_path, 4191 hash=folder_hash, 4192 ) 4193 except Exception as e: 4194 print(f"Error during compression: {e}") 4195 return None 4196 4197 def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool: 4198 """ 4199 Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash. 4200 4201 The SHA1 hash is extracted from the archive's filename, which must follow 4202 the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. 4203 This format is essential for successful restoration. 4204 4205 Parameters: 4206 - tar_lzma_path (str): The path to the .tar.lzma file. 4207 - output_folder_path (str, optional): The directory to extract the contents to. 4208 Defaults to "uncompressed". 4209 - debug (bool, optional): Whether to print debug information. Default is False. 4210 4211 Returns: 4212 - bool: True if the restoration was successful and the hash matches, False otherwise. 4213 """ 4214 try: 4215 output_folder_path = pathlib.Path(output_folder_path).resolve() 4216 os.makedirs(output_folder_path, exist_ok=True) 4217 filename = os.path.basename(tar_lzma_path) 4218 match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename) 4219 if not match: 4220 if debug: 4221 print(f"Error: Invalid filename format: '{filename}'") 4222 return False 4223 4224 expected_hash_from_filename = match.group(3) 4225 4226 with lzma.open(tar_lzma_path, "rb") as lzma_file: 4227 tar_buffer = io.BytesIO(lzma_file.read()) # Read the entire decompressed tar into memory 4228 with tarfile.open(fileobj=tar_buffer, mode="r") as tar: 4229 tar.extractall(output_folder_path) 4230 tar_buffer.seek(0) # Reset buffer to calculate hash 4231 extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4232 4233 new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path)) 4234 assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found." 4235 if extracted_hash == expected_hash_from_filename: 4236 if debug: 4237 print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.") 4238 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4239 old_path = os.path.dirname(self.path()) 4240 tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now) 4241 if debug: 4242 print('[xxx] - old_path:', old_path) 4243 print('[xxx] - tmp_path:', tmp_path) 4244 print('[xxx] - new_path:', new_path) 4245 try: 4246 shutil.move(old_path, tmp_path) 4247 shutil.move(new_path, old_path) 4248 assert self.load() 4249 shutil.rmtree(tmp_path) 4250 return True 4251 except Exception as e: 4252 print(f"Error applying the restored files: {e}") 4253 shutil.move(tmp_path, old_path) 4254 return False 4255 else: 4256 if debug: 4257 print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}") 4258 # Optionally remove the extracted folder if the hash doesn't match 4259 # shutil.rmtree(output_folder_path, ignore_errors=True) 4260 return False 4261 4262 except Exception as e: 4263 print(f"Error during uncompression or hash check: {e}") 4264 return False 4265 4266 def _test_core(self, restore: bool = False, debug: bool = False): 4267 4268 random.seed(1234567890) 4269 4270 # sanity check - core 4271 4272 assert sorted([6, 0, 9, 3], reverse=False) == [0, 3, 6, 9] 4273 assert sorted([6, 0, 9, 3], reverse=True) == [9, 6, 3, 0] 4274 assert sorted( 4275 {6: '6', 0: '0', 9: '9', 3: '3'}.items(), 4276 reverse=False, 4277 ) == [(0, '0'), (3, '3'), (6, '6'), (9, '9')] 4278 assert sorted( 4279 {6: '6', 0: '0', 9: '9', 3: '3'}.items(), 4280 reverse=True, 4281 ) == [(9, '9'), (6, '6'), (3, '3'), (0, '0')] 4282 assert sorted( 4283 {'6': 6, '0': 0, '9': 9, '3': 3}.items(), 4284 reverse=False, 4285 ) == [('0', 0), ('3', 3), ('6', 6), ('9', 9)] 4286 assert sorted( 4287 {'6': 6, '0': 0, '9': 9, '3': 3}.items(), 4288 reverse=True, 4289 ) == [('9', 9), ('6', 6), ('3', 3), ('0', 0)] 4290 4291 Timestamp.test() 4292 AccountID.test(debug) 4293 Time.test(debug) 4294 4295 # test to prevents setting non-existent attributes 4296 4297 for cls in [ 4298 StrictDataclass, 4299 BoxZakat, 4300 Box, 4301 Log, 4302 Account, 4303 Exchange, 4304 History, 4305 BoxPlan, 4306 ZakatSummary, 4307 ZakatReport, 4308 Vault, 4309 AccountPaymentPart, 4310 PaymentParts, 4311 SubtractAge, 4312 SubtractAges, 4313 SubtractReport, 4314 TransferTime, 4315 TransferTimes, 4316 TransferRecord, 4317 ImportStatistics, 4318 CSVRecord, 4319 ImportReport, 4320 SizeInfo, 4321 FileInfo, 4322 FileStats, 4323 TimeSummary, 4324 Transaction, 4325 DailyRecords, 4326 Timeline, 4327 ]: 4328 failed = False 4329 try: 4330 x = cls() 4331 x.x = 123 4332 except: 4333 failed = True 4334 assert failed 4335 4336 # sanity check - random forward time 4337 4338 xlist = [] 4339 limit = 1000 4340 for _ in range(limit): 4341 y = Time.time() 4342 z = '-' 4343 if y not in xlist: 4344 xlist.append(y) 4345 else: 4346 z = 'x' 4347 if debug: 4348 print(z, y) 4349 xx = len(xlist) 4350 if debug: 4351 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 4352 assert limit == xx 4353 4354 # test ZakatTracker.split_at_last_symbol 4355 4356 test_cases = [ 4357 ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")), 4358 ("No symbol here.", '$', ("No symbol here.", "")), 4359 ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")), 4360 ("Here is a symbol%", '%', ("Here is a symbol", "")), 4361 ("@only a symbol", '@', ("", "only a symbol")), 4362 ("", '#', ("", "")), 4363 ("test/test/test.txt", '/', ("test/test", "test.txt")), 4364 ("abc#def#ghi", "#", ("abc#def", "ghi")), 4365 ("abc", "#", ("abc", "")), 4366 ("//https://test", '//', ("//https:", "test")), 4367 ] 4368 4369 for data, symbol, expected in test_cases: 4370 result = ZakatTracker.split_at_last_symbol(data, symbol) 4371 assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}" 4372 4373 # human_readable_size 4374 4375 assert ZakatTracker.human_readable_size(0) == '0.00 B' 4376 assert ZakatTracker.human_readable_size(512) == '512.00 B' 4377 assert ZakatTracker.human_readable_size(1023) == '1023.00 B' 4378 4379 assert ZakatTracker.human_readable_size(1024) == '1.00 KB' 4380 assert ZakatTracker.human_readable_size(2048) == '2.00 KB' 4381 assert ZakatTracker.human_readable_size(5120) == '5.00 KB' 4382 4383 assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB' 4384 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB' 4385 4386 assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB' 4387 assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB' 4388 assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB' 4389 4390 assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB' 4391 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB' 4392 assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB' 4393 4394 try: 4395 # noinspection PyTypeChecker 4396 ZakatTracker.human_readable_size('not a number') 4397 assert False, 'Expected TypeError for invalid input' 4398 except TypeError: 4399 pass 4400 4401 try: 4402 # noinspection PyTypeChecker 4403 ZakatTracker.human_readable_size(1024, decimal_places='not an int') 4404 assert False, 'Expected TypeError for invalid decimal_places' 4405 except TypeError: 4406 pass 4407 4408 # get_dict_size 4409 assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch' 4410 assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary' 4411 4412 # number scale 4413 error = 0 4414 total = 0 4415 for sign in ['', '-']: 4416 for max_i, max_j, decimal_places in [ 4417 (101, 101, 2), # fiat currency minimum unit took 2 decimal places 4418 (1, 1_000, 8), # cryptocurrency like Satoshi in Bitcoin took 8 decimal places 4419 (1, 1_000, 18) # cryptocurrency like Wei in Ethereum took 18 decimal places 4420 ]: 4421 for return_type in ( 4422 float, 4423 decimal.Decimal, 4424 ): 4425 for i in range(max_i): 4426 for j in range(max_j): 4427 total += 1 4428 num_str = f'{sign}{i}.{j:0{decimal_places}d}' 4429 num = return_type(num_str) 4430 scaled = self.scale(num, decimal_places=decimal_places) 4431 unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places) 4432 if debug: 4433 print( 4434 f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}') 4435 if unscaled != num: 4436 if debug: 4437 print('***** SCALE ERROR *****') 4438 error += 1 4439 if debug: 4440 print(f'total: {total}, error({error}): {100 * error / total}%') 4441 assert error == 0 4442 4443 # test lock 4444 4445 assert self.nolock() 4446 assert self.__history() is True 4447 lock = self.lock() 4448 assert lock is not None 4449 assert lock > 0 4450 failed = False 4451 try: 4452 self.lock() 4453 except: 4454 failed = True 4455 assert failed 4456 assert self.free(lock) 4457 assert not self.free(lock) 4458 4459 wallet_account_id = self.create_account('wallet') 4460 4461 table = { 4462 AccountID('1'): [ 4463 (0, 10, 1000, 1000, 1000, 1, 1), 4464 (0, 20, 3000, 3000, 3000, 2, 2), 4465 (0, 30, 6000, 6000, 6000, 3, 3), 4466 (1, 15, 4500, 4500, 4500, 3, 4), 4467 (1, 50, -500, -500, -500, 4, 5), 4468 (1, 100, -10500, -10500, -10500, 5, 6), 4469 ], 4470 wallet_account_id: [ 4471 (1, 90, -9000, -9000, -9000, 1, 1), 4472 (0, 100, 1000, 1000, 1000, 2, 2), 4473 (1, 190, -18000, -18000, -18000, 3, 3), 4474 (0, 1000, 82000, 82000, 82000, 4, 4), 4475 ], 4476 } 4477 for x in table: 4478 for y in table[x]: 4479 lock = self.lock() 4480 if y[0] == 0: 4481 ref = self.track( 4482 unscaled_value=y[1], 4483 desc='test-add', 4484 account=x, 4485 created_time_ns=Time.time(), 4486 debug=debug, 4487 ) 4488 else: 4489 report = self.subtract( 4490 unscaled_value=y[1], 4491 desc='test-sub', 4492 account=x, 4493 created_time_ns=Time.time(), 4494 ) 4495 ref = report.log_ref 4496 if debug: 4497 print('_sub', z, Time.time()) 4498 assert ref != 0 4499 assert len(self.__vault.account[x].log[ref].file) == 0 4500 for i in range(3): 4501 file_ref = self.add_file(x, ref, 'file_' + str(i)) 4502 assert file_ref != 0 4503 if debug: 4504 print('ref', ref, 'file', file_ref) 4505 assert len(self.__vault.account[x].log[ref].file) == i + 1 4506 assert file_ref in self.__vault.account[x].log[ref].file 4507 file_ref = self.add_file(x, ref, 'file_' + str(3)) 4508 assert self.remove_file(x, ref, file_ref) 4509 timeline = self.timeline(debug=debug) 4510 if debug: 4511 print('timeline', timeline) 4512 assert timeline.daily 4513 assert timeline.weekly 4514 assert timeline.monthly 4515 assert timeline.yearly 4516 z = self.balance(x) 4517 if debug: 4518 print('debug-0', z, y) 4519 assert z == y[2] 4520 z = self.balance(x, False) 4521 if debug: 4522 print('debug-1', z, y[3]) 4523 assert z == y[3] 4524 o = self.__vault.account[x].log 4525 z = 0 4526 for i in o: 4527 z += o[i].value 4528 if debug: 4529 print('debug-2', z, type(z)) 4530 print('debug-2', y[4], type(y[4])) 4531 assert z == y[4] 4532 if debug: 4533 print('debug-2 - PASSED') 4534 assert self.box_size(x) == y[5] 4535 assert self.log_size(x) == y[6] 4536 assert not self.nolock() 4537 assert lock is not None 4538 self.free(lock) 4539 assert self.nolock() 4540 assert self.boxes(x) != {} 4541 assert self.logs(x) != {} 4542 4543 assert not self.hide(x) 4544 assert self.hide(x, False) is False 4545 assert self.hide(x) is False 4546 assert self.hide(x, True) 4547 assert self.hide(x) 4548 4549 assert self.zakatable(x) 4550 assert self.zakatable(x, False) is False 4551 assert self.zakatable(x) is False 4552 assert self.zakatable(x, True) 4553 assert self.zakatable(x) 4554 4555 if restore is True: 4556 # invalid restore point 4557 for lock in [0, time.time_ns(), Time.time()]: 4558 failed = False 4559 try: 4560 self.recall(dry=True, lock=lock) 4561 except: 4562 failed = True 4563 assert failed 4564 count = len(self.__vault.history) 4565 if debug: 4566 print('history-count', count) 4567 assert count == 12 4568 # try mode 4569 for _ in range(count): 4570 assert self.recall(dry=True, debug=debug) 4571 count = len(self.__vault.history) 4572 if debug: 4573 print('history-count', count) 4574 assert count == 12 4575 _accounts = list(table.keys()) 4576 accounts_limit = len(_accounts) + 1 4577 for i in range(-1, -accounts_limit, -1): 4578 account = _accounts[i] 4579 if debug: 4580 print(account, len(table[account])) 4581 transaction_limit = len(table[account]) + 1 4582 for j in range(-1, -transaction_limit, -1): 4583 row = table[account][j] 4584 if debug: 4585 print(row, self.balance(account), self.balance(account, False)) 4586 assert self.balance(account) == self.balance(account, False) 4587 assert self.balance(account) == row[2] 4588 assert self.recall(dry=False, debug=debug) 4589 assert self.recall(dry=False, debug=debug) 4590 assert self.recall(dry=False, debug=debug) 4591 assert not self.recall(dry=False, debug=debug) 4592 count = len(self.__vault.history) 4593 if debug: 4594 print('history-count', count) 4595 assert count == 0 4596 self.reset() 4597 4598 def _test_storage(self, account_id: Optional[AccountID] = None, debug: bool = False): 4599 old_vault = dataclasses.replace(self.__vault) 4600 old_vault_deep = copy.deepcopy(self.__vault) 4601 old_vault_dict = dataclasses.asdict(self.__vault) 4602 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 4603 if os.path.exists(_path): 4604 os.remove(_path) 4605 for hashed in [False, True]: 4606 self.save(hash_required=hashed) 4607 assert os.path.getsize(_path) > 0 4608 self.reset() 4609 assert self.recall(dry=False, debug=debug) is False 4610 for hash_required in [False, True]: 4611 if debug: 4612 print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4613 self.load(hash_required=hashed and hash_required) 4614 if debug: 4615 print('[debug]', type(self.__vault)) 4616 assert self.__vault.account is not None 4617 assert old_vault == self.__vault 4618 assert old_vault_deep == self.__vault 4619 assert old_vault_dict == dataclasses.asdict(self.__vault) 4620 if account_id is not None: 4621 # corrupt the data 4622 log_ref = None 4623 tmp_file_ref = Time.time() 4624 for k in self.__vault.account[account_id].log: 4625 log_ref = k 4626 self.__vault.account[account_id].log[k].file[tmp_file_ref] = 'HACKED' 4627 break 4628 assert old_vault != self.__vault 4629 assert old_vault_deep != self.__vault 4630 assert old_vault_dict != dataclasses.asdict(self.__vault) 4631 # fix the data 4632 del self.__vault.account[account_id].log[log_ref].file[tmp_file_ref] 4633 assert old_vault == self.__vault 4634 assert old_vault_deep == self.__vault 4635 assert old_vault_dict == dataclasses.asdict(self.__vault) 4636 if hashed: 4637 continue 4638 failed = False 4639 try: 4640 hash_required = True 4641 if debug: 4642 print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4643 self.load(hash_required=True) 4644 except: 4645 failed = True 4646 assert failed 4647 4648 compressed_dir = "test_compressed" 4649 extracted_dir = "test_extracted" 4650 4651 old_vault = dataclasses.replace(self.__vault) 4652 old_vault_deep = copy.deepcopy(self.__vault) 4653 old_vault_dict = dataclasses.asdict(self.__vault) 4654 4655 # Test backup 4656 backup = self.backup(os.path.dirname(self.path()), compressed_dir, debug=debug) 4657 assert backup.path is not None, "Backup should create a file." 4658 assert backup.hash is not None, "Backup should return a hash." 4659 assert os.path.exists(backup.path), f"Backup file not found at {backup.path}" 4660 assert backup.path.startswith(os.path.join(compressed_dir, f"zakatdb_v{self.Version()}")), "Backup filename should start with zakatdb_v<version>" 4661 assert backup.hash in backup.path, "Backup filename should contain the hash." 4662 4663 # Test restore 4664 restore_successful = self.restore(backup.path, extracted_dir, debug=debug) 4665 assert restore_successful, "Restore should be successful when hash matches." 4666 4667 assert old_vault == self.__vault 4668 assert old_vault_deep == self.__vault 4669 assert old_vault_dict == dataclasses.asdict(self.__vault) 4670 4671 # Test restore with incorrect filename format 4672 invalid_backup_path = os.path.join(compressed_dir, "invalid_name.tar.lzma") 4673 with open(invalid_backup_path, "w") as f: 4674 f.write("") # Create an empty file 4675 restore_failed_format = self.restore(invalid_backup_path, "temp_extract", debug=debug) 4676 assert not restore_failed_format, "Restore should fail with incorrect filename format." 4677 if os.path.exists("temp_extract"): 4678 shutil.rmtree("temp_extract") 4679 4680 assert old_vault == self.__vault 4681 assert old_vault_deep == self.__vault 4682 assert old_vault_dict == dataclasses.asdict(self.__vault) 4683 4684 # Clean up test files and directories 4685 if not debug: 4686 if os.path.exists(compressed_dir): 4687 shutil.rmtree(compressed_dir) 4688 if os.path.exists(extracted_dir): 4689 shutil.rmtree(extracted_dir) 4690 4691 def test(self, debug: bool = False) -> bool: 4692 if debug: 4693 print('test', f'debug={debug}') 4694 try: 4695 4696 self._test_core(True, debug) 4697 self._test_core(False, debug) 4698 4699 # test_names 4700 self.reset() 4701 x = "test_names" 4702 failed = False 4703 try: 4704 assert self.name(x) == '' 4705 except: 4706 failed = True 4707 assert failed 4708 assert self.names() == {} 4709 failed = False 4710 try: 4711 assert self.name(x, 'qwe') == '' 4712 except: 4713 failed = True 4714 assert failed 4715 account_id0 = self.create_account(x) 4716 assert isinstance(account_id0, AccountID) 4717 assert int(account_id0) > 0 4718 assert self.name(account_id0) == x 4719 assert self.name(account_id0, 'qwe') == 'qwe' 4720 if debug: 4721 print(self.names(keyword='qwe')) 4722 assert self.names(keyword='asd') == {} 4723 assert self.names(keyword='qwe') == {'qwe': account_id0} 4724 4725 # test_create_account 4726 account_name = "test_account" 4727 assert self.names(keyword=account_name) == {} 4728 account_id = self.create_account(account_name) 4729 assert isinstance(account_id, AccountID) 4730 assert int(account_id) > 0 4731 assert account_id in self.__vault.account 4732 assert self.name(account_id) == account_name 4733 assert self.names(keyword=account_name) == {account_name: account_id} 4734 4735 failed = False 4736 try: 4737 self.create_account(account_name) 4738 except: 4739 failed = True 4740 assert failed 4741 4742 # bad are names is forbidden 4743 4744 for bad_name in [ 4745 None, 4746 '', 4747 Time.time(), 4748 -Time.time(), 4749 f'{Time.time()}', 4750 f'{-Time.time()}', 4751 0.0, 4752 '0.0', 4753 ' ', 4754 ]: 4755 failed = False 4756 try: 4757 self.create_account(bad_name) 4758 except: 4759 failed = True 4760 assert failed 4761 4762 # rename account 4763 assert self.name(account_id) == account_name 4764 assert self.name(account_id, 'asd') == 'asd' 4765 assert self.name(account_id) == 'asd' 4766 # use old and not used name 4767 account_id2 = self.create_account(account_name) 4768 assert int(account_id2) > 0 4769 assert account_id != account_id2 4770 assert self.name(account_id2) == account_name 4771 assert self.names(keyword=account_name) == {account_name: account_id2} 4772 4773 assert self.__history() 4774 count = len(self.__vault.history) 4775 if debug: 4776 print('history-count', count) 4777 assert count == 8 4778 4779 assert self.recall(dry=False, debug=debug) 4780 assert self.name(account_id2) == '' 4781 assert self.account_exists(account_id2) 4782 assert self.recall(dry=False, debug=debug) 4783 assert not self.account_exists(account_id2) 4784 assert self.recall(dry=False, debug=debug) 4785 assert self.name(account_id) == account_name 4786 assert self.recall(dry=False, debug=debug) 4787 assert self.account_exists(account_id) 4788 assert self.recall(dry=False, debug=debug) 4789 assert not self.account_exists(account_id) 4790 assert self.names(keyword='qwe') == {'qwe': account_id0} 4791 assert self.recall(dry=False, debug=debug) 4792 assert self.names(keyword='qwe') == {} 4793 assert self.name(account_id0) == x 4794 assert self.recall(dry=False, debug=debug) 4795 assert self.name(account_id0) == '' 4796 assert self.account_exists(account_id0) 4797 assert self.recall(dry=False, debug=debug) 4798 assert not self.account_exists(account_id0) 4799 assert not self.recall(dry=False, debug=debug) 4800 4801 # Not allowed for duplicate transactions in the same account and time 4802 4803 created = Time.time() 4804 same_account_id = self.create_account('same') 4805 self.track(100, 'test-1', same_account_id, True, created) 4806 failed = False 4807 try: 4808 self.track(50, 'test-1', same_account_id, True, created) 4809 except: 4810 failed = True 4811 assert failed is True 4812 4813 self.reset() 4814 4815 # Same account transfer 4816 for x in [1, 'a', True, 1.8, None]: 4817 failed = False 4818 try: 4819 self.transfer(1, x, x, 'same-account', debug=debug) 4820 except: 4821 failed = True 4822 assert failed is True 4823 4824 # Always preserve box age during transfer 4825 4826 series: list[tuple[int, int]] = [ 4827 (30, 4), 4828 (60, 3), 4829 (90, 2), 4830 ] 4831 case = { 4832 3000: { 4833 'series': series, 4834 'rest': 15000, 4835 }, 4836 6000: { 4837 'series': series, 4838 'rest': 12000, 4839 }, 4840 9000: { 4841 'series': series, 4842 'rest': 9000, 4843 }, 4844 18000: { 4845 'series': series, 4846 'rest': 0, 4847 }, 4848 27000: { 4849 'series': series, 4850 'rest': -9000, 4851 }, 4852 36000: { 4853 'series': series, 4854 'rest': -18000, 4855 }, 4856 } 4857 4858 selected_time = Time.time() - ZakatTracker.TimeCycle() 4859 ages_account_id = self.create_account('ages') 4860 future_account_id = self.create_account('future') 4861 4862 for total in case: 4863 if debug: 4864 print('--------------------------------------------------------') 4865 print(f'case[{total}]', case[total]) 4866 for x in case[total]['series']: 4867 self.track( 4868 unscaled_value=x[0], 4869 desc=f'test-{x} ages', 4870 account=ages_account_id, 4871 created_time_ns=selected_time * x[1], 4872 ) 4873 4874 unscaled_total = self.unscale(total) 4875 if debug: 4876 print('unscaled_total', unscaled_total) 4877 refs = self.transfer( 4878 unscaled_amount=unscaled_total, 4879 from_account=ages_account_id, 4880 to_account=future_account_id, 4881 desc='Zakat Movement', 4882 debug=debug, 4883 ) 4884 4885 if debug: 4886 print('refs', refs) 4887 4888 ages_cache_balance = self.balance(ages_account_id) 4889 ages_fresh_balance = self.balance(ages_account_id, False) 4890 rest = case[total]['rest'] 4891 if debug: 4892 print('source', ages_cache_balance, ages_fresh_balance, rest) 4893 assert ages_cache_balance == rest 4894 assert ages_fresh_balance == rest 4895 4896 future_cache_balance = self.balance(future_account_id) 4897 future_fresh_balance = self.balance(future_account_id, False) 4898 if debug: 4899 print('target', future_cache_balance, future_fresh_balance, total) 4900 print('refs', refs) 4901 assert future_cache_balance == total 4902 assert future_fresh_balance == total 4903 4904 # TODO: check boxes times for `ages` should equal box times in `future` 4905 for ref in self.__vault.account[ages_account_id].box: 4906 ages_capital = self.__vault.account[ages_account_id].box[ref].capital 4907 ages_rest = self.__vault.account[ages_account_id].box[ref].rest 4908 future_capital = 0 4909 if ref in self.__vault.account[future_account_id].box: 4910 future_capital = self.__vault.account[future_account_id].box[ref].capital 4911 future_rest = 0 4912 if ref in self.__vault.account[future_account_id].box: 4913 future_rest = self.__vault.account[future_account_id].box[ref].rest 4914 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 4915 if debug: 4916 print('================================================================') 4917 print('ages', ages_capital, ages_rest) 4918 print('future', future_capital, future_rest) 4919 if ages_rest == 0: 4920 assert ages_capital == future_capital 4921 elif ages_rest < 0: 4922 assert -ages_capital == future_capital 4923 elif ages_rest > 0: 4924 assert ages_capital == ages_rest + future_capital 4925 self.reset() 4926 assert len(self.__vault.history) == 0 4927 4928 assert self.__history() 4929 assert self.__history(False) is False 4930 assert self.__history() is False 4931 assert self.__history(True) 4932 assert self.__history() 4933 if debug: 4934 print('####################################################################') 4935 4936 wallet_account_id = self.create_account('wallet') 4937 safe_account_id = self.create_account('safe') 4938 bank_account_id = self.create_account('bank') 4939 transaction = [ 4940 ( 4941 20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1, 4942 2000, 2000, 2000, 1, 1, 4943 ), 4944 ( 4945 750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2, 4946 75000, 75000, 75000, 1, 1, 4947 ), 4948 ( 4949 600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2, 4950 60000, 60000, 60000, 1, 1, 4951 ), 4952 ] 4953 for z in transaction: 4954 lock = self.lock() 4955 x = z[1] 4956 y = z[2] 4957 self.transfer( 4958 unscaled_amount=z[0], 4959 from_account=x, 4960 to_account=y, 4961 desc='test-transfer', 4962 debug=debug, 4963 ) 4964 zz = self.balance(x) 4965 if debug: 4966 print(zz, z) 4967 assert zz == z[3] 4968 xx = self.accounts()[x] 4969 assert xx.balance == z[3] 4970 assert self.balance(x, False) == z[4] 4971 assert xx.balance == z[4] 4972 4973 s = 0 4974 log = self.__vault.account[x].log 4975 for i in log: 4976 s += log[i].value 4977 if debug: 4978 print('s', s, 'z[5]', z[5]) 4979 assert s == z[5] 4980 4981 assert self.box_size(x) == z[6] 4982 assert self.log_size(x) == z[7] 4983 4984 yy = self.accounts()[y] 4985 assert self.balance(y) == z[8] 4986 assert yy.balance == z[8] 4987 assert self.balance(y, False) == z[9] 4988 assert yy.balance == z[9] 4989 4990 s = 0 4991 log = self.__vault.account[y].log 4992 for i in log: 4993 s += log[i].value 4994 assert s == z[10] 4995 4996 assert self.box_size(y) == z[11] 4997 assert self.log_size(y) == z[12] 4998 assert lock is not None 4999 assert self.free(lock) 5000 5001 assert self.nolock() 5002 history_count = len(self.__vault.history) 5003 transaction_count = len(transaction) 5004 if debug: 5005 print('history-count', history_count, transaction_count) 5006 assert history_count == transaction_count * 3 5007 assert not self.free(Time.time()) 5008 assert self.free(self.lock()) 5009 assert self.nolock() 5010 assert len(self.__vault.history) == transaction_count * 3 5011 5012 # recall 5013 5014 assert self.nolock() 5015 for i in range(transaction_count * 3, 0, -1): 5016 assert len(self.__vault.history) == i 5017 assert self.recall(dry=False, debug=debug) is True 5018 assert len(self.__vault.history) == 0 5019 assert self.recall(dry=False, debug=debug) is False 5020 assert len(self.__vault.history) == 0 5021 5022 # exchange 5023 5024 cash_account_id = self.create_account('cash') 5025 self.exchange(cash_account_id, 25, 3.75, '2024-06-25') 5026 self.exchange(cash_account_id, 22, 3.73, '2024-06-22') 5027 self.exchange(cash_account_id, 15, 3.69, '2024-06-15') 5028 self.exchange(cash_account_id, 10, 3.66) 5029 5030 assert self.nolock() 5031 5032 bank_account_id = self.create_account('bank') 5033 for i in range(1, 30): 5034 exchange = self.exchange(cash_account_id, i) 5035 rate, description, created = exchange.rate, exchange.description, exchange.time 5036 if debug: 5037 print(i, rate, description, created) 5038 assert created 5039 if i < 10: 5040 assert rate == 1 5041 assert description is None 5042 elif i == 10: 5043 assert rate == 3.66 5044 assert description is None 5045 elif i < 15: 5046 assert rate == 3.66 5047 assert description is None 5048 elif i == 15: 5049 assert rate == 3.69 5050 assert description is not None 5051 elif i < 22: 5052 assert rate == 3.69 5053 assert description is not None 5054 elif i == 22: 5055 assert rate == 3.73 5056 assert description is not None 5057 elif i >= 25: 5058 assert rate == 3.75 5059 assert description is not None 5060 exchange = self.exchange(bank_account_id, i) 5061 rate, description, created = exchange.rate, exchange.description, exchange.time 5062 if debug: 5063 print(i, rate, description, created) 5064 assert created 5065 assert rate == 1 5066 assert description is None 5067 5068 assert len(self.__vault.exchange) == 1 5069 assert len(self.exchanges()) == 1 5070 self.__vault.exchange.clear() 5071 assert len(self.__vault.exchange) == 0 5072 assert len(self.exchanges()) == 0 5073 self.reset() 5074 5075 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 5076 cash_account_id = self.create_account('cash') 5077 self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 5078 self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 5079 self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 5080 self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66) 5081 5082 assert self.nolock() 5083 5084 test_account_id = self.create_account('test') 5085 for i in [x * 0.12 for x in range(-15, 21)]: 5086 if i <= 0: 5087 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange() 5088 else: 5089 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange() 5090 5091 assert self.nolock() 5092 5093 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 5094 bank_account_id = self.create_account('bank') 5095 for i in range(1, 31): 5096 timestamp_ns = ZakatTracker.day_to_time(i) 5097 exchange = self.exchange(cash_account_id, timestamp_ns) 5098 rate, description, created = exchange.rate, exchange.description, exchange.time 5099 if debug: 5100 print(i, rate, description, created) 5101 assert created 5102 if i < 10: 5103 assert rate == 1 5104 assert description is None 5105 elif i == 10: 5106 assert rate == 3.66 5107 assert description is None 5108 elif i < 15: 5109 assert rate == 3.66 5110 assert description is None 5111 elif i == 15: 5112 assert rate == 3.69 5113 assert description is not None 5114 elif i < 22: 5115 assert rate == 3.69 5116 assert description is not None 5117 elif i == 22: 5118 assert rate == 3.73 5119 assert description is not None 5120 elif i >= 25: 5121 assert rate == 3.75 5122 assert description is not None 5123 exchange = self.exchange(bank_account_id, i) 5124 rate, description, created = exchange.rate, exchange.description, exchange.time 5125 if debug: 5126 print(i, rate, description, created) 5127 assert created 5128 assert rate == 1 5129 assert description is None 5130 5131 assert self.nolock() 5132 if debug: 5133 print(self.__vault.history, len(self.__vault.history)) 5134 for _ in range(len(self.__vault.history)): 5135 assert self.recall(dry=False, debug=debug) 5136 assert not self.recall(dry=False, debug=debug) 5137 5138 self.reset() 5139 5140 # test transfer between accounts with different exchange rate 5141 5142 a_SAR = self.create_account('Bank (SAR)') 5143 b_USD = self.create_account('Bank (USD)') 5144 c_SAR = self.create_account('Safe (SAR)') 5145 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 5146 for case in [ 5147 (0, a_SAR, 'SAR Gift', 1000, 100000), 5148 (1, a_SAR, 1), 5149 (0, b_USD, 'USD Gift', 500, 50000), 5150 (1, b_USD, 1), 5151 (2, b_USD, 3.75), 5152 (1, b_USD, 3.75), 5153 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 5154 (0, c_SAR, 'Salary', 750, 75000), 5155 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 5156 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 5157 ]: 5158 if debug: 5159 print('case', case) 5160 match (case[0]): 5161 case 0: # track 5162 _, account, desc, x, balance = case 5163 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 5164 5165 cached_value = self.balance(account, cached=True) 5166 fresh_value = self.balance(account, cached=False) 5167 if debug: 5168 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 5169 assert cached_value == balance 5170 assert fresh_value == balance 5171 case 1: # check-exchange 5172 _, account, expected_rate = case 5173 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5174 if debug: 5175 print('t-exchange', t_exchange) 5176 assert t_exchange.rate == expected_rate 5177 case 2: # do-exchange 5178 _, account, rate = case 5179 self.exchange(account, rate=rate, debug=debug) 5180 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5181 if debug: 5182 print('b-exchange', b_exchange) 5183 assert b_exchange.rate == rate 5184 case 3: # transfer 5185 _, x, a, b, desc, a_balance, b_balance = case 5186 self.transfer(x, a, b, desc, debug=debug) 5187 5188 cached_value = self.balance(a, cached=True) 5189 fresh_value = self.balance(a, cached=False) 5190 if debug: 5191 print( 5192 'account', a, 5193 'cached_value', cached_value, 5194 'fresh_value', fresh_value, 5195 'a_balance', a_balance, 5196 ) 5197 assert cached_value == a_balance 5198 assert fresh_value == a_balance 5199 5200 cached_value = self.balance(b, cached=True) 5201 fresh_value = self.balance(b, cached=False) 5202 if debug: 5203 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 5204 assert cached_value == b_balance 5205 assert fresh_value == b_balance 5206 5207 # Transfer all in many chunks randomly from B to A 5208 a_SAR_balance = 137125 5209 b_USD_balance = 50100 5210 b_USD_exchange = self.exchange(b_USD) 5211 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 5212 if debug: 5213 print('amounts', amounts) 5214 i = 0 5215 for x in amounts: 5216 if debug: 5217 print(f'{i} - transfer-with-exchange({x})') 5218 self.transfer( 5219 unscaled_amount=self.unscale(x), 5220 from_account=b_USD, 5221 to_account=a_SAR, 5222 desc=f'{x} USD -> SAR', 5223 debug=debug, 5224 ) 5225 5226 b_USD_balance -= x 5227 cached_value = self.balance(b_USD, cached=True) 5228 fresh_value = self.balance(b_USD, cached=False) 5229 if debug: 5230 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5231 b_USD_balance) 5232 assert cached_value == b_USD_balance 5233 assert fresh_value == b_USD_balance 5234 5235 a_SAR_balance += int(x * b_USD_exchange.rate) 5236 cached_value = self.balance(a_SAR, cached=True) 5237 fresh_value = self.balance(a_SAR, cached=False) 5238 if debug: 5239 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5240 a_SAR_balance, 'rate', b_USD_exchange.rate) 5241 assert cached_value == a_SAR_balance 5242 assert fresh_value == a_SAR_balance 5243 i += 1 5244 5245 # Transfer all in many chunks randomly from C to A 5246 c_SAR_balance = 37500 5247 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 5248 if debug: 5249 print('amounts', amounts) 5250 i = 0 5251 for x in amounts: 5252 if debug: 5253 print(f'{i} - transfer-with-exchange({x})') 5254 self.transfer( 5255 unscaled_amount=self.unscale(x), 5256 from_account=c_SAR, 5257 to_account=a_SAR, 5258 desc=f'{x} SAR -> a_SAR', 5259 debug=debug, 5260 ) 5261 5262 c_SAR_balance -= x 5263 cached_value = self.balance(c_SAR, cached=True) 5264 fresh_value = self.balance(c_SAR, cached=False) 5265 if debug: 5266 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5267 c_SAR_balance) 5268 assert cached_value == c_SAR_balance 5269 assert fresh_value == c_SAR_balance 5270 5271 a_SAR_balance += x 5272 cached_value = self.balance(a_SAR, cached=True) 5273 fresh_value = self.balance(a_SAR, cached=False) 5274 if debug: 5275 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5276 a_SAR_balance) 5277 assert cached_value == a_SAR_balance 5278 assert fresh_value == a_SAR_balance 5279 i += 1 5280 5281 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 5282 5283 # check & zakat with exchange rates for many cycles 5284 5285 lock = None 5286 safe_account_id = self.create_account('safe') 5287 cave_account_id = self.create_account('cave') 5288 for rate, values in { 5289 1: { 5290 'in': [1000, 2000, 10000], 5291 'exchanged': [100000, 200000, 1000000], 5292 'out': [2500, 5000, 73140], 5293 }, 5294 3.75: { 5295 'in': [200, 1000, 5000], 5296 'exchanged': [75000, 375000, 1875000], 5297 'out': [1875, 9375, 137138], 5298 }, 5299 }.items(): 5300 a, b, c = values['in'] 5301 m, n, o = values['exchanged'] 5302 x, y, z = values['out'] 5303 if debug: 5304 print('rate', rate, 'values', values) 5305 for case in [ 5306 (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5307 {safe_account_id: {0: {'below_nisab': x}}}, 5308 ], False, m), 5309 (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5310 {safe_account_id: {0: {'count': 1, 'total': y}}}, 5311 ], True, n), 5312 (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [ 5313 {cave_account_id: {0: {'count': 3, 'total': z}}}, 5314 ], True, o), 5315 ]: 5316 if debug: 5317 print(f'############# check(rate: {rate}) #############') 5318 print('case', case) 5319 self.reset() 5320 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 5321 self.track( 5322 unscaled_value=case[0], 5323 desc='test-check', 5324 account=case[1], 5325 created_time_ns=case[2], 5326 ) 5327 assert self.snapshot() 5328 5329 # assert self.nolock() 5330 # history_size = len(self.__vault.history) 5331 # print('history_size', history_size) 5332 # assert history_size == 2 5333 lock = self.lock() 5334 assert lock 5335 assert not self.nolock() 5336 assert self.__vault.cache.zakat is None 5337 report = self.check(2.17, None, debug) 5338 if debug: 5339 print('[report]', report) 5340 assert case[4] == report.valid 5341 assert case[5] == report.summary.total_wealth 5342 assert case[5] == report.summary.total_zakatable_amount 5343 if report.valid: 5344 assert self.__vault.cache.zakat is not None 5345 assert report.plan 5346 assert self.zakat(report, debug=debug) 5347 assert self.__vault.cache.zakat is None 5348 if debug: 5349 pp().pprint(self.__vault) 5350 self._test_storage(debug=debug) 5351 5352 for x in report.plan: 5353 assert case[1] == x 5354 if report.plan[x][0].below_nisab: 5355 if debug: 5356 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 5357 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 5358 else: 5359 if debug: 5360 print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total']) 5361 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 5362 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 5363 assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total'] 5364 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 5365 assert report.plan[x][0].count == case[3][0][x][0]['count'] 5366 else: 5367 assert self.__vault.cache.zakat is None 5368 result = self.zakat(report, debug=debug) 5369 if debug: 5370 print('zakat-result', result, case[4]) 5371 assert result == case[4] 5372 report = self.check(2.17, None, debug) 5373 assert report.valid is False 5374 self._test_storage(account_id=cave_account_id, debug=debug) 5375 5376 # recall after zakat 5377 5378 history_size = len(self.__vault.history) 5379 if debug: 5380 print('history_size', history_size) 5381 assert history_size == 3 5382 assert not self.nolock() 5383 assert self.recall(dry=False, debug=debug) is False 5384 self.free(lock) 5385 assert self.nolock() 5386 5387 for i in range(3, 0, -1): 5388 history_size = len(self.__vault.history) 5389 if debug: 5390 print('history_size', history_size) 5391 assert history_size == i 5392 assert self.recall(dry=False, debug=debug) is True 5393 5394 assert self.nolock() 5395 assert self.recall(dry=False, debug=debug) is False 5396 5397 history_size = len(self.__vault.history) 5398 if debug: 5399 print('history_size', history_size) 5400 assert history_size == 0 5401 5402 account_size = len(self.__vault.account) 5403 if debug: 5404 print('account_size', account_size) 5405 assert account_size == 0 5406 5407 report_size = len(self.__vault.report) 5408 if debug: 5409 print('report_size', report_size) 5410 assert report_size == 0 5411 5412 assert self.nolock() 5413 5414 # csv 5415 5416 csv_count = 1000 5417 5418 for with_rate, path in { 5419 False: 'test-import_csv-no-exchange', 5420 True: 'test-import_csv-with-exchange', 5421 }.items(): 5422 5423 if debug: 5424 print('test_import_csv', with_rate, path) 5425 5426 csv_path = path + '.csv' 5427 if os.path.exists(csv_path): 5428 os.remove(csv_path) 5429 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 5430 if debug: 5431 print('generate_random_csv_file', c) 5432 assert c == csv_count 5433 assert os.path.getsize(csv_path) > 0 5434 cache_path = self.import_csv_cache_path() 5435 if os.path.exists(cache_path): 5436 os.remove(cache_path) 5437 self.reset() 5438 lock = self.lock() 5439 import_report = self.import_csv(csv_path, debug=debug) 5440 bad_count = len(import_report.bad) 5441 if debug: 5442 print(f'csv-imported: {import_report.statistics} = count({csv_count})') 5443 print('bad', import_report.bad) 5444 assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count 5445 assert import_report.statistics.created == csv_count 5446 assert bad_count == 0 5447 assert bad_count == import_report.statistics.bad 5448 tmp_size = os.path.getsize(cache_path) 5449 assert tmp_size > 0 5450 5451 import_report_2 = self.import_csv(csv_path, debug=debug) 5452 bad_2_count = len(import_report_2.bad) 5453 if debug: 5454 print(f'csv-imported: {import_report_2}') 5455 print('bad', import_report_2.bad) 5456 assert tmp_size == os.path.getsize(cache_path) 5457 assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count 5458 assert import_report.statistics.created == import_report_2.statistics.found 5459 assert bad_count == bad_2_count 5460 assert import_report_2.statistics.found == csv_count 5461 assert bad_2_count == 0 5462 assert bad_2_count == import_report_2.statistics.bad 5463 assert import_report_2.statistics.created == 0 5464 5465 # payment parts 5466 5467 positive_parts = self.build_payment_parts(100, positive_only=True) 5468 assert self.check_payment_parts(positive_parts) != 0 5469 assert self.check_payment_parts(positive_parts) != 0 5470 all_parts = self.build_payment_parts(300, positive_only=False) 5471 assert self.check_payment_parts(all_parts) != 0 5472 assert self.check_payment_parts(all_parts) != 0 5473 if debug: 5474 pp().pprint(positive_parts) 5475 pp().pprint(all_parts) 5476 # dynamic discount 5477 suite = [] 5478 count = 3 5479 for exceed in [False, True]: 5480 case = [] 5481 for part in [positive_parts, all_parts]: 5482 #part = parts.copy() 5483 demand = part.demand 5484 if debug: 5485 print(demand, part.total) 5486 i = 0 5487 z = demand / count 5488 cp = PaymentParts( 5489 demand=demand, 5490 exceed=exceed, 5491 total=part.total, 5492 ) 5493 j = '' 5494 for x, y in part.account.items(): 5495 x_exchange = self.exchange(x) 5496 zz = self.exchange_calc(z, 1, x_exchange.rate) 5497 if exceed and zz <= demand: 5498 i += 1 5499 y.part = zz 5500 if debug: 5501 print(exceed, y) 5502 cp.account[x] = y 5503 case.append(y) 5504 elif not exceed and y.balance >= zz: 5505 i += 1 5506 y.part = zz 5507 if debug: 5508 print(exceed, y) 5509 cp.account[x] = y 5510 case.append(y) 5511 j = x 5512 if i >= count: 5513 break 5514 if debug: 5515 print('[debug]', j) 5516 print('[debug]', cp.account[j]) 5517 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 5518 suite.append(cp) 5519 if debug: 5520 print('suite', len(suite)) 5521 for case in suite: 5522 if debug: 5523 print('case', case) 5524 result = self.check_payment_parts(case) 5525 if debug: 5526 print('check_payment_parts', result, f'exceed: {exceed}') 5527 assert result == 0 5528 5529 assert self.__vault.cache.zakat is None 5530 report = self.check(2.17, None, debug) 5531 if debug: 5532 print('valid', report.valid) 5533 zakat_result = self.zakat(report, parts=case, debug=debug) 5534 if debug: 5535 print('zakat-result', zakat_result) 5536 assert report.valid == zakat_result 5537 # test verified zakat report is required 5538 if zakat_result: 5539 assert self.__vault.cache.zakat is None 5540 failed = False 5541 try: 5542 self.zakat(report, parts=case, debug=debug) 5543 except: 5544 failed = True 5545 assert failed 5546 5547 assert self.free(lock) 5548 5549 assert self.save(path + f'.{self.ext()}') 5550 assert self.save(f'1000-transactions-test.{self.ext()}') 5551 return True 5552 except Exception as e: 5553 if self.__debug_output: 5554 pp().pprint(self.__vault) 5555 print('============================================================================') 5556 pp().pprint(self.__debug_output) 5557 assert self.save(f'test-snapshot.{self.ext()}') 5558 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.4' 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, 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 - debug (bool, optional): A flag indicating whether to print debug information. 3724 3725 Returns: 3726 - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache, 3727 and a dictionary of bad transactions. 3728 3729 Notes: 3730 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3731 are appropriate for the currency pairs involved in the conversions. 3732 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3733 to 1.0 or the previous rate for that account. 3734 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3735 transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and 3736 `zakat` operations. 3737 3738 Example: 3739 The CSV file should have the following format, rate and reference are optionals per transaction: 3740 account, desc, value, date, rate, reference 3741 For example: 3742 safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554 3743 """ 3744 if debug: 3745 print('import_csv', f'debug={debug}') 3746 cache: list[int] = [] 3747 try: 3748 if not self.memory_mode(): 3749 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3750 cache = json.load(stream) 3751 except Exception as e: 3752 if debug: 3753 print(e) 3754 date_formats = [ 3755 '%Y-%m-%d %H:%M:%S.%f', 3756 '%Y-%m-%dT%H:%M:%S.%f', 3757 '%Y-%m-%dT%H%M%S.%f', 3758 '%Y-%m-%d', 3759 ] 3760 statistics = ImportStatistics(0, 0, 0) 3761 data: dict[int, list[CSVRecord]] = {} 3762 with open(path, newline='', encoding='utf-8') as f: 3763 i = 0 3764 for row in csv.reader(f, delimiter=','): 3765 if debug: 3766 print(f"csv_row({i})", row, type(row)) 3767 if row == self.get_transaction_csv_headers(): 3768 continue 3769 i += 1 3770 hashed = hash(tuple(row)) 3771 if hashed in cache: 3772 statistics.found += 1 3773 continue 3774 account = row[0] 3775 desc = row[1] 3776 value = float(row[2]) 3777 rate = 1.0 3778 reference = '' 3779 if row[4:5]: # Empty list if index is out of range 3780 rate = float(row[4]) 3781 if row[5:6]: 3782 reference = row[5] 3783 date: int = 0 3784 for time_format in date_formats: 3785 try: 3786 date_str = row[3] 3787 if "." not in date_str: 3788 date_str += ".000000" 3789 date = Time.time(datetime.datetime.strptime(date_str, time_format)) 3790 break 3791 except Exception as e: 3792 if debug: 3793 print(e) 3794 record = CSVRecord( 3795 index=i, 3796 account=account, 3797 desc=desc, 3798 value=value, 3799 date=date, 3800 rate=rate, 3801 reference=reference, 3802 hashed=hashed, 3803 error='', 3804 ) 3805 if date <= 0: 3806 record.error = 'invalid date' 3807 statistics.bad += 1 3808 if value == 0: 3809 record.error = 'invalid value' 3810 statistics.bad += 1 3811 continue 3812 if date not in data: 3813 data[date] = [] 3814 data[date].append(record) 3815 3816 if debug: 3817 print('import_csv', len(data)) 3818 3819 if statistics.bad > 0: 3820 return ImportReport( 3821 statistics=statistics, 3822 bad=[ 3823 item 3824 for sublist in data.values() 3825 for item in sublist 3826 if item.error 3827 ], 3828 ) 3829 3830 no_lock = self.nolock() 3831 lock = self.__lock() 3832 names = self.names() 3833 3834 # sync accounts 3835 if debug: 3836 print('before-names', names, len(names)) 3837 for date, rows in sorted(data.items()): 3838 new_rows: list[CSVRecord] = [] 3839 for row in rows: 3840 if row.account not in names: 3841 account_id = self.create_account(row.account) 3842 names[row.account] = account_id 3843 account_id = names[row.account] 3844 assert account_id 3845 row.account = account_id 3846 new_rows.append(row) 3847 assert new_rows 3848 assert date in data 3849 data[date] = new_rows 3850 if debug: 3851 print('after-names', names, len(names)) 3852 assert names == self.names() 3853 3854 # do ops 3855 for date, rows in sorted(data.items()): 3856 try: 3857 def process(x: CSVRecord): 3858 x.value = self.unscale( 3859 x.value, 3860 decimal_places=scale_decimal_places, 3861 ) if scale_decimal_places > 0 else x.value 3862 if x.rate > 0: 3863 self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate) 3864 if x.value > 0: 3865 self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3866 elif x.value < 0: 3867 self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date) 3868 return x.hashed 3869 len_rows = len(rows) 3870 # If records are found at the same time with different accounts in the same amount 3871 # (one positive and the other negative), this indicates it is a transfer. 3872 if len_rows > 2 or len_rows == 1: 3873 for row in rows: 3874 hashed = process(row) 3875 assert hashed not in cache 3876 cache.append(hashed) 3877 statistics.created += 1 3878 continue 3879 x1 = rows[0] 3880 x2 = rows[1] 3881 if x1.account == x2.account: 3882 continue 3883 # raise Exception(f'invalid transfer') 3884 # not transfer - same time - normal ops 3885 if abs(x1.value) != abs(x2.value) and x1.date == x2.date: 3886 rows[1].date += 1 3887 for row in rows: 3888 hashed = process(row) 3889 assert hashed not in cache 3890 cache.append(hashed) 3891 statistics.created += 1 3892 continue 3893 if x1.rate > 0: 3894 self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate) 3895 if x2.rate > 0: 3896 self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate) 3897 x1.value = self.unscale( 3898 x1.value, 3899 decimal_places=scale_decimal_places, 3900 ) if scale_decimal_places > 0 else x1.value 3901 x2.value = self.unscale( 3902 x2.value, 3903 decimal_places=scale_decimal_places, 3904 ) if scale_decimal_places > 0 else x2.value 3905 # just transfer 3906 values = { 3907 x1.value: x1.account, 3908 x2.value: x2.account, 3909 } 3910 if debug: 3911 print('values', values) 3912 if len(values) <= 1: 3913 continue 3914 self.transfer( 3915 unscaled_amount=abs(x1.value), 3916 from_account=values[min(values.keys())], 3917 to_account=values[max(values.keys())], 3918 desc=x1.desc, 3919 created_time_ns=x1.date, 3920 ) 3921 except Exception as e: 3922 for row in rows: 3923 _tuple = tuple() 3924 for field in row: 3925 _tuple += (field,) 3926 _tuple += (e,) 3927 bad[i] = _tuple 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.
- 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', count: int = 1_000, with_rate: bool = False, 4074 debug: bool = False) -> int: 4075 """ 4076 Generate a random CSV file with specified parameters. 4077 The function generates a CSV file at the specified path with the given count of rows. 4078 Each row contains a randomly generated account, description, value, and date. 4079 The value is randomly generated between 1000 and 100000, 4080 and the date is randomly generated between 1950-01-01 and 2023-12-31. 4081 If the row number is not divisible by 13, the value is multiplied by -1. 4082 4083 Parameters: 4084 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 4085 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 4086 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 4087 - debug (bool, optional): A flag indicating whether to print debug information. 4088 4089 Returns: 4090 - int: number of generated records. 4091 """ 4092 if debug: 4093 print('generate_random_csv_file', f'debug={debug}') 4094 i = 0 4095 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 4096 writer = csv.writer(csvfile) 4097 writer.writerow(ZakatTracker.get_transaction_csv_headers()) 4098 for i in range(count): 4099 account = f'acc-{random.randint(1, count)}' 4100 desc = f'Some text {random.randint(1, count)}' 4101 value = random.randint(1000, 100000) 4102 date = ZakatTracker.generate_random_date( 4103 datetime.datetime(1000, 1, 1), 4104 datetime.datetime(2023, 12, 31), 4105 ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S') 4106 if not i % 13 == 0: 4107 value *= -1 4108 row = [account, desc, value, date] 4109 if with_rate: 4110 rate = random.randint(1, 100) * 0.12 4111 if debug: 4112 print('before-append', row) 4113 row.append(rate) 4114 if debug: 4115 print('after-append', row) 4116 if i % 2 == 1: 4117 row += (Time.time(),) 4118 writer.writerow(row) 4119 i = i + 1 4120 return i
Generate a random CSV file with specified parameters. The function generates a CSV file at the specified path with the given count of rows. Each row contains a randomly generated account, description, value, and date. The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. If the row number is not divisible by 13, the value is multiplied by -1.
Parameters:
- path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
- count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
- with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
- debug (bool, optional): A flag indicating whether to print debug information.
Returns:
- int: number of generated records.
4122 @staticmethod 4123 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 4124 """ 4125 Creates a list of random integers whose sum does not exceed the specified maximum. 4126 4127 Parameters: 4128 - max_sum (int): The maximum allowed sum of the list elements. 4129 - min_value (int, optional): The minimum possible value for an element (inclusive). 4130 - max_value (int, optional): The maximum possible value for an element (inclusive). 4131 4132 Returns: 4133 - A list of random integers. 4134 """ 4135 result = [] 4136 current_sum = 0 4137 4138 while current_sum < max_sum: 4139 # Calculate the remaining space for the next element 4140 remaining_sum = max_sum - current_sum 4141 # Determine the maximum possible value for the next element 4142 next_max_value = min(remaining_sum, max_value) 4143 # Generate a random element within the allowed range 4144 next_element = random.randint(min_value, next_max_value) 4145 result.append(next_element) 4146 current_sum += next_element 4147 4148 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.
4150 def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]: 4151 """ 4152 Compresses a folder into a .tar.lzma archive. 4153 4154 The archive is named following a specific format: 4155 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. This format 4156 is crucial for the `restore` function, so avoid renaming the files. 4157 4158 Parameters: 4159 - folder_path (str): The path to the folder to be compressed. 4160 - output_directory (str, optional): The directory to save the compressed file. 4161 Defaults to "compressed". 4162 - debug (bool, optional): Whether to print debug information. Default is False. 4163 4164 Returns: 4165 - Optional[Backup]: A Backup object containing the path to the created archive 4166 and its SHA1 hash on success, None on failure. 4167 """ 4168 try: 4169 os.makedirs(output_directory, exist_ok=True) 4170 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4171 4172 # Create a temporary tar archive in memory to calculate the hash 4173 tar_buffer = io.BytesIO() 4174 with tarfile.open(fileobj=tar_buffer, mode="w") as tar: 4175 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4176 tar_buffer.seek(0) 4177 folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4178 output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma" 4179 output_path = os.path.join(output_directory, output_filename) 4180 4181 # Compress the folder to the final .tar.lzma file 4182 with lzma.open(output_path, "wb") as lzma_file: 4183 tar_buffer.seek(0) # Reset the buffer 4184 with tarfile.open(fileobj=lzma_file, mode="w") as tar: 4185 tar.add(folder_path, arcname=os.path.basename(folder_path)) 4186 4187 if debug: 4188 print(f"Folder '{folder_path}' has been compressed to '{output_path}'") 4189 return Backup( 4190 path=output_path, 4191 hash=folder_hash, 4192 ) 4193 except Exception as e: 4194 print(f"Error during compression: {e}") 4195 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.
4197 def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool: 4198 """ 4199 Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash. 4200 4201 The SHA1 hash is extracted from the archive's filename, which must follow 4202 the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'. 4203 This format is essential for successful restoration. 4204 4205 Parameters: 4206 - tar_lzma_path (str): The path to the .tar.lzma file. 4207 - output_folder_path (str, optional): The directory to extract the contents to. 4208 Defaults to "uncompressed". 4209 - debug (bool, optional): Whether to print debug information. Default is False. 4210 4211 Returns: 4212 - bool: True if the restoration was successful and the hash matches, False otherwise. 4213 """ 4214 try: 4215 output_folder_path = pathlib.Path(output_folder_path).resolve() 4216 os.makedirs(output_folder_path, exist_ok=True) 4217 filename = os.path.basename(tar_lzma_path) 4218 match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename) 4219 if not match: 4220 if debug: 4221 print(f"Error: Invalid filename format: '{filename}'") 4222 return False 4223 4224 expected_hash_from_filename = match.group(3) 4225 4226 with lzma.open(tar_lzma_path, "rb") as lzma_file: 4227 tar_buffer = io.BytesIO(lzma_file.read()) # Read the entire decompressed tar into memory 4228 with tarfile.open(fileobj=tar_buffer, mode="r") as tar: 4229 tar.extractall(output_folder_path) 4230 tar_buffer.seek(0) # Reset buffer to calculate hash 4231 extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest() 4232 4233 new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path)) 4234 assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found." 4235 if extracted_hash == expected_hash_from_filename: 4236 if debug: 4237 print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.") 4238 now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 4239 old_path = os.path.dirname(self.path()) 4240 tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now) 4241 if debug: 4242 print('[xxx] - old_path:', old_path) 4243 print('[xxx] - tmp_path:', tmp_path) 4244 print('[xxx] - new_path:', new_path) 4245 try: 4246 shutil.move(old_path, tmp_path) 4247 shutil.move(new_path, old_path) 4248 assert self.load() 4249 shutil.rmtree(tmp_path) 4250 return True 4251 except Exception as e: 4252 print(f"Error applying the restored files: {e}") 4253 shutil.move(tmp_path, old_path) 4254 return False 4255 else: 4256 if debug: 4257 print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}") 4258 # Optionally remove the extracted folder if the hash doesn't match 4259 # shutil.rmtree(output_folder_path, ignore_errors=True) 4260 return False 4261 4262 except Exception as e: 4263 print(f"Error during uncompression or hash check: {e}") 4264 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.
4691 def test(self, debug: bool = False) -> bool: 4692 if debug: 4693 print('test', f'debug={debug}') 4694 try: 4695 4696 self._test_core(True, debug) 4697 self._test_core(False, debug) 4698 4699 # test_names 4700 self.reset() 4701 x = "test_names" 4702 failed = False 4703 try: 4704 assert self.name(x) == '' 4705 except: 4706 failed = True 4707 assert failed 4708 assert self.names() == {} 4709 failed = False 4710 try: 4711 assert self.name(x, 'qwe') == '' 4712 except: 4713 failed = True 4714 assert failed 4715 account_id0 = self.create_account(x) 4716 assert isinstance(account_id0, AccountID) 4717 assert int(account_id0) > 0 4718 assert self.name(account_id0) == x 4719 assert self.name(account_id0, 'qwe') == 'qwe' 4720 if debug: 4721 print(self.names(keyword='qwe')) 4722 assert self.names(keyword='asd') == {} 4723 assert self.names(keyword='qwe') == {'qwe': account_id0} 4724 4725 # test_create_account 4726 account_name = "test_account" 4727 assert self.names(keyword=account_name) == {} 4728 account_id = self.create_account(account_name) 4729 assert isinstance(account_id, AccountID) 4730 assert int(account_id) > 0 4731 assert account_id in self.__vault.account 4732 assert self.name(account_id) == account_name 4733 assert self.names(keyword=account_name) == {account_name: account_id} 4734 4735 failed = False 4736 try: 4737 self.create_account(account_name) 4738 except: 4739 failed = True 4740 assert failed 4741 4742 # bad are names is forbidden 4743 4744 for bad_name in [ 4745 None, 4746 '', 4747 Time.time(), 4748 -Time.time(), 4749 f'{Time.time()}', 4750 f'{-Time.time()}', 4751 0.0, 4752 '0.0', 4753 ' ', 4754 ]: 4755 failed = False 4756 try: 4757 self.create_account(bad_name) 4758 except: 4759 failed = True 4760 assert failed 4761 4762 # rename account 4763 assert self.name(account_id) == account_name 4764 assert self.name(account_id, 'asd') == 'asd' 4765 assert self.name(account_id) == 'asd' 4766 # use old and not used name 4767 account_id2 = self.create_account(account_name) 4768 assert int(account_id2) > 0 4769 assert account_id != account_id2 4770 assert self.name(account_id2) == account_name 4771 assert self.names(keyword=account_name) == {account_name: account_id2} 4772 4773 assert self.__history() 4774 count = len(self.__vault.history) 4775 if debug: 4776 print('history-count', count) 4777 assert count == 8 4778 4779 assert self.recall(dry=False, debug=debug) 4780 assert self.name(account_id2) == '' 4781 assert self.account_exists(account_id2) 4782 assert self.recall(dry=False, debug=debug) 4783 assert not self.account_exists(account_id2) 4784 assert self.recall(dry=False, debug=debug) 4785 assert self.name(account_id) == account_name 4786 assert self.recall(dry=False, debug=debug) 4787 assert self.account_exists(account_id) 4788 assert self.recall(dry=False, debug=debug) 4789 assert not self.account_exists(account_id) 4790 assert self.names(keyword='qwe') == {'qwe': account_id0} 4791 assert self.recall(dry=False, debug=debug) 4792 assert self.names(keyword='qwe') == {} 4793 assert self.name(account_id0) == x 4794 assert self.recall(dry=False, debug=debug) 4795 assert self.name(account_id0) == '' 4796 assert self.account_exists(account_id0) 4797 assert self.recall(dry=False, debug=debug) 4798 assert not self.account_exists(account_id0) 4799 assert not self.recall(dry=False, debug=debug) 4800 4801 # Not allowed for duplicate transactions in the same account and time 4802 4803 created = Time.time() 4804 same_account_id = self.create_account('same') 4805 self.track(100, 'test-1', same_account_id, True, created) 4806 failed = False 4807 try: 4808 self.track(50, 'test-1', same_account_id, True, created) 4809 except: 4810 failed = True 4811 assert failed is True 4812 4813 self.reset() 4814 4815 # Same account transfer 4816 for x in [1, 'a', True, 1.8, None]: 4817 failed = False 4818 try: 4819 self.transfer(1, x, x, 'same-account', debug=debug) 4820 except: 4821 failed = True 4822 assert failed is True 4823 4824 # Always preserve box age during transfer 4825 4826 series: list[tuple[int, int]] = [ 4827 (30, 4), 4828 (60, 3), 4829 (90, 2), 4830 ] 4831 case = { 4832 3000: { 4833 'series': series, 4834 'rest': 15000, 4835 }, 4836 6000: { 4837 'series': series, 4838 'rest': 12000, 4839 }, 4840 9000: { 4841 'series': series, 4842 'rest': 9000, 4843 }, 4844 18000: { 4845 'series': series, 4846 'rest': 0, 4847 }, 4848 27000: { 4849 'series': series, 4850 'rest': -9000, 4851 }, 4852 36000: { 4853 'series': series, 4854 'rest': -18000, 4855 }, 4856 } 4857 4858 selected_time = Time.time() - ZakatTracker.TimeCycle() 4859 ages_account_id = self.create_account('ages') 4860 future_account_id = self.create_account('future') 4861 4862 for total in case: 4863 if debug: 4864 print('--------------------------------------------------------') 4865 print(f'case[{total}]', case[total]) 4866 for x in case[total]['series']: 4867 self.track( 4868 unscaled_value=x[0], 4869 desc=f'test-{x} ages', 4870 account=ages_account_id, 4871 created_time_ns=selected_time * x[1], 4872 ) 4873 4874 unscaled_total = self.unscale(total) 4875 if debug: 4876 print('unscaled_total', unscaled_total) 4877 refs = self.transfer( 4878 unscaled_amount=unscaled_total, 4879 from_account=ages_account_id, 4880 to_account=future_account_id, 4881 desc='Zakat Movement', 4882 debug=debug, 4883 ) 4884 4885 if debug: 4886 print('refs', refs) 4887 4888 ages_cache_balance = self.balance(ages_account_id) 4889 ages_fresh_balance = self.balance(ages_account_id, False) 4890 rest = case[total]['rest'] 4891 if debug: 4892 print('source', ages_cache_balance, ages_fresh_balance, rest) 4893 assert ages_cache_balance == rest 4894 assert ages_fresh_balance == rest 4895 4896 future_cache_balance = self.balance(future_account_id) 4897 future_fresh_balance = self.balance(future_account_id, False) 4898 if debug: 4899 print('target', future_cache_balance, future_fresh_balance, total) 4900 print('refs', refs) 4901 assert future_cache_balance == total 4902 assert future_fresh_balance == total 4903 4904 # TODO: check boxes times for `ages` should equal box times in `future` 4905 for ref in self.__vault.account[ages_account_id].box: 4906 ages_capital = self.__vault.account[ages_account_id].box[ref].capital 4907 ages_rest = self.__vault.account[ages_account_id].box[ref].rest 4908 future_capital = 0 4909 if ref in self.__vault.account[future_account_id].box: 4910 future_capital = self.__vault.account[future_account_id].box[ref].capital 4911 future_rest = 0 4912 if ref in self.__vault.account[future_account_id].box: 4913 future_rest = self.__vault.account[future_account_id].box[ref].rest 4914 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 4915 if debug: 4916 print('================================================================') 4917 print('ages', ages_capital, ages_rest) 4918 print('future', future_capital, future_rest) 4919 if ages_rest == 0: 4920 assert ages_capital == future_capital 4921 elif ages_rest < 0: 4922 assert -ages_capital == future_capital 4923 elif ages_rest > 0: 4924 assert ages_capital == ages_rest + future_capital 4925 self.reset() 4926 assert len(self.__vault.history) == 0 4927 4928 assert self.__history() 4929 assert self.__history(False) is False 4930 assert self.__history() is False 4931 assert self.__history(True) 4932 assert self.__history() 4933 if debug: 4934 print('####################################################################') 4935 4936 wallet_account_id = self.create_account('wallet') 4937 safe_account_id = self.create_account('safe') 4938 bank_account_id = self.create_account('bank') 4939 transaction = [ 4940 ( 4941 20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1, 4942 2000, 2000, 2000, 1, 1, 4943 ), 4944 ( 4945 750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2, 4946 75000, 75000, 75000, 1, 1, 4947 ), 4948 ( 4949 600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2, 4950 60000, 60000, 60000, 1, 1, 4951 ), 4952 ] 4953 for z in transaction: 4954 lock = self.lock() 4955 x = z[1] 4956 y = z[2] 4957 self.transfer( 4958 unscaled_amount=z[0], 4959 from_account=x, 4960 to_account=y, 4961 desc='test-transfer', 4962 debug=debug, 4963 ) 4964 zz = self.balance(x) 4965 if debug: 4966 print(zz, z) 4967 assert zz == z[3] 4968 xx = self.accounts()[x] 4969 assert xx.balance == z[3] 4970 assert self.balance(x, False) == z[4] 4971 assert xx.balance == z[4] 4972 4973 s = 0 4974 log = self.__vault.account[x].log 4975 for i in log: 4976 s += log[i].value 4977 if debug: 4978 print('s', s, 'z[5]', z[5]) 4979 assert s == z[5] 4980 4981 assert self.box_size(x) == z[6] 4982 assert self.log_size(x) == z[7] 4983 4984 yy = self.accounts()[y] 4985 assert self.balance(y) == z[8] 4986 assert yy.balance == z[8] 4987 assert self.balance(y, False) == z[9] 4988 assert yy.balance == z[9] 4989 4990 s = 0 4991 log = self.__vault.account[y].log 4992 for i in log: 4993 s += log[i].value 4994 assert s == z[10] 4995 4996 assert self.box_size(y) == z[11] 4997 assert self.log_size(y) == z[12] 4998 assert lock is not None 4999 assert self.free(lock) 5000 5001 assert self.nolock() 5002 history_count = len(self.__vault.history) 5003 transaction_count = len(transaction) 5004 if debug: 5005 print('history-count', history_count, transaction_count) 5006 assert history_count == transaction_count * 3 5007 assert not self.free(Time.time()) 5008 assert self.free(self.lock()) 5009 assert self.nolock() 5010 assert len(self.__vault.history) == transaction_count * 3 5011 5012 # recall 5013 5014 assert self.nolock() 5015 for i in range(transaction_count * 3, 0, -1): 5016 assert len(self.__vault.history) == i 5017 assert self.recall(dry=False, debug=debug) is True 5018 assert len(self.__vault.history) == 0 5019 assert self.recall(dry=False, debug=debug) is False 5020 assert len(self.__vault.history) == 0 5021 5022 # exchange 5023 5024 cash_account_id = self.create_account('cash') 5025 self.exchange(cash_account_id, 25, 3.75, '2024-06-25') 5026 self.exchange(cash_account_id, 22, 3.73, '2024-06-22') 5027 self.exchange(cash_account_id, 15, 3.69, '2024-06-15') 5028 self.exchange(cash_account_id, 10, 3.66) 5029 5030 assert self.nolock() 5031 5032 bank_account_id = self.create_account('bank') 5033 for i in range(1, 30): 5034 exchange = self.exchange(cash_account_id, i) 5035 rate, description, created = exchange.rate, exchange.description, exchange.time 5036 if debug: 5037 print(i, rate, description, created) 5038 assert created 5039 if i < 10: 5040 assert rate == 1 5041 assert description is None 5042 elif i == 10: 5043 assert rate == 3.66 5044 assert description is None 5045 elif i < 15: 5046 assert rate == 3.66 5047 assert description is None 5048 elif i == 15: 5049 assert rate == 3.69 5050 assert description is not None 5051 elif i < 22: 5052 assert rate == 3.69 5053 assert description is not None 5054 elif i == 22: 5055 assert rate == 3.73 5056 assert description is not None 5057 elif i >= 25: 5058 assert rate == 3.75 5059 assert description is not None 5060 exchange = self.exchange(bank_account_id, i) 5061 rate, description, created = exchange.rate, exchange.description, exchange.time 5062 if debug: 5063 print(i, rate, description, created) 5064 assert created 5065 assert rate == 1 5066 assert description is None 5067 5068 assert len(self.__vault.exchange) == 1 5069 assert len(self.exchanges()) == 1 5070 self.__vault.exchange.clear() 5071 assert len(self.__vault.exchange) == 0 5072 assert len(self.exchanges()) == 0 5073 self.reset() 5074 5075 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 5076 cash_account_id = self.create_account('cash') 5077 self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 5078 self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 5079 self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 5080 self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66) 5081 5082 assert self.nolock() 5083 5084 test_account_id = self.create_account('test') 5085 for i in [x * 0.12 for x in range(-15, 21)]: 5086 if i <= 0: 5087 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange() 5088 else: 5089 assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange() 5090 5091 assert self.nolock() 5092 5093 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 5094 bank_account_id = self.create_account('bank') 5095 for i in range(1, 31): 5096 timestamp_ns = ZakatTracker.day_to_time(i) 5097 exchange = self.exchange(cash_account_id, timestamp_ns) 5098 rate, description, created = exchange.rate, exchange.description, exchange.time 5099 if debug: 5100 print(i, rate, description, created) 5101 assert created 5102 if i < 10: 5103 assert rate == 1 5104 assert description is None 5105 elif i == 10: 5106 assert rate == 3.66 5107 assert description is None 5108 elif i < 15: 5109 assert rate == 3.66 5110 assert description is None 5111 elif i == 15: 5112 assert rate == 3.69 5113 assert description is not None 5114 elif i < 22: 5115 assert rate == 3.69 5116 assert description is not None 5117 elif i == 22: 5118 assert rate == 3.73 5119 assert description is not None 5120 elif i >= 25: 5121 assert rate == 3.75 5122 assert description is not None 5123 exchange = self.exchange(bank_account_id, i) 5124 rate, description, created = exchange.rate, exchange.description, exchange.time 5125 if debug: 5126 print(i, rate, description, created) 5127 assert created 5128 assert rate == 1 5129 assert description is None 5130 5131 assert self.nolock() 5132 if debug: 5133 print(self.__vault.history, len(self.__vault.history)) 5134 for _ in range(len(self.__vault.history)): 5135 assert self.recall(dry=False, debug=debug) 5136 assert not self.recall(dry=False, debug=debug) 5137 5138 self.reset() 5139 5140 # test transfer between accounts with different exchange rate 5141 5142 a_SAR = self.create_account('Bank (SAR)') 5143 b_USD = self.create_account('Bank (USD)') 5144 c_SAR = self.create_account('Safe (SAR)') 5145 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 5146 for case in [ 5147 (0, a_SAR, 'SAR Gift', 1000, 100000), 5148 (1, a_SAR, 1), 5149 (0, b_USD, 'USD Gift', 500, 50000), 5150 (1, b_USD, 1), 5151 (2, b_USD, 3.75), 5152 (1, b_USD, 3.75), 5153 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 5154 (0, c_SAR, 'Salary', 750, 75000), 5155 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 5156 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 5157 ]: 5158 if debug: 5159 print('case', case) 5160 match (case[0]): 5161 case 0: # track 5162 _, account, desc, x, balance = case 5163 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 5164 5165 cached_value = self.balance(account, cached=True) 5166 fresh_value = self.balance(account, cached=False) 5167 if debug: 5168 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 5169 assert cached_value == balance 5170 assert fresh_value == balance 5171 case 1: # check-exchange 5172 _, account, expected_rate = case 5173 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5174 if debug: 5175 print('t-exchange', t_exchange) 5176 assert t_exchange.rate == expected_rate 5177 case 2: # do-exchange 5178 _, account, rate = case 5179 self.exchange(account, rate=rate, debug=debug) 5180 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 5181 if debug: 5182 print('b-exchange', b_exchange) 5183 assert b_exchange.rate == rate 5184 case 3: # transfer 5185 _, x, a, b, desc, a_balance, b_balance = case 5186 self.transfer(x, a, b, desc, debug=debug) 5187 5188 cached_value = self.balance(a, cached=True) 5189 fresh_value = self.balance(a, cached=False) 5190 if debug: 5191 print( 5192 'account', a, 5193 'cached_value', cached_value, 5194 'fresh_value', fresh_value, 5195 'a_balance', a_balance, 5196 ) 5197 assert cached_value == a_balance 5198 assert fresh_value == a_balance 5199 5200 cached_value = self.balance(b, cached=True) 5201 fresh_value = self.balance(b, cached=False) 5202 if debug: 5203 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 5204 assert cached_value == b_balance 5205 assert fresh_value == b_balance 5206 5207 # Transfer all in many chunks randomly from B to A 5208 a_SAR_balance = 137125 5209 b_USD_balance = 50100 5210 b_USD_exchange = self.exchange(b_USD) 5211 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 5212 if debug: 5213 print('amounts', amounts) 5214 i = 0 5215 for x in amounts: 5216 if debug: 5217 print(f'{i} - transfer-with-exchange({x})') 5218 self.transfer( 5219 unscaled_amount=self.unscale(x), 5220 from_account=b_USD, 5221 to_account=a_SAR, 5222 desc=f'{x} USD -> SAR', 5223 debug=debug, 5224 ) 5225 5226 b_USD_balance -= x 5227 cached_value = self.balance(b_USD, cached=True) 5228 fresh_value = self.balance(b_USD, cached=False) 5229 if debug: 5230 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5231 b_USD_balance) 5232 assert cached_value == b_USD_balance 5233 assert fresh_value == b_USD_balance 5234 5235 a_SAR_balance += int(x * b_USD_exchange.rate) 5236 cached_value = self.balance(a_SAR, cached=True) 5237 fresh_value = self.balance(a_SAR, cached=False) 5238 if debug: 5239 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5240 a_SAR_balance, 'rate', b_USD_exchange.rate) 5241 assert cached_value == a_SAR_balance 5242 assert fresh_value == a_SAR_balance 5243 i += 1 5244 5245 # Transfer all in many chunks randomly from C to A 5246 c_SAR_balance = 37500 5247 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 5248 if debug: 5249 print('amounts', amounts) 5250 i = 0 5251 for x in amounts: 5252 if debug: 5253 print(f'{i} - transfer-with-exchange({x})') 5254 self.transfer( 5255 unscaled_amount=self.unscale(x), 5256 from_account=c_SAR, 5257 to_account=a_SAR, 5258 desc=f'{x} SAR -> a_SAR', 5259 debug=debug, 5260 ) 5261 5262 c_SAR_balance -= x 5263 cached_value = self.balance(c_SAR, cached=True) 5264 fresh_value = self.balance(c_SAR, cached=False) 5265 if debug: 5266 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 5267 c_SAR_balance) 5268 assert cached_value == c_SAR_balance 5269 assert fresh_value == c_SAR_balance 5270 5271 a_SAR_balance += x 5272 cached_value = self.balance(a_SAR, cached=True) 5273 fresh_value = self.balance(a_SAR, cached=False) 5274 if debug: 5275 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 5276 a_SAR_balance) 5277 assert cached_value == a_SAR_balance 5278 assert fresh_value == a_SAR_balance 5279 i += 1 5280 5281 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 5282 5283 # check & zakat with exchange rates for many cycles 5284 5285 lock = None 5286 safe_account_id = self.create_account('safe') 5287 cave_account_id = self.create_account('cave') 5288 for rate, values in { 5289 1: { 5290 'in': [1000, 2000, 10000], 5291 'exchanged': [100000, 200000, 1000000], 5292 'out': [2500, 5000, 73140], 5293 }, 5294 3.75: { 5295 'in': [200, 1000, 5000], 5296 'exchanged': [75000, 375000, 1875000], 5297 'out': [1875, 9375, 137138], 5298 }, 5299 }.items(): 5300 a, b, c = values['in'] 5301 m, n, o = values['exchanged'] 5302 x, y, z = values['out'] 5303 if debug: 5304 print('rate', rate, 'values', values) 5305 for case in [ 5306 (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5307 {safe_account_id: {0: {'below_nisab': x}}}, 5308 ], False, m), 5309 (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [ 5310 {safe_account_id: {0: {'count': 1, 'total': y}}}, 5311 ], True, n), 5312 (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [ 5313 {cave_account_id: {0: {'count': 3, 'total': z}}}, 5314 ], True, o), 5315 ]: 5316 if debug: 5317 print(f'############# check(rate: {rate}) #############') 5318 print('case', case) 5319 self.reset() 5320 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 5321 self.track( 5322 unscaled_value=case[0], 5323 desc='test-check', 5324 account=case[1], 5325 created_time_ns=case[2], 5326 ) 5327 assert self.snapshot() 5328 5329 # assert self.nolock() 5330 # history_size = len(self.__vault.history) 5331 # print('history_size', history_size) 5332 # assert history_size == 2 5333 lock = self.lock() 5334 assert lock 5335 assert not self.nolock() 5336 assert self.__vault.cache.zakat is None 5337 report = self.check(2.17, None, debug) 5338 if debug: 5339 print('[report]', report) 5340 assert case[4] == report.valid 5341 assert case[5] == report.summary.total_wealth 5342 assert case[5] == report.summary.total_zakatable_amount 5343 if report.valid: 5344 assert self.__vault.cache.zakat is not None 5345 assert report.plan 5346 assert self.zakat(report, debug=debug) 5347 assert self.__vault.cache.zakat is None 5348 if debug: 5349 pp().pprint(self.__vault) 5350 self._test_storage(debug=debug) 5351 5352 for x in report.plan: 5353 assert case[1] == x 5354 if report.plan[x][0].below_nisab: 5355 if debug: 5356 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 5357 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 5358 else: 5359 if debug: 5360 print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total']) 5361 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 5362 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 5363 assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total'] 5364 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 5365 assert report.plan[x][0].count == case[3][0][x][0]['count'] 5366 else: 5367 assert self.__vault.cache.zakat is None 5368 result = self.zakat(report, debug=debug) 5369 if debug: 5370 print('zakat-result', result, case[4]) 5371 assert result == case[4] 5372 report = self.check(2.17, None, debug) 5373 assert report.valid is False 5374 self._test_storage(account_id=cave_account_id, debug=debug) 5375 5376 # recall after zakat 5377 5378 history_size = len(self.__vault.history) 5379 if debug: 5380 print('history_size', history_size) 5381 assert history_size == 3 5382 assert not self.nolock() 5383 assert self.recall(dry=False, debug=debug) is False 5384 self.free(lock) 5385 assert self.nolock() 5386 5387 for i in range(3, 0, -1): 5388 history_size = len(self.__vault.history) 5389 if debug: 5390 print('history_size', history_size) 5391 assert history_size == i 5392 assert self.recall(dry=False, debug=debug) is True 5393 5394 assert self.nolock() 5395 assert self.recall(dry=False, debug=debug) is False 5396 5397 history_size = len(self.__vault.history) 5398 if debug: 5399 print('history_size', history_size) 5400 assert history_size == 0 5401 5402 account_size = len(self.__vault.account) 5403 if debug: 5404 print('account_size', account_size) 5405 assert account_size == 0 5406 5407 report_size = len(self.__vault.report) 5408 if debug: 5409 print('report_size', report_size) 5410 assert report_size == 0 5411 5412 assert self.nolock() 5413 5414 # csv 5415 5416 csv_count = 1000 5417 5418 for with_rate, path in { 5419 False: 'test-import_csv-no-exchange', 5420 True: 'test-import_csv-with-exchange', 5421 }.items(): 5422 5423 if debug: 5424 print('test_import_csv', with_rate, path) 5425 5426 csv_path = path + '.csv' 5427 if os.path.exists(csv_path): 5428 os.remove(csv_path) 5429 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 5430 if debug: 5431 print('generate_random_csv_file', c) 5432 assert c == csv_count 5433 assert os.path.getsize(csv_path) > 0 5434 cache_path = self.import_csv_cache_path() 5435 if os.path.exists(cache_path): 5436 os.remove(cache_path) 5437 self.reset() 5438 lock = self.lock() 5439 import_report = self.import_csv(csv_path, debug=debug) 5440 bad_count = len(import_report.bad) 5441 if debug: 5442 print(f'csv-imported: {import_report.statistics} = count({csv_count})') 5443 print('bad', import_report.bad) 5444 assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count 5445 assert import_report.statistics.created == csv_count 5446 assert bad_count == 0 5447 assert bad_count == import_report.statistics.bad 5448 tmp_size = os.path.getsize(cache_path) 5449 assert tmp_size > 0 5450 5451 import_report_2 = self.import_csv(csv_path, debug=debug) 5452 bad_2_count = len(import_report_2.bad) 5453 if debug: 5454 print(f'csv-imported: {import_report_2}') 5455 print('bad', import_report_2.bad) 5456 assert tmp_size == os.path.getsize(cache_path) 5457 assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count 5458 assert import_report.statistics.created == import_report_2.statistics.found 5459 assert bad_count == bad_2_count 5460 assert import_report_2.statistics.found == csv_count 5461 assert bad_2_count == 0 5462 assert bad_2_count == import_report_2.statistics.bad 5463 assert import_report_2.statistics.created == 0 5464 5465 # payment parts 5466 5467 positive_parts = self.build_payment_parts(100, positive_only=True) 5468 assert self.check_payment_parts(positive_parts) != 0 5469 assert self.check_payment_parts(positive_parts) != 0 5470 all_parts = self.build_payment_parts(300, positive_only=False) 5471 assert self.check_payment_parts(all_parts) != 0 5472 assert self.check_payment_parts(all_parts) != 0 5473 if debug: 5474 pp().pprint(positive_parts) 5475 pp().pprint(all_parts) 5476 # dynamic discount 5477 suite = [] 5478 count = 3 5479 for exceed in [False, True]: 5480 case = [] 5481 for part in [positive_parts, all_parts]: 5482 #part = parts.copy() 5483 demand = part.demand 5484 if debug: 5485 print(demand, part.total) 5486 i = 0 5487 z = demand / count 5488 cp = PaymentParts( 5489 demand=demand, 5490 exceed=exceed, 5491 total=part.total, 5492 ) 5493 j = '' 5494 for x, y in part.account.items(): 5495 x_exchange = self.exchange(x) 5496 zz = self.exchange_calc(z, 1, x_exchange.rate) 5497 if exceed and zz <= demand: 5498 i += 1 5499 y.part = zz 5500 if debug: 5501 print(exceed, y) 5502 cp.account[x] = y 5503 case.append(y) 5504 elif not exceed and y.balance >= zz: 5505 i += 1 5506 y.part = zz 5507 if debug: 5508 print(exceed, y) 5509 cp.account[x] = y 5510 case.append(y) 5511 j = x 5512 if i >= count: 5513 break 5514 if debug: 5515 print('[debug]', j) 5516 print('[debug]', cp.account[j]) 5517 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 5518 suite.append(cp) 5519 if debug: 5520 print('suite', len(suite)) 5521 for case in suite: 5522 if debug: 5523 print('case', case) 5524 result = self.check_payment_parts(case) 5525 if debug: 5526 print('check_payment_parts', result, f'exceed: {exceed}') 5527 assert result == 0 5528 5529 assert self.__vault.cache.zakat is None 5530 report = self.check(2.17, None, debug) 5531 if debug: 5532 print('valid', report.valid) 5533 zakat_result = self.zakat(report, parts=case, debug=debug) 5534 if debug: 5535 print('zakat-result', zakat_result) 5536 assert report.valid == zakat_result 5537 # test verified zakat report is required 5538 if zakat_result: 5539 assert self.__vault.cache.zakat is None 5540 failed = False 5541 try: 5542 self.zakat(report, parts=case, debug=debug) 5543 except: 5544 failed = True 5545 assert failed 5546 5547 assert self.free(lock) 5548 5549 assert self.save(path + f'.{self.ext()}') 5550 assert self.save(f'1000-transactions-test.{self.ext()}') 5551 return True 5552 except Exception as e: 5553 if self.__debug_output: 5554 pp().pprint(self.__vault) 5555 print('============================================================================') 5556 pp().pprint(self.__debug_output) 5557 assert self.save(f'test-snapshot.{self.ext()}') 5558 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.
5561def test(path: Optional[str] = None, debug: bool = False): 5562 """ 5563 Executes a test suite for the ZakatTracker. 5564 5565 This function initializes a ZakatTracker instance, optionally using a specified 5566 database path or a temporary directory. It then runs the test suite and, if debug 5567 mode is enabled, prints detailed test results and execution time. 5568 5569 Parameters: 5570 - path (str, optional): The path to the ZakatTracker database. If None, a 5571 temporary directory is created. Defaults to None. 5572 - debug (bool, optional): Enables debug mode, which prints detailed test 5573 results and execution time. Defaults to False. 5574 5575 Returns: 5576 None. The function asserts the result of the ZakatTracker's test suite. 5577 5578 Raises: 5579 - AssertionError: If the ZakatTracker's test suite fails. 5580 5581 Examples: 5582 - `test()` Runs tests using a temporary database. 5583 - `test(debug=True)` Runs the test suite in debug mode with a temporary directory. 5584 - `test(path="/path/to/my/db")` Runs tests using a specified database path. 5585 - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path. 5586 """ 5587 no_path = path is None 5588 if no_path: 5589 path = tempfile.mkdtemp() 5590 print(f"Random database path {path}") 5591 if os.path.exists(path): 5592 shutil.rmtree(path) 5593 assert ZakatTracker(':memory:').memory_mode() 5594 ledger = ZakatTracker( 5595 db_path=path, 5596 history_mode=True, 5597 ) 5598 start = time.time_ns() 5599 assert not ledger.memory_mode() 5600 assert ledger.test(debug=debug) 5601 if no_path and os.path.exists(path): 5602 shutil.rmtree(path) 5603 if debug: 5604 print('#########################') 5605 print('######## TEST DONE ########') 5606 print('#########################') 5607 print(Time.duration_from_nanoseconds(time.time_ns() - start)) 5608 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.