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