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 ZakatTracker, 21 AccountName, 22 Timestamp, 23 Box, 24 Log, 25 Account, 26 Exchange, 27 History, 28 Vault, 29 AccountPaymentPart, 30 PaymentParts, 31 SubtractAge, 32 SubtractAges, 33 SubtractReport, 34 TransferTime, 35 TransferTimes, 36 TransferRecord, 37 TransferReport, 38 BoxPlan, 39 ZakatPlan, 40 ZakatReportStatistics, 41 ZakatReport, 42 test, 43 Action, 44 JSONEncoder, 45 JSONDecoder, 46 MathOperation, 47 WeekDay, 48) 49 50from zakat.file_server import ( 51 start_file_server, 52 find_available_port, 53 FileType, 54) 55 56# Shortcuts 57time = Time.time 58time_to_datetime = Time.time_to_datetime 59tracker = ZakatTracker 60 61# Version information for the module 62__version__ = ZakatTracker.Version() 63__all__ = [ 64 "Time", 65 "time", 66 "time_to_datetime", 67 "tracker", 68 "ZakatTracker", 69 "AccountName", 70 "Timestamp", 71 "Box", 72 "Log", 73 "Account", 74 "Exchange", 75 "History", 76 "Vault", 77 "AccountPaymentPart", 78 "PaymentParts", 79 "SubtractAge", 80 "SubtractAges", 81 "SubtractReport", 82 "TransferTime", 83 "TransferTimes", 84 "TransferRecord", 85 "TransferReport", 86 "BoxPlan", 87 "ZakatPlan", 88 "ZakatReportStatistics", 89 "ZakatReport", 90 "test", 91 "Action", 92 "JSONEncoder", 93 "JSONDecoder", 94 "MathOperation", 95 "WeekDay", 96 "start_file_server", 97 "find_available_port", 98 "FileType", 99]
658class Time: 659 """ 660 Utility class for generating and manipulating nanosecond-precision timestamps. 661 662 This class provides static methods for converting between datetime objects and 663 nanosecond-precision timestamps, ensuring uniqueness and monotonicity. 664 """ 665 __last_time_ns = None 666 __time_diff_ns = None 667 668 @staticmethod 669 def minimum_time_diff_ns() -> tuple[int, int]: 670 """ 671 Calculates the minimum time difference between two consecutive calls to 672 `Time._time()` in nanoseconds. 673 674 This method is used internally to determine the minimum granularity of 675 time measurements within the system. 676 677 Returns: 678 - tuple[int, int]: 679 - The minimum time difference in nanoseconds. 680 - The number of iterations required to measure the difference. 681 """ 682 i = 0 683 x = y = Time._time() 684 while x == y: 685 y = Time._time() 686 i += 1 687 return y - x, i 688 689 @staticmethod 690 def _time(now: Optional[datetime.datetime] = None) -> Timestamp: 691 """ 692 Internal method to generate a nanosecond-precision timestamp from a datetime object. 693 694 Parameters: 695 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. 696 If not provided, the current datetime is used. 697 698 Returns: 699 - int: The timestamp in nanoseconds since the epoch (January 1, 1AD). 700 """ 701 if now is None: 702 now = datetime.datetime.now() 703 ns_in_day = (now - now.replace( 704 hour=0, 705 minute=0, 706 second=0, 707 microsecond=0, 708 )).total_seconds() * 10 ** 9 709 return Timestamp(now.toordinal() * 86_400_000_000_000 + ns_in_day) 710 711 @staticmethod 712 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 713 """ 714 Generates a unique, monotonically increasing timestamp based on the provided 715 datetime object or the current datetime. 716 717 This method ensures that timestamps are unique even if called in rapid succession 718 by introducing a small delay if necessary, based on the system's minimum 719 time resolution. 720 721 Parameters: 722 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. 723 If not provided, the current datetime is used. 724 725 Returns: 726 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 727 """ 728 new_time = Time._time(now) 729 if Time.__last_time_ns is None: 730 Time.__last_time_ns = new_time 731 return new_time 732 while new_time == Time.__last_time_ns: 733 if Time.__time_diff_ns is None: 734 diff, _ = Time.minimum_time_diff_ns() 735 Time.__time_diff_ns = math.ceil(diff) 736 time.sleep(Time.__time_diff_ns / 1_000_000_000) 737 new_time = Time._time() 738 Time.__last_time_ns = new_time 739 return new_time 740 741 @staticmethod 742 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 743 """ 744 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 745 back to a datetime object. 746 747 Parameters: 748 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 749 750 Returns: 751 - datetime.datetime: The corresponding datetime object. 752 """ 753 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 754 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 755 return datetime.datetime.combine(d, datetime.time()) + t 756 757 @staticmethod 758 def duration_from_nanoseconds(ns: int, 759 show_zeros_in_spoken_time: bool = False, 760 spoken_time_separator=',', 761 millennia: str = 'Millennia', 762 century: str = 'Century', 763 years: str = 'Years', 764 days: str = 'Days', 765 hours: str = 'Hours', 766 minutes: str = 'Minutes', 767 seconds: str = 'Seconds', 768 milli_seconds: str = 'MilliSeconds', 769 micro_seconds: str = 'MicroSeconds', 770 nano_seconds: str = 'NanoSeconds', 771 ) -> tuple: 772 """ 773 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 774 Convert NanoSeconds to Human Readable Time Format. 775 A NanoSeconds is a unit of time in the International System of Units (SI) equal 776 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 777 Its symbol is μs, sometimes simplified to us when Unicode is not available. 778 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 779 780 INPUT : ms (AKA: MilliSeconds) 781 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 782 OUTPUT Variables: time_lapsed, spoken_time 783 784 Example Input: duration_from_nanoseconds(ns) 785 **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'** 786 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') 787 duration_from_nanoseconds(1234567890123456789012) 788 """ 789 us, ns = divmod(ns, 1000) 790 ms, us = divmod(us, 1000) 791 s, ms = divmod(ms, 1000) 792 m, s = divmod(s, 60) 793 h, m = divmod(m, 60) 794 d, h = divmod(h, 24) 795 y, d = divmod(d, 365) 796 c, y = divmod(y, 100) 797 n, c = divmod(c, 10) 798 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}' 799 spoken_time_part = [] 800 if n > 0 or show_zeros_in_spoken_time: 801 spoken_time_part.append(f'{n: 3d} {millennia}') 802 if c > 0 or show_zeros_in_spoken_time: 803 spoken_time_part.append(f'{c: 4d} {century}') 804 if y > 0 or show_zeros_in_spoken_time: 805 spoken_time_part.append(f'{y: 3d} {years}') 806 if d > 0 or show_zeros_in_spoken_time: 807 spoken_time_part.append(f'{d: 4d} {days}') 808 if h > 0 or show_zeros_in_spoken_time: 809 spoken_time_part.append(f'{h: 2d} {hours}') 810 if m > 0 or show_zeros_in_spoken_time: 811 spoken_time_part.append(f'{m: 2d} {minutes}') 812 if s > 0 or show_zeros_in_spoken_time: 813 spoken_time_part.append(f'{s: 2d} {seconds}') 814 if ms > 0 or show_zeros_in_spoken_time: 815 spoken_time_part.append(f'{ms: 3d} {milli_seconds}') 816 if us > 0 or show_zeros_in_spoken_time: 817 spoken_time_part.append(f'{us: 3d} {micro_seconds}') 818 if ns > 0 or show_zeros_in_spoken_time: 819 spoken_time_part.append(f'{ns: 3d} {nano_seconds}') 820 return time_lapsed, spoken_time_separator.join(spoken_time_part) 821 822 @staticmethod 823 def test(debug: bool = False): 824 """ 825 Performs unit tests to verify the correctness of the `Time` class methods. 826 827 This method checks the conversion between datetime objects and timestamps, 828 ensuring accuracy and consistency across various date ranges. 829 830 Parameters: 831 - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False. 832 """ 833 test_cases = [ 834 datetime.datetime(1, 1, 1), 835 datetime.datetime(1970, 1, 1), 836 datetime.datetime(1969, 12, 31), 837 datetime.datetime.now(), 838 datetime.datetime(9999, 12, 31, 23, 59, 59), 839 ] 840 841 for test_date in test_cases: 842 timestamp = Time.time(test_date) 843 converted = Time.time_to_datetime(timestamp) 844 if debug: 845 print(f'{timestamp} <=> {converted}') 846 assert timestamp > 0 847 assert test_date.year == converted.year 848 assert test_date.month == converted.month 849 assert test_date.day == converted.day 850 assert test_date.hour == converted.hour 851 assert test_date.minute == converted.minute 852 assert test_date.second in [converted.second - 1, converted.second, converted.second + 1] 853 854 # sanity check - convert date since 1AD to 9999AD 855 856 for year in range(1, 10_000): 857 ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45', '%Y-%m-%d %H:%M:%S')) 858 date = Time.time_to_datetime(ns) 859 if debug: 860 print(date) 861 assert ns > 0 862 assert date.year == year 863 assert date.month == 12 864 assert date.day == 30 865 assert date.hour == 18 866 assert date.minute == 30 867 assert date.second in [44, 45]
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.
668 @staticmethod 669 def minimum_time_diff_ns() -> tuple[int, int]: 670 """ 671 Calculates the minimum time difference between two consecutive calls to 672 `Time._time()` in nanoseconds. 673 674 This method is used internally to determine the minimum granularity of 675 time measurements within the system. 676 677 Returns: 678 - tuple[int, int]: 679 - The minimum time difference in nanoseconds. 680 - The number of iterations required to measure the difference. 681 """ 682 i = 0 683 x = y = Time._time() 684 while x == y: 685 y = Time._time() 686 i += 1 687 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.
711 @staticmethod 712 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 713 """ 714 Generates a unique, monotonically increasing timestamp based on the provided 715 datetime object or the current datetime. 716 717 This method ensures that timestamps are unique even if called in rapid succession 718 by introducing a small delay if necessary, based on the system's minimum 719 time resolution. 720 721 Parameters: 722 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. 723 If not provided, the current datetime is used. 724 725 Returns: 726 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 727 """ 728 new_time = Time._time(now) 729 if Time.__last_time_ns is None: 730 Time.__last_time_ns = new_time 731 return new_time 732 while new_time == Time.__last_time_ns: 733 if Time.__time_diff_ns is None: 734 diff, _ = Time.minimum_time_diff_ns() 735 Time.__time_diff_ns = math.ceil(diff) 736 time.sleep(Time.__time_diff_ns / 1_000_000_000) 737 new_time = Time._time() 738 Time.__last_time_ns = new_time 739 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).
741 @staticmethod 742 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 743 """ 744 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 745 back to a datetime object. 746 747 Parameters: 748 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 749 750 Returns: 751 - datetime.datetime: The corresponding datetime object. 752 """ 753 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 754 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 755 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.
757 @staticmethod 758 def duration_from_nanoseconds(ns: int, 759 show_zeros_in_spoken_time: bool = False, 760 spoken_time_separator=',', 761 millennia: str = 'Millennia', 762 century: str = 'Century', 763 years: str = 'Years', 764 days: str = 'Days', 765 hours: str = 'Hours', 766 minutes: str = 'Minutes', 767 seconds: str = 'Seconds', 768 milli_seconds: str = 'MilliSeconds', 769 micro_seconds: str = 'MicroSeconds', 770 nano_seconds: str = 'NanoSeconds', 771 ) -> tuple: 772 """ 773 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 774 Convert NanoSeconds to Human Readable Time Format. 775 A NanoSeconds is a unit of time in the International System of Units (SI) equal 776 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 777 Its symbol is μs, sometimes simplified to us when Unicode is not available. 778 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 779 780 INPUT : ms (AKA: MilliSeconds) 781 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 782 OUTPUT Variables: time_lapsed, spoken_time 783 784 Example Input: duration_from_nanoseconds(ns) 785 **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'** 786 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') 787 duration_from_nanoseconds(1234567890123456789012) 788 """ 789 us, ns = divmod(ns, 1000) 790 ms, us = divmod(us, 1000) 791 s, ms = divmod(ms, 1000) 792 m, s = divmod(s, 60) 793 h, m = divmod(m, 60) 794 d, h = divmod(h, 24) 795 y, d = divmod(d, 365) 796 c, y = divmod(y, 100) 797 n, c = divmod(c, 10) 798 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}' 799 spoken_time_part = [] 800 if n > 0 or show_zeros_in_spoken_time: 801 spoken_time_part.append(f'{n: 3d} {millennia}') 802 if c > 0 or show_zeros_in_spoken_time: 803 spoken_time_part.append(f'{c: 4d} {century}') 804 if y > 0 or show_zeros_in_spoken_time: 805 spoken_time_part.append(f'{y: 3d} {years}') 806 if d > 0 or show_zeros_in_spoken_time: 807 spoken_time_part.append(f'{d: 4d} {days}') 808 if h > 0 or show_zeros_in_spoken_time: 809 spoken_time_part.append(f'{h: 2d} {hours}') 810 if m > 0 or show_zeros_in_spoken_time: 811 spoken_time_part.append(f'{m: 2d} {minutes}') 812 if s > 0 or show_zeros_in_spoken_time: 813 spoken_time_part.append(f'{s: 2d} {seconds}') 814 if ms > 0 or show_zeros_in_spoken_time: 815 spoken_time_part.append(f'{ms: 3d} {milli_seconds}') 816 if us > 0 or show_zeros_in_spoken_time: 817 spoken_time_part.append(f'{us: 3d} {micro_seconds}') 818 if ns > 0 or show_zeros_in_spoken_time: 819 spoken_time_part.append(f'{ns: 3d} {nano_seconds}') 820 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)
822 @staticmethod 823 def test(debug: bool = False): 824 """ 825 Performs unit tests to verify the correctness of the `Time` class methods. 826 827 This method checks the conversion between datetime objects and timestamps, 828 ensuring accuracy and consistency across various date ranges. 829 830 Parameters: 831 - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False. 832 """ 833 test_cases = [ 834 datetime.datetime(1, 1, 1), 835 datetime.datetime(1970, 1, 1), 836 datetime.datetime(1969, 12, 31), 837 datetime.datetime.now(), 838 datetime.datetime(9999, 12, 31, 23, 59, 59), 839 ] 840 841 for test_date in test_cases: 842 timestamp = Time.time(test_date) 843 converted = Time.time_to_datetime(timestamp) 844 if debug: 845 print(f'{timestamp} <=> {converted}') 846 assert timestamp > 0 847 assert test_date.year == converted.year 848 assert test_date.month == converted.month 849 assert test_date.day == converted.day 850 assert test_date.hour == converted.hour 851 assert test_date.minute == converted.minute 852 assert test_date.second in [converted.second - 1, converted.second, converted.second + 1] 853 854 # sanity check - convert date since 1AD to 9999AD 855 856 for year in range(1, 10_000): 857 ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45', '%Y-%m-%d %H:%M:%S')) 858 date = Time.time_to_datetime(ns) 859 if debug: 860 print(date) 861 assert ns > 0 862 assert date.year == year 863 assert date.month == 12 864 assert date.day == 30 865 assert date.hour == 18 866 assert date.minute == 30 867 assert date.second in [44, 45]
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.
711 @staticmethod 712 def time(now: Optional[datetime.datetime] = None) -> Timestamp: 713 """ 714 Generates a unique, monotonically increasing timestamp based on the provided 715 datetime object or the current datetime. 716 717 This method ensures that timestamps are unique even if called in rapid succession 718 by introducing a small delay if necessary, based on the system's minimum 719 time resolution. 720 721 Parameters: 722 - now (datetime.datetime, optional): The datetime object to generate the timestamp from. 723 If not provided, the current datetime is used. 724 725 Returns: 726 - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD). 727 """ 728 new_time = Time._time(now) 729 if Time.__last_time_ns is None: 730 Time.__last_time_ns = new_time 731 return new_time 732 while new_time == Time.__last_time_ns: 733 if Time.__time_diff_ns is None: 734 diff, _ = Time.minimum_time_diff_ns() 735 Time.__time_diff_ns = math.ceil(diff) 736 time.sleep(Time.__time_diff_ns / 1_000_000_000) 737 new_time = Time._time() 738 Time.__last_time_ns = new_time 739 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).
741 @staticmethod 742 def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime: 743 """ 744 Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) 745 back to a datetime object. 746 747 Parameters: 748 - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD). 749 750 Returns: 751 - datetime.datetime: The corresponding datetime object. 752 """ 753 d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000) 754 t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9) 755 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.
870class ZakatTracker: 871 """ 872 A class for tracking and calculating Zakat. 873 874 This class provides functionalities for recording transactions, calculating Zakat due, 875 and managing account balances. It also offers features like importing transactions from 876 CSV files, exporting data to JSON format, and saving/loading the tracker state. 877 878 The `ZakatTracker` class is designed to handle both positive and negative transactions, 879 allowing for flexible tracking of financial activities related to Zakat. It also supports 880 the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due 881 based on the current silver price. 882 883 The class uses a json file as its database to persist the tracker state, 884 ensuring data integrity across sessions. It also provides options for enabling or 885 disabling history tracking, allowing users to choose their preferred level of detail. 886 887 In addition, the `ZakatTracker` class includes various helper methods like 888 `time`, `time_to_datetime`, `lock`, `free`, `recall`, `save`, `load` 889 and more. These methods provide additional functionalities and flexibility 890 for interacting with and managing the Zakat tracker. 891 892 Attributes: 893 - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage. 894 - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat. 895 - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price. 896 - ZakatTracker.Version (function): The version of the ZakatTracker class. 897 898 Data Structure: 899 900 The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data. 901 902 __vault (dict): 903 - account (dict): 904 - {account_name} (dict): 905 - balance (int): The current balance of the account. 906 - box (dict): A dictionary storing transaction details. 907 - {timestamp} (dict): 908 - capital (int): The initial amount of the transaction. 909 - count (int): The number of times Zakat has been calculated for this transaction. 910 - last (int): The timestamp of the last Zakat calculation. 911 - rest (int): The remaining amount after Zakat deductions and withdrawal. 912 - total (int): The total Zakat deducted from this transaction. 913 - count (int): The total number of transactions for the account. 914 - log (dict): A dictionary storing transaction logs. 915 - {timestamp} (dict): 916 - value (int): The transaction amount (positive or negative). 917 - desc (str): The description of the transaction. 918 - ref (int): The box reference (positive or None). 919 - file (dict): A dictionary storing file references associated with the transaction. 920 - hide (bool): Indicates whether the account is hidden or not. 921 - zakatable (bool): Indicates whether the account is subject to Zakat. 922 - exchange (dict): 923 - {account_name} (dict): 924 - {timestamps} (dict): 925 - rate (float): Exchange rate when compared to local currency. 926 - description (str): The description of the exchange rate. 927 - history (dict): 928 - {timestamp} (list): A list of dictionaries storing the history of actions performed. 929 - {action_dict} (dict): 930 - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT). 931 - account (str): The account number associated with the action. 932 - ref (int): The reference number of the transaction. 933 - file (int): The reference number of the file (if applicable). 934 - key (str): The key associated with the action (e.g., 'rest', 'total'). 935 - value (int): The value associated with the action. 936 - math (MathOperation): The mathematical operation performed (if applicable). 937 - lock (int or None): The timestamp indicating the current lock status (None if not locked). 938 - report (dict): 939 - {timestamp} (tuple): A tuple storing Zakat report details. 940 941 """ 942 943 @staticmethod 944 def Version() -> str: 945 """ 946 Returns the current version of the software. 947 948 This function returns a string representing the current version of the software, 949 including major, minor, and patch version numbers in the format 'X.Y.Z'. 950 951 Returns: 952 - str: The current version of the software. 953 """ 954 version = '0.3.1' 955 git_hash, unstaged_count, commit_count_since_last_tag = get_git_status() 956 if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0): 957 version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}" 958 print(version) 959 return version 960 961 @staticmethod 962 def ZakatCut(x: float) -> float: 963 """ 964 Calculates the Zakat amount due on an asset. 965 966 This function calculates the zakat amount due on a given asset value over one lunar year. 967 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 968 that exceeds a certain threshold (Nisab). 969 970 Parameters: 971 - x (float): The total value of the asset on which Zakat is to be calculated. 972 973 Returns: 974 - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 975 """ 976 return 0.025 * x # Zakat Cut in one Lunar Year 977 978 @staticmethod 979 def TimeCycle(days: int = 355) -> int: 980 """ 981 Calculates the approximate duration of a lunar year in nanoseconds. 982 983 This function calculates the approximate duration of a lunar year based on the given number of days. 984 It converts the given number of days into nanoseconds for use in high-precision timing applications. 985 986 Parameters: 987 - days (int, optional): The number of days in a lunar year. Defaults to 355, 988 which is an approximation of the average length of a lunar year. 989 990 Returns: 991 - int: The approximate duration of a lunar year in nanoseconds. 992 """ 993 return int(60 * 60 * 24 * days * 1e9) # Lunar Year in nanoseconds 994 995 @staticmethod 996 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 997 """ 998 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 999 1000 This function calculates the Nisab value, which is the minimum threshold of wealth, 1001 that makes an individual liable for paying Zakat. 1002 The Nisab value is determined by the equivalent value of a specific amount 1003 of gold or silver (currently 595 grams in silver) in the local currency. 1004 1005 Parameters: 1006 - gram_price (float): The price per gram of Nisab. 1007 - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver. 1008 1009 Returns: 1010 - float: The total value of Nisab based on the given price per gram. 1011 """ 1012 return gram_price * gram_quantity 1013 1014 @staticmethod 1015 def ext() -> str: 1016 """ 1017 Returns the file extension used by the ZakatTracker class. 1018 1019 Parameters: 1020 None 1021 1022 Returns: 1023 - str: The file extension used by the ZakatTracker class, which is 'json'. 1024 """ 1025 return 'json' 1026 1027 __base_path = "" 1028 __vault_path = "" 1029 __memory_mode = False 1030 __vault: Vault 1031 1032 def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True): 1033 """ 1034 Initialize ZakatTracker with database path and history mode. 1035 1036 Parameters: 1037 - db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database. 1038 - history_mode (bool, optional): The mode for tracking history. Default is True. 1039 1040 Returns: 1041 None 1042 """ 1043 self.reset() 1044 self.__memory_mode = db_path == ':memory:' 1045 self.__history(history_mode) 1046 if not self.__memory_mode: 1047 self.path(f'{db_path}/db.{self.ext()}') 1048 1049 def memory_mode(self) -> bool: 1050 """ 1051 Check if the ZakatTracker is operating in memory mode. 1052 1053 Returns: 1054 - bool: True if the database is in memory, False otherwise. 1055 """ 1056 return self.__memory_mode 1057 1058 def path(self, path: Optional[str] = None) -> str: 1059 """ 1060 Set or get the path to the database file. 1061 1062 If no path is provided, the current path is returned. 1063 If a path is provided, it is set as the new path. 1064 The function also creates the necessary directories if the provided path is a file. 1065 1066 Parameters: 1067 - path (str, optional): The new path to the database file. If not provided, the current path is returned. 1068 1069 Returns: 1070 - str: The current or new path to the database file. 1071 """ 1072 if path is None: 1073 return self.__vault_path 1074 self.__vault_path = pathlib.Path(path).resolve() 1075 base_path = pathlib.Path(path).resolve() 1076 if base_path.is_file() or base_path.suffix: 1077 base_path = base_path.parent 1078 base_path.mkdir(parents=True, exist_ok=True) 1079 self.__base_path = base_path 1080 return str(self.__vault_path) 1081 1082 def base_path(self, *args) -> str: 1083 """ 1084 Generate a base path by joining the provided arguments with the existing base path. 1085 1086 Parameters: 1087 - *args (str): Variable length argument list of strings to be joined with the base path. 1088 1089 Returns: 1090 - str: The generated base path. If no arguments are provided, the existing base path is returned. 1091 """ 1092 if not args: 1093 return str(self.__base_path) 1094 filtered_args = [] 1095 ignored_filename = None 1096 for arg in args: 1097 if pathlib.Path(arg).suffix: 1098 ignored_filename = arg 1099 else: 1100 filtered_args.append(arg) 1101 base_path = pathlib.Path(self.__base_path) 1102 full_path = base_path.joinpath(*filtered_args) 1103 full_path.mkdir(parents=True, exist_ok=True) 1104 if ignored_filename is not None: 1105 return full_path.resolve() / ignored_filename # Join with the ignored filename 1106 return str(full_path.resolve()) 1107 1108 @staticmethod 1109 def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int: 1110 """ 1111 Scales a numerical value by a specified power of 10, returning an integer. 1112 1113 This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and 1114 facilitate precise scaling operations, particularly useful in financial or scientific calculations. 1115 1116 Parameters: 1117 - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal. 1118 - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled 1119 by a factor of 100 (e.g., converts 1.23 to 123). 1120 1121 Returns: 1122 - The scaled value, rounded to the nearest integer. 1123 1124 Raises: 1125 - TypeError: If the input `x` is not a valid numeric type. 1126 1127 Examples: 1128 ```bash 1129 >>> ZakatTracker.scale(3.14159) 1130 314 1131 >>> ZakatTracker.scale(1234, decimal_places=3) 1132 1234000 1133 >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4) 1134 50 1135 ``` 1136 """ 1137 if not isinstance(x, (float, int, decimal.Decimal)): 1138 raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.') 1139 return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places)) 1140 1141 @staticmethod 1142 def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal: 1143 """ 1144 Unscales an integer by a power of 10. 1145 1146 Parameters: 1147 - x (int): The integer to unscale. 1148 - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float. 1149 - decimal_places (int, optional): The power of 10 to use. Defaults to 2. 1150 1151 Returns: 1152 - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type. 1153 1154 Raises: 1155 - TypeError: If the return_type is not float or decimal.Decimal. 1156 """ 1157 if return_type not in (float, decimal.Decimal): 1158 raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.') 1159 return round(return_type(x / (10 ** decimal_places)), decimal_places) 1160 1161 def reset(self) -> None: 1162 """ 1163 Reset the internal data structure to its initial state. 1164 1165 Parameters: 1166 None 1167 1168 Returns: 1169 None 1170 """ 1171 self.__vault = Vault() 1172 1173 def clean_history(self, lock: Optional[Timestamp] = None) -> int: 1174 """ 1175 Cleans up the empty history records of actions performed on the ZakatTracker instance. 1176 1177 Parameters: 1178 - lock (Timestamp, optional): The lock ID is used to clean up the empty history. 1179 If not provided, it cleans up the empty history records for all locks. 1180 1181 Returns: 1182 - int: The number of locks cleaned up. 1183 """ 1184 count = 0 1185 if lock in self.__vault.history: 1186 if len(self.__vault.history[lock]) <= 0: 1187 count += 1 1188 del self.__vault.history[lock] 1189 return count 1190 for key in self.__vault.history: 1191 if len(self.__vault.history[key]) <= 0: 1192 count += 1 1193 del self.__vault.history[key] 1194 return count 1195 1196 def __history(self, status: Optional[bool] = None) -> bool: 1197 """ 1198 Enable or disable history tracking. 1199 1200 Parameters: 1201 - status (bool, optional): The status of history tracking. Default is True. 1202 1203 Returns: 1204 None 1205 """ 1206 if status is not None: 1207 self.__history_mode = status 1208 return self.__history_mode 1209 1210 def __step(self, action: Optional[Action] = None, 1211 account: Optional[AccountName] = None, 1212 ref: Optional[Timestamp] = None, 1213 file: Optional[Timestamp] = None, 1214 value: Optional[any] = None, # !!! 1215 key: Optional[str] = None, 1216 math_operation: Optional[MathOperation] = None, 1217 lock_once: bool = True, 1218 debug: bool = False, 1219 ) -> Optional[Timestamp]: 1220 """ 1221 This method is responsible for recording the actions performed on the ZakatTracker. 1222 1223 Parameters: 1224 - action (Action, optional): The type of action performed. 1225 - account (AccountName, optional): The account number on which the action was performed. 1226 - ref (Optional, optional): The reference number of the action. 1227 - file (Timestamp, optional): The file reference number of the action. 1228 - value (any, optional): The value associated with the action. 1229 - key (str, optional): The key associated with the action. 1230 - math_operation (MathOperation, optional): The mathematical operation performed during the action. 1231 - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True. 1232 - debug (bool, optional): If True, the function will print debug information. Default is False. 1233 1234 Returns: 1235 - Optional[Timestamp]: The lock time of the recorded action. If no lock was performed, it returns 0. 1236 """ 1237 if not self.__history(): 1238 return None 1239 no_lock = self.nolock() 1240 lock = self.__vault.lock 1241 if no_lock: 1242 lock = self.__vault.lock = Time.time() 1243 self.__vault.history[lock] = [] 1244 if action is None: 1245 if lock_once: 1246 assert no_lock, 'forbidden: lock called twice!!!' 1247 return lock 1248 if debug: 1249 print_stack() 1250 assert lock is not None 1251 assert lock > 0 1252 assert account is None or action != Action.REPORT 1253 self.__vault.history[lock].append(History( 1254 action=action, 1255 account=account, 1256 ref=ref, 1257 file=file, 1258 key=key, 1259 value=value, 1260 math=math_operation, 1261 )) 1262 return lock 1263 1264 def nolock(self) -> bool: 1265 """ 1266 Check if the vault lock is currently not set. 1267 1268 Parameters: 1269 None 1270 1271 Returns: 1272 - bool: True if the vault lock is not set, False otherwise. 1273 """ 1274 return self.__vault.lock is None 1275 1276 def __lock(self) -> Optional[Timestamp]: 1277 """ 1278 Acquires a lock, potentially repeatedly, by calling the internal `_step` method. 1279 1280 This method specifically invokes the `_step` method with `lock_once` set to `False` 1281 indicating that the lock should be acquired even if it was previously acquired. 1282 This is useful for ensuring a lock is held throughout a critical section of code 1283 1284 Returns: 1285 - Optional[Timestamp]: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt. 1286 """ 1287 return self.__step(lock_once=False) 1288 1289 def lock(self) -> Optional[Timestamp]: 1290 """ 1291 Acquires a lock on the ZakatTracker instance. 1292 1293 Parameters: 1294 None 1295 1296 Returns: 1297 - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later. 1298 """ 1299 return self.__step() 1300 1301 def steps(self) -> dict: 1302 """ 1303 Returns a copy of the history of steps taken in the ZakatTracker. 1304 1305 The history is a dictionary where each key is a unique identifier for a step, 1306 and the corresponding value is a dictionary containing information about the step. 1307 1308 Parameters: 1309 None 1310 1311 Returns: 1312 - dict: A copy of the history of steps taken in the ZakatTracker. 1313 """ 1314 return self.__vault.history.copy() 1315 1316 def free(self, lock: Timestamp, auto_save: bool = True) -> bool: 1317 """ 1318 Releases the lock on the database. 1319 1320 Parameters: 1321 - lock (Timestamp): The lock ID to be released. 1322 - auto_save (bool, optional): Whether to automatically save the database after releasing the lock. 1323 1324 Returns: 1325 - bool: True if the lock is successfully released and (optionally) saved, False otherwise. 1326 """ 1327 if lock == self.__vault.lock: 1328 self.clean_history(lock) 1329 self.__vault.lock = None 1330 if auto_save and not self.memory_mode(): 1331 return self.save(self.path()) 1332 return True 1333 return False 1334 1335 def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool: 1336 """ 1337 Revert the last operation. 1338 1339 Parameters: 1340 - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 1341 - lock (Timestamp, optional): An optional lock value to ensure the recall 1342 operation is performed on the expected history entry. If provided, 1343 it checks if the current lock and the most recent history key 1344 match the given lock value. Defaults to None. 1345 - debug (bool, optional): If True, the function will print debug information. Default is False. 1346 1347 Returns: 1348 - bool: True if the operation was successful, False otherwise. 1349 """ 1350 if not self.nolock() or len(self.__vault.history) == 0: 1351 return False 1352 if len(self.__vault.history) <= 0: 1353 return False 1354 ref = sorted(self.__vault.history.keys())[-1] 1355 if debug: 1356 print('recall', ref) 1357 memory = self.__vault.history[ref] 1358 if debug: 1359 print(type(memory), 'memory', memory) 1360 if lock is not None: 1361 assert self.__vault.lock == lock, "Invalid current lock" 1362 assert ref == lock, "Invalid last lock" 1363 assert self.__history(), "History mode should be enabled, found off!!!" 1364 limit = len(memory) + 1 1365 sub_positive_log_negative = 0 1366 for i in range(-1, -limit, -1): 1367 x = memory[i] 1368 if debug: 1369 print(type(x), x) 1370 match x.action: 1371 case Action.CREATE: 1372 if x.account is not None: 1373 if self.account_exists(x.account): 1374 if debug: 1375 print('account', self.__vault.account[x.account]) 1376 assert len(self.__vault.account[x.account].box) == 0 1377 assert len(self.__vault.account[x.account].log) == 0 1378 assert self.__vault.account[x.account].balance == 0 1379 assert self.__vault.account[x.account].count == 0 1380 if dry: 1381 continue 1382 del self.__vault.account[x.account] 1383 1384 case Action.TRACK: 1385 if x.account is not None: 1386 if self.account_exists(x.account): 1387 if dry: 1388 continue 1389 assert x.value is not None 1390 assert x.ref is not None 1391 self.__vault.account[x.account].balance -= x.value 1392 self.__vault.account[x.account].count -= 1 1393 del self.__vault.account[x.account].box[x.ref] 1394 1395 case Action.LOG: 1396 if x.account is not None: 1397 if self.account_exists(x.account): 1398 if x.ref in self.__vault.account[x.account].log: 1399 if dry: 1400 continue 1401 assert x.value is not None 1402 if sub_positive_log_negative == -x.value: 1403 self.__vault.account[x.account].count -= 1 1404 sub_positive_log_negative = 0 1405 box_ref = self.__vault.account[x.account].log[x.ref].ref 1406 if not box_ref is None: 1407 assert self.box_exists(x.account, box_ref) 1408 box_value = self.__vault.account[x.account].log[x.ref].value 1409 assert box_value < 0 1410 1411 try: 1412 self.__vault.account[x.account].box[box_ref].rest += -box_value 1413 except TypeError: 1414 self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value) 1415 1416 try: 1417 self.__vault.account[x.account].balance += -box_value 1418 except TypeError: 1419 self.__vault.account[x.account].balance += decimal.Decimal(-box_value) 1420 1421 self.__vault.account[x.account].count -= 1 1422 del self.__vault.account[x.account].log[x.ref] 1423 1424 case Action.SUBTRACT: 1425 if x.account is not None: 1426 if self.account_exists(x.account): 1427 if x.ref in self.__vault.account[x.account].box: 1428 if dry: 1429 continue 1430 assert x.value is not None 1431 self.__vault.account[x.account].box[x.ref].rest += x.value 1432 self.__vault.account[x.account].balance += x.value 1433 sub_positive_log_negative = x.value 1434 1435 case Action.ADD_FILE: 1436 if x.account is not None: 1437 if self.account_exists(x.account): 1438 if x.ref in self.__vault.account[x.account].log: 1439 if x.file in self.__vault.account[x.account].log[x.ref].file: 1440 if dry: 1441 continue 1442 del self.__vault.account[x.account].log[x.ref].file[x.file] 1443 1444 case Action.REMOVE_FILE: 1445 if x.account is not None: 1446 if self.account_exists(x.account): 1447 if x.ref in self.__vault.account[x.account].log: 1448 if dry: 1449 continue 1450 assert x.file is not None 1451 assert x.value is not None 1452 self.__vault.account[x.account].log[x.ref].file[x.file] = x.value 1453 1454 case Action.BOX_TRANSFER: 1455 if x.account is not None: 1456 if self.account_exists(x.account): 1457 if x.ref in self.__vault.account[x.account].box: 1458 if dry: 1459 continue 1460 assert x.value is not None 1461 self.__vault.account[x.account].box[x.ref].rest -= x.value 1462 1463 case Action.EXCHANGE: 1464 if x.account is not None: 1465 if x.account in self.__vault.exchange: 1466 if x.ref in self.__vault.exchange[x.account]: 1467 if dry: 1468 continue 1469 del self.__vault.exchange[x.account][x.ref] 1470 1471 case Action.REPORT: 1472 if x.ref in self.__vault.report: 1473 if dry: 1474 continue 1475 del self.__vault.report[x.ref] 1476 1477 case Action.ZAKAT: 1478 if x.account is not None: 1479 if self.account_exists(x.account): 1480 if x.ref in self.__vault.account[x.account].box: 1481 assert x.key is not None 1482 if hasattr(self.__vault.account[x.account].box[x.ref], x.key): 1483 if dry: 1484 continue 1485 match x.math: 1486 case MathOperation.ADDITION: 1487 setattr( 1488 self.__vault.account[x.account].box[x.ref], 1489 x.key, 1490 getattr(self.__vault.account[x.account].box[x.ref], x.key) - x.value, 1491 ) 1492 case MathOperation.EQUAL: 1493 setattr( 1494 self.__vault.account[x.account].box[x.ref], 1495 x.key, 1496 x.value, 1497 ) 1498 case MathOperation.SUBTRACTION: 1499 setattr( 1500 self.__vault.account[x.account].box[x.ref], 1501 x.key, 1502 getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value, 1503 ) 1504 1505 if not dry: 1506 del self.__vault.history[ref] 1507 return True 1508 1509 def vault(self) -> dict: 1510 """ 1511 Returns a copy of the internal vault dictionary. 1512 1513 This method is used to retrieve the current state of the ZakatTracker object. 1514 It provides a snapshot of the internal data structure, allowing for further 1515 processing or analysis. 1516 1517 Parameters: 1518 None 1519 1520 Returns: 1521 - dict: A copy of the internal vault dictionary. 1522 """ 1523 return dataclasses.asdict(self.__vault) 1524 1525 @staticmethod 1526 def stats_init() -> dict[str, tuple[int, str]]: 1527 """ 1528 Initialize and return a dictionary containing initial statistics for the ZakatTracker instance. 1529 1530 The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements: 1531 - The initial size of the respective statistic in bytes (int). 1532 - The initial size of the respective statistic in a human-readable format (str). 1533 1534 Parameters: 1535 None 1536 1537 Returns: 1538 - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance. 1539 """ 1540 return { 1541 'database': (0, '0'), 1542 'ram': (0, '0'), 1543 } 1544 1545 def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]: 1546 """ 1547 Calculates and returns statistics about the object's data storage. 1548 1549 This method determines the size of the database file on disk and the 1550 size of the data currently held in RAM (likely within a dictionary). 1551 Both sizes are reported in bytes and in a human-readable format 1552 (e.g., KB, MB). 1553 1554 Parameters: 1555 - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True 1556 1557 Returns: 1558 - dict[str, tuple[float, str]]: A dictionary containing the following statistics: 1559 1560 * 'database': A tuple with two elements: 1561 - The database file size in bytes (float). 1562 - The database file size in human-readable format (str). 1563 * 'ram': A tuple with two elements: 1564 - The RAM usage (dictionary size) in bytes (float). 1565 - The RAM usage in human-readable format (str). 1566 1567 Example: 1568 ```bash 1569 >>> x = ZakatTracker() 1570 >>> stats = x.stats() 1571 >>> print(stats['database']) 1572 (256000, '250.0 KB') 1573 >>> print(stats['ram']) 1574 (12345, '12.1 KB') 1575 ``` 1576 """ 1577 ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault()) 1578 file_size = os.path.getsize(self.path()) 1579 return { 1580 'database': (file_size, self.human_readable_size(file_size)), 1581 'ram': (ram_size, self.human_readable_size(ram_size)), 1582 } 1583 1584 def files(self) -> list[dict[str, str | int]]: 1585 """ 1586 Retrieves information about files associated with this class. 1587 1588 This class method provides a standardized way to gather details about 1589 files used by the class for storage, snapshots, and CSV imports. 1590 1591 Parameters: 1592 None 1593 1594 Returns: 1595 - list[dict[str, str | int]]: A list of dictionaries, each containing information 1596 about a specific file: 1597 1598 * type (str): The type of file ('database', 'snapshot', 'import_csv'). 1599 * path (str): The full file path. 1600 * exists (bool): Whether the file exists on the filesystem. 1601 * size (int): The file size in bytes (0 if the file doesn't exist). 1602 * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB'). 1603 1604 Example: 1605 ``` 1606 file_info = MyClass.files() 1607 for info in file_info: 1608 print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}') 1609 ``` 1610 """ 1611 result = [] 1612 for file_type, path in { 1613 'database': self.path(), 1614 'snapshot': self.snapshot_cache_path(), 1615 'import_csv': self.import_csv_cache_path(), 1616 }.items(): 1617 exists = os.path.exists(path) 1618 size = os.path.getsize(path) if exists else 0 1619 human_readable_size = self.human_readable_size(size) if exists else 0 1620 result.append({ 1621 'type': file_type, 1622 'path': path, 1623 'exists': exists, 1624 'size': size, 1625 'human_readable_size': human_readable_size, 1626 }) 1627 return result 1628 1629 def account_exists(self, account: AccountName) -> bool: 1630 """ 1631 Check if the given account exists in the vault. 1632 1633 Parameters: 1634 - account (AccountName): The account number to check. 1635 1636 Returns: 1637 - bool: True if the account exists, False otherwise. 1638 """ 1639 return account in self.__vault.account 1640 1641 def box_size(self, account: AccountName) -> int: 1642 """ 1643 Calculate the size of the box for a specific account. 1644 1645 Parameters: 1646 - account (AccountName): The account number for which the box size needs to be calculated. 1647 1648 Returns: 1649 - int: The size of the box for the given account. If the account does not exist, -1 is returned. 1650 """ 1651 if self.account_exists(account): 1652 return len(self.__vault.account[account].box) 1653 return -1 1654 1655 def log_size(self, account: AccountName) -> int: 1656 """ 1657 Get the size of the log for a specific account. 1658 1659 Parameters: 1660 - account (AccountName): The account number for which the log size needs to be calculated. 1661 1662 Returns: 1663 - int: The size of the log for the given account. If the account does not exist, -1 is returned. 1664 """ 1665 if self.account_exists(account): 1666 return len(self.__vault.account[account].log) 1667 return -1 1668 1669 @staticmethod 1670 def hash_data(data: bytes, algorithm: str = 'blake2b') -> str: 1671 """ 1672 Calculates the hash of given byte data using the specified algorithm. 1673 1674 Parameters: 1675 - data (bytes): The byte data to hash. 1676 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 1677 1678 Returns: 1679 - str: The hexadecimal representation of the data's hash. 1680 """ 1681 hash_obj = hashlib.new(algorithm) 1682 hash_obj.update(data) 1683 return hash_obj.hexdigest() 1684 1685 @staticmethod 1686 def hash_file(file_path: str, algorithm: str = 'blake2b') -> str: 1687 """ 1688 Calculates the hash of a file using the specified algorithm. 1689 1690 Parameters: 1691 - file_path (str): The path to the file. 1692 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 1693 1694 Returns: 1695 - str: The hexadecimal representation of the file's hash. 1696 """ 1697 hash_obj = hashlib.new(algorithm) # Create the hash object 1698 with open(file_path, 'rb') as file: # Open file in binary mode for reading 1699 for chunk in iter(lambda: file.read(4096), b''): # Read file in chunks 1700 hash_obj.update(chunk) 1701 return hash_obj.hexdigest() # Return the hash as a hexadecimal string 1702 1703 def snapshot_cache_path(self): 1704 """ 1705 Generate the path for the cache file used to store snapshots. 1706 1707 The cache file is a json file that stores the timestamps of the snapshots. 1708 The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'. 1709 1710 Parameters: 1711 None 1712 1713 Returns: 1714 - str: The path to the cache file. 1715 """ 1716 path = str(self.path()) 1717 ext = self.ext() 1718 ext_len = len(ext) 1719 if path.endswith(f'.{ext}'): 1720 path = path[:-ext_len - 1] 1721 _, filename = os.path.split(path + f'.snapshots.{ext}') 1722 return self.base_path(filename) 1723 1724 def snapshot(self) -> bool: 1725 """ 1726 This function creates a snapshot of the current database state. 1727 1728 The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. 1729 If a snapshot with the same hash exists, the function returns True without creating a new snapshot. 1730 If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state 1731 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. 1732 1733 Parameters: 1734 None 1735 1736 Returns: 1737 - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails. 1738 """ 1739 current_hash = self.hash_file(self.path()) 1740 cache: dict[str, int] = {} # hash: time_ns 1741 try: 1742 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 1743 cache = json.load(stream, cls=JSONDecoder) 1744 except: 1745 pass 1746 if current_hash in cache: 1747 return True 1748 ref = time.time_ns() 1749 cache[current_hash] = ref 1750 if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')): 1751 return False 1752 with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream: 1753 stream.write(json.dumps(cache, cls=JSONEncoder)) 1754 return True 1755 1756 def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \ 1757 -> dict[int, tuple[str, str, bool]]: 1758 """ 1759 Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status. 1760 1761 Parameters: 1762 - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True. 1763 - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False. 1764 1765 Returns: 1766 - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, 1767 and the values are tuples containing the snapshot's hash, path, and existence status. 1768 """ 1769 cache: dict[str, int] = {} # hash: time_ns 1770 try: 1771 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 1772 cache = json.load(stream, cls=JSONDecoder) 1773 except: 1774 pass 1775 if not cache: 1776 return {} 1777 result: dict[int, tuple[str, str, bool]] = {} # time_ns: (hash, path, exists) 1778 for hash_file, ref in cache.items(): 1779 path = self.base_path('snapshots', f'{ref}.{self.ext()}') 1780 exists = os.path.exists(path) 1781 valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True 1782 if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists): 1783 continue 1784 if exists or not hide_missing: 1785 result[ref] = (hash_file, path, exists) 1786 return result 1787 1788 def ref_exists(self, account: AccountName, ref_type: str, ref: Timestamp) -> bool: 1789 """ 1790 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 1791 1792 Parameters: 1793 - account (AccountName): The account number for which to check the existence of the reference. 1794 - ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 1795 - ref (Timestamp): The reference (transaction) number to check for existence. 1796 1797 Returns: 1798 - bool: True if the reference exists for the given account and reference type, False otherwise. 1799 """ 1800 if account in self.__vault.account: 1801 return ref in getattr(self.__vault.account[account], ref_type) 1802 return False 1803 1804 def box_exists(self, account: AccountName, ref: Timestamp) -> bool: 1805 """ 1806 Check if a specific box (transaction) exists in the vault for a given account and reference. 1807 1808 Parameters: 1809 - account (AccountName): The account number for which to check the existence of the box. 1810 - ref (Timestamp): The reference (transaction) number to check for existence. 1811 1812 Returns: 1813 - bool: True if the box exists for the given account and reference, False otherwise. 1814 """ 1815 return self.ref_exists(account, 'box', ref) 1816 1817 def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = AccountName('1'), 1818 created_time_ns: Optional[Timestamp] = None, 1819 debug: bool = False) -> Timestamp: 1820 """ 1821 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. 1822 1823 Parameters: 1824 - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0. 1825 - desc (str, optional): The description of the transaction. Default is an empty string. 1826 - account (AccountName, optional): The account for which the transaction is being tracked. Default is '1'. 1827 - 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. 1828 - debug (bool, optional): Whether to print debug information. Default is False. 1829 1830 Returns: 1831 - Timestamp: The timestamp of the transaction in nanoseconds since epoch(1AD). 1832 1833 Raises: 1834 - ValueError: The created_time_ns should be greater than zero. 1835 - ValueError: The log transaction happened again in the same nanosecond time. 1836 - ValueError: The box transaction happened again in the same nanosecond time. 1837 """ 1838 return self.__track( 1839 unscaled_value=unscaled_value, 1840 desc=desc, 1841 account=account, 1842 logging=True, 1843 created_time_ns=created_time_ns, 1844 debug=debug, 1845 ) 1846 1847 def __track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = AccountName('1'), 1848 logging: bool = True, 1849 created_time_ns: Optional[Timestamp] = None, 1850 debug: bool = False) -> Timestamp: 1851 """ 1852 Internal function to track a transaction. 1853 1854 This function handles the core logic for tracking a transaction, including account creation, logging, and box creation. 1855 1856 Parameters: 1857 - unscaled_value (float | int | decimal.Decimal, optional): The monetary value of the transaction. Defaults to 0. 1858 - desc (str, optional): A description of the transaction. Defaults to an empty string. 1859 - account (AccountName, optional): The name of the account to track the transaction for. Defaults to '1'. 1860 - logging (bool, optional): Enables transaction logging. Defaults to True. 1861 - 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. 1862 - debug (bool, optional): Enables debug printing. Defaults to False. 1863 1864 Returns: 1865 - Timestamp: The timestamp of the transaction in nanoseconds since the epoch. 1866 1867 Raises: 1868 - ValueError: If `created_time_ns` is not greater than zero. 1869 - ValueError: If a box transaction already exists for the given `account` and `created_time_ns`. 1870 """ 1871 if debug: 1872 print('track', f'unscaled_value={unscaled_value}, debug={debug}') 1873 if created_time_ns is None: 1874 created_time_ns = Time.time() 1875 if created_time_ns <= 0: 1876 raise ValueError('The created should be greater than zero.') 1877 no_lock = self.nolock() 1878 lock = self.__lock() 1879 if not self.account_exists(account): 1880 if debug: 1881 print(f'account {account} created') 1882 self.__vault.account[account] = Account( 1883 balance=0, 1884 created=created_time_ns, 1885 ) 1886 self.__step(Action.CREATE, account) 1887 if unscaled_value == 0: 1888 if no_lock: 1889 assert lock is not None 1890 self.free(lock) 1891 return NO_TIME() 1892 value = self.scale(unscaled_value) 1893 if logging: 1894 self.__log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 1895 if debug: 1896 print('create-box', created_time_ns) 1897 if self.box_exists(account, created_time_ns): 1898 raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).') 1899 if debug: 1900 print('created-box', created_time_ns) 1901 self.__vault.account[account].box[created_time_ns] = Box( 1902 capital=value, 1903 count=0, 1904 last=0, 1905 rest=value, 1906 total=0, 1907 ) 1908 self.__step(Action.TRACK, account, ref=created_time_ns, value=value) 1909 if no_lock: 1910 assert lock is not None 1911 self.free(lock) 1912 return created_time_ns 1913 1914 def log_exists(self, account: AccountName, ref: Timestamp) -> bool: 1915 """ 1916 Checks if a specific transaction log entry exists for a given account. 1917 1918 Parameters: 1919 - account (AccountName): The account number associated with the transaction log. 1920 - ref (Timestamp): The reference to the transaction log entry. 1921 1922 Returns: 1923 - bool: True if the transaction log entry exists, False otherwise. 1924 """ 1925 return self.ref_exists(account, 'log', ref) 1926 1927 def __log(self, value: int, desc: str = '', account: AccountName = AccountName('1'), 1928 created_time_ns: Optional[Timestamp] = None, 1929 ref: Optional[Timestamp] = None, 1930 debug: bool = False) -> Timestamp: 1931 """ 1932 Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details. 1933 It also creates a step in the history of the transaction. 1934 1935 Parameters: 1936 - value (int): The value of the transaction. 1937 - desc (str, optional): The description of the transaction. 1938 - account (AccountName, optional): The account to log the transaction into. Default is '1'. 1939 - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 1940 If not provided, it will be generated. 1941 - ref (Timestamp, optional): The reference of the object. 1942 - debug (bool, optional): Whether to print debug information. Default is False. 1943 1944 Returns: 1945 - Timestamp: The timestamp of the logged transaction. 1946 1947 Raises: 1948 - ValueError: The created_time_ns should be greater than zero. 1949 - ValueError: The log transaction happened again in the same nanosecond time. 1950 """ 1951 if debug: 1952 print('_log', f'debug={debug}') 1953 if created_time_ns is None: 1954 created_time_ns = Time.time() 1955 if created_time_ns <= 0: 1956 raise ValueError('The created should be greater than zero.') 1957 try: 1958 self.__vault.account[account].balance += value 1959 except TypeError: 1960 self.__vault.account[account].balance += decimal.Decimal(value) 1961 self.__vault.account[account].count += 1 1962 if debug: 1963 print('create-log', created_time_ns) 1964 if self.log_exists(account, created_time_ns): 1965 raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).') 1966 if debug: 1967 print('created-log', created_time_ns) 1968 self.__vault.account[account].log[created_time_ns] = Log( 1969 value=value, 1970 desc=desc, 1971 ref=ref, 1972 file={}, 1973 ) 1974 self.__step(Action.LOG, account, ref=created_time_ns, value=value) 1975 return created_time_ns 1976 1977 def exchange(self, account: AccountName, created_time_ns: Optional[Timestamp] = None, 1978 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange: 1979 """ 1980 This method is used to record or retrieve exchange rates for a specific account. 1981 1982 Parameters: 1983 - account (AccountName): The account number for which the exchange rate is being recorded or retrieved. 1984 - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 1985 - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 1986 - description (str, optional): A description of the exchange rate. 1987 - debug (bool, optional): Whether to print debug information. Default is False. 1988 1989 Returns: 1990 - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 1991 it returns a dictionary with default values for the rate and description. 1992 1993 Raises: 1994 - ValueError: The created should be greater than zero. 1995 """ 1996 if debug: 1997 print('exchange', f'debug={debug}') 1998 if created_time_ns is None: 1999 created_time_ns = Time.time() 2000 if created_time_ns <= 0: 2001 raise ValueError('The created should be greater than zero.') 2002 if rate is not None: 2003 if rate <= 0: 2004 return Exchange() 2005 if account not in self.__vault.exchange: 2006 self.__vault.exchange[account] = {} 2007 if len(self.__vault.exchange[account]) == 0 and rate <= 1: 2008 return Exchange(time=created_time_ns, rate=1) 2009 no_lock = self.nolock() 2010 lock = self.__lock() 2011 self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description) 2012 self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate) 2013 if no_lock: 2014 assert lock is not None 2015 self.free(lock) 2016 if debug: 2017 print('exchange-created-1', 2018 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2019 2020 if account in self.__vault.exchange: 2021 valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns] 2022 if valid_rates: 2023 latest_rate = max(valid_rates, key=lambda x: x[0]) 2024 if debug: 2025 print('exchange-read-1', 2026 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}', 2027 'latest_rate', latest_rate) 2028 result = latest_rate[1] 2029 result.time = latest_rate[0] 2030 return result # إرجاع قاموس يحتوي على المعدل والوصف 2031 if debug: 2032 print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2033 return Exchange(time=created_time_ns, rate=1, description=None) # إرجاع القيمة الافتراضية مع وصف فارغ 2034 2035 @staticmethod 2036 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 2037 """ 2038 This function calculates the exchanged amount of a currency. 2039 2040 Parameters: 2041 - x (float): The original amount of the currency. 2042 - x_rate (float): The exchange rate of the original currency. 2043 - y_rate (float): The exchange rate of the target currency. 2044 2045 Returns: 2046 - float: The exchanged amount of the target currency. 2047 """ 2048 return (x * x_rate) / y_rate 2049 2050 def exchanges(self) -> dict: 2051 """ 2052 Retrieve the recorded exchange rates for all accounts. 2053 2054 Parameters: 2055 None 2056 2057 Returns: 2058 - dict: A dictionary containing all recorded exchange rates. 2059 The keys are account names or numbers, and the values are dictionaries containing the exchange rates. 2060 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 2061 """ 2062 return self.__vault.exchange.copy() 2063 2064 def accounts(self) -> dict: 2065 """ 2066 Returns a dictionary containing account numbers as keys and their respective balances as values. 2067 2068 Parameters: 2069 None 2070 2071 Returns: 2072 - dict: A dictionary where keys are account numbers and values are their respective balances. 2073 """ 2074 result = {} 2075 for i in self.__vault.account: 2076 result[i] = self.__vault.account[i].balance 2077 return result 2078 2079 def boxes(self, account: AccountName) -> dict: 2080 """ 2081 Retrieve the boxes (transactions) associated with a specific account. 2082 2083 Parameters: 2084 - account (AccountName): The account number for which to retrieve the boxes. 2085 2086 Returns: 2087 - dict: A dictionary containing the boxes associated with the given account. 2088 If the account does not exist, an empty dictionary is returned. 2089 """ 2090 if self.account_exists(account): 2091 return self.__vault.account[account].box 2092 return {} 2093 2094 def logs(self, account: AccountName) -> dict[Timestamp, Log]: 2095 """ 2096 Retrieve the logs (transactions) associated with a specific account. 2097 2098 Parameters: 2099 - account (AccountName): The account number for which to retrieve the logs. 2100 2101 Returns: 2102 - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. 2103 If the account does not exist, an empty dictionary is returned. 2104 """ 2105 if self.account_exists(account): 2106 return self.__vault.account[account].log 2107 return {} 2108 2109 @staticmethod 2110 def daily_logs_init() -> dict[str, dict]: 2111 """ 2112 Initialize a dictionary to store daily, weekly, monthly, and yearly logs. 2113 2114 Parameters: 2115 None 2116 2117 Returns: 2118 - dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary. 2119 Later each key maps to another dictionary, which will store the logs for the corresponding time period. 2120 """ 2121 return { 2122 'daily': {}, 2123 'weekly': {}, 2124 'monthly': {}, 2125 'yearly': {}, 2126 } 2127 2128 def daily_logs(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False): 2129 """ 2130 Retrieve the daily logs (transactions) from all accounts. 2131 2132 The function groups the logs by day, month, and year, and calculates the total value for each group. 2133 It returns a dictionary where the keys are the timestamps of the daily groups, 2134 and the values are dictionaries containing the total value and the logs for that group. 2135 2136 Parameters: 2137 - weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday. 2138 - debug (bool, optional): Whether to print debug information. Default is False. 2139 2140 Returns: 2141 - dict: A dictionary containing the daily logs. 2142 2143 Example: 2144 ```bash 2145 >>> tracker = ZakatTracker() 2146 >>> tracker.subtract(51, 'desc', 'account1') 2147 >>> ref = tracker.track(100, 'desc', 'account2') 2148 >>> tracker.add_file('account2', ref, 'file_0') 2149 >>> tracker.add_file('account2', ref, 'file_1') 2150 >>> tracker.add_file('account2', ref, 'file_2') 2151 >>> tracker.daily_logs() 2152 { 2153 'daily': { 2154 '2024-06-30': { 2155 'positive': 100, 2156 'negative': 51, 2157 'total': 99, 2158 'rows': [ 2159 { 2160 'account': 'account1', 2161 'desc': 'desc', 2162 'file': {}, 2163 'ref': None, 2164 'value': -51, 2165 'time': 1690977015000000000, 2166 'transfer': False, 2167 }, 2168 { 2169 'account': 'account2', 2170 'desc': 'desc', 2171 'file': { 2172 1722919011626770944: 'file_0', 2173 1722919011626812928: 'file_1', 2174 1722919011626846976: 'file_2', 2175 }, 2176 'ref': None, 2177 'value': 100, 2178 'time': 1690977015000000000, 2179 'transfer': False, 2180 }, 2181 ], 2182 }, 2183 }, 2184 'weekly': { 2185 datetime: { 2186 'positive': 100, 2187 'negative': 51, 2188 'total': 99, 2189 }, 2190 }, 2191 'monthly': { 2192 '2024-06': { 2193 'positive': 100, 2194 'negative': 51, 2195 'total': 99, 2196 }, 2197 }, 2198 'yearly': { 2199 2024: { 2200 'positive': 100, 2201 'negative': 51, 2202 'total': 99, 2203 }, 2204 }, 2205 } 2206 ``` 2207 """ 2208 logs = {} 2209 for account in self.accounts(): 2210 for k, v in self.logs(account).items(): 2211 l = dataclasses.asdict(v) 2212 l['time'] = k 2213 l['account'] = account 2214 if k not in logs: 2215 logs[k] = [] 2216 logs[k].append(l) 2217 if debug: 2218 print('logs', logs) 2219 y = self.daily_logs_init() 2220 for i in sorted(logs, reverse=True): 2221 dt = Time.time_to_datetime(i) 2222 daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}' 2223 weekly = dt - datetime.timedelta(days=weekday.value) 2224 monthly = f'{dt.year}-{dt.month:02d}' 2225 yearly = dt.year 2226 # daily 2227 if daily not in y['daily']: 2228 y['daily'][daily] = { 2229 'positive': 0, 2230 'negative': 0, 2231 'total': 0, 2232 'rows': [], 2233 } 2234 transfer = len(logs[i]) > 1 2235 if debug: 2236 print('logs[i]', logs[i]) 2237 for z in logs[i]: 2238 if debug: 2239 print('z', z) 2240 # daily 2241 value = z['value'] 2242 if value > 0: 2243 y['daily'][daily]['positive'] += value 2244 else: 2245 y['daily'][daily]['negative'] += -value 2246 y['daily'][daily]['total'] += value 2247 z['transfer'] = transfer 2248 y['daily'][daily]['rows'].append(z) 2249 # weekly 2250 if weekly not in y['weekly']: 2251 y['weekly'][weekly] = { 2252 'positive': 0, 2253 'negative': 0, 2254 'total': 0, 2255 } 2256 if value > 0: 2257 y['weekly'][weekly]['positive'] += value 2258 else: 2259 y['weekly'][weekly]['negative'] += -value 2260 y['weekly'][weekly]['total'] += value 2261 # monthly 2262 if monthly not in y['monthly']: 2263 y['monthly'][monthly] = { 2264 'positive': 0, 2265 'negative': 0, 2266 'total': 0, 2267 } 2268 if value > 0: 2269 y['monthly'][monthly]['positive'] += value 2270 else: 2271 y['monthly'][monthly]['negative'] += -value 2272 y['monthly'][monthly]['total'] += value 2273 # yearly 2274 if yearly not in y['yearly']: 2275 y['yearly'][yearly] = { 2276 'positive': 0, 2277 'negative': 0, 2278 'total': 0, 2279 } 2280 if value > 0: 2281 y['yearly'][yearly]['positive'] += value 2282 else: 2283 y['yearly'][yearly]['negative'] += -value 2284 y['yearly'][yearly]['total'] += value 2285 if debug: 2286 print('y', y) 2287 return y 2288 2289 def add_file(self, account: AccountName, ref: Timestamp, path: str) -> Timestamp: 2290 """ 2291 Adds a file reference to a specific transaction log entry in the vault. 2292 2293 Parameters: 2294 - account (AccountName): The account number associated with the transaction log. 2295 - ref (Timestamp): The reference to the transaction log entry. 2296 - path (str): The path of the file to be added. 2297 2298 Returns: 2299 - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 2300 """ 2301 if self.account_exists(account): 2302 if ref in self.__vault.account[account].log: 2303 no_lock = self.nolock() 2304 lock = self.__lock() 2305 file_ref = Time.time() 2306 self.__vault.account[account].log[ref].file[file_ref] = path 2307 self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref) 2308 if no_lock: 2309 assert lock is not None 2310 self.free(lock) 2311 return file_ref 2312 return Timestamp(0) 2313 2314 def remove_file(self, account: AccountName, ref: Timestamp, file_ref: Timestamp) -> bool: 2315 """ 2316 Removes a file reference from a specific transaction log entry in the vault. 2317 2318 Parameters: 2319 - account (AccountName): The account number associated with the transaction log. 2320 - ref (Timestamp): The reference to the transaction log entry. 2321 - file_ref (Timestamp): The reference of the file to be removed. 2322 2323 Returns: 2324 - bool: True if the file reference is successfully removed, False otherwise. 2325 """ 2326 if self.account_exists(account): 2327 if ref in self.__vault.account[account].log: 2328 if file_ref in self.__vault.account[account].log[ref].file: 2329 no_lock = self.nolock() 2330 lock = self.__lock() 2331 x = self.__vault.account[account].log[ref].file[file_ref] 2332 del self.__vault.account[account].log[ref].file[file_ref] 2333 self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 2334 if no_lock: 2335 assert lock is not None 2336 self.free(lock) 2337 return True 2338 return False 2339 2340 def balance(self, account: AccountName = AccountName('1'), cached: bool = True) -> int: 2341 """ 2342 Calculate and return the balance of a specific account. 2343 2344 Parameters: 2345 - account (AccountName, optional): The account number. Default is '1'. 2346 - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 2347 2348 Returns: 2349 - int: The balance of the account. 2350 2351 Notes: 2352 - If cached is True, the function returns the cached balance. 2353 - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 2354 """ 2355 if cached: 2356 return self.__vault.account[account].balance 2357 x = 0 2358 return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1] 2359 2360 def hide(self, account: AccountName, status: Optional[bool] = None) -> bool: 2361 """ 2362 Check or set the hide status of a specific account. 2363 2364 Parameters: 2365 - account (AccountName): The account number. 2366 - status (bool, optional): The new hide status. If not provided, the function will return the current status. 2367 2368 Returns: 2369 - bool: The current or updated hide status of the account. 2370 2371 Raises: 2372 None 2373 2374 Example: 2375 ```bash 2376 >>> tracker = ZakatTracker() 2377 >>> ref = tracker.track(51, 'desc', 'account1') 2378 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 2379 False 2380 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 2381 True 2382 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 2383 True 2384 >>> tracker.hide('account1', False) 2385 False 2386 ``` 2387 """ 2388 if self.account_exists(account): 2389 if status is None: 2390 return self.__vault.account[account].hide 2391 self.__vault.account[account].hide = status 2392 return status 2393 return False 2394 2395 def zakatable(self, account: AccountName, status: Optional[bool] = None) -> bool: 2396 """ 2397 Check or set the zakatable status of a specific account. 2398 2399 Parameters: 2400 - account (AccountName): The account number. 2401 - status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 2402 2403 Returns: 2404 - bool: The current or updated zakatable status of the account. 2405 2406 Raises: 2407 None 2408 2409 Example: 2410 ```bash 2411 >>> tracker = ZakatTracker() 2412 >>> ref = tracker.track(51, 'desc', 'account1') 2413 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 2414 True 2415 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 2416 True 2417 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 2418 True 2419 >>> tracker.zakatable('account1', False) 2420 False 2421 ``` 2422 """ 2423 if self.account_exists(account): 2424 if status is None: 2425 return self.__vault.account[account].zakatable 2426 self.__vault.account[account].zakatable = status 2427 return status 2428 return False 2429 2430 def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountName = AccountName('1'), 2431 created_time_ns: Optional[Timestamp] = None, 2432 debug: bool = False) \ 2433 -> SubtractReport: 2434 """ 2435 Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, 2436 the remaining amount will be transferred to a new transaction with a negative value. 2437 2438 Parameters: 2439 - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted. 2440 - desc (str, optional): A description for the transaction. Defaults to an empty string. 2441 - account (AccountName, optional): The account from which the value will be subtracted. Defaults to '1'. 2442 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 2443 If not provided, the current timestamp will be used. 2444 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 2445 2446 Returns: 2447 - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 2448 2449 Raises: 2450 - ValueError: The unscaled_value should be greater than zero. 2451 - ValueError: The created_time_ns should be greater than zero. 2452 - ValueError: The box transaction happened again in the same nanosecond time. 2453 - ValueError: The log transaction happened again in the same nanosecond time. 2454 """ 2455 if debug: 2456 print('sub', f'debug={debug}') 2457 if unscaled_value <= 0: 2458 raise ValueError('The unscaled_value should be greater than zero.') 2459 if created_time_ns is None: 2460 created_time_ns = Time.time() 2461 if created_time_ns <= 0: 2462 raise ValueError('The created should be greater than zero.') 2463 no_lock = self.nolock() 2464 lock = self.__lock() 2465 self.__track(0, '', account) 2466 value = self.scale(unscaled_value) 2467 self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 2468 ids = sorted(self.__vault.account[account].box.keys()) 2469 limit = len(ids) + 1 2470 target = value 2471 if debug: 2472 print('ids', ids) 2473 ages = SubtractAges() 2474 for i in range(-1, -limit, -1): 2475 if target == 0: 2476 break 2477 j = ids[i] 2478 if debug: 2479 print('i', i, 'j', j) 2480 rest = self.__vault.account[account].box[j].rest 2481 if rest >= target: 2482 self.__vault.account[account].box[j].rest -= target 2483 self.__step(Action.SUBTRACT, account, ref=j, value=target) 2484 ages.append(SubtractAge(box_ref=j, total=target)) 2485 target = 0 2486 break 2487 elif target > rest > 0: 2488 chunk = rest 2489 target -= chunk 2490 self.__vault.account[account].box[j].rest = 0 2491 self.__step(Action.SUBTRACT, account, ref=j, value=chunk) 2492 ages.append(SubtractAge(box_ref=j, total=chunk)) 2493 if target > 0: 2494 self.__track( 2495 unscaled_value=self.unscale(-target), 2496 desc=desc, 2497 account=account, 2498 logging=False, 2499 created_time_ns=created_time_ns, 2500 ) 2501 ages.append(SubtractAge(box_ref=created_time_ns, total=target)) 2502 if no_lock: 2503 assert lock is not None 2504 self.free(lock) 2505 return SubtractReport( 2506 log_ref=created_time_ns, 2507 ages=ages, 2508 ) 2509 2510 def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountName, to_account: AccountName, desc: str = '', 2511 created_time_ns: Optional[Timestamp] = None, 2512 debug: bool = False) -> TransferReport: 2513 """ 2514 Transfers a specified value from one account to another. 2515 2516 Parameters: 2517 - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred. 2518 - from_account (AccountName): The account from which the value will be transferred. 2519 - to_account (AccountName): The account to which the value will be transferred. 2520 - desc (str, optional): A description for the transaction. Defaults to an empty string. 2521 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used. 2522 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 2523 2524 Returns: 2525 - TransferReport: A class of timestamps corresponding to the transactions made during the transfer. 2526 2527 Raises: 2528 - ValueError: Transfer to the same account is forbidden. 2529 - ValueError: The created_time_ns should be greater than zero. 2530 - ValueError: The box transaction happened again in the same nanosecond time. 2531 - ValueError: The log transaction happened again in the same nanosecond time. 2532 """ 2533 if debug: 2534 print('transfer', f'debug={debug}') 2535 if from_account == to_account: 2536 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 2537 if unscaled_amount <= 0: 2538 return [] 2539 if created_time_ns is None: 2540 created_time_ns = Time.time() 2541 if created_time_ns <= 0: 2542 raise ValueError('The created should be greater than zero.') 2543 no_lock = self.nolock() 2544 lock = self.__lock() 2545 subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug) 2546 source_exchange = self.exchange(from_account, created_time_ns) 2547 target_exchange = self.exchange(to_account, created_time_ns) 2548 2549 if debug: 2550 print('ages', subtract_report.ages) 2551 2552 transfer_report = TransferReport() 2553 for subtract in subtract_report.ages: 2554 times = TransferTimes() 2555 age = subtract.box_ref 2556 value = subtract.total 2557 assert source_exchange.rate is not None 2558 assert target_exchange.rate is not None 2559 target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate)) 2560 if debug: 2561 print('target_amount', target_amount) 2562 # Perform the transfer 2563 if self.box_exists(to_account, age): 2564 if debug: 2565 print('box_exists', age) 2566 capital = self.__vault.account[to_account].box[age].capital 2567 rest = self.__vault.account[to_account].box[age].rest 2568 if debug: 2569 print( 2570 f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 2571 selected_age = age 2572 if rest + target_amount > capital: 2573 self.__vault.account[to_account].box[age].capital += target_amount 2574 selected_age = Time.time() 2575 self.__vault.account[to_account].box[age].rest += target_amount 2576 self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 2577 y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 2578 created_time_ns=None, ref=None, debug=debug) 2579 times.append(TransferTime(box_ref=age, log_ref=y)) 2580 continue 2581 if debug: 2582 print( 2583 f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 2584 box_ref = self.__track( 2585 unscaled_value=self.unscale(int(target_amount)), 2586 desc=desc, 2587 account=to_account, 2588 logging=True, 2589 created_time_ns=age, 2590 debug=debug, 2591 ) 2592 transfer_report.append(TransferRecord( 2593 box_ref=box_ref, 2594 times=times, 2595 )) 2596 if no_lock: 2597 assert lock is not None 2598 self.free(lock) 2599 return transfer_report 2600 2601 def check(self, 2602 silver_gram_price: float, 2603 unscaled_nisab: Optional[float | int | decimal.Decimal] = None, 2604 debug: bool = False, 2605 created_time_ns: Optional[Timestamp] = None, 2606 cycle: Optional[float] = None) -> ZakatReport: 2607 """ 2608 Check the eligibility for Zakat based on the given parameters. 2609 2610 Parameters: 2611 - silver_gram_price (float): The price of a gram of silver. 2612 - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. 2613 If not provided, it will be calculated based on the silver_gram_price. 2614 - debug (bool, optional): Flag to enable debug mode. 2615 - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time(). 2616 - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 2617 2618 Returns: 2619 - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, 2620 a list of brief statistics, and a dictionary containing the Zakat plan. 2621 """ 2622 if debug: 2623 print('check', f'debug={debug}') 2624 if created_time_ns is None: 2625 created_time_ns = Time.time() 2626 if cycle is None: 2627 cycle = ZakatTracker.TimeCycle() 2628 if unscaled_nisab is None: 2629 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 2630 nisab = self.scale(unscaled_nisab) 2631 plan = ZakatPlan() 2632 statistics = ZakatReportStatistics() 2633 below_nisab = 0 2634 valid = False 2635 if debug: 2636 print('exchanges', self.exchanges()) 2637 for x in self.__vault.account: 2638 if not self.zakatable(x): 2639 continue 2640 _box = self.__vault.account[x].box 2641 _log = self.__vault.account[x].log 2642 limit = len(_box) + 1 2643 ids = sorted(self.__vault.account[x].box.keys()) 2644 for i in range(-1, -limit, -1): 2645 j = ids[i] 2646 rest = float(_box[j].rest) 2647 if rest <= 0: 2648 continue 2649 exchange = self.exchange(x, created_time_ns=Time.time()) 2650 assert exchange.rate is not None 2651 rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1) 2652 statistics.overall_wealth += rest 2653 epoch = (created_time_ns - j) / cycle 2654 if debug: 2655 print(f'Epoch: {epoch}', _box[j]) 2656 if _box[j].last > 0: 2657 epoch = (created_time_ns - _box[j].last) / cycle 2658 if debug: 2659 print(f'Epoch: {epoch}') 2660 epoch = math.floor(epoch) 2661 if debug: 2662 print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch) 2663 if epoch == 0: 2664 continue 2665 if debug: 2666 print('Epoch - PASSED') 2667 statistics.zakatable_transactions_balance += rest 2668 is_nisab = rest >= nisab 2669 total = 0 2670 if is_nisab: 2671 for _ in range(epoch): 2672 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 2673 valid = total > 0 2674 elif rest > 0: 2675 below_nisab += rest 2676 total = ZakatTracker.ZakatCut(float(rest)) 2677 if total > 0: 2678 if x not in plan: 2679 plan[x] = [] 2680 statistics.zakat_cut_balances += total 2681 plan[x].append(BoxPlan( 2682 below_nisab=not is_nisab, 2683 total=total, 2684 count=epoch, 2685 ref=j, 2686 box=_box[j], 2687 log=_log[j], 2688 exchange=exchange, 2689 )) 2690 valid = valid or below_nisab >= nisab 2691 if debug: 2692 print(f'below_nisab({below_nisab}) >= nisab({nisab})') 2693 return ZakatReport( 2694 valid=valid, 2695 statistics=statistics, 2696 plan=plan, 2697 ) 2698 2699 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts: 2700 """ 2701 Build payment parts for the Zakat distribution. 2702 2703 Parameters: 2704 - scaled_demand (int): The total demand for payment in local currency. 2705 - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True. 2706 2707 Returns: 2708 - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: 2709 { 2710 'account': { 2711 'account_id': {'balance': float, 'rate': float, 'part': float}, 2712 ... 2713 }, 2714 'exceed': bool, 2715 'demand': int, 2716 'total': float, 2717 } 2718 """ 2719 total = 0.0 2720 parts = PaymentParts( 2721 account={}, 2722 exceed=False, 2723 demand=int(round(scaled_demand)), 2724 total=0, 2725 ) 2726 for x, y in self.accounts().items(): 2727 if positive_only and y <= 0: 2728 continue 2729 total += float(y) 2730 exchange = self.exchange(x) 2731 parts.account[x] = AccountPaymentPart(balance=y, rate=exchange.rate, part=0) 2732 parts.total = total 2733 return parts 2734 2735 @staticmethod 2736 def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int: 2737 """ 2738 Checks the validity of payment parts. 2739 2740 Parameters: 2741 - parts (dict[str, PaymentParts): A dictionary containing payment parts information. 2742 - debug (bool, optional): Flag to enable debug mode. 2743 2744 Returns: 2745 - int: Returns 0 if the payment parts are valid, otherwise returns the error code. 2746 2747 Error Codes: 2748 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 2749 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 2750 3: 'part' value in parts['account'][x] is less than 0. 2751 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 2752 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 2753 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 2754 """ 2755 if debug: 2756 print('check_payment_parts', f'debug={debug}') 2757 # for i in ['demand', 'account', 'total', 'exceed']: 2758 # if i not in parts: 2759 # return 1 2760 exceed = parts.exceed 2761 # for j in ['balance', 'rate', 'part']: 2762 # if j not in parts.account[x]: 2763 # return 2 2764 for x in parts.account: 2765 if parts.account[x].part < 0: 2766 return 3 2767 if not exceed and parts.account[x].balance <= 0: 2768 return 4 2769 demand = parts.demand 2770 z = 0.0 2771 for _, y in parts.account.items(): 2772 if not exceed and y.part > y.balance: 2773 return 5 2774 z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0) 2775 z = round(z, 2) 2776 demand = round(demand, 2) 2777 if debug: 2778 print('check_payment_parts', f'z = {z}, demand = {demand}') 2779 print('check_payment_parts', type(z), type(demand)) 2780 print('check_payment_parts', z != demand) 2781 print('check_payment_parts', str(z) != str(demand)) 2782 if z != demand and str(z) != str(demand): 2783 return 6 2784 return 0 2785 2786 def zakat(self, report: ZakatReport, 2787 parts: Optional[PaymentParts] = None, debug: bool = False) -> bool: 2788 """ 2789 Perform Zakat calculation based on the given report and optional parts. 2790 2791 Parameters: 2792 - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan. 2793 - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat. 2794 - debug (bool, optional): A flag indicating whether to print debug information. 2795 2796 Returns: 2797 - bool: True if the zakat calculation is successful, False otherwise. 2798 """ 2799 if debug: 2800 print('zakat', f'debug={debug}') 2801 if not report.valid: 2802 return report.valid 2803 parts_exist = parts is not None 2804 if parts_exist: 2805 if self.check_payment_parts(parts, debug=debug) != 0: 2806 return False 2807 if debug: 2808 print('######### zakat #######') 2809 print('parts_exist', parts_exist) 2810 no_lock = self.nolock() 2811 lock = self.__lock() 2812 report_time = Time.time() 2813 self.__vault.report[report_time] = report 2814 self.__step(Action.REPORT, ref=report_time) 2815 created_time_ns = Time.time() 2816 for x in report.plan: 2817 target_exchange = self.exchange(x) 2818 if debug: 2819 print(report.plan[x]) 2820 print('-------------') 2821 print(self.__vault.account[x].box) 2822 if debug: 2823 print('plan[x]', report.plan[x]) 2824 for plan in report.plan[x]: 2825 j = plan.ref 2826 if debug: 2827 print('j', j) 2828 assert j 2829 self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].last, 2830 key='last', 2831 math_operation=MathOperation.EQUAL) 2832 self.__vault.account[x].box[j].last = created_time_ns 2833 assert target_exchange.rate is not None 2834 amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate)) 2835 self.__vault.account[x].box[j].total += amount 2836 self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 2837 math_operation=MathOperation.ADDITION) 2838 self.__vault.account[x].box[j].count += plan.count 2839 self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count', 2840 math_operation=MathOperation.ADDITION) 2841 if not parts_exist: 2842 try: 2843 self.__vault.account[x].box[j].rest -= amount 2844 except TypeError: 2845 self.__vault.account[x].box[j].rest -= decimal.Decimal(amount) 2846 # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 2847 # math_operation=MathOperation.SUBTRACTION) 2848 self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug) 2849 if parts_exist: 2850 for account, part in parts.account.items(): 2851 if part.part == 0: 2852 continue 2853 if debug: 2854 print('zakat-part', account, part.rate) 2855 target_exchange = self.exchange(account) 2856 assert target_exchange.rate is not None 2857 amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate) 2858 self.subtract( 2859 unscaled_value=self.unscale(int(amount)), 2860 desc='zakat-part-دفعة-زكاة', 2861 account=account, 2862 debug=debug, 2863 ) 2864 if no_lock: 2865 assert lock is not None 2866 self.free(lock) 2867 return True 2868 2869 @staticmethod 2870 def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]: 2871 """Splits a string at the last occurrence of a given symbol. 2872 2873 Parameters: 2874 - data (str): The input string. 2875 - symbol (str): The symbol to split at. 2876 2877 Returns: 2878 - tuple[str, str]: A tuple containing two strings, the part before the last symbol and 2879 the part after the last symbol. If the symbol is not found, returns (data, ""). 2880 """ 2881 last_symbol_index = data.rfind(symbol) 2882 2883 if last_symbol_index != -1: 2884 before_symbol = data[:last_symbol_index] 2885 after_symbol = data[last_symbol_index + len(symbol):] 2886 return before_symbol, after_symbol 2887 return data, "" 2888 2889 def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool: 2890 """ 2891 Saves the ZakatTracker's current state to a json file. 2892 2893 This method serializes the internal data (`__vault`). 2894 2895 Parameters: 2896 - path (str, optional): File path for saving. Defaults to a predefined location. 2897 - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True. 2898 2899 Returns: 2900 - bool: True if the save operation is successful, False otherwise. 2901 """ 2902 if path is None: 2903 path = self.path() 2904 # first save in tmp file 2905 temp = f'{path}.tmp' 2906 try: 2907 with open(temp, 'w', encoding='utf-8') as stream: 2908 data = json.dumps(self.__vault, cls=JSONEncoder) 2909 stream.write(data) 2910 if hash_required: 2911 hashed = self.hash_data(data.encode()) 2912 stream.write(f'//{hashed}') 2913 # then move tmp file to original location 2914 shutil.move(temp, path) 2915 return True 2916 except (IOError, OSError) as e: 2917 print(f'Error saving file: {e}') 2918 if os.path.exists(temp): 2919 os.remove(temp) 2920 return False 2921 2922 @staticmethod 2923 def load_vault_from_json(json_string: str) -> Vault: 2924 """Loads a Vault dataclass from a JSON string.""" 2925 data = json.loads(json_string) 2926 2927 vault = Vault() 2928 2929 # Load Accounts 2930 for account_name, account_data in data.get("account", {}).items(): 2931 account_name = AccountName(account_name) 2932 box_data = account_data.get('box', {}) 2933 box = {Timestamp(ts): Box(**box_data[str(ts)]) for ts in box_data} 2934 2935 log_data = account_data.get('log', {}) 2936 log = {Timestamp(ts): Log( 2937 value=log_data[str(ts)]['value'], 2938 desc=log_data[str(ts)]['desc'], 2939 ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None, 2940 file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()} 2941 ) for ts in log_data} 2942 2943 vault.account[account_name] = Account( 2944 balance=account_data["balance"], 2945 created=Timestamp(account_data["created"]), 2946 box=box, 2947 count=account_data.get("count", 0), 2948 log=log, 2949 hide=account_data.get("hide", False), 2950 zakatable=account_data.get("zakatable", True), 2951 ) 2952 2953 # Load Exchanges 2954 for account_name, exchange_data in data.get("exchange", {}).items(): 2955 account_name = AccountName(account_name) 2956 vault.exchange[account_name] = {} 2957 for timestamp, exchange_details in exchange_data.items(): 2958 vault.exchange[account_name][Timestamp(timestamp)] = Exchange( 2959 rate=exchange_details.get("rate"), 2960 description=exchange_details.get("description"), 2961 time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None 2962 ) 2963 2964 # Load History 2965 for timestamp, history_list in data.get("history", {}).items(): 2966 vault.history[Timestamp(timestamp)] = [] 2967 for history_data in history_list: 2968 vault.history[Timestamp(timestamp)].append(History( 2969 action=Action(history_data["action"]), 2970 account=AccountName(history_data["account"]) if history_data.get("account") is not None else None, 2971 ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None, 2972 file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None, 2973 key=history_data.get("key"), 2974 value=history_data.get("value"), 2975 math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None 2976 )) 2977 2978 # Load Lock 2979 vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None 2980 2981 # Load Report 2982 for timestamp, report_data in data.get("report", {}).items(): 2983 zakat_plan = ZakatPlan() 2984 for account_name, box_plans in report_data.get("plan", {}).items(): 2985 account_name = AccountName(account_name) 2986 zakat_plan[account_name] = [] 2987 for box_plan_data in box_plans: 2988 zakat_plan[account_name].append(BoxPlan( 2989 box=Box(**box_plan_data["box"]), 2990 log=Log(**box_plan_data["log"]), 2991 exchange=Exchange(**box_plan_data["exchange"]), 2992 below_nisab=box_plan_data["below_nisab"], 2993 total=box_plan_data["total"], 2994 count=box_plan_data["count"], 2995 ref=Timestamp(box_plan_data["ref"]) 2996 )) 2997 2998 vault.report[Timestamp(timestamp)] = ZakatReport( 2999 valid=report_data["valid"], 3000 statistics=ZakatReportStatistics(**report_data["statistics"]), 3001 plan=zakat_plan 3002 ) 3003 3004 return vault 3005 3006 def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool: 3007 """ 3008 Load the current state of the ZakatTracker object from a json file. 3009 3010 Parameters: 3011 - path (str, optional): The path where the json file is located. If not provided, it will use the default path. 3012 - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True. 3013 - debug (bool, optional): Flag to enable debug mode. 3014 3015 Returns: 3016 - bool: True if the load operation is successful, False otherwise. 3017 """ 3018 if path is None: 3019 path = self.path() 3020 try: 3021 if os.path.exists(path): 3022 with open(path, 'r', encoding='utf-8') as stream: 3023 file = stream.read() 3024 data, hashed = self.split_at_last_symbol(file, '//') 3025 if hash_required: 3026 assert hashed 3027 if debug: 3028 print('[debug-load]', hashed) 3029 new_hash = self.hash_data(data.encode()) 3030 if debug: 3031 print('[debug-load]', new_hash) 3032 assert hashed == new_hash, "Hash verification failed. File may be corrupted." 3033 self.__vault = self.load_vault_from_json(data) 3034 return True 3035 else: 3036 print(f'File not found: {path}') 3037 return False 3038 except (IOError, OSError) as e: 3039 print(f'Error loading file: {e}') 3040 return False 3041 3042 def import_csv_cache_path(self): 3043 """ 3044 Generates the cache file path for imported CSV data. 3045 3046 This function constructs the file path where cached data from CSV imports 3047 will be stored. The cache file is a json file (.json extension) appended 3048 to the base path of the object. 3049 3050 Parameters: 3051 None 3052 3053 Returns: 3054 - str: The full path to the import CSV cache file. 3055 3056 Example: 3057 ```bash 3058 >>> obj = ZakatTracker('/data/reports') 3059 >>> obj.import_csv_cache_path() 3060 '/data/reports.import_csv.json' 3061 ``` 3062 """ 3063 path = str(self.path()) 3064 ext = self.ext() 3065 ext_len = len(ext) 3066 if path.endswith(f'.{ext}'): 3067 path = path[:-ext_len - 1] 3068 _, filename = os.path.split(path + f'.import_csv.{ext}') 3069 return self.base_path(filename) 3070 3071 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple: 3072 """ 3073 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 3074 3075 Parameters: 3076 - path (str, optional): The path to the CSV file. Default is 'file.csv'. 3077 - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0. 3078 - debug (bool, optional): A flag indicating whether to print debug information. 3079 3080 Returns: 3081 - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 3082 and a dictionary of bad transactions. 3083 3084 Notes: 3085 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3086 are appropriate for the currency pairs involved in the conversions. 3087 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3088 to 1.0 or the previous rate for that account. 3089 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3090 transactions of the same account within the whole imported and existing dataset when doing `check` and 3091 `zakat` operations. 3092 3093 Example: 3094 The CSV file should have the following format, rate is optional per transaction: 3095 account, desc, value, date, rate 3096 For example: 3097 safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1 3098 """ 3099 if debug: 3100 print('import_csv', f'debug={debug}') 3101 cache: list[int] = [] 3102 try: 3103 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3104 cache = json.load(stream) 3105 except: 3106 pass 3107 date_formats = [ 3108 '%Y-%m-%d %H:%M:%S', 3109 '%Y-%m-%dT%H:%M:%S', 3110 '%Y-%m-%dT%H%M%S.%f', 3111 '%Y-%m-%d', 3112 ] 3113 created, found, bad = 0, 0, {} 3114 data: dict[int, list] = {} 3115 with open(path, newline='', encoding='utf-8') as f: 3116 i = 0 3117 for row in csv.reader(f, delimiter=','): 3118 i += 1 3119 hashed = hash(tuple(row)) 3120 if hashed in cache: 3121 found += 1 3122 continue 3123 account = row[0] 3124 desc = row[1] 3125 value = float(row[2]) 3126 rate = 1.0 3127 if row[4:5]: # Empty list if index is out of range 3128 rate = float(row[4]) 3129 date: int = 0 3130 for time_format in date_formats: 3131 try: 3132 date = Time.time(datetime.datetime.strptime(row[3], time_format)) 3133 break 3134 except: 3135 pass 3136 if date <= 0: 3137 bad[i] = row + ['invalid date'] 3138 if value == 0: 3139 bad[i] = row + ['invalid value'] 3140 continue 3141 if date not in data: 3142 data[date] = [] 3143 data[date].append((i, account, desc, value, date, rate, hashed)) 3144 3145 if debug: 3146 print('import_csv', len(data)) 3147 3148 if bad: 3149 return created, found, bad 3150 3151 no_lock = self.nolock() 3152 lock = self.__lock() 3153 for date, rows in sorted(data.items()): 3154 try: 3155 len_rows = len(rows) 3156 if len_rows == 1: 3157 (_, account, desc, unscaled_value, date, rate, hashed) = rows[0] 3158 value = self.unscale( 3159 unscaled_value, 3160 decimal_places=scale_decimal_places, 3161 ) if scale_decimal_places > 0 else unscaled_value 3162 if rate > 0: 3163 self.exchange(account=account, created_time_ns=date, rate=rate) 3164 if value > 0: 3165 self.track(unscaled_value=value, desc=desc, account=account, created_time_ns=date) 3166 elif value < 0: 3167 self.subtract(unscaled_value=-value, desc=desc, account=account, created_time_ns=date) 3168 created += 1 3169 cache.append(hashed) 3170 continue 3171 if debug: 3172 print('-- Duplicated time detected', date, 'len', len_rows) 3173 print(rows) 3174 print('---------------------------------') 3175 # If records are found at the same time with different accounts in the same amount 3176 # (one positive and the other negative), this indicates it is a transfer. 3177 if len_rows != 2: 3178 raise Exception(f'more than two transactions({len_rows}) at the same time') 3179 (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0] 3180 (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1] 3181 if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs( 3182 unscaled_value2) or date1 != date2: 3183 raise Exception('invalid transfer') 3184 if rate1 > 0: 3185 self.exchange(account1, created_time_ns=date1, rate=rate1) 3186 if rate2 > 0: 3187 self.exchange(account2, created_time_ns=date2, rate=rate2) 3188 value1 = self.unscale( 3189 unscaled_value1, 3190 decimal_places=scale_decimal_places, 3191 ) if scale_decimal_places > 0 else unscaled_value1 3192 value2 = self.unscale( 3193 unscaled_value2, 3194 decimal_places=scale_decimal_places, 3195 ) if scale_decimal_places > 0 else unscaled_value2 3196 values = { 3197 value1: account1, 3198 value2: account2, 3199 } 3200 self.transfer( 3201 unscaled_amount=abs(value1), 3202 from_account=values[min(values.keys())], 3203 to_account=values[max(values.keys())], 3204 desc=desc1, 3205 created_time_ns=date1, 3206 ) 3207 except Exception as e: 3208 for (i, account, desc, value, date, rate, _) in rows: 3209 bad[i] = (account, desc, value, date, rate, e) 3210 break 3211 with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream: 3212 stream.write(json.dumps(cache)) 3213 if no_lock: 3214 assert lock is not None 3215 self.free(lock) 3216 y = created, found, bad 3217 if debug: 3218 debug_path = f'{self.import_csv_cache_path()}.debug.json' 3219 with open(debug_path, 'w', encoding='utf-8') as file: 3220 json.dump(y, file, indent=4, cls=JSONEncoder) 3221 print(f'generated debug report @ `{debug_path}`...') 3222 return y 3223 3224 ######## 3225 # TESTS # 3226 ####### 3227 3228 @staticmethod 3229 def human_readable_size(size: float, decimal_places: int = 2) -> str: 3230 """ 3231 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 3232 3233 This function iterates through progressively larger units of information 3234 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 3235 range that can be expressed with a reasonable number before the unit. 3236 3237 Parameters: 3238 - size (float): The size in bytes to convert. 3239 - decimal_places (int, optional): The number of decimal places to display 3240 in the result. Defaults to 2. 3241 3242 Returns: 3243 - str: A string representation of the size in a human-readable format, 3244 rounded to the specified number of decimal places. For example: 3245 - '1.50 KB' (1536 bytes) 3246 - '23.00 MB' (24117248 bytes) 3247 - '1.23 GB' (1325899906 bytes) 3248 """ 3249 if type(size) not in (float, int): 3250 raise TypeError('size must be a float or integer') 3251 if type(decimal_places) != int: 3252 raise TypeError('decimal_places must be an integer') 3253 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 3254 if size < 1024.0: 3255 break 3256 size /= 1024.0 3257 return f'{size:.{decimal_places}f} {unit}' 3258 3259 @staticmethod 3260 def get_dict_size(obj: dict, seen: Optional[set] = None) -> float: 3261 """ 3262 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 3263 3264 This function traverses the dictionary structure, accounting for the size of keys, values, 3265 and any nested objects. It handles various data types commonly found in dictionaries 3266 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 3267 of circular references. 3268 3269 Parameters: 3270 - obj (dict): The dictionary whose size is to be calculated. 3271 - seen (set, optional): A set used internally to track visited objects 3272 and avoid circular references. Defaults to None. 3273 3274 Returns: 3275 - float: An approximate size of the dictionary and its contents in bytes. 3276 3277 Notes: 3278 - This function is a method of the `ZakatTracker` class and is likely used to 3279 estimate the memory footprint of data structures relevant to Zakat calculations. 3280 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 3281 not account for all memory overhead depending on the Python implementation. 3282 - Circular references are handled to prevent infinite recursion. 3283 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 3284 - String sizes are estimated based on character length and encoding. 3285 """ 3286 size = 0 3287 if seen is None: 3288 seen = set() 3289 3290 obj_id = id(obj) 3291 if obj_id in seen: 3292 return 0 3293 3294 seen.add(obj_id) 3295 size += sys.getsizeof(obj) 3296 3297 if isinstance(obj, dict): 3298 for k, v in obj.items(): 3299 size += ZakatTracker.get_dict_size(k, seen) 3300 size += ZakatTracker.get_dict_size(v, seen) 3301 elif isinstance(obj, (list, tuple, set, frozenset)): 3302 for item in obj: 3303 size += ZakatTracker.get_dict_size(item, seen) 3304 elif isinstance(obj, (int, float, complex)): # Handle numbers 3305 pass # Basic numbers have a fixed size, so nothing to add here 3306 elif isinstance(obj, str): # Handle strings 3307 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 3308 return size 3309 3310 @staticmethod 3311 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 3312 """ 3313 Convert a specific day, month, and year into a timestamp. 3314 3315 Parameters: 3316 - day (int): The day of the month. 3317 - month (int, optional): The month of the year. Default is 6 (June). 3318 - year (int, optional): The year. Default is 2024. 3319 3320 Returns: 3321 - int: The timestamp representing the given day, month, and year. 3322 3323 Note: 3324 - This method assumes the default month and year if not provided. 3325 """ 3326 return Time.time(datetime.datetime(year, month, day)) 3327 3328 @staticmethod 3329 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 3330 """ 3331 Generate a random date between two given dates. 3332 3333 Parameters: 3334 - start_date (datetime.datetime): The start date from which to generate a random date. 3335 - end_date (datetime.datetime): The end date until which to generate a random date. 3336 3337 Returns: 3338 - datetime.datetime: A random date between the start_date and end_date. 3339 """ 3340 time_between_dates = end_date - start_date 3341 days_between_dates = time_between_dates.days 3342 random_number_of_days = random.randrange(days_between_dates) 3343 return start_date + datetime.timedelta(days=random_number_of_days) 3344 3345 @staticmethod 3346 def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False, 3347 debug: bool = False) -> int: 3348 """ 3349 Generate a random CSV file with specified parameters. 3350 The function generates a CSV file at the specified path with the given count of rows. 3351 Each row contains a randomly generated account, description, value, and date. 3352 The value is randomly generated between 1000 and 100000, 3353 and the date is randomly generated between 1950-01-01 and 2023-12-31. 3354 If the row number is not divisible by 13, the value is multiplied by -1. 3355 3356 Parameters: 3357 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 3358 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 3359 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 3360 - debug (bool, optional): A flag indicating whether to print debug information. 3361 3362 Returns: 3363 None 3364 """ 3365 if debug: 3366 print('generate_random_csv_file', f'debug={debug}') 3367 i = 0 3368 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 3369 writer = csv.writer(csvfile) 3370 for i in range(count): 3371 account = f'acc-{random.randint(1, count)}' 3372 desc = f'Some text {random.randint(1, count)}' 3373 value = random.randint(1000, 100000) 3374 date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1), 3375 datetime.datetime(2023, 12, 31)).strftime('%Y-%m-%d %H:%M:%S') 3376 if not i % 13 == 0: 3377 value *= -1 3378 row = [account, desc, value, date] 3379 if with_rate: 3380 rate = random.randint(1, 100) * 0.12 3381 if debug: 3382 print('before-append', row) 3383 row.append(rate) 3384 if debug: 3385 print('after-append', row) 3386 writer.writerow(row) 3387 i = i + 1 3388 return i 3389 3390 @staticmethod 3391 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 3392 """ 3393 Creates a list of random integers whose sum does not exceed the specified maximum. 3394 3395 Parameters: 3396 - max_sum (int): The maximum allowed sum of the list elements. 3397 - min_value (int, optional): The minimum possible value for an element (inclusive). 3398 - max_value (int, optional): The maximum possible value for an element (inclusive). 3399 3400 Returns: 3401 - A list of random integers. 3402 """ 3403 result = [] 3404 current_sum = 0 3405 3406 while current_sum < max_sum: 3407 # Calculate the remaining space for the next element 3408 remaining_sum = max_sum - current_sum 3409 # Determine the maximum possible value for the next element 3410 next_max_value = min(remaining_sum, max_value) 3411 # Generate a random element within the allowed range 3412 next_element = random.randint(min_value, next_max_value) 3413 result.append(next_element) 3414 current_sum += next_element 3415 3416 return result 3417 3418 def _test_core(self, restore: bool = False, debug: bool = False): 3419 3420 if debug: 3421 random.seed(1234567890) 3422 3423 Time.test(debug) 3424 3425 # sanity check - random forward time 3426 3427 xlist = [] 3428 limit = 1000 3429 for _ in range(limit): 3430 y = Time.time() 3431 z = '-' 3432 if y not in xlist: 3433 xlist.append(y) 3434 else: 3435 z = 'x' 3436 if debug: 3437 print(z, y) 3438 xx = len(xlist) 3439 if debug: 3440 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 3441 assert limit == xx 3442 3443 # test ZakatTracker.split_at_last_symbol 3444 3445 test_cases = [ 3446 ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")), 3447 ("No symbol here.", '$', ("No symbol here.", "")), 3448 ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")), 3449 ("Here is a symbol%", '%', ("Here is a symbol", "")), 3450 ("@only a symbol", '@', ("", "only a symbol")), 3451 ("", '#', ("", "")), 3452 ("test/test/test.txt", '/', ("test/test", "test.txt")), 3453 ("abc#def#ghi", "#", ("abc#def", "ghi")), 3454 ("abc", "#", ("abc", "")), 3455 ("//https://test", '//', ("//https:", "test")), 3456 ] 3457 3458 for data, symbol, expected in test_cases: 3459 result = ZakatTracker.split_at_last_symbol(data, symbol) 3460 assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}" 3461 3462 # human_readable_size 3463 3464 assert ZakatTracker.human_readable_size(0) == '0.00 B' 3465 assert ZakatTracker.human_readable_size(512) == '512.00 B' 3466 assert ZakatTracker.human_readable_size(1023) == '1023.00 B' 3467 3468 assert ZakatTracker.human_readable_size(1024) == '1.00 KB' 3469 assert ZakatTracker.human_readable_size(2048) == '2.00 KB' 3470 assert ZakatTracker.human_readable_size(5120) == '5.00 KB' 3471 3472 assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB' 3473 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB' 3474 3475 assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB' 3476 assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB' 3477 assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB' 3478 3479 assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB' 3480 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB' 3481 assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB' 3482 3483 try: 3484 # noinspection PyTypeChecker 3485 ZakatTracker.human_readable_size('not a number') 3486 assert False, 'Expected TypeError for invalid input' 3487 except TypeError: 3488 pass 3489 3490 try: 3491 # noinspection PyTypeChecker 3492 ZakatTracker.human_readable_size(1024, decimal_places='not an int') 3493 assert False, 'Expected TypeError for invalid decimal_places' 3494 except TypeError: 3495 pass 3496 3497 # get_dict_size 3498 assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch' 3499 assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary' 3500 3501 # number scale 3502 error = 0 3503 total = 0 3504 for sign in ['', '-']: 3505 for max_i, max_j, decimal_places in [ 3506 (101, 101, 2), # fiat currency minimum unit took 2 decimal places 3507 (1, 1_000, 8), # cryptocurrency like Satoshi in Bitcoin took 8 decimal places 3508 (1, 1_000, 18) # cryptocurrency like Wei in Ethereum took 18 decimal places 3509 ]: 3510 for return_type in ( 3511 float, 3512 decimal.Decimal, 3513 ): 3514 for i in range(max_i): 3515 for j in range(max_j): 3516 total += 1 3517 num_str = f'{sign}{i}.{j:0{decimal_places}d}' 3518 num = return_type(num_str) 3519 scaled = self.scale(num, decimal_places=decimal_places) 3520 unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places) 3521 if debug: 3522 print( 3523 f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}') 3524 if unscaled != num: 3525 if debug: 3526 print('***** SCALE ERROR *****') 3527 error += 1 3528 if debug: 3529 print(f'total: {total}, error({error}): {100 * error / total}%') 3530 assert error == 0 3531 3532 # test lock 3533 3534 assert self.nolock() 3535 assert self.__history() is True 3536 lock = self.lock() 3537 assert lock is not None 3538 assert lock > 0 3539 failed = False 3540 try: 3541 self.lock() 3542 except: 3543 failed = True 3544 assert failed 3545 assert self.free(lock) 3546 assert not self.free(lock) 3547 3548 table = { 3549 AccountName('1'): [ 3550 (0, 10, 1000, 1000, 1000, 1, 1), 3551 (0, 20, 3000, 3000, 3000, 2, 2), 3552 (0, 30, 6000, 6000, 6000, 3, 3), 3553 (1, 15, 4500, 4500, 4500, 3, 4), 3554 (1, 50, -500, -500, -500, 4, 5), 3555 (1, 100, -10500, -10500, -10500, 5, 6), 3556 ], 3557 AccountName('wallet'): [ 3558 (1, 90, -9000, -9000, -9000, 1, 1), 3559 (0, 100, 1000, 1000, 1000, 2, 2), 3560 (1, 190, -18000, -18000, -18000, 3, 3), 3561 (0, 1000, 82000, 82000, 82000, 4, 4), 3562 ], 3563 } 3564 for x in table: 3565 for y in table[x]: 3566 lock = self.lock() 3567 if y[0] == 0: 3568 ref = self.track( 3569 unscaled_value=y[1], 3570 desc='test-add', 3571 account=x, 3572 created_time_ns=Time.time(), 3573 debug=debug, 3574 ) 3575 else: 3576 report = self.subtract( 3577 unscaled_value=y[1], 3578 desc='test-sub', 3579 account=x, 3580 created_time_ns=Time.time(), 3581 ) 3582 ref = report.log_ref 3583 if debug: 3584 print('_sub', z, Time.time()) 3585 assert ref != 0 3586 assert len(self.__vault.account[x].log[ref].file) == 0 3587 for i in range(3): 3588 file_ref = self.add_file(x, ref, 'file_' + str(i)) 3589 time.sleep(0.0000001) 3590 assert file_ref != 0 3591 if debug: 3592 print('ref', ref, 'file', file_ref) 3593 assert len(self.__vault.account[x].log[ref].file) == i + 1 3594 file_ref = self.add_file(x, ref, 'file_' + str(3)) 3595 assert self.remove_file(x, ref, file_ref) 3596 daily_logs = self.daily_logs(debug=debug) 3597 if debug: 3598 print('daily_logs', daily_logs) 3599 for k, v in daily_logs.items(): 3600 assert k 3601 assert v 3602 z = self.balance(x) 3603 if debug: 3604 print('debug-0', z, y) 3605 assert z == y[2] 3606 z = self.balance(x, False) 3607 if debug: 3608 print('debug-1', z, y[3]) 3609 assert z == y[3] 3610 o = self.__vault.account[x].log 3611 z = 0 3612 for i in o: 3613 z += o[i].value 3614 if debug: 3615 print('debug-2', z, type(z)) 3616 print('debug-2', y[4], type(y[4])) 3617 assert z == y[4] 3618 if debug: 3619 print('debug-2 - PASSED') 3620 assert self.box_size(x) == y[5] 3621 assert self.log_size(x) == y[6] 3622 assert not self.nolock() 3623 assert lock is not None 3624 self.free(lock) 3625 assert self.nolock() 3626 assert self.boxes(x) != {} 3627 assert self.logs(x) != {} 3628 3629 assert not self.hide(x) 3630 assert self.hide(x, False) is False 3631 assert self.hide(x) is False 3632 assert self.hide(x, True) 3633 assert self.hide(x) 3634 3635 assert self.zakatable(x) 3636 assert self.zakatable(x, False) is False 3637 assert self.zakatable(x) is False 3638 assert self.zakatable(x, True) 3639 assert self.zakatable(x) 3640 3641 if restore is True: 3642 # invalid restore point 3643 for lock in [0, time.time_ns(), Time.time()]: 3644 failed = False 3645 try: 3646 self.recall(True, lock) 3647 except: 3648 failed = True 3649 assert failed 3650 count = len(self.__vault.history) 3651 if debug: 3652 print('history-count', count) 3653 assert count == 10 3654 # try mode 3655 for _ in range(count): 3656 assert self.recall(True, debug=debug) 3657 count = len(self.__vault.history) 3658 if debug: 3659 print('history-count', count) 3660 assert count == 10 3661 _accounts = list(table.keys()) 3662 accounts_limit = len(_accounts) + 1 3663 for i in range(-1, -accounts_limit, -1): 3664 account = _accounts[i] 3665 if debug: 3666 print(account, len(table[account])) 3667 transaction_limit = len(table[account]) + 1 3668 for j in range(-1, -transaction_limit, -1): 3669 row = table[account][j] 3670 if debug: 3671 print(row, self.balance(account), self.balance(account, False)) 3672 assert self.balance(account) == self.balance(account, False) 3673 assert self.balance(account) == row[2] 3674 assert self.recall(False, debug=debug) 3675 assert self.recall(False, debug=debug) is False 3676 count = len(self.__vault.history) 3677 if debug: 3678 print('history-count', count) 3679 assert count == 0 3680 self.reset() 3681 3682 def test(self, debug: bool = False) -> bool: 3683 if debug: 3684 print('test', f'debug={debug}') 3685 try: 3686 3687 self._test_core(True, debug) 3688 self._test_core(False, debug) 3689 3690 assert self.__history() 3691 3692 # Not allowed for duplicate transactions in the same account and time 3693 3694 created = Time.time() 3695 self.track(100, 'test-1', 'same', True, created) 3696 failed = False 3697 try: 3698 self.track(50, 'test-1', 'same', True, created) 3699 except: 3700 failed = True 3701 assert failed is True 3702 3703 self.reset() 3704 3705 # Same account transfer 3706 for x in [1, 'a', True, 1.8, None]: 3707 failed = False 3708 try: 3709 self.transfer(1, x, x, 'same-account', debug=debug) 3710 except: 3711 failed = True 3712 assert failed is True 3713 3714 # Always preserve box age during transfer 3715 3716 series: list[tuple[int, int]] = [ 3717 (30, 4), 3718 (60, 3), 3719 (90, 2), 3720 ] 3721 case = { 3722 3000: { 3723 'series': series, 3724 'rest': 15000, 3725 }, 3726 6000: { 3727 'series': series, 3728 'rest': 12000, 3729 }, 3730 9000: { 3731 'series': series, 3732 'rest': 9000, 3733 }, 3734 18000: { 3735 'series': series, 3736 'rest': 0, 3737 }, 3738 27000: { 3739 'series': series, 3740 'rest': -9000, 3741 }, 3742 36000: { 3743 'series': series, 3744 'rest': -18000, 3745 }, 3746 } 3747 3748 selected_time = Time.time() - ZakatTracker.TimeCycle() 3749 3750 for total in case: 3751 if debug: 3752 print('--------------------------------------------------------') 3753 print(f'case[{total}]', case[total]) 3754 for x in case[total]['series']: 3755 self.track( 3756 unscaled_value=x[0], 3757 desc=f'test-{x} ages', 3758 account=AccountName('ages'), 3759 created_time_ns=selected_time * x[1], 3760 ) 3761 3762 unscaled_total = self.unscale(total) 3763 if debug: 3764 print('unscaled_total', unscaled_total) 3765 refs = self.transfer( 3766 unscaled_amount=unscaled_total, 3767 from_account='ages', 3768 to_account='future', 3769 desc='Zakat Movement', 3770 debug=debug, 3771 ) 3772 3773 if debug: 3774 print('refs', refs) 3775 3776 ages_cache_balance = self.balance('ages') 3777 ages_fresh_balance = self.balance('ages', False) 3778 rest = case[total]['rest'] 3779 if debug: 3780 print('source', ages_cache_balance, ages_fresh_balance, rest) 3781 assert ages_cache_balance == rest 3782 assert ages_fresh_balance == rest 3783 3784 future_cache_balance = self.balance('future') 3785 future_fresh_balance = self.balance('future', False) 3786 if debug: 3787 print('target', future_cache_balance, future_fresh_balance, total) 3788 print('refs', refs) 3789 assert future_cache_balance == total 3790 assert future_fresh_balance == total 3791 3792 # TODO: check boxes times for `ages` should equal box times in `future` 3793 for ref in self.__vault.account['ages'].box: 3794 ages_capital = self.__vault.account['ages'].box[ref].capital 3795 ages_rest = self.__vault.account['ages'].box[ref].rest 3796 future_capital = 0 3797 if ref in self.__vault.account['future'].box: 3798 future_capital = self.__vault.account['future'].box[ref].capital 3799 future_rest = 0 3800 if ref in self.__vault.account['future'].box: 3801 future_rest = self.__vault.account['future'].box[ref].rest 3802 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 3803 if debug: 3804 print('================================================================') 3805 print('ages', ages_capital, ages_rest) 3806 print('future', future_capital, future_rest) 3807 if ages_rest == 0: 3808 assert ages_capital == future_capital 3809 elif ages_rest < 0: 3810 assert -ages_capital == future_capital 3811 elif ages_rest > 0: 3812 assert ages_capital == ages_rest + future_capital 3813 self.reset() 3814 assert len(self.__vault.history) == 0 3815 3816 assert self.__history() 3817 assert self.__history(False) is False 3818 assert self.__history() is False 3819 assert self.__history(True) 3820 assert self.__history() 3821 if debug: 3822 print('####################################################################') 3823 3824 transaction = [ 3825 ( 3826 20, 'wallet', 1, -2000, -2000, -2000, 1, 1, 3827 2000, 2000, 2000, 1, 1, 3828 ), 3829 ( 3830 750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2, 3831 75000, 75000, 75000, 1, 1, 3832 ), 3833 ( 3834 600, 'safe', 'bank', 15000, 15000, 15000, 1, 2, 3835 60000, 60000, 60000, 1, 1, 3836 ), 3837 ] 3838 for z in transaction: 3839 lock = self.lock() 3840 x = z[1] 3841 y = z[2] 3842 self.transfer( 3843 unscaled_amount=z[0], 3844 from_account=x, 3845 to_account=y, 3846 desc='test-transfer', 3847 debug=debug, 3848 ) 3849 zz = self.balance(x) 3850 if debug: 3851 print(zz, z) 3852 assert zz == z[3] 3853 xx = self.accounts()[x] 3854 assert xx == z[3] 3855 assert self.balance(x, False) == z[4] 3856 assert xx == z[4] 3857 3858 s = 0 3859 log = self.__vault.account[x].log 3860 for i in log: 3861 s += log[i].value 3862 if debug: 3863 print('s', s, 'z[5]', z[5]) 3864 assert s == z[5] 3865 3866 assert self.box_size(x) == z[6] 3867 assert self.log_size(x) == z[7] 3868 3869 yy = self.accounts()[y] 3870 assert self.balance(y) == z[8] 3871 assert yy == z[8] 3872 assert self.balance(y, False) == z[9] 3873 assert yy == z[9] 3874 3875 s = 0 3876 log = self.__vault.account[y].log 3877 for i in log: 3878 s += log[i].value 3879 assert s == z[10] 3880 3881 assert self.box_size(y) == z[11] 3882 assert self.log_size(y) == z[12] 3883 assert lock is not None 3884 assert self.free(lock) 3885 3886 if debug: 3887 pp().pprint(self.check(2.17)) 3888 3889 assert self.nolock() 3890 history_count = len(self.__vault.history) 3891 if debug: 3892 print('history-count', history_count) 3893 transaction_count = len(transaction) 3894 assert history_count == transaction_count 3895 assert not self.free(Time.time()) 3896 assert self.free(self.lock()) 3897 assert self.nolock() 3898 assert len(self.__vault.history) == transaction_count 3899 3900 # recall 3901 3902 assert self.nolock() 3903 assert len(self.__vault.history) == 3 3904 assert self.recall(False, debug=debug) is True 3905 assert len(self.__vault.history) == 2 3906 assert self.recall(False, debug=debug) is True 3907 assert len(self.__vault.history) == 1 3908 assert self.recall(False, debug=debug) is True 3909 assert len(self.__vault.history) == 0 3910 assert self.recall(False, debug=debug) is False 3911 assert len(self.__vault.history) == 0 3912 3913 # exchange 3914 3915 self.exchange('cash', 25, 3.75, '2024-06-25') 3916 self.exchange('cash', 22, 3.73, '2024-06-22') 3917 self.exchange('cash', 15, 3.69, '2024-06-15') 3918 self.exchange('cash', 10, 3.66) 3919 3920 assert self.nolock() 3921 3922 for i in range(1, 30): 3923 exchange = self.exchange('cash', i) 3924 rate, description, created = exchange.rate, exchange.description, exchange.time 3925 if debug: 3926 print(i, rate, description, created) 3927 assert created 3928 if i < 10: 3929 assert rate == 1 3930 assert description is None 3931 elif i == 10: 3932 assert rate == 3.66 3933 assert description is None 3934 elif i < 15: 3935 assert rate == 3.66 3936 assert description is None 3937 elif i == 15: 3938 assert rate == 3.69 3939 assert description is not None 3940 elif i < 22: 3941 assert rate == 3.69 3942 assert description is not None 3943 elif i == 22: 3944 assert rate == 3.73 3945 assert description is not None 3946 elif i >= 25: 3947 assert rate == 3.75 3948 assert description is not None 3949 exchange = self.exchange('bank', i) 3950 rate, description, created = exchange.rate, exchange.description, exchange.time 3951 if debug: 3952 print(i, rate, description, created) 3953 assert created 3954 assert rate == 1 3955 assert description is None 3956 3957 assert len(self.__vault.exchange) == 1 3958 assert len(self.exchanges()) == 1 3959 self.__vault.exchange.clear() 3960 assert len(self.__vault.exchange) == 0 3961 assert len(self.exchanges()) == 0 3962 self.reset() 3963 3964 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 3965 self.exchange('cash', ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 3966 self.exchange('cash', ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 3967 self.exchange('cash', ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 3968 self.exchange('cash', ZakatTracker.day_to_time(10), 3.66) 3969 3970 assert self.nolock() 3971 3972 for i in [x * 0.12 for x in range(-15, 21)]: 3973 if i <= 0: 3974 assert self.exchange('test', Time.time(), i, f'range({i})') == Exchange() 3975 else: 3976 assert self.exchange('test', Time.time(), i, f'range({i})') is not Exchange() 3977 3978 assert self.nolock() 3979 3980 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 3981 for i in range(1, 31): 3982 timestamp_ns = ZakatTracker.day_to_time(i) 3983 exchange = self.exchange('cash', timestamp_ns) 3984 rate, description, created = exchange.rate, exchange.description, exchange.time 3985 if debug: 3986 print(i, rate, description, created) 3987 assert created 3988 if i < 10: 3989 assert rate == 1 3990 assert description is None 3991 elif i == 10: 3992 assert rate == 3.66 3993 assert description is None 3994 elif i < 15: 3995 assert rate == 3.66 3996 assert description is None 3997 elif i == 15: 3998 assert rate == 3.69 3999 assert description is not None 4000 elif i < 22: 4001 assert rate == 3.69 4002 assert description is not None 4003 elif i == 22: 4004 assert rate == 3.73 4005 assert description is not None 4006 elif i >= 25: 4007 assert rate == 3.75 4008 assert description is not None 4009 exchange = self.exchange('bank', i) 4010 rate, description, created = exchange.rate, exchange.description, exchange.time 4011 if debug: 4012 print(i, rate, description, created) 4013 assert created 4014 assert rate == 1 4015 assert description is None 4016 4017 assert self.nolock() 4018 4019 self.reset() 4020 4021 # test transfer between accounts with different exchange rate 4022 4023 a_SAR = 'Bank (SAR)' 4024 b_USD = 'Bank (USD)' 4025 c_SAR = 'Safe (SAR)' 4026 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 4027 for case in [ 4028 (0, a_SAR, 'SAR Gift', 1000, 100000), 4029 (1, a_SAR, 1), 4030 (0, b_USD, 'USD Gift', 500, 50000), 4031 (1, b_USD, 1), 4032 (2, b_USD, 3.75), 4033 (1, b_USD, 3.75), 4034 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 4035 (0, c_SAR, 'Salary', 750, 75000), 4036 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 4037 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 4038 ]: 4039 if debug: 4040 print('case', case) 4041 match (case[0]): 4042 case 0: # track 4043 _, account, desc, x, balance = case 4044 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 4045 4046 cached_value = self.balance(account, cached=True) 4047 fresh_value = self.balance(account, cached=False) 4048 if debug: 4049 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 4050 assert cached_value == balance 4051 assert fresh_value == balance 4052 case 1: # check-exchange 4053 _, account, expected_rate = case 4054 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4055 if debug: 4056 print('t-exchange', t_exchange) 4057 assert t_exchange.rate == expected_rate 4058 case 2: # do-exchange 4059 _, account, rate = case 4060 self.exchange(account, rate=rate, debug=debug) 4061 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4062 if debug: 4063 print('b-exchange', b_exchange) 4064 assert b_exchange.rate == rate 4065 case 3: # transfer 4066 _, x, a, b, desc, a_balance, b_balance = case 4067 self.transfer(x, a, b, desc, debug=debug) 4068 4069 cached_value = self.balance(a, cached=True) 4070 fresh_value = self.balance(a, cached=False) 4071 if debug: 4072 print( 4073 'account', a, 4074 'cached_value', cached_value, 4075 'fresh_value', fresh_value, 4076 'a_balance', a_balance, 4077 ) 4078 assert cached_value == a_balance 4079 assert fresh_value == a_balance 4080 4081 cached_value = self.balance(b, cached=True) 4082 fresh_value = self.balance(b, cached=False) 4083 if debug: 4084 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 4085 assert cached_value == b_balance 4086 assert fresh_value == b_balance 4087 4088 # Transfer all in many chunks randomly from B to A 4089 a_SAR_balance = 137125 4090 b_USD_balance = 50100 4091 b_USD_exchange = self.exchange(b_USD) 4092 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 4093 if debug: 4094 print('amounts', amounts) 4095 i = 0 4096 for x in amounts: 4097 if debug: 4098 print(f'{i} - transfer-with-exchange({x})') 4099 self.transfer( 4100 unscaled_amount=self.unscale(x), 4101 from_account=b_USD, 4102 to_account=a_SAR, 4103 desc=f'{x} USD -> SAR', 4104 debug=debug, 4105 ) 4106 4107 b_USD_balance -= x 4108 cached_value = self.balance(b_USD, cached=True) 4109 fresh_value = self.balance(b_USD, cached=False) 4110 if debug: 4111 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 4112 b_USD_balance) 4113 assert cached_value == b_USD_balance 4114 assert fresh_value == b_USD_balance 4115 4116 a_SAR_balance += int(x * b_USD_exchange.rate) 4117 cached_value = self.balance(a_SAR, cached=True) 4118 fresh_value = self.balance(a_SAR, cached=False) 4119 if debug: 4120 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 4121 a_SAR_balance, 'rate', b_USD_exchange.rate) 4122 assert cached_value == a_SAR_balance 4123 assert fresh_value == a_SAR_balance 4124 i += 1 4125 4126 # Transfer all in many chunks randomly from C to A 4127 c_SAR_balance = 37500 4128 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 4129 if debug: 4130 print('amounts', amounts) 4131 i = 0 4132 for x in amounts: 4133 if debug: 4134 print(f'{i} - transfer-with-exchange({x})') 4135 self.transfer( 4136 unscaled_amount=self.unscale(x), 4137 from_account=c_SAR, 4138 to_account=a_SAR, 4139 desc=f'{x} SAR -> a_SAR', 4140 debug=debug, 4141 ) 4142 4143 c_SAR_balance -= x 4144 cached_value = self.balance(c_SAR, cached=True) 4145 fresh_value = self.balance(c_SAR, cached=False) 4146 if debug: 4147 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 4148 c_SAR_balance) 4149 assert cached_value == c_SAR_balance 4150 assert fresh_value == c_SAR_balance 4151 4152 a_SAR_balance += x 4153 cached_value = self.balance(a_SAR, cached=True) 4154 fresh_value = self.balance(a_SAR, cached=False) 4155 if debug: 4156 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 4157 a_SAR_balance) 4158 assert cached_value == a_SAR_balance 4159 assert fresh_value == a_SAR_balance 4160 i += 1 4161 4162 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 4163 4164 # check & zakat with exchange rates for many cycles 4165 4166 lock = None 4167 for rate, values in { 4168 1: { 4169 'in': [1000, 2000, 10000], 4170 'exchanged': [100000, 200000, 1000000], 4171 'out': [2500, 5000, 73140], 4172 }, 4173 3.75: { 4174 'in': [200, 1000, 5000], 4175 'exchanged': [75000, 375000, 1875000], 4176 'out': [1875, 9375, 137138], 4177 }, 4178 }.items(): 4179 a, b, c = values['in'] 4180 m, n, o = values['exchanged'] 4181 x, y, z = values['out'] 4182 if debug: 4183 print('rate', rate, 'values', values) 4184 for case in [ 4185 (a, 'safe', Time.time() - ZakatTracker.TimeCycle(), [ 4186 {'safe': {0: {'below_nisab': x}}}, 4187 ], False, m), 4188 (b, 'safe', Time.time() - ZakatTracker.TimeCycle(), [ 4189 {'safe': {0: {'count': 1, 'total': y}}}, 4190 ], True, n), 4191 (c, 'cave', Time.time() - (ZakatTracker.TimeCycle() * 3), [ 4192 {'cave': {0: {'count': 3, 'total': z}}}, 4193 ], True, o), 4194 ]: 4195 if debug: 4196 print(f'############# check(rate: {rate}) #############') 4197 print('case', case) 4198 self.reset() 4199 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 4200 self.track( 4201 unscaled_value=case[0], 4202 desc='test-check', 4203 account=case[1], 4204 created_time_ns=case[2], 4205 ) 4206 assert self.snapshot() 4207 4208 # assert self.nolock() 4209 # history_size = len(self.__vault.history) 4210 # print('history_size', history_size) 4211 # assert history_size == 2 4212 lock = self.lock() 4213 assert lock 4214 assert not self.nolock() 4215 report = self.check(2.17, None, debug) 4216 if debug: 4217 print('report', report) 4218 assert case[4] == report.valid 4219 assert case[5] == report.statistics.overall_wealth 4220 assert case[5] == report.statistics.zakatable_transactions_balance 4221 4222 if debug: 4223 pp().pprint(report.plan) 4224 4225 for x in report.plan: 4226 assert case[1] == x 4227 if report.plan[x][0].below_nisab: 4228 if debug: 4229 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 4230 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 4231 else: 4232 if debug: 4233 print('[assert]', int(report.statistics.zakat_cut_balances), case[3][0][x][0]['total']) 4234 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 4235 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 4236 assert int(report.statistics.zakat_cut_balances) == case[3][0][x][0]['total'] 4237 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 4238 assert report.plan[x][0].count == case[3][0][x][0]['count'] 4239 if debug: 4240 pp().pprint(report) 4241 result = self.zakat(report, debug=debug) 4242 if debug: 4243 print('zakat-result', result, case[4]) 4244 assert result == case[4] 4245 report = self.check(2.17, None, debug) 4246 assert report.valid is False 4247 4248 # storage 4249 4250 old_vault = dataclasses.replace(self.__vault) 4251 old_vault_deep = copy.deepcopy(self.__vault) 4252 old_vault_dict = dataclasses.asdict(self.__vault) 4253 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 4254 if os.path.exists(_path): 4255 os.remove(_path) 4256 for hashed in [False, True]: 4257 self.save(hash_required=hashed) 4258 assert os.path.getsize(_path) > 0 4259 self.reset() 4260 assert self.recall(False, debug=debug) is False 4261 for hash_required in [False, True]: 4262 if debug: 4263 print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4264 self.load(hash_required=hashed and hash_required) 4265 if debug: 4266 print('[debug]', type(self.__vault)) 4267 assert self.__vault.account is not None 4268 assert old_vault == self.__vault 4269 assert old_vault_deep == self.__vault 4270 assert old_vault_dict == dataclasses.asdict(self.__vault) 4271 # corrupt the data 4272 log_ref = NO_TIME() 4273 tmp_file_ref = Time.time() 4274 for k in self.__vault.account['cave'].log: 4275 log_ref = k 4276 self.__vault.account['cave'].log[k].file[tmp_file_ref] = 'HACKED' 4277 break 4278 assert old_vault != self.__vault 4279 assert old_vault_deep != self.__vault 4280 assert old_vault_dict != dataclasses.asdict(self.__vault) 4281 # fix the data 4282 del self.__vault.account['cave'].log[log_ref].file[tmp_file_ref] 4283 assert old_vault == self.__vault 4284 assert old_vault_deep == self.__vault 4285 assert old_vault_dict == dataclasses.asdict(self.__vault) 4286 if hashed: 4287 continue 4288 failed = False 4289 try: 4290 hash_required = True 4291 if debug: 4292 print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4293 self.load(hash_required=True) 4294 except: 4295 failed = True 4296 assert failed 4297 4298 # recall after zakat 4299 4300 history_size = len(self.__vault.history) 4301 if debug: 4302 print('history_size', history_size) 4303 assert history_size == 3 4304 assert not self.nolock() 4305 assert self.recall(False, debug=debug) is False 4306 self.free(lock) 4307 assert self.nolock() 4308 4309 for i in range(3, 0, -1): 4310 history_size = len(self.__vault.history) 4311 if debug: 4312 print('history_size', history_size) 4313 assert history_size == i 4314 assert self.recall(False, debug=debug) is True 4315 4316 assert self.nolock() 4317 assert self.recall(False, debug=debug) is False 4318 4319 history_size = len(self.__vault.history) 4320 if debug: 4321 print('history_size', history_size) 4322 assert history_size == 0 4323 4324 account_size = len(self.__vault.account) 4325 if debug: 4326 print('account_size', account_size) 4327 assert account_size == 0 4328 4329 report_size = len(self.__vault.report) 4330 if debug: 4331 print('report_size', report_size) 4332 assert report_size == 0 4333 4334 assert self.nolock() 4335 4336 # csv 4337 4338 csv_count = 1000 4339 4340 for with_rate, path in { 4341 False: 'test-import_csv-no-exchange', 4342 True: 'test-import_csv-with-exchange', 4343 }.items(): 4344 4345 if debug: 4346 print('test_import_csv', with_rate, path) 4347 4348 csv_path = path + '.csv' 4349 if os.path.exists(csv_path): 4350 os.remove(csv_path) 4351 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 4352 if debug: 4353 print('generate_random_csv_file', c) 4354 assert c == csv_count 4355 assert os.path.getsize(csv_path) > 0 4356 cache_path = self.import_csv_cache_path() 4357 if os.path.exists(cache_path): 4358 os.remove(cache_path) 4359 self.reset() 4360 lock = self.lock() 4361 (created, found, bad) = self.import_csv(csv_path, debug) 4362 bad_count = len(bad) 4363 if debug: 4364 print(f'csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})') 4365 print('bad', bad) 4366 # TODO: assert created + found + bad_count == csv_count 4367 # TODO: assert created == csv_count 4368 # TODO: assert bad_count == 0 4369 assert bad_count > 0 4370 tmp_size = os.path.getsize(cache_path) 4371 assert tmp_size > 0 4372 4373 (created_2, found_2, bad_2) = self.import_csv(csv_path) 4374 bad_2_count = len(bad_2) 4375 if debug: 4376 print(f'csv-imported: ({created_2}, {found_2}, {bad_2_count})') 4377 print('bad', bad) 4378 assert bad_2_count > 0 4379 # TODO: assert tmp_size == os.path.getsize(cache_path) 4380 # TODO: assert created_2 + found_2 + bad_2_count == csv_count 4381 # TODO: assert created == found_2 4382 # TODO: assert bad_count == bad_2_count 4383 # TODO: assert found_2 == csv_count 4384 # TODO: assert bad_2_count == 0 4385 # TODO: assert created_2 == 0 4386 4387 # payment parts 4388 4389 positive_parts = self.build_payment_parts(100, positive_only=True) 4390 assert self.check_payment_parts(positive_parts) != 0 4391 assert self.check_payment_parts(positive_parts) != 0 4392 all_parts = self.build_payment_parts(300, positive_only=False) 4393 assert self.check_payment_parts(all_parts) != 0 4394 assert self.check_payment_parts(all_parts) != 0 4395 if debug: 4396 pp().pprint(positive_parts) 4397 pp().pprint(all_parts) 4398 # dynamic discount 4399 suite = [] 4400 count = 3 4401 for exceed in [False, True]: 4402 case = [] 4403 for part in [positive_parts, all_parts]: 4404 #part = parts.copy() 4405 demand = part.demand 4406 if debug: 4407 print(demand, part.total) 4408 i = 0 4409 z = demand / count 4410 cp = PaymentParts( 4411 demand=demand, 4412 exceed=exceed, 4413 total=part.total, 4414 ) 4415 j = '' 4416 for x, y in part.account.items(): 4417 x_exchange = self.exchange(x) 4418 zz = self.exchange_calc(z, 1, x_exchange.rate) 4419 if exceed and zz <= demand: 4420 i += 1 4421 y.part = zz 4422 if debug: 4423 print(exceed, y) 4424 cp.account[x] = y 4425 case.append(y) 4426 elif not exceed and y.balance >= zz: 4427 i += 1 4428 y.part = zz 4429 if debug: 4430 print(exceed, y) 4431 cp.account[x] = y 4432 case.append(y) 4433 j = x 4434 if i >= count: 4435 break 4436 if debug: 4437 print('[debug]', cp.account[j]) 4438 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 4439 suite.append(cp) 4440 if debug: 4441 print('suite', len(suite)) 4442 for case in suite: 4443 if debug: 4444 print('case', case) 4445 result = self.check_payment_parts(case) 4446 if debug: 4447 print('check_payment_parts', result, f'exceed: {exceed}') 4448 assert result == 0 4449 4450 report = self.check(2.17, None, debug) 4451 if debug: 4452 print('valid', report.valid) 4453 zakat_result = self.zakat(report, parts=case, debug=debug) 4454 if debug: 4455 print('zakat-result', zakat_result) 4456 assert report.valid == zakat_result 4457 4458 assert self.free(lock) 4459 4460 assert self.save(path + f'.{self.ext()}') 4461 4462 assert self.save(f'1000-transactions-test.{self.ext()}') 4463 return True 4464 except Exception as e: 4465 # pp().pprint(self.__vault) 4466 assert self.save(f'test-snapshot.{self.ext()}') 4467 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.
__vault (dict):
- account (dict):
- {account_name} (dict):
- balance (int): The current balance of the account.
- box (dict): A dictionary storing transaction details.
- {timestamp} (dict):
- capital (int): The initial amount of the transaction.
- count (int): The number of times Zakat has been calculated for this transaction.
- last (int): The timestamp of the last Zakat calculation.
- rest (int): The remaining amount after Zakat deductions and withdrawal.
- 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_name} (dict):
- {timestamps} (dict):
- rate (float): Exchange rate when compared to local currency.
- description (str): The description of the exchange rate.
- history (dict):
- {timestamp} (list): A list of dictionaries storing the history of actions performed.
- {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 number 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.
1032 def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True): 1033 """ 1034 Initialize ZakatTracker with database path and history mode. 1035 1036 Parameters: 1037 - db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database. 1038 - history_mode (bool, optional): The mode for tracking history. Default is True. 1039 1040 Returns: 1041 None 1042 """ 1043 self.reset() 1044 self.__memory_mode = db_path == ':memory:' 1045 self.__history(history_mode) 1046 if not self.__memory_mode: 1047 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
943 @staticmethod 944 def Version() -> str: 945 """ 946 Returns the current version of the software. 947 948 This function returns a string representing the current version of the software, 949 including major, minor, and patch version numbers in the format 'X.Y.Z'. 950 951 Returns: 952 - str: The current version of the software. 953 """ 954 version = '0.3.1' 955 git_hash, unstaged_count, commit_count_since_last_tag = get_git_status() 956 if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0): 957 version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}" 958 print(version) 959 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.
961 @staticmethod 962 def ZakatCut(x: float) -> float: 963 """ 964 Calculates the Zakat amount due on an asset. 965 966 This function calculates the zakat amount due on a given asset value over one lunar year. 967 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 968 that exceeds a certain threshold (Nisab). 969 970 Parameters: 971 - x (float): The total value of the asset on which Zakat is to be calculated. 972 973 Returns: 974 - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 975 """ 976 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.
978 @staticmethod 979 def TimeCycle(days: int = 355) -> int: 980 """ 981 Calculates the approximate duration of a lunar year in nanoseconds. 982 983 This function calculates the approximate duration of a lunar year based on the given number of days. 984 It converts the given number of days into nanoseconds for use in high-precision timing applications. 985 986 Parameters: 987 - days (int, optional): The number of days in a lunar year. Defaults to 355, 988 which is an approximation of the average length of a lunar year. 989 990 Returns: 991 - int: The approximate duration of a lunar year in nanoseconds. 992 """ 993 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.
995 @staticmethod 996 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 997 """ 998 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 999 1000 This function calculates the Nisab value, which is the minimum threshold of wealth, 1001 that makes an individual liable for paying Zakat. 1002 The Nisab value is determined by the equivalent value of a specific amount 1003 of gold or silver (currently 595 grams in silver) in the local currency. 1004 1005 Parameters: 1006 - gram_price (float): The price per gram of Nisab. 1007 - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver. 1008 1009 Returns: 1010 - float: The total value of Nisab based on the given price per gram. 1011 """ 1012 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.
1014 @staticmethod 1015 def ext() -> str: 1016 """ 1017 Returns the file extension used by the ZakatTracker class. 1018 1019 Parameters: 1020 None 1021 1022 Returns: 1023 - str: The file extension used by the ZakatTracker class, which is 'json'. 1024 """ 1025 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'.
1049 def memory_mode(self) -> bool: 1050 """ 1051 Check if the ZakatTracker is operating in memory mode. 1052 1053 Returns: 1054 - bool: True if the database is in memory, False otherwise. 1055 """ 1056 return self.__memory_mode
Check if the ZakatTracker is operating in memory mode.
Returns:
- bool: True if the database is in memory, False otherwise.
1058 def path(self, path: Optional[str] = None) -> str: 1059 """ 1060 Set or get the path to the database file. 1061 1062 If no path is provided, the current path is returned. 1063 If a path is provided, it is set as the new path. 1064 The function also creates the necessary directories if the provided path is a file. 1065 1066 Parameters: 1067 - path (str, optional): The new path to the database file. If not provided, the current path is returned. 1068 1069 Returns: 1070 - str: The current or new path to the database file. 1071 """ 1072 if path is None: 1073 return self.__vault_path 1074 self.__vault_path = pathlib.Path(path).resolve() 1075 base_path = pathlib.Path(path).resolve() 1076 if base_path.is_file() or base_path.suffix: 1077 base_path = base_path.parent 1078 base_path.mkdir(parents=True, exist_ok=True) 1079 self.__base_path = base_path 1080 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.
1082 def base_path(self, *args) -> str: 1083 """ 1084 Generate a base path by joining the provided arguments with the existing base path. 1085 1086 Parameters: 1087 - *args (str): Variable length argument list of strings to be joined with the base path. 1088 1089 Returns: 1090 - str: The generated base path. If no arguments are provided, the existing base path is returned. 1091 """ 1092 if not args: 1093 return str(self.__base_path) 1094 filtered_args = [] 1095 ignored_filename = None 1096 for arg in args: 1097 if pathlib.Path(arg).suffix: 1098 ignored_filename = arg 1099 else: 1100 filtered_args.append(arg) 1101 base_path = pathlib.Path(self.__base_path) 1102 full_path = base_path.joinpath(*filtered_args) 1103 full_path.mkdir(parents=True, exist_ok=True) 1104 if ignored_filename is not None: 1105 return full_path.resolve() / ignored_filename # Join with the ignored filename 1106 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.
1108 @staticmethod 1109 def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int: 1110 """ 1111 Scales a numerical value by a specified power of 10, returning an integer. 1112 1113 This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and 1114 facilitate precise scaling operations, particularly useful in financial or scientific calculations. 1115 1116 Parameters: 1117 - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal. 1118 - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled 1119 by a factor of 100 (e.g., converts 1.23 to 123). 1120 1121 Returns: 1122 - The scaled value, rounded to the nearest integer. 1123 1124 Raises: 1125 - TypeError: If the input `x` is not a valid numeric type. 1126 1127 Examples: 1128 ```bash 1129 >>> ZakatTracker.scale(3.14159) 1130 314 1131 >>> ZakatTracker.scale(1234, decimal_places=3) 1132 1234000 1133 >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4) 1134 50 1135 ``` 1136 """ 1137 if not isinstance(x, (float, int, decimal.Decimal)): 1138 raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.') 1139 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
1141 @staticmethod 1142 def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal: 1143 """ 1144 Unscales an integer by a power of 10. 1145 1146 Parameters: 1147 - x (int): The integer to unscale. 1148 - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float. 1149 - decimal_places (int, optional): The power of 10 to use. Defaults to 2. 1150 1151 Returns: 1152 - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type. 1153 1154 Raises: 1155 - TypeError: If the return_type is not float or decimal.Decimal. 1156 """ 1157 if return_type not in (float, decimal.Decimal): 1158 raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.') 1159 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.
1161 def reset(self) -> None: 1162 """ 1163 Reset the internal data structure to its initial state. 1164 1165 Parameters: 1166 None 1167 1168 Returns: 1169 None 1170 """ 1171 self.__vault = Vault()
Reset the internal data structure to its initial state.
Parameters: None
Returns: None
1173 def clean_history(self, lock: Optional[Timestamp] = None) -> int: 1174 """ 1175 Cleans up the empty history records of actions performed on the ZakatTracker instance. 1176 1177 Parameters: 1178 - lock (Timestamp, optional): The lock ID is used to clean up the empty history. 1179 If not provided, it cleans up the empty history records for all locks. 1180 1181 Returns: 1182 - int: The number of locks cleaned up. 1183 """ 1184 count = 0 1185 if lock in self.__vault.history: 1186 if len(self.__vault.history[lock]) <= 0: 1187 count += 1 1188 del self.__vault.history[lock] 1189 return count 1190 for key in self.__vault.history: 1191 if len(self.__vault.history[key]) <= 0: 1192 count += 1 1193 del self.__vault.history[key] 1194 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.
1264 def nolock(self) -> bool: 1265 """ 1266 Check if the vault lock is currently not set. 1267 1268 Parameters: 1269 None 1270 1271 Returns: 1272 - bool: True if the vault lock is not set, False otherwise. 1273 """ 1274 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.
1289 def lock(self) -> Optional[Timestamp]: 1290 """ 1291 Acquires a lock on the ZakatTracker instance. 1292 1293 Parameters: 1294 None 1295 1296 Returns: 1297 - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later. 1298 """ 1299 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.
1301 def steps(self) -> dict: 1302 """ 1303 Returns a copy of the history of steps taken in the ZakatTracker. 1304 1305 The history is a dictionary where each key is a unique identifier for a step, 1306 and the corresponding value is a dictionary containing information about the step. 1307 1308 Parameters: 1309 None 1310 1311 Returns: 1312 - dict: A copy of the history of steps taken in the ZakatTracker. 1313 """ 1314 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.
1316 def free(self, lock: Timestamp, auto_save: bool = True) -> bool: 1317 """ 1318 Releases the lock on the database. 1319 1320 Parameters: 1321 - lock (Timestamp): The lock ID to be released. 1322 - auto_save (bool, optional): Whether to automatically save the database after releasing the lock. 1323 1324 Returns: 1325 - bool: True if the lock is successfully released and (optionally) saved, False otherwise. 1326 """ 1327 if lock == self.__vault.lock: 1328 self.clean_history(lock) 1329 self.__vault.lock = None 1330 if auto_save and not self.memory_mode(): 1331 return self.save(self.path()) 1332 return True 1333 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.
1335 def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool: 1336 """ 1337 Revert the last operation. 1338 1339 Parameters: 1340 - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 1341 - lock (Timestamp, optional): An optional lock value to ensure the recall 1342 operation is performed on the expected history entry. If provided, 1343 it checks if the current lock and the most recent history key 1344 match the given lock value. Defaults to None. 1345 - debug (bool, optional): If True, the function will print debug information. Default is False. 1346 1347 Returns: 1348 - bool: True if the operation was successful, False otherwise. 1349 """ 1350 if not self.nolock() or len(self.__vault.history) == 0: 1351 return False 1352 if len(self.__vault.history) <= 0: 1353 return False 1354 ref = sorted(self.__vault.history.keys())[-1] 1355 if debug: 1356 print('recall', ref) 1357 memory = self.__vault.history[ref] 1358 if debug: 1359 print(type(memory), 'memory', memory) 1360 if lock is not None: 1361 assert self.__vault.lock == lock, "Invalid current lock" 1362 assert ref == lock, "Invalid last lock" 1363 assert self.__history(), "History mode should be enabled, found off!!!" 1364 limit = len(memory) + 1 1365 sub_positive_log_negative = 0 1366 for i in range(-1, -limit, -1): 1367 x = memory[i] 1368 if debug: 1369 print(type(x), x) 1370 match x.action: 1371 case Action.CREATE: 1372 if x.account is not None: 1373 if self.account_exists(x.account): 1374 if debug: 1375 print('account', self.__vault.account[x.account]) 1376 assert len(self.__vault.account[x.account].box) == 0 1377 assert len(self.__vault.account[x.account].log) == 0 1378 assert self.__vault.account[x.account].balance == 0 1379 assert self.__vault.account[x.account].count == 0 1380 if dry: 1381 continue 1382 del self.__vault.account[x.account] 1383 1384 case Action.TRACK: 1385 if x.account is not None: 1386 if self.account_exists(x.account): 1387 if dry: 1388 continue 1389 assert x.value is not None 1390 assert x.ref is not None 1391 self.__vault.account[x.account].balance -= x.value 1392 self.__vault.account[x.account].count -= 1 1393 del self.__vault.account[x.account].box[x.ref] 1394 1395 case Action.LOG: 1396 if x.account is not None: 1397 if self.account_exists(x.account): 1398 if x.ref in self.__vault.account[x.account].log: 1399 if dry: 1400 continue 1401 assert x.value is not None 1402 if sub_positive_log_negative == -x.value: 1403 self.__vault.account[x.account].count -= 1 1404 sub_positive_log_negative = 0 1405 box_ref = self.__vault.account[x.account].log[x.ref].ref 1406 if not box_ref is None: 1407 assert self.box_exists(x.account, box_ref) 1408 box_value = self.__vault.account[x.account].log[x.ref].value 1409 assert box_value < 0 1410 1411 try: 1412 self.__vault.account[x.account].box[box_ref].rest += -box_value 1413 except TypeError: 1414 self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value) 1415 1416 try: 1417 self.__vault.account[x.account].balance += -box_value 1418 except TypeError: 1419 self.__vault.account[x.account].balance += decimal.Decimal(-box_value) 1420 1421 self.__vault.account[x.account].count -= 1 1422 del self.__vault.account[x.account].log[x.ref] 1423 1424 case Action.SUBTRACT: 1425 if x.account is not None: 1426 if self.account_exists(x.account): 1427 if x.ref in self.__vault.account[x.account].box: 1428 if dry: 1429 continue 1430 assert x.value is not None 1431 self.__vault.account[x.account].box[x.ref].rest += x.value 1432 self.__vault.account[x.account].balance += x.value 1433 sub_positive_log_negative = x.value 1434 1435 case Action.ADD_FILE: 1436 if x.account is not None: 1437 if self.account_exists(x.account): 1438 if x.ref in self.__vault.account[x.account].log: 1439 if x.file in self.__vault.account[x.account].log[x.ref].file: 1440 if dry: 1441 continue 1442 del self.__vault.account[x.account].log[x.ref].file[x.file] 1443 1444 case Action.REMOVE_FILE: 1445 if x.account is not None: 1446 if self.account_exists(x.account): 1447 if x.ref in self.__vault.account[x.account].log: 1448 if dry: 1449 continue 1450 assert x.file is not None 1451 assert x.value is not None 1452 self.__vault.account[x.account].log[x.ref].file[x.file] = x.value 1453 1454 case Action.BOX_TRANSFER: 1455 if x.account is not None: 1456 if self.account_exists(x.account): 1457 if x.ref in self.__vault.account[x.account].box: 1458 if dry: 1459 continue 1460 assert x.value is not None 1461 self.__vault.account[x.account].box[x.ref].rest -= x.value 1462 1463 case Action.EXCHANGE: 1464 if x.account is not None: 1465 if x.account in self.__vault.exchange: 1466 if x.ref in self.__vault.exchange[x.account]: 1467 if dry: 1468 continue 1469 del self.__vault.exchange[x.account][x.ref] 1470 1471 case Action.REPORT: 1472 if x.ref in self.__vault.report: 1473 if dry: 1474 continue 1475 del self.__vault.report[x.ref] 1476 1477 case Action.ZAKAT: 1478 if x.account is not None: 1479 if self.account_exists(x.account): 1480 if x.ref in self.__vault.account[x.account].box: 1481 assert x.key is not None 1482 if hasattr(self.__vault.account[x.account].box[x.ref], x.key): 1483 if dry: 1484 continue 1485 match x.math: 1486 case MathOperation.ADDITION: 1487 setattr( 1488 self.__vault.account[x.account].box[x.ref], 1489 x.key, 1490 getattr(self.__vault.account[x.account].box[x.ref], x.key) - x.value, 1491 ) 1492 case MathOperation.EQUAL: 1493 setattr( 1494 self.__vault.account[x.account].box[x.ref], 1495 x.key, 1496 x.value, 1497 ) 1498 case MathOperation.SUBTRACTION: 1499 setattr( 1500 self.__vault.account[x.account].box[x.ref], 1501 x.key, 1502 getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value, 1503 ) 1504 1505 if not dry: 1506 del self.__vault.history[ref] 1507 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.
1509 def vault(self) -> dict: 1510 """ 1511 Returns a copy of the internal vault dictionary. 1512 1513 This method is used to retrieve the current state of the ZakatTracker object. 1514 It provides a snapshot of the internal data structure, allowing for further 1515 processing or analysis. 1516 1517 Parameters: 1518 None 1519 1520 Returns: 1521 - dict: A copy of the internal vault dictionary. 1522 """ 1523 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.
1525 @staticmethod 1526 def stats_init() -> dict[str, tuple[int, str]]: 1527 """ 1528 Initialize and return a dictionary containing initial statistics for the ZakatTracker instance. 1529 1530 The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements: 1531 - The initial size of the respective statistic in bytes (int). 1532 - The initial size of the respective statistic in a human-readable format (str). 1533 1534 Parameters: 1535 None 1536 1537 Returns: 1538 - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance. 1539 """ 1540 return { 1541 'database': (0, '0'), 1542 'ram': (0, '0'), 1543 }
Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
- The initial size of the respective statistic in bytes (int).
- The initial size of the respective statistic in a human-readable format (str).
Parameters: None
Returns:
- dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
1545 def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]: 1546 """ 1547 Calculates and returns statistics about the object's data storage. 1548 1549 This method determines the size of the database file on disk and the 1550 size of the data currently held in RAM (likely within a dictionary). 1551 Both sizes are reported in bytes and in a human-readable format 1552 (e.g., KB, MB). 1553 1554 Parameters: 1555 - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True 1556 1557 Returns: 1558 - dict[str, tuple[float, str]]: A dictionary containing the following statistics: 1559 1560 * 'database': A tuple with two elements: 1561 - The database file size in bytes (float). 1562 - The database file size in human-readable format (str). 1563 * 'ram': A tuple with two elements: 1564 - The RAM usage (dictionary size) in bytes (float). 1565 - The RAM usage in human-readable format (str). 1566 1567 Example: 1568 ```bash 1569 >>> x = ZakatTracker() 1570 >>> stats = x.stats() 1571 >>> print(stats['database']) 1572 (256000, '250.0 KB') 1573 >>> print(stats['ram']) 1574 (12345, '12.1 KB') 1575 ``` 1576 """ 1577 ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault()) 1578 file_size = os.path.getsize(self.path()) 1579 return { 1580 'database': (file_size, self.human_readable_size(file_size)), 1581 'ram': (ram_size, self.human_readable_size(ram_size)), 1582 }
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:
- dict[str, tuple[float, str]]: A dictionary 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'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
1584 def files(self) -> list[dict[str, str | int]]: 1585 """ 1586 Retrieves information about files associated with this class. 1587 1588 This class method provides a standardized way to gather details about 1589 files used by the class for storage, snapshots, and CSV imports. 1590 1591 Parameters: 1592 None 1593 1594 Returns: 1595 - list[dict[str, str | int]]: A list of dictionaries, each containing information 1596 about a specific file: 1597 1598 * type (str): The type of file ('database', 'snapshot', 'import_csv'). 1599 * path (str): The full file path. 1600 * exists (bool): Whether the file exists on the filesystem. 1601 * size (int): The file size in bytes (0 if the file doesn't exist). 1602 * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB'). 1603 1604 Example: 1605 ``` 1606 file_info = MyClass.files() 1607 for info in file_info: 1608 print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}') 1609 ``` 1610 """ 1611 result = [] 1612 for file_type, path in { 1613 'database': self.path(), 1614 'snapshot': self.snapshot_cache_path(), 1615 'import_csv': self.import_csv_cache_path(), 1616 }.items(): 1617 exists = os.path.exists(path) 1618 size = os.path.getsize(path) if exists else 0 1619 human_readable_size = self.human_readable_size(size) if exists else 0 1620 result.append({ 1621 'type': file_type, 1622 'path': path, 1623 'exists': exists, 1624 'size': size, 1625 'human_readable_size': human_readable_size, 1626 }) 1627 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[dict[str, str | int]]: A list of dictionaries, 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').
Example:
file_info = MyClass.files()
for info in file_info:
print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}')
1629 def account_exists(self, account: AccountName) -> bool: 1630 """ 1631 Check if the given account exists in the vault. 1632 1633 Parameters: 1634 - account (AccountName): The account number to check. 1635 1636 Returns: 1637 - bool: True if the account exists, False otherwise. 1638 """ 1639 return account in self.__vault.account
Check if the given account exists in the vault.
Parameters:
- account (AccountName): The account number to check.
Returns:
- bool: True if the account exists, False otherwise.
1641 def box_size(self, account: AccountName) -> int: 1642 """ 1643 Calculate the size of the box for a specific account. 1644 1645 Parameters: 1646 - account (AccountName): The account number for which the box size needs to be calculated. 1647 1648 Returns: 1649 - int: The size of the box for the given account. If the account does not exist, -1 is returned. 1650 """ 1651 if self.account_exists(account): 1652 return len(self.__vault.account[account].box) 1653 return -1
Calculate the size of the box for a specific account.
Parameters:
- account (AccountName): The account number 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.
1655 def log_size(self, account: AccountName) -> int: 1656 """ 1657 Get the size of the log for a specific account. 1658 1659 Parameters: 1660 - account (AccountName): The account number for which the log size needs to be calculated. 1661 1662 Returns: 1663 - int: The size of the log for the given account. If the account does not exist, -1 is returned. 1664 """ 1665 if self.account_exists(account): 1666 return len(self.__vault.account[account].log) 1667 return -1
Get the size of the log for a specific account.
Parameters:
- account (AccountName): The account number 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.
1669 @staticmethod 1670 def hash_data(data: bytes, algorithm: str = 'blake2b') -> str: 1671 """ 1672 Calculates the hash of given byte data using the specified algorithm. 1673 1674 Parameters: 1675 - data (bytes): The byte data to hash. 1676 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 1677 1678 Returns: 1679 - str: The hexadecimal representation of the data's hash. 1680 """ 1681 hash_obj = hashlib.new(algorithm) 1682 hash_obj.update(data) 1683 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.
1685 @staticmethod 1686 def hash_file(file_path: str, algorithm: str = 'blake2b') -> str: 1687 """ 1688 Calculates the hash of a file using the specified algorithm. 1689 1690 Parameters: 1691 - file_path (str): The path to the file. 1692 - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'. 1693 1694 Returns: 1695 - str: The hexadecimal representation of the file's hash. 1696 """ 1697 hash_obj = hashlib.new(algorithm) # Create the hash object 1698 with open(file_path, 'rb') as file: # Open file in binary mode for reading 1699 for chunk in iter(lambda: file.read(4096), b''): # Read file in chunks 1700 hash_obj.update(chunk) 1701 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.
1703 def snapshot_cache_path(self): 1704 """ 1705 Generate the path for the cache file used to store snapshots. 1706 1707 The cache file is a json file that stores the timestamps of the snapshots. 1708 The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'. 1709 1710 Parameters: 1711 None 1712 1713 Returns: 1714 - str: The path to the cache file. 1715 """ 1716 path = str(self.path()) 1717 ext = self.ext() 1718 ext_len = len(ext) 1719 if path.endswith(f'.{ext}'): 1720 path = path[:-ext_len - 1] 1721 _, filename = os.path.split(path + f'.snapshots.{ext}') 1722 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.
1724 def snapshot(self) -> bool: 1725 """ 1726 This function creates a snapshot of the current database state. 1727 1728 The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. 1729 If a snapshot with the same hash exists, the function returns True without creating a new snapshot. 1730 If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state 1731 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. 1732 1733 Parameters: 1734 None 1735 1736 Returns: 1737 - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails. 1738 """ 1739 current_hash = self.hash_file(self.path()) 1740 cache: dict[str, int] = {} # hash: time_ns 1741 try: 1742 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 1743 cache = json.load(stream, cls=JSONDecoder) 1744 except: 1745 pass 1746 if current_hash in cache: 1747 return True 1748 ref = time.time_ns() 1749 cache[current_hash] = ref 1750 if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')): 1751 return False 1752 with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream: 1753 stream.write(json.dumps(cache, cls=JSONEncoder)) 1754 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.
1756 def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \ 1757 -> dict[int, tuple[str, str, bool]]: 1758 """ 1759 Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status. 1760 1761 Parameters: 1762 - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True. 1763 - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False. 1764 1765 Returns: 1766 - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, 1767 and the values are tuples containing the snapshot's hash, path, and existence status. 1768 """ 1769 cache: dict[str, int] = {} # hash: time_ns 1770 try: 1771 with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream: 1772 cache = json.load(stream, cls=JSONDecoder) 1773 except: 1774 pass 1775 if not cache: 1776 return {} 1777 result: dict[int, tuple[str, str, bool]] = {} # time_ns: (hash, path, exists) 1778 for hash_file, ref in cache.items(): 1779 path = self.base_path('snapshots', f'{ref}.{self.ext()}') 1780 exists = os.path.exists(path) 1781 valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True 1782 if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists): 1783 continue 1784 if exists or not hide_missing: 1785 result[ref] = (hash_file, path, exists) 1786 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.
1788 def ref_exists(self, account: AccountName, ref_type: str, ref: Timestamp) -> bool: 1789 """ 1790 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 1791 1792 Parameters: 1793 - account (AccountName): The account number for which to check the existence of the reference. 1794 - ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 1795 - ref (Timestamp): The reference (transaction) number to check for existence. 1796 1797 Returns: 1798 - bool: True if the reference exists for the given account and reference type, False otherwise. 1799 """ 1800 if account in self.__vault.account: 1801 return ref in getattr(self.__vault.account[account], ref_type) 1802 return False
Check if a specific reference (transaction) exists in the vault for a given account and reference type.
Parameters:
- account (AccountName): The account number 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.
1804 def box_exists(self, account: AccountName, ref: Timestamp) -> bool: 1805 """ 1806 Check if a specific box (transaction) exists in the vault for a given account and reference. 1807 1808 Parameters: 1809 - account (AccountName): The account number for which to check the existence of the box. 1810 - ref (Timestamp): The reference (transaction) number to check for existence. 1811 1812 Returns: 1813 - bool: True if the box exists for the given account and reference, False otherwise. 1814 """ 1815 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 (AccountName): The account number 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.
1817 def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = AccountName('1'), 1818 created_time_ns: Optional[Timestamp] = None, 1819 debug: bool = False) -> Timestamp: 1820 """ 1821 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. 1822 1823 Parameters: 1824 - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0. 1825 - desc (str, optional): The description of the transaction. Default is an empty string. 1826 - account (AccountName, optional): The account for which the transaction is being tracked. Default is '1'. 1827 - 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. 1828 - debug (bool, optional): Whether to print debug information. Default is False. 1829 1830 Returns: 1831 - Timestamp: The timestamp of the transaction in nanoseconds since epoch(1AD). 1832 1833 Raises: 1834 - ValueError: The created_time_ns should be greater than zero. 1835 - ValueError: The log transaction happened again in the same nanosecond time. 1836 - ValueError: The box transaction happened again in the same nanosecond time. 1837 """ 1838 return self.__track( 1839 unscaled_value=unscaled_value, 1840 desc=desc, 1841 account=account, 1842 logging=True, 1843 created_time_ns=created_time_ns, 1844 debug=debug, 1845 )
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 (AccountName, optional): The account 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:
- 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.
1914 def log_exists(self, account: AccountName, ref: Timestamp) -> bool: 1915 """ 1916 Checks if a specific transaction log entry exists for a given account. 1917 1918 Parameters: 1919 - account (AccountName): The account number associated with the transaction log. 1920 - ref (Timestamp): The reference to the transaction log entry. 1921 1922 Returns: 1923 - bool: True if the transaction log entry exists, False otherwise. 1924 """ 1925 return self.ref_exists(account, 'log', ref)
Checks if a specific transaction log entry exists for a given account.
Parameters:
- account (AccountName): The account number 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.
1977 def exchange(self, account: AccountName, created_time_ns: Optional[Timestamp] = None, 1978 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange: 1979 """ 1980 This method is used to record or retrieve exchange rates for a specific account. 1981 1982 Parameters: 1983 - account (AccountName): The account number for which the exchange rate is being recorded or retrieved. 1984 - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 1985 - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 1986 - description (str, optional): A description of the exchange rate. 1987 - debug (bool, optional): Whether to print debug information. Default is False. 1988 1989 Returns: 1990 - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 1991 it returns a dictionary with default values for the rate and description. 1992 1993 Raises: 1994 - ValueError: The created should be greater than zero. 1995 """ 1996 if debug: 1997 print('exchange', f'debug={debug}') 1998 if created_time_ns is None: 1999 created_time_ns = Time.time() 2000 if created_time_ns <= 0: 2001 raise ValueError('The created should be greater than zero.') 2002 if rate is not None: 2003 if rate <= 0: 2004 return Exchange() 2005 if account not in self.__vault.exchange: 2006 self.__vault.exchange[account] = {} 2007 if len(self.__vault.exchange[account]) == 0 and rate <= 1: 2008 return Exchange(time=created_time_ns, rate=1) 2009 no_lock = self.nolock() 2010 lock = self.__lock() 2011 self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description) 2012 self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate) 2013 if no_lock: 2014 assert lock is not None 2015 self.free(lock) 2016 if debug: 2017 print('exchange-created-1', 2018 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2019 2020 if account in self.__vault.exchange: 2021 valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns] 2022 if valid_rates: 2023 latest_rate = max(valid_rates, key=lambda x: x[0]) 2024 if debug: 2025 print('exchange-read-1', 2026 f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}', 2027 'latest_rate', latest_rate) 2028 result = latest_rate[1] 2029 result.time = latest_rate[0] 2030 return result # إرجاع قاموس يحتوي على المعدل والوصف 2031 if debug: 2032 print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}') 2033 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 (AccountName): The account number 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.
2035 @staticmethod 2036 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 2037 """ 2038 This function calculates the exchanged amount of a currency. 2039 2040 Parameters: 2041 - x (float): The original amount of the currency. 2042 - x_rate (float): The exchange rate of the original currency. 2043 - y_rate (float): The exchange rate of the target currency. 2044 2045 Returns: 2046 - float: The exchanged amount of the target currency. 2047 """ 2048 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.
2050 def exchanges(self) -> dict: 2051 """ 2052 Retrieve the recorded exchange rates for all accounts. 2053 2054 Parameters: 2055 None 2056 2057 Returns: 2058 - dict: A dictionary containing all recorded exchange rates. 2059 The keys are account names or numbers, and the values are dictionaries containing the exchange rates. 2060 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 2061 """ 2062 return self.__vault.exchange.copy()
Retrieve the recorded exchange rates for all accounts.
Parameters: None
Returns:
- dict: A dictionary containing all recorded exchange rates. The keys are account names 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.
2064 def accounts(self) -> dict: 2065 """ 2066 Returns a dictionary containing account numbers as keys and their respective balances as values. 2067 2068 Parameters: 2069 None 2070 2071 Returns: 2072 - dict: A dictionary where keys are account numbers and values are their respective balances. 2073 """ 2074 result = {} 2075 for i in self.__vault.account: 2076 result[i] = self.__vault.account[i].balance 2077 return result
Returns a dictionary containing account numbers as keys and their respective balances as values.
Parameters: None
Returns:
- dict: A dictionary where keys are account numbers and values are their respective balances.
2079 def boxes(self, account: AccountName) -> dict: 2080 """ 2081 Retrieve the boxes (transactions) associated with a specific account. 2082 2083 Parameters: 2084 - account (AccountName): The account number for which to retrieve the boxes. 2085 2086 Returns: 2087 - dict: A dictionary containing the boxes associated with the given account. 2088 If the account does not exist, an empty dictionary is returned. 2089 """ 2090 if self.account_exists(account): 2091 return self.__vault.account[account].box 2092 return {}
Retrieve the boxes (transactions) associated with a specific account.
Parameters:
- account (AccountName): The account number for which to retrieve the boxes.
Returns:
- dict: A dictionary containing the boxes associated with the given account. If the account does not exist, an empty dictionary is returned.
2094 def logs(self, account: AccountName) -> dict[Timestamp, Log]: 2095 """ 2096 Retrieve the logs (transactions) associated with a specific account. 2097 2098 Parameters: 2099 - account (AccountName): The account number for which to retrieve the logs. 2100 2101 Returns: 2102 - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. 2103 If the account does not exist, an empty dictionary is returned. 2104 """ 2105 if self.account_exists(account): 2106 return self.__vault.account[account].log 2107 return {}
Retrieve the logs (transactions) associated with a specific account.
Parameters:
- account (AccountName): The account number 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.
2109 @staticmethod 2110 def daily_logs_init() -> dict[str, dict]: 2111 """ 2112 Initialize a dictionary to store daily, weekly, monthly, and yearly logs. 2113 2114 Parameters: 2115 None 2116 2117 Returns: 2118 - dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary. 2119 Later each key maps to another dictionary, which will store the logs for the corresponding time period. 2120 """ 2121 return { 2122 'daily': {}, 2123 'weekly': {}, 2124 'monthly': {}, 2125 'yearly': {}, 2126 }
Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
Parameters: None
Returns:
- dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary. Later each key maps to another dictionary, which will store the logs for the corresponding time period.
2128 def daily_logs(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False): 2129 """ 2130 Retrieve the daily logs (transactions) from all accounts. 2131 2132 The function groups the logs by day, month, and year, and calculates the total value for each group. 2133 It returns a dictionary where the keys are the timestamps of the daily groups, 2134 and the values are dictionaries containing the total value and the logs for that group. 2135 2136 Parameters: 2137 - weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday. 2138 - debug (bool, optional): Whether to print debug information. Default is False. 2139 2140 Returns: 2141 - dict: A dictionary containing the daily logs. 2142 2143 Example: 2144 ```bash 2145 >>> tracker = ZakatTracker() 2146 >>> tracker.subtract(51, 'desc', 'account1') 2147 >>> ref = tracker.track(100, 'desc', 'account2') 2148 >>> tracker.add_file('account2', ref, 'file_0') 2149 >>> tracker.add_file('account2', ref, 'file_1') 2150 >>> tracker.add_file('account2', ref, 'file_2') 2151 >>> tracker.daily_logs() 2152 { 2153 'daily': { 2154 '2024-06-30': { 2155 'positive': 100, 2156 'negative': 51, 2157 'total': 99, 2158 'rows': [ 2159 { 2160 'account': 'account1', 2161 'desc': 'desc', 2162 'file': {}, 2163 'ref': None, 2164 'value': -51, 2165 'time': 1690977015000000000, 2166 'transfer': False, 2167 }, 2168 { 2169 'account': 'account2', 2170 'desc': 'desc', 2171 'file': { 2172 1722919011626770944: 'file_0', 2173 1722919011626812928: 'file_1', 2174 1722919011626846976: 'file_2', 2175 }, 2176 'ref': None, 2177 'value': 100, 2178 'time': 1690977015000000000, 2179 'transfer': False, 2180 }, 2181 ], 2182 }, 2183 }, 2184 'weekly': { 2185 datetime: { 2186 'positive': 100, 2187 'negative': 51, 2188 'total': 99, 2189 }, 2190 }, 2191 'monthly': { 2192 '2024-06': { 2193 'positive': 100, 2194 'negative': 51, 2195 'total': 99, 2196 }, 2197 }, 2198 'yearly': { 2199 2024: { 2200 'positive': 100, 2201 'negative': 51, 2202 'total': 99, 2203 }, 2204 }, 2205 } 2206 ``` 2207 """ 2208 logs = {} 2209 for account in self.accounts(): 2210 for k, v in self.logs(account).items(): 2211 l = dataclasses.asdict(v) 2212 l['time'] = k 2213 l['account'] = account 2214 if k not in logs: 2215 logs[k] = [] 2216 logs[k].append(l) 2217 if debug: 2218 print('logs', logs) 2219 y = self.daily_logs_init() 2220 for i in sorted(logs, reverse=True): 2221 dt = Time.time_to_datetime(i) 2222 daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}' 2223 weekly = dt - datetime.timedelta(days=weekday.value) 2224 monthly = f'{dt.year}-{dt.month:02d}' 2225 yearly = dt.year 2226 # daily 2227 if daily not in y['daily']: 2228 y['daily'][daily] = { 2229 'positive': 0, 2230 'negative': 0, 2231 'total': 0, 2232 'rows': [], 2233 } 2234 transfer = len(logs[i]) > 1 2235 if debug: 2236 print('logs[i]', logs[i]) 2237 for z in logs[i]: 2238 if debug: 2239 print('z', z) 2240 # daily 2241 value = z['value'] 2242 if value > 0: 2243 y['daily'][daily]['positive'] += value 2244 else: 2245 y['daily'][daily]['negative'] += -value 2246 y['daily'][daily]['total'] += value 2247 z['transfer'] = transfer 2248 y['daily'][daily]['rows'].append(z) 2249 # weekly 2250 if weekly not in y['weekly']: 2251 y['weekly'][weekly] = { 2252 'positive': 0, 2253 'negative': 0, 2254 'total': 0, 2255 } 2256 if value > 0: 2257 y['weekly'][weekly]['positive'] += value 2258 else: 2259 y['weekly'][weekly]['negative'] += -value 2260 y['weekly'][weekly]['total'] += value 2261 # monthly 2262 if monthly not in y['monthly']: 2263 y['monthly'][monthly] = { 2264 'positive': 0, 2265 'negative': 0, 2266 'total': 0, 2267 } 2268 if value > 0: 2269 y['monthly'][monthly]['positive'] += value 2270 else: 2271 y['monthly'][monthly]['negative'] += -value 2272 y['monthly'][monthly]['total'] += value 2273 # yearly 2274 if yearly not in y['yearly']: 2275 y['yearly'][yearly] = { 2276 'positive': 0, 2277 'negative': 0, 2278 'total': 0, 2279 } 2280 if value > 0: 2281 y['yearly'][yearly]['positive'] += value 2282 else: 2283 y['yearly'][yearly]['negative'] += -value 2284 y['yearly'][yearly]['total'] += value 2285 if debug: 2286 print('y', y) 2287 return y
Retrieve the daily logs (transactions) from all accounts.
The function groups the logs by day, month, and year, and calculates the total value for each group. It returns a dictionary where the keys are the timestamps of the daily groups, and the values are dictionaries containing the total value and the logs for that group.
Parameters:
- weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
- debug (bool, optional): Whether to print debug information. Default is False.
Returns:
- dict: A dictionary containing the daily logs.
Example:
>>> tracker = ZakatTracker()
>>> tracker.subtract(51, 'desc', 'account1')
>>> ref = tracker.track(100, 'desc', 'account2')
>>> tracker.add_file('account2', ref, 'file_0')
>>> tracker.add_file('account2', ref, 'file_1')
>>> tracker.add_file('account2', ref, 'file_2')
>>> tracker.daily_logs()
{
'daily': {
'2024-06-30': {
'positive': 100,
'negative': 51,
'total': 99,
'rows': [
{
'account': 'account1',
'desc': 'desc',
'file': {},
'ref': None,
'value': -51,
'time': 1690977015000000000,
'transfer': False,
},
{
'account': 'account2',
'desc': 'desc',
'file': {
1722919011626770944: 'file_0',
1722919011626812928: 'file_1',
1722919011626846976: 'file_2',
},
'ref': None,
'value': 100,
'time': 1690977015000000000,
'transfer': False,
},
],
},
},
'weekly': {
datetime: {
'positive': 100,
'negative': 51,
'total': 99,
},
},
'monthly': {
'2024-06': {
'positive': 100,
'negative': 51,
'total': 99,
},
},
'yearly': {
2024: {
'positive': 100,
'negative': 51,
'total': 99,
},
},
}
2289 def add_file(self, account: AccountName, ref: Timestamp, path: str) -> Timestamp: 2290 """ 2291 Adds a file reference to a specific transaction log entry in the vault. 2292 2293 Parameters: 2294 - account (AccountName): The account number associated with the transaction log. 2295 - ref (Timestamp): The reference to the transaction log entry. 2296 - path (str): The path of the file to be added. 2297 2298 Returns: 2299 - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 2300 """ 2301 if self.account_exists(account): 2302 if ref in self.__vault.account[account].log: 2303 no_lock = self.nolock() 2304 lock = self.__lock() 2305 file_ref = Time.time() 2306 self.__vault.account[account].log[ref].file[file_ref] = path 2307 self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref) 2308 if no_lock: 2309 assert lock is not None 2310 self.free(lock) 2311 return file_ref 2312 return Timestamp(0)
Adds a file reference to a specific transaction log entry in the vault.
Parameters:
- account (AccountName): The account number 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.
2314 def remove_file(self, account: AccountName, ref: Timestamp, file_ref: Timestamp) -> bool: 2315 """ 2316 Removes a file reference from a specific transaction log entry in the vault. 2317 2318 Parameters: 2319 - account (AccountName): The account number associated with the transaction log. 2320 - ref (Timestamp): The reference to the transaction log entry. 2321 - file_ref (Timestamp): The reference of the file to be removed. 2322 2323 Returns: 2324 - bool: True if the file reference is successfully removed, False otherwise. 2325 """ 2326 if self.account_exists(account): 2327 if ref in self.__vault.account[account].log: 2328 if file_ref in self.__vault.account[account].log[ref].file: 2329 no_lock = self.nolock() 2330 lock = self.__lock() 2331 x = self.__vault.account[account].log[ref].file[file_ref] 2332 del self.__vault.account[account].log[ref].file[file_ref] 2333 self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 2334 if no_lock: 2335 assert lock is not None 2336 self.free(lock) 2337 return True 2338 return False
Removes a file reference from a specific transaction log entry in the vault.
Parameters:
- account (AccountName): The account number 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.
2340 def balance(self, account: AccountName = AccountName('1'), cached: bool = True) -> int: 2341 """ 2342 Calculate and return the balance of a specific account. 2343 2344 Parameters: 2345 - account (AccountName, optional): The account number. Default is '1'. 2346 - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 2347 2348 Returns: 2349 - int: The balance of the account. 2350 2351 Notes: 2352 - If cached is True, the function returns the cached balance. 2353 - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 2354 """ 2355 if cached: 2356 return self.__vault.account[account].balance 2357 x = 0 2358 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 (AccountName, optional): The account number. 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.
2360 def hide(self, account: AccountName, status: Optional[bool] = None) -> bool: 2361 """ 2362 Check or set the hide status of a specific account. 2363 2364 Parameters: 2365 - account (AccountName): The account number. 2366 - status (bool, optional): The new hide status. If not provided, the function will return the current status. 2367 2368 Returns: 2369 - bool: The current or updated hide status of the account. 2370 2371 Raises: 2372 None 2373 2374 Example: 2375 ```bash 2376 >>> tracker = ZakatTracker() 2377 >>> ref = tracker.track(51, 'desc', 'account1') 2378 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 2379 False 2380 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 2381 True 2382 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 2383 True 2384 >>> tracker.hide('account1', False) 2385 False 2386 ``` 2387 """ 2388 if self.account_exists(account): 2389 if status is None: 2390 return self.__vault.account[account].hide 2391 self.__vault.account[account].hide = status 2392 return status 2393 return False
Check or set the hide status of a specific account.
Parameters:
- account (AccountName): The account number.
- 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
2395 def zakatable(self, account: AccountName, status: Optional[bool] = None) -> bool: 2396 """ 2397 Check or set the zakatable status of a specific account. 2398 2399 Parameters: 2400 - account (AccountName): The account number. 2401 - status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 2402 2403 Returns: 2404 - bool: The current or updated zakatable status of the account. 2405 2406 Raises: 2407 None 2408 2409 Example: 2410 ```bash 2411 >>> tracker = ZakatTracker() 2412 >>> ref = tracker.track(51, 'desc', 'account1') 2413 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 2414 True 2415 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 2416 True 2417 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 2418 True 2419 >>> tracker.zakatable('account1', False) 2420 False 2421 ``` 2422 """ 2423 if self.account_exists(account): 2424 if status is None: 2425 return self.__vault.account[account].zakatable 2426 self.__vault.account[account].zakatable = status 2427 return status 2428 return False
Check or set the zakatable status of a specific account.
Parameters:
- account (AccountName): The account number.
- 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
2430 def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountName = AccountName('1'), 2431 created_time_ns: Optional[Timestamp] = None, 2432 debug: bool = False) \ 2433 -> SubtractReport: 2434 """ 2435 Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance, 2436 the remaining amount will be transferred to a new transaction with a negative value. 2437 2438 Parameters: 2439 - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted. 2440 - desc (str, optional): A description for the transaction. Defaults to an empty string. 2441 - account (AccountName, optional): The account from which the value will be subtracted. Defaults to '1'. 2442 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). 2443 If not provided, the current timestamp will be used. 2444 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 2445 2446 Returns: 2447 - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 2448 2449 Raises: 2450 - ValueError: The unscaled_value should be greater than zero. 2451 - ValueError: The created_time_ns should be greater than zero. 2452 - ValueError: The box transaction happened again in the same nanosecond time. 2453 - ValueError: The log transaction happened again in the same nanosecond time. 2454 """ 2455 if debug: 2456 print('sub', f'debug={debug}') 2457 if unscaled_value <= 0: 2458 raise ValueError('The unscaled_value should be greater than zero.') 2459 if created_time_ns is None: 2460 created_time_ns = Time.time() 2461 if created_time_ns <= 0: 2462 raise ValueError('The created should be greater than zero.') 2463 no_lock = self.nolock() 2464 lock = self.__lock() 2465 self.__track(0, '', account) 2466 value = self.scale(unscaled_value) 2467 self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug) 2468 ids = sorted(self.__vault.account[account].box.keys()) 2469 limit = len(ids) + 1 2470 target = value 2471 if debug: 2472 print('ids', ids) 2473 ages = SubtractAges() 2474 for i in range(-1, -limit, -1): 2475 if target == 0: 2476 break 2477 j = ids[i] 2478 if debug: 2479 print('i', i, 'j', j) 2480 rest = self.__vault.account[account].box[j].rest 2481 if rest >= target: 2482 self.__vault.account[account].box[j].rest -= target 2483 self.__step(Action.SUBTRACT, account, ref=j, value=target) 2484 ages.append(SubtractAge(box_ref=j, total=target)) 2485 target = 0 2486 break 2487 elif target > rest > 0: 2488 chunk = rest 2489 target -= chunk 2490 self.__vault.account[account].box[j].rest = 0 2491 self.__step(Action.SUBTRACT, account, ref=j, value=chunk) 2492 ages.append(SubtractAge(box_ref=j, total=chunk)) 2493 if target > 0: 2494 self.__track( 2495 unscaled_value=self.unscale(-target), 2496 desc=desc, 2497 account=account, 2498 logging=False, 2499 created_time_ns=created_time_ns, 2500 ) 2501 ages.append(SubtractAge(box_ref=created_time_ns, total=target)) 2502 if no_lock: 2503 assert lock is not None 2504 self.free(lock) 2505 return SubtractReport( 2506 log_ref=created_time_ns, 2507 ages=ages, 2508 )
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 (AccountName, optional): The account 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.
2510 def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountName, to_account: AccountName, desc: str = '', 2511 created_time_ns: Optional[Timestamp] = None, 2512 debug: bool = False) -> TransferReport: 2513 """ 2514 Transfers a specified value from one account to another. 2515 2516 Parameters: 2517 - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred. 2518 - from_account (AccountName): The account from which the value will be transferred. 2519 - to_account (AccountName): The account to which the value will be transferred. 2520 - desc (str, optional): A description for the transaction. Defaults to an empty string. 2521 - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used. 2522 - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False. 2523 2524 Returns: 2525 - TransferReport: A class of timestamps corresponding to the transactions made during the transfer. 2526 2527 Raises: 2528 - ValueError: Transfer to the same account is forbidden. 2529 - ValueError: The created_time_ns should be greater than zero. 2530 - ValueError: The box transaction happened again in the same nanosecond time. 2531 - ValueError: The log transaction happened again in the same nanosecond time. 2532 """ 2533 if debug: 2534 print('transfer', f'debug={debug}') 2535 if from_account == to_account: 2536 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 2537 if unscaled_amount <= 0: 2538 return [] 2539 if created_time_ns is None: 2540 created_time_ns = Time.time() 2541 if created_time_ns <= 0: 2542 raise ValueError('The created should be greater than zero.') 2543 no_lock = self.nolock() 2544 lock = self.__lock() 2545 subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug) 2546 source_exchange = self.exchange(from_account, created_time_ns) 2547 target_exchange = self.exchange(to_account, created_time_ns) 2548 2549 if debug: 2550 print('ages', subtract_report.ages) 2551 2552 transfer_report = TransferReport() 2553 for subtract in subtract_report.ages: 2554 times = TransferTimes() 2555 age = subtract.box_ref 2556 value = subtract.total 2557 assert source_exchange.rate is not None 2558 assert target_exchange.rate is not None 2559 target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate)) 2560 if debug: 2561 print('target_amount', target_amount) 2562 # Perform the transfer 2563 if self.box_exists(to_account, age): 2564 if debug: 2565 print('box_exists', age) 2566 capital = self.__vault.account[to_account].box[age].capital 2567 rest = self.__vault.account[to_account].box[age].rest 2568 if debug: 2569 print( 2570 f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 2571 selected_age = age 2572 if rest + target_amount > capital: 2573 self.__vault.account[to_account].box[age].capital += target_amount 2574 selected_age = Time.time() 2575 self.__vault.account[to_account].box[age].rest += target_amount 2576 self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 2577 y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 2578 created_time_ns=None, ref=None, debug=debug) 2579 times.append(TransferTime(box_ref=age, log_ref=y)) 2580 continue 2581 if debug: 2582 print( 2583 f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).') 2584 box_ref = self.__track( 2585 unscaled_value=self.unscale(int(target_amount)), 2586 desc=desc, 2587 account=to_account, 2588 logging=True, 2589 created_time_ns=age, 2590 debug=debug, 2591 ) 2592 transfer_report.append(TransferRecord( 2593 box_ref=box_ref, 2594 times=times, 2595 )) 2596 if no_lock: 2597 assert lock is not None 2598 self.free(lock) 2599 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 (AccountName): The account from which the value will be transferred.
- to_account (AccountName): The account 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:
- 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.
2601 def check(self, 2602 silver_gram_price: float, 2603 unscaled_nisab: Optional[float | int | decimal.Decimal] = None, 2604 debug: bool = False, 2605 created_time_ns: Optional[Timestamp] = None, 2606 cycle: Optional[float] = None) -> ZakatReport: 2607 """ 2608 Check the eligibility for Zakat based on the given parameters. 2609 2610 Parameters: 2611 - silver_gram_price (float): The price of a gram of silver. 2612 - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. 2613 If not provided, it will be calculated based on the silver_gram_price. 2614 - debug (bool, optional): Flag to enable debug mode. 2615 - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time(). 2616 - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 2617 2618 Returns: 2619 - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, 2620 a list of brief statistics, and a dictionary containing the Zakat plan. 2621 """ 2622 if debug: 2623 print('check', f'debug={debug}') 2624 if created_time_ns is None: 2625 created_time_ns = Time.time() 2626 if cycle is None: 2627 cycle = ZakatTracker.TimeCycle() 2628 if unscaled_nisab is None: 2629 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 2630 nisab = self.scale(unscaled_nisab) 2631 plan = ZakatPlan() 2632 statistics = ZakatReportStatistics() 2633 below_nisab = 0 2634 valid = False 2635 if debug: 2636 print('exchanges', self.exchanges()) 2637 for x in self.__vault.account: 2638 if not self.zakatable(x): 2639 continue 2640 _box = self.__vault.account[x].box 2641 _log = self.__vault.account[x].log 2642 limit = len(_box) + 1 2643 ids = sorted(self.__vault.account[x].box.keys()) 2644 for i in range(-1, -limit, -1): 2645 j = ids[i] 2646 rest = float(_box[j].rest) 2647 if rest <= 0: 2648 continue 2649 exchange = self.exchange(x, created_time_ns=Time.time()) 2650 assert exchange.rate is not None 2651 rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1) 2652 statistics.overall_wealth += rest 2653 epoch = (created_time_ns - j) / cycle 2654 if debug: 2655 print(f'Epoch: {epoch}', _box[j]) 2656 if _box[j].last > 0: 2657 epoch = (created_time_ns - _box[j].last) / cycle 2658 if debug: 2659 print(f'Epoch: {epoch}') 2660 epoch = math.floor(epoch) 2661 if debug: 2662 print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch) 2663 if epoch == 0: 2664 continue 2665 if debug: 2666 print('Epoch - PASSED') 2667 statistics.zakatable_transactions_balance += rest 2668 is_nisab = rest >= nisab 2669 total = 0 2670 if is_nisab: 2671 for _ in range(epoch): 2672 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 2673 valid = total > 0 2674 elif rest > 0: 2675 below_nisab += rest 2676 total = ZakatTracker.ZakatCut(float(rest)) 2677 if total > 0: 2678 if x not in plan: 2679 plan[x] = [] 2680 statistics.zakat_cut_balances += total 2681 plan[x].append(BoxPlan( 2682 below_nisab=not is_nisab, 2683 total=total, 2684 count=epoch, 2685 ref=j, 2686 box=_box[j], 2687 log=_log[j], 2688 exchange=exchange, 2689 )) 2690 valid = valid or below_nisab >= nisab 2691 if debug: 2692 print(f'below_nisab({below_nisab}) >= nisab({nisab})') 2693 return ZakatReport( 2694 valid=valid, 2695 statistics=statistics, 2696 plan=plan, 2697 )
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.
2699 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts: 2700 """ 2701 Build payment parts for the Zakat distribution. 2702 2703 Parameters: 2704 - scaled_demand (int): The total demand for payment in local currency. 2705 - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True. 2706 2707 Returns: 2708 - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: 2709 { 2710 'account': { 2711 'account_id': {'balance': float, 'rate': float, 'part': float}, 2712 ... 2713 }, 2714 'exceed': bool, 2715 'demand': int, 2716 'total': float, 2717 } 2718 """ 2719 total = 0.0 2720 parts = PaymentParts( 2721 account={}, 2722 exceed=False, 2723 demand=int(round(scaled_demand)), 2724 total=0, 2725 ) 2726 for x, y in self.accounts().items(): 2727 if positive_only and y <= 0: 2728 continue 2729 total += float(y) 2730 exchange = self.exchange(x) 2731 parts.account[x] = AccountPaymentPart(balance=y, rate=exchange.rate, part=0) 2732 parts.total = total 2733 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, }
2735 @staticmethod 2736 def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int: 2737 """ 2738 Checks the validity of payment parts. 2739 2740 Parameters: 2741 - parts (dict[str, PaymentParts): A dictionary containing payment parts information. 2742 - debug (bool, optional): Flag to enable debug mode. 2743 2744 Returns: 2745 - int: Returns 0 if the payment parts are valid, otherwise returns the error code. 2746 2747 Error Codes: 2748 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 2749 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 2750 3: 'part' value in parts['account'][x] is less than 0. 2751 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 2752 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 2753 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 2754 """ 2755 if debug: 2756 print('check_payment_parts', f'debug={debug}') 2757 # for i in ['demand', 'account', 'total', 'exceed']: 2758 # if i not in parts: 2759 # return 1 2760 exceed = parts.exceed 2761 # for j in ['balance', 'rate', 'part']: 2762 # if j not in parts.account[x]: 2763 # return 2 2764 for x in parts.account: 2765 if parts.account[x].part < 0: 2766 return 3 2767 if not exceed and parts.account[x].balance <= 0: 2768 return 4 2769 demand = parts.demand 2770 z = 0.0 2771 for _, y in parts.account.items(): 2772 if not exceed and y.part > y.balance: 2773 return 5 2774 z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0) 2775 z = round(z, 2) 2776 demand = round(demand, 2) 2777 if debug: 2778 print('check_payment_parts', f'z = {z}, demand = {demand}') 2779 print('check_payment_parts', type(z), type(demand)) 2780 print('check_payment_parts', z != demand) 2781 print('check_payment_parts', str(z) != str(demand)) 2782 if z != demand and str(z) != str(demand): 2783 return 6 2784 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.
2786 def zakat(self, report: ZakatReport, 2787 parts: Optional[PaymentParts] = None, debug: bool = False) -> bool: 2788 """ 2789 Perform Zakat calculation based on the given report and optional parts. 2790 2791 Parameters: 2792 - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan. 2793 - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat. 2794 - debug (bool, optional): A flag indicating whether to print debug information. 2795 2796 Returns: 2797 - bool: True if the zakat calculation is successful, False otherwise. 2798 """ 2799 if debug: 2800 print('zakat', f'debug={debug}') 2801 if not report.valid: 2802 return report.valid 2803 parts_exist = parts is not None 2804 if parts_exist: 2805 if self.check_payment_parts(parts, debug=debug) != 0: 2806 return False 2807 if debug: 2808 print('######### zakat #######') 2809 print('parts_exist', parts_exist) 2810 no_lock = self.nolock() 2811 lock = self.__lock() 2812 report_time = Time.time() 2813 self.__vault.report[report_time] = report 2814 self.__step(Action.REPORT, ref=report_time) 2815 created_time_ns = Time.time() 2816 for x in report.plan: 2817 target_exchange = self.exchange(x) 2818 if debug: 2819 print(report.plan[x]) 2820 print('-------------') 2821 print(self.__vault.account[x].box) 2822 if debug: 2823 print('plan[x]', report.plan[x]) 2824 for plan in report.plan[x]: 2825 j = plan.ref 2826 if debug: 2827 print('j', j) 2828 assert j 2829 self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].last, 2830 key='last', 2831 math_operation=MathOperation.EQUAL) 2832 self.__vault.account[x].box[j].last = created_time_ns 2833 assert target_exchange.rate is not None 2834 amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate)) 2835 self.__vault.account[x].box[j].total += amount 2836 self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 2837 math_operation=MathOperation.ADDITION) 2838 self.__vault.account[x].box[j].count += plan.count 2839 self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count', 2840 math_operation=MathOperation.ADDITION) 2841 if not parts_exist: 2842 try: 2843 self.__vault.account[x].box[j].rest -= amount 2844 except TypeError: 2845 self.__vault.account[x].box[j].rest -= decimal.Decimal(amount) 2846 # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 2847 # math_operation=MathOperation.SUBTRACTION) 2848 self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug) 2849 if parts_exist: 2850 for account, part in parts.account.items(): 2851 if part.part == 0: 2852 continue 2853 if debug: 2854 print('zakat-part', account, part.rate) 2855 target_exchange = self.exchange(account) 2856 assert target_exchange.rate is not None 2857 amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate) 2858 self.subtract( 2859 unscaled_value=self.unscale(int(amount)), 2860 desc='zakat-part-دفعة-زكاة', 2861 account=account, 2862 debug=debug, 2863 ) 2864 if no_lock: 2865 assert lock is not None 2866 self.free(lock) 2867 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.
2869 @staticmethod 2870 def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]: 2871 """Splits a string at the last occurrence of a given symbol. 2872 2873 Parameters: 2874 - data (str): The input string. 2875 - symbol (str): The symbol to split at. 2876 2877 Returns: 2878 - tuple[str, str]: A tuple containing two strings, the part before the last symbol and 2879 the part after the last symbol. If the symbol is not found, returns (data, ""). 2880 """ 2881 last_symbol_index = data.rfind(symbol) 2882 2883 if last_symbol_index != -1: 2884 before_symbol = data[:last_symbol_index] 2885 after_symbol = data[last_symbol_index + len(symbol):] 2886 return before_symbol, after_symbol 2887 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, "").
2889 def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool: 2890 """ 2891 Saves the ZakatTracker's current state to a json file. 2892 2893 This method serializes the internal data (`__vault`). 2894 2895 Parameters: 2896 - path (str, optional): File path for saving. Defaults to a predefined location. 2897 - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True. 2898 2899 Returns: 2900 - bool: True if the save operation is successful, False otherwise. 2901 """ 2902 if path is None: 2903 path = self.path() 2904 # first save in tmp file 2905 temp = f'{path}.tmp' 2906 try: 2907 with open(temp, 'w', encoding='utf-8') as stream: 2908 data = json.dumps(self.__vault, cls=JSONEncoder) 2909 stream.write(data) 2910 if hash_required: 2911 hashed = self.hash_data(data.encode()) 2912 stream.write(f'//{hashed}') 2913 # then move tmp file to original location 2914 shutil.move(temp, path) 2915 return True 2916 except (IOError, OSError) as e: 2917 print(f'Error saving file: {e}') 2918 if os.path.exists(temp): 2919 os.remove(temp) 2920 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.
2922 @staticmethod 2923 def load_vault_from_json(json_string: str) -> Vault: 2924 """Loads a Vault dataclass from a JSON string.""" 2925 data = json.loads(json_string) 2926 2927 vault = Vault() 2928 2929 # Load Accounts 2930 for account_name, account_data in data.get("account", {}).items(): 2931 account_name = AccountName(account_name) 2932 box_data = account_data.get('box', {}) 2933 box = {Timestamp(ts): Box(**box_data[str(ts)]) for ts in box_data} 2934 2935 log_data = account_data.get('log', {}) 2936 log = {Timestamp(ts): Log( 2937 value=log_data[str(ts)]['value'], 2938 desc=log_data[str(ts)]['desc'], 2939 ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None, 2940 file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()} 2941 ) for ts in log_data} 2942 2943 vault.account[account_name] = Account( 2944 balance=account_data["balance"], 2945 created=Timestamp(account_data["created"]), 2946 box=box, 2947 count=account_data.get("count", 0), 2948 log=log, 2949 hide=account_data.get("hide", False), 2950 zakatable=account_data.get("zakatable", True), 2951 ) 2952 2953 # Load Exchanges 2954 for account_name, exchange_data in data.get("exchange", {}).items(): 2955 account_name = AccountName(account_name) 2956 vault.exchange[account_name] = {} 2957 for timestamp, exchange_details in exchange_data.items(): 2958 vault.exchange[account_name][Timestamp(timestamp)] = Exchange( 2959 rate=exchange_details.get("rate"), 2960 description=exchange_details.get("description"), 2961 time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None 2962 ) 2963 2964 # Load History 2965 for timestamp, history_list in data.get("history", {}).items(): 2966 vault.history[Timestamp(timestamp)] = [] 2967 for history_data in history_list: 2968 vault.history[Timestamp(timestamp)].append(History( 2969 action=Action(history_data["action"]), 2970 account=AccountName(history_data["account"]) if history_data.get("account") is not None else None, 2971 ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None, 2972 file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None, 2973 key=history_data.get("key"), 2974 value=history_data.get("value"), 2975 math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None 2976 )) 2977 2978 # Load Lock 2979 vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None 2980 2981 # Load Report 2982 for timestamp, report_data in data.get("report", {}).items(): 2983 zakat_plan = ZakatPlan() 2984 for account_name, box_plans in report_data.get("plan", {}).items(): 2985 account_name = AccountName(account_name) 2986 zakat_plan[account_name] = [] 2987 for box_plan_data in box_plans: 2988 zakat_plan[account_name].append(BoxPlan( 2989 box=Box(**box_plan_data["box"]), 2990 log=Log(**box_plan_data["log"]), 2991 exchange=Exchange(**box_plan_data["exchange"]), 2992 below_nisab=box_plan_data["below_nisab"], 2993 total=box_plan_data["total"], 2994 count=box_plan_data["count"], 2995 ref=Timestamp(box_plan_data["ref"]) 2996 )) 2997 2998 vault.report[Timestamp(timestamp)] = ZakatReport( 2999 valid=report_data["valid"], 3000 statistics=ZakatReportStatistics(**report_data["statistics"]), 3001 plan=zakat_plan 3002 ) 3003 3004 return vault
Loads a Vault dataclass from a JSON string.
3006 def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool: 3007 """ 3008 Load the current state of the ZakatTracker object from a json file. 3009 3010 Parameters: 3011 - path (str, optional): The path where the json file is located. If not provided, it will use the default path. 3012 - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True. 3013 - debug (bool, optional): Flag to enable debug mode. 3014 3015 Returns: 3016 - bool: True if the load operation is successful, False otherwise. 3017 """ 3018 if path is None: 3019 path = self.path() 3020 try: 3021 if os.path.exists(path): 3022 with open(path, 'r', encoding='utf-8') as stream: 3023 file = stream.read() 3024 data, hashed = self.split_at_last_symbol(file, '//') 3025 if hash_required: 3026 assert hashed 3027 if debug: 3028 print('[debug-load]', hashed) 3029 new_hash = self.hash_data(data.encode()) 3030 if debug: 3031 print('[debug-load]', new_hash) 3032 assert hashed == new_hash, "Hash verification failed. File may be corrupted." 3033 self.__vault = self.load_vault_from_json(data) 3034 return True 3035 else: 3036 print(f'File not found: {path}') 3037 return False 3038 except (IOError, OSError) as e: 3039 print(f'Error loading file: {e}') 3040 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.
3042 def import_csv_cache_path(self): 3043 """ 3044 Generates the cache file path for imported CSV data. 3045 3046 This function constructs the file path where cached data from CSV imports 3047 will be stored. The cache file is a json file (.json extension) appended 3048 to the base path of the object. 3049 3050 Parameters: 3051 None 3052 3053 Returns: 3054 - str: The full path to the import CSV cache file. 3055 3056 Example: 3057 ```bash 3058 >>> obj = ZakatTracker('/data/reports') 3059 >>> obj.import_csv_cache_path() 3060 '/data/reports.import_csv.json' 3061 ``` 3062 """ 3063 path = str(self.path()) 3064 ext = self.ext() 3065 ext_len = len(ext) 3066 if path.endswith(f'.{ext}'): 3067 path = path[:-ext_len - 1] 3068 _, filename = os.path.split(path + f'.import_csv.{ext}') 3069 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'
3071 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple: 3072 """ 3073 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 3074 3075 Parameters: 3076 - path (str, optional): The path to the CSV file. Default is 'file.csv'. 3077 - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0. 3078 - debug (bool, optional): A flag indicating whether to print debug information. 3079 3080 Returns: 3081 - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 3082 and a dictionary of bad transactions. 3083 3084 Notes: 3085 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 3086 are appropriate for the currency pairs involved in the conversions. 3087 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 3088 to 1.0 or the previous rate for that account. 3089 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 3090 transactions of the same account within the whole imported and existing dataset when doing `check` and 3091 `zakat` operations. 3092 3093 Example: 3094 The CSV file should have the following format, rate is optional per transaction: 3095 account, desc, value, date, rate 3096 For example: 3097 safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1 3098 """ 3099 if debug: 3100 print('import_csv', f'debug={debug}') 3101 cache: list[int] = [] 3102 try: 3103 with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream: 3104 cache = json.load(stream) 3105 except: 3106 pass 3107 date_formats = [ 3108 '%Y-%m-%d %H:%M:%S', 3109 '%Y-%m-%dT%H:%M:%S', 3110 '%Y-%m-%dT%H%M%S.%f', 3111 '%Y-%m-%d', 3112 ] 3113 created, found, bad = 0, 0, {} 3114 data: dict[int, list] = {} 3115 with open(path, newline='', encoding='utf-8') as f: 3116 i = 0 3117 for row in csv.reader(f, delimiter=','): 3118 i += 1 3119 hashed = hash(tuple(row)) 3120 if hashed in cache: 3121 found += 1 3122 continue 3123 account = row[0] 3124 desc = row[1] 3125 value = float(row[2]) 3126 rate = 1.0 3127 if row[4:5]: # Empty list if index is out of range 3128 rate = float(row[4]) 3129 date: int = 0 3130 for time_format in date_formats: 3131 try: 3132 date = Time.time(datetime.datetime.strptime(row[3], time_format)) 3133 break 3134 except: 3135 pass 3136 if date <= 0: 3137 bad[i] = row + ['invalid date'] 3138 if value == 0: 3139 bad[i] = row + ['invalid value'] 3140 continue 3141 if date not in data: 3142 data[date] = [] 3143 data[date].append((i, account, desc, value, date, rate, hashed)) 3144 3145 if debug: 3146 print('import_csv', len(data)) 3147 3148 if bad: 3149 return created, found, bad 3150 3151 no_lock = self.nolock() 3152 lock = self.__lock() 3153 for date, rows in sorted(data.items()): 3154 try: 3155 len_rows = len(rows) 3156 if len_rows == 1: 3157 (_, account, desc, unscaled_value, date, rate, hashed) = rows[0] 3158 value = self.unscale( 3159 unscaled_value, 3160 decimal_places=scale_decimal_places, 3161 ) if scale_decimal_places > 0 else unscaled_value 3162 if rate > 0: 3163 self.exchange(account=account, created_time_ns=date, rate=rate) 3164 if value > 0: 3165 self.track(unscaled_value=value, desc=desc, account=account, created_time_ns=date) 3166 elif value < 0: 3167 self.subtract(unscaled_value=-value, desc=desc, account=account, created_time_ns=date) 3168 created += 1 3169 cache.append(hashed) 3170 continue 3171 if debug: 3172 print('-- Duplicated time detected', date, 'len', len_rows) 3173 print(rows) 3174 print('---------------------------------') 3175 # If records are found at the same time with different accounts in the same amount 3176 # (one positive and the other negative), this indicates it is a transfer. 3177 if len_rows != 2: 3178 raise Exception(f'more than two transactions({len_rows}) at the same time') 3179 (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0] 3180 (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1] 3181 if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs( 3182 unscaled_value2) or date1 != date2: 3183 raise Exception('invalid transfer') 3184 if rate1 > 0: 3185 self.exchange(account1, created_time_ns=date1, rate=rate1) 3186 if rate2 > 0: 3187 self.exchange(account2, created_time_ns=date2, rate=rate2) 3188 value1 = self.unscale( 3189 unscaled_value1, 3190 decimal_places=scale_decimal_places, 3191 ) if scale_decimal_places > 0 else unscaled_value1 3192 value2 = self.unscale( 3193 unscaled_value2, 3194 decimal_places=scale_decimal_places, 3195 ) if scale_decimal_places > 0 else unscaled_value2 3196 values = { 3197 value1: account1, 3198 value2: account2, 3199 } 3200 self.transfer( 3201 unscaled_amount=abs(value1), 3202 from_account=values[min(values.keys())], 3203 to_account=values[max(values.keys())], 3204 desc=desc1, 3205 created_time_ns=date1, 3206 ) 3207 except Exception as e: 3208 for (i, account, desc, value, date, rate, _) in rows: 3209 bad[i] = (account, desc, value, date, rate, e) 3210 break 3211 with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream: 3212 stream.write(json.dumps(cache)) 3213 if no_lock: 3214 assert lock is not None 3215 self.free(lock) 3216 y = created, found, bad 3217 if debug: 3218 debug_path = f'{self.import_csv_cache_path()}.debug.json' 3219 with open(debug_path, 'w', encoding='utf-8') as file: 3220 json.dump(y, file, indent=4, cls=JSONEncoder) 3221 print(f'generated debug report @ `{debug_path}`...') 3222 return y
The function reads the CSV file, checks for duplicate transactions, and creates the transactions 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:
- tuple: A tuple 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
check
andzakat
operations.
Example: The CSV file should have the following format, rate is optional per transaction: account, desc, value, date, rate For example: safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1
3228 @staticmethod 3229 def human_readable_size(size: float, decimal_places: int = 2) -> str: 3230 """ 3231 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 3232 3233 This function iterates through progressively larger units of information 3234 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 3235 range that can be expressed with a reasonable number before the unit. 3236 3237 Parameters: 3238 - size (float): The size in bytes to convert. 3239 - decimal_places (int, optional): The number of decimal places to display 3240 in the result. Defaults to 2. 3241 3242 Returns: 3243 - str: A string representation of the size in a human-readable format, 3244 rounded to the specified number of decimal places. For example: 3245 - '1.50 KB' (1536 bytes) 3246 - '23.00 MB' (24117248 bytes) 3247 - '1.23 GB' (1325899906 bytes) 3248 """ 3249 if type(size) not in (float, int): 3250 raise TypeError('size must be a float or integer') 3251 if type(decimal_places) != int: 3252 raise TypeError('decimal_places must be an integer') 3253 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 3254 if size < 1024.0: 3255 break 3256 size /= 1024.0 3257 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)
3259 @staticmethod 3260 def get_dict_size(obj: dict, seen: Optional[set] = None) -> float: 3261 """ 3262 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 3263 3264 This function traverses the dictionary structure, accounting for the size of keys, values, 3265 and any nested objects. It handles various data types commonly found in dictionaries 3266 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 3267 of circular references. 3268 3269 Parameters: 3270 - obj (dict): The dictionary whose size is to be calculated. 3271 - seen (set, optional): A set used internally to track visited objects 3272 and avoid circular references. Defaults to None. 3273 3274 Returns: 3275 - float: An approximate size of the dictionary and its contents in bytes. 3276 3277 Notes: 3278 - This function is a method of the `ZakatTracker` class and is likely used to 3279 estimate the memory footprint of data structures relevant to Zakat calculations. 3280 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 3281 not account for all memory overhead depending on the Python implementation. 3282 - Circular references are handled to prevent infinite recursion. 3283 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 3284 - String sizes are estimated based on character length and encoding. 3285 """ 3286 size = 0 3287 if seen is None: 3288 seen = set() 3289 3290 obj_id = id(obj) 3291 if obj_id in seen: 3292 return 0 3293 3294 seen.add(obj_id) 3295 size += sys.getsizeof(obj) 3296 3297 if isinstance(obj, dict): 3298 for k, v in obj.items(): 3299 size += ZakatTracker.get_dict_size(k, seen) 3300 size += ZakatTracker.get_dict_size(v, seen) 3301 elif isinstance(obj, (list, tuple, set, frozenset)): 3302 for item in obj: 3303 size += ZakatTracker.get_dict_size(item, seen) 3304 elif isinstance(obj, (int, float, complex)): # Handle numbers 3305 pass # Basic numbers have a fixed size, so nothing to add here 3306 elif isinstance(obj, str): # Handle strings 3307 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 3308 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.
3310 @staticmethod 3311 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 3312 """ 3313 Convert a specific day, month, and year into a timestamp. 3314 3315 Parameters: 3316 - day (int): The day of the month. 3317 - month (int, optional): The month of the year. Default is 6 (June). 3318 - year (int, optional): The year. Default is 2024. 3319 3320 Returns: 3321 - int: The timestamp representing the given day, month, and year. 3322 3323 Note: 3324 - This method assumes the default month and year if not provided. 3325 """ 3326 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:
- int: The timestamp representing the given day, month, and year.
Note:
- This method assumes the default month and year if not provided.
3328 @staticmethod 3329 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 3330 """ 3331 Generate a random date between two given dates. 3332 3333 Parameters: 3334 - start_date (datetime.datetime): The start date from which to generate a random date. 3335 - end_date (datetime.datetime): The end date until which to generate a random date. 3336 3337 Returns: 3338 - datetime.datetime: A random date between the start_date and end_date. 3339 """ 3340 time_between_dates = end_date - start_date 3341 days_between_dates = time_between_dates.days 3342 random_number_of_days = random.randrange(days_between_dates) 3343 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.
3345 @staticmethod 3346 def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False, 3347 debug: bool = False) -> int: 3348 """ 3349 Generate a random CSV file with specified parameters. 3350 The function generates a CSV file at the specified path with the given count of rows. 3351 Each row contains a randomly generated account, description, value, and date. 3352 The value is randomly generated between 1000 and 100000, 3353 and the date is randomly generated between 1950-01-01 and 2023-12-31. 3354 If the row number is not divisible by 13, the value is multiplied by -1. 3355 3356 Parameters: 3357 - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'. 3358 - count (int, optional): The number of rows to generate in the CSV file. Default is 1000. 3359 - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False. 3360 - debug (bool, optional): A flag indicating whether to print debug information. 3361 3362 Returns: 3363 None 3364 """ 3365 if debug: 3366 print('generate_random_csv_file', f'debug={debug}') 3367 i = 0 3368 with open(path, 'w', newline='', encoding='utf-8') as csvfile: 3369 writer = csv.writer(csvfile) 3370 for i in range(count): 3371 account = f'acc-{random.randint(1, count)}' 3372 desc = f'Some text {random.randint(1, count)}' 3373 value = random.randint(1000, 100000) 3374 date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1), 3375 datetime.datetime(2023, 12, 31)).strftime('%Y-%m-%d %H:%M:%S') 3376 if not i % 13 == 0: 3377 value *= -1 3378 row = [account, desc, value, date] 3379 if with_rate: 3380 rate = random.randint(1, 100) * 0.12 3381 if debug: 3382 print('before-append', row) 3383 row.append(rate) 3384 if debug: 3385 print('after-append', row) 3386 writer.writerow(row) 3387 i = i + 1 3388 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: None
3390 @staticmethod 3391 def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10): 3392 """ 3393 Creates a list of random integers whose sum does not exceed the specified maximum. 3394 3395 Parameters: 3396 - max_sum (int): The maximum allowed sum of the list elements. 3397 - min_value (int, optional): The minimum possible value for an element (inclusive). 3398 - max_value (int, optional): The maximum possible value for an element (inclusive). 3399 3400 Returns: 3401 - A list of random integers. 3402 """ 3403 result = [] 3404 current_sum = 0 3405 3406 while current_sum < max_sum: 3407 # Calculate the remaining space for the next element 3408 remaining_sum = max_sum - current_sum 3409 # Determine the maximum possible value for the next element 3410 next_max_value = min(remaining_sum, max_value) 3411 # Generate a random element within the allowed range 3412 next_element = random.randint(min_value, next_max_value) 3413 result.append(next_element) 3414 current_sum += next_element 3415 3416 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.
3682 def test(self, debug: bool = False) -> bool: 3683 if debug: 3684 print('test', f'debug={debug}') 3685 try: 3686 3687 self._test_core(True, debug) 3688 self._test_core(False, debug) 3689 3690 assert self.__history() 3691 3692 # Not allowed for duplicate transactions in the same account and time 3693 3694 created = Time.time() 3695 self.track(100, 'test-1', 'same', True, created) 3696 failed = False 3697 try: 3698 self.track(50, 'test-1', 'same', True, created) 3699 except: 3700 failed = True 3701 assert failed is True 3702 3703 self.reset() 3704 3705 # Same account transfer 3706 for x in [1, 'a', True, 1.8, None]: 3707 failed = False 3708 try: 3709 self.transfer(1, x, x, 'same-account', debug=debug) 3710 except: 3711 failed = True 3712 assert failed is True 3713 3714 # Always preserve box age during transfer 3715 3716 series: list[tuple[int, int]] = [ 3717 (30, 4), 3718 (60, 3), 3719 (90, 2), 3720 ] 3721 case = { 3722 3000: { 3723 'series': series, 3724 'rest': 15000, 3725 }, 3726 6000: { 3727 'series': series, 3728 'rest': 12000, 3729 }, 3730 9000: { 3731 'series': series, 3732 'rest': 9000, 3733 }, 3734 18000: { 3735 'series': series, 3736 'rest': 0, 3737 }, 3738 27000: { 3739 'series': series, 3740 'rest': -9000, 3741 }, 3742 36000: { 3743 'series': series, 3744 'rest': -18000, 3745 }, 3746 } 3747 3748 selected_time = Time.time() - ZakatTracker.TimeCycle() 3749 3750 for total in case: 3751 if debug: 3752 print('--------------------------------------------------------') 3753 print(f'case[{total}]', case[total]) 3754 for x in case[total]['series']: 3755 self.track( 3756 unscaled_value=x[0], 3757 desc=f'test-{x} ages', 3758 account=AccountName('ages'), 3759 created_time_ns=selected_time * x[1], 3760 ) 3761 3762 unscaled_total = self.unscale(total) 3763 if debug: 3764 print('unscaled_total', unscaled_total) 3765 refs = self.transfer( 3766 unscaled_amount=unscaled_total, 3767 from_account='ages', 3768 to_account='future', 3769 desc='Zakat Movement', 3770 debug=debug, 3771 ) 3772 3773 if debug: 3774 print('refs', refs) 3775 3776 ages_cache_balance = self.balance('ages') 3777 ages_fresh_balance = self.balance('ages', False) 3778 rest = case[total]['rest'] 3779 if debug: 3780 print('source', ages_cache_balance, ages_fresh_balance, rest) 3781 assert ages_cache_balance == rest 3782 assert ages_fresh_balance == rest 3783 3784 future_cache_balance = self.balance('future') 3785 future_fresh_balance = self.balance('future', False) 3786 if debug: 3787 print('target', future_cache_balance, future_fresh_balance, total) 3788 print('refs', refs) 3789 assert future_cache_balance == total 3790 assert future_fresh_balance == total 3791 3792 # TODO: check boxes times for `ages` should equal box times in `future` 3793 for ref in self.__vault.account['ages'].box: 3794 ages_capital = self.__vault.account['ages'].box[ref].capital 3795 ages_rest = self.__vault.account['ages'].box[ref].rest 3796 future_capital = 0 3797 if ref in self.__vault.account['future'].box: 3798 future_capital = self.__vault.account['future'].box[ref].capital 3799 future_rest = 0 3800 if ref in self.__vault.account['future'].box: 3801 future_rest = self.__vault.account['future'].box[ref].rest 3802 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 3803 if debug: 3804 print('================================================================') 3805 print('ages', ages_capital, ages_rest) 3806 print('future', future_capital, future_rest) 3807 if ages_rest == 0: 3808 assert ages_capital == future_capital 3809 elif ages_rest < 0: 3810 assert -ages_capital == future_capital 3811 elif ages_rest > 0: 3812 assert ages_capital == ages_rest + future_capital 3813 self.reset() 3814 assert len(self.__vault.history) == 0 3815 3816 assert self.__history() 3817 assert self.__history(False) is False 3818 assert self.__history() is False 3819 assert self.__history(True) 3820 assert self.__history() 3821 if debug: 3822 print('####################################################################') 3823 3824 transaction = [ 3825 ( 3826 20, 'wallet', 1, -2000, -2000, -2000, 1, 1, 3827 2000, 2000, 2000, 1, 1, 3828 ), 3829 ( 3830 750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2, 3831 75000, 75000, 75000, 1, 1, 3832 ), 3833 ( 3834 600, 'safe', 'bank', 15000, 15000, 15000, 1, 2, 3835 60000, 60000, 60000, 1, 1, 3836 ), 3837 ] 3838 for z in transaction: 3839 lock = self.lock() 3840 x = z[1] 3841 y = z[2] 3842 self.transfer( 3843 unscaled_amount=z[0], 3844 from_account=x, 3845 to_account=y, 3846 desc='test-transfer', 3847 debug=debug, 3848 ) 3849 zz = self.balance(x) 3850 if debug: 3851 print(zz, z) 3852 assert zz == z[3] 3853 xx = self.accounts()[x] 3854 assert xx == z[3] 3855 assert self.balance(x, False) == z[4] 3856 assert xx == z[4] 3857 3858 s = 0 3859 log = self.__vault.account[x].log 3860 for i in log: 3861 s += log[i].value 3862 if debug: 3863 print('s', s, 'z[5]', z[5]) 3864 assert s == z[5] 3865 3866 assert self.box_size(x) == z[6] 3867 assert self.log_size(x) == z[7] 3868 3869 yy = self.accounts()[y] 3870 assert self.balance(y) == z[8] 3871 assert yy == z[8] 3872 assert self.balance(y, False) == z[9] 3873 assert yy == z[9] 3874 3875 s = 0 3876 log = self.__vault.account[y].log 3877 for i in log: 3878 s += log[i].value 3879 assert s == z[10] 3880 3881 assert self.box_size(y) == z[11] 3882 assert self.log_size(y) == z[12] 3883 assert lock is not None 3884 assert self.free(lock) 3885 3886 if debug: 3887 pp().pprint(self.check(2.17)) 3888 3889 assert self.nolock() 3890 history_count = len(self.__vault.history) 3891 if debug: 3892 print('history-count', history_count) 3893 transaction_count = len(transaction) 3894 assert history_count == transaction_count 3895 assert not self.free(Time.time()) 3896 assert self.free(self.lock()) 3897 assert self.nolock() 3898 assert len(self.__vault.history) == transaction_count 3899 3900 # recall 3901 3902 assert self.nolock() 3903 assert len(self.__vault.history) == 3 3904 assert self.recall(False, debug=debug) is True 3905 assert len(self.__vault.history) == 2 3906 assert self.recall(False, debug=debug) is True 3907 assert len(self.__vault.history) == 1 3908 assert self.recall(False, debug=debug) is True 3909 assert len(self.__vault.history) == 0 3910 assert self.recall(False, debug=debug) is False 3911 assert len(self.__vault.history) == 0 3912 3913 # exchange 3914 3915 self.exchange('cash', 25, 3.75, '2024-06-25') 3916 self.exchange('cash', 22, 3.73, '2024-06-22') 3917 self.exchange('cash', 15, 3.69, '2024-06-15') 3918 self.exchange('cash', 10, 3.66) 3919 3920 assert self.nolock() 3921 3922 for i in range(1, 30): 3923 exchange = self.exchange('cash', i) 3924 rate, description, created = exchange.rate, exchange.description, exchange.time 3925 if debug: 3926 print(i, rate, description, created) 3927 assert created 3928 if i < 10: 3929 assert rate == 1 3930 assert description is None 3931 elif i == 10: 3932 assert rate == 3.66 3933 assert description is None 3934 elif i < 15: 3935 assert rate == 3.66 3936 assert description is None 3937 elif i == 15: 3938 assert rate == 3.69 3939 assert description is not None 3940 elif i < 22: 3941 assert rate == 3.69 3942 assert description is not None 3943 elif i == 22: 3944 assert rate == 3.73 3945 assert description is not None 3946 elif i >= 25: 3947 assert rate == 3.75 3948 assert description is not None 3949 exchange = self.exchange('bank', i) 3950 rate, description, created = exchange.rate, exchange.description, exchange.time 3951 if debug: 3952 print(i, rate, description, created) 3953 assert created 3954 assert rate == 1 3955 assert description is None 3956 3957 assert len(self.__vault.exchange) == 1 3958 assert len(self.exchanges()) == 1 3959 self.__vault.exchange.clear() 3960 assert len(self.__vault.exchange) == 0 3961 assert len(self.exchanges()) == 0 3962 self.reset() 3963 3964 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 3965 self.exchange('cash', ZakatTracker.day_to_time(25), 3.75, '2024-06-25') 3966 self.exchange('cash', ZakatTracker.day_to_time(22), 3.73, '2024-06-22') 3967 self.exchange('cash', ZakatTracker.day_to_time(15), 3.69, '2024-06-15') 3968 self.exchange('cash', ZakatTracker.day_to_time(10), 3.66) 3969 3970 assert self.nolock() 3971 3972 for i in [x * 0.12 for x in range(-15, 21)]: 3973 if i <= 0: 3974 assert self.exchange('test', Time.time(), i, f'range({i})') == Exchange() 3975 else: 3976 assert self.exchange('test', Time.time(), i, f'range({i})') is not Exchange() 3977 3978 assert self.nolock() 3979 3980 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 3981 for i in range(1, 31): 3982 timestamp_ns = ZakatTracker.day_to_time(i) 3983 exchange = self.exchange('cash', timestamp_ns) 3984 rate, description, created = exchange.rate, exchange.description, exchange.time 3985 if debug: 3986 print(i, rate, description, created) 3987 assert created 3988 if i < 10: 3989 assert rate == 1 3990 assert description is None 3991 elif i == 10: 3992 assert rate == 3.66 3993 assert description is None 3994 elif i < 15: 3995 assert rate == 3.66 3996 assert description is None 3997 elif i == 15: 3998 assert rate == 3.69 3999 assert description is not None 4000 elif i < 22: 4001 assert rate == 3.69 4002 assert description is not None 4003 elif i == 22: 4004 assert rate == 3.73 4005 assert description is not None 4006 elif i >= 25: 4007 assert rate == 3.75 4008 assert description is not None 4009 exchange = self.exchange('bank', i) 4010 rate, description, created = exchange.rate, exchange.description, exchange.time 4011 if debug: 4012 print(i, rate, description, created) 4013 assert created 4014 assert rate == 1 4015 assert description is None 4016 4017 assert self.nolock() 4018 4019 self.reset() 4020 4021 # test transfer between accounts with different exchange rate 4022 4023 a_SAR = 'Bank (SAR)' 4024 b_USD = 'Bank (USD)' 4025 c_SAR = 'Safe (SAR)' 4026 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 4027 for case in [ 4028 (0, a_SAR, 'SAR Gift', 1000, 100000), 4029 (1, a_SAR, 1), 4030 (0, b_USD, 'USD Gift', 500, 50000), 4031 (1, b_USD, 1), 4032 (2, b_USD, 3.75), 4033 (1, b_USD, 3.75), 4034 (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500), 4035 (0, c_SAR, 'Salary', 750, 75000), 4036 (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000), 4037 (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100), 4038 ]: 4039 if debug: 4040 print('case', case) 4041 match (case[0]): 4042 case 0: # track 4043 _, account, desc, x, balance = case 4044 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 4045 4046 cached_value = self.balance(account, cached=True) 4047 fresh_value = self.balance(account, cached=False) 4048 if debug: 4049 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 4050 assert cached_value == balance 4051 assert fresh_value == balance 4052 case 1: # check-exchange 4053 _, account, expected_rate = case 4054 t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4055 if debug: 4056 print('t-exchange', t_exchange) 4057 assert t_exchange.rate == expected_rate 4058 case 2: # do-exchange 4059 _, account, rate = case 4060 self.exchange(account, rate=rate, debug=debug) 4061 b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug) 4062 if debug: 4063 print('b-exchange', b_exchange) 4064 assert b_exchange.rate == rate 4065 case 3: # transfer 4066 _, x, a, b, desc, a_balance, b_balance = case 4067 self.transfer(x, a, b, desc, debug=debug) 4068 4069 cached_value = self.balance(a, cached=True) 4070 fresh_value = self.balance(a, cached=False) 4071 if debug: 4072 print( 4073 'account', a, 4074 'cached_value', cached_value, 4075 'fresh_value', fresh_value, 4076 'a_balance', a_balance, 4077 ) 4078 assert cached_value == a_balance 4079 assert fresh_value == a_balance 4080 4081 cached_value = self.balance(b, cached=True) 4082 fresh_value = self.balance(b, cached=False) 4083 if debug: 4084 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 4085 assert cached_value == b_balance 4086 assert fresh_value == b_balance 4087 4088 # Transfer all in many chunks randomly from B to A 4089 a_SAR_balance = 137125 4090 b_USD_balance = 50100 4091 b_USD_exchange = self.exchange(b_USD) 4092 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 4093 if debug: 4094 print('amounts', amounts) 4095 i = 0 4096 for x in amounts: 4097 if debug: 4098 print(f'{i} - transfer-with-exchange({x})') 4099 self.transfer( 4100 unscaled_amount=self.unscale(x), 4101 from_account=b_USD, 4102 to_account=a_SAR, 4103 desc=f'{x} USD -> SAR', 4104 debug=debug, 4105 ) 4106 4107 b_USD_balance -= x 4108 cached_value = self.balance(b_USD, cached=True) 4109 fresh_value = self.balance(b_USD, cached=False) 4110 if debug: 4111 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 4112 b_USD_balance) 4113 assert cached_value == b_USD_balance 4114 assert fresh_value == b_USD_balance 4115 4116 a_SAR_balance += int(x * b_USD_exchange.rate) 4117 cached_value = self.balance(a_SAR, cached=True) 4118 fresh_value = self.balance(a_SAR, cached=False) 4119 if debug: 4120 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 4121 a_SAR_balance, 'rate', b_USD_exchange.rate) 4122 assert cached_value == a_SAR_balance 4123 assert fresh_value == a_SAR_balance 4124 i += 1 4125 4126 # Transfer all in many chunks randomly from C to A 4127 c_SAR_balance = 37500 4128 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 4129 if debug: 4130 print('amounts', amounts) 4131 i = 0 4132 for x in amounts: 4133 if debug: 4134 print(f'{i} - transfer-with-exchange({x})') 4135 self.transfer( 4136 unscaled_amount=self.unscale(x), 4137 from_account=c_SAR, 4138 to_account=a_SAR, 4139 desc=f'{x} SAR -> a_SAR', 4140 debug=debug, 4141 ) 4142 4143 c_SAR_balance -= x 4144 cached_value = self.balance(c_SAR, cached=True) 4145 fresh_value = self.balance(c_SAR, cached=False) 4146 if debug: 4147 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 4148 c_SAR_balance) 4149 assert cached_value == c_SAR_balance 4150 assert fresh_value == c_SAR_balance 4151 4152 a_SAR_balance += x 4153 cached_value = self.balance(a_SAR, cached=True) 4154 fresh_value = self.balance(a_SAR, cached=False) 4155 if debug: 4156 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 4157 a_SAR_balance) 4158 assert cached_value == a_SAR_balance 4159 assert fresh_value == a_SAR_balance 4160 i += 1 4161 4162 assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}') 4163 4164 # check & zakat with exchange rates for many cycles 4165 4166 lock = None 4167 for rate, values in { 4168 1: { 4169 'in': [1000, 2000, 10000], 4170 'exchanged': [100000, 200000, 1000000], 4171 'out': [2500, 5000, 73140], 4172 }, 4173 3.75: { 4174 'in': [200, 1000, 5000], 4175 'exchanged': [75000, 375000, 1875000], 4176 'out': [1875, 9375, 137138], 4177 }, 4178 }.items(): 4179 a, b, c = values['in'] 4180 m, n, o = values['exchanged'] 4181 x, y, z = values['out'] 4182 if debug: 4183 print('rate', rate, 'values', values) 4184 for case in [ 4185 (a, 'safe', Time.time() - ZakatTracker.TimeCycle(), [ 4186 {'safe': {0: {'below_nisab': x}}}, 4187 ], False, m), 4188 (b, 'safe', Time.time() - ZakatTracker.TimeCycle(), [ 4189 {'safe': {0: {'count': 1, 'total': y}}}, 4190 ], True, n), 4191 (c, 'cave', Time.time() - (ZakatTracker.TimeCycle() * 3), [ 4192 {'cave': {0: {'count': 3, 'total': z}}}, 4193 ], True, o), 4194 ]: 4195 if debug: 4196 print(f'############# check(rate: {rate}) #############') 4197 print('case', case) 4198 self.reset() 4199 self.exchange(account=case[1], created_time_ns=case[2], rate=rate) 4200 self.track( 4201 unscaled_value=case[0], 4202 desc='test-check', 4203 account=case[1], 4204 created_time_ns=case[2], 4205 ) 4206 assert self.snapshot() 4207 4208 # assert self.nolock() 4209 # history_size = len(self.__vault.history) 4210 # print('history_size', history_size) 4211 # assert history_size == 2 4212 lock = self.lock() 4213 assert lock 4214 assert not self.nolock() 4215 report = self.check(2.17, None, debug) 4216 if debug: 4217 print('report', report) 4218 assert case[4] == report.valid 4219 assert case[5] == report.statistics.overall_wealth 4220 assert case[5] == report.statistics.zakatable_transactions_balance 4221 4222 if debug: 4223 pp().pprint(report.plan) 4224 4225 for x in report.plan: 4226 assert case[1] == x 4227 if report.plan[x][0].below_nisab: 4228 if debug: 4229 print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab']) 4230 assert report.plan[x][0].total == case[3][0][x][0]['below_nisab'] 4231 else: 4232 if debug: 4233 print('[assert]', int(report.statistics.zakat_cut_balances), case[3][0][x][0]['total']) 4234 print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total']) 4235 print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count']) 4236 assert int(report.statistics.zakat_cut_balances) == case[3][0][x][0]['total'] 4237 assert int(report.plan[x][0].total) == case[3][0][x][0]['total'] 4238 assert report.plan[x][0].count == case[3][0][x][0]['count'] 4239 if debug: 4240 pp().pprint(report) 4241 result = self.zakat(report, debug=debug) 4242 if debug: 4243 print('zakat-result', result, case[4]) 4244 assert result == case[4] 4245 report = self.check(2.17, None, debug) 4246 assert report.valid is False 4247 4248 # storage 4249 4250 old_vault = dataclasses.replace(self.__vault) 4251 old_vault_deep = copy.deepcopy(self.__vault) 4252 old_vault_dict = dataclasses.asdict(self.__vault) 4253 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 4254 if os.path.exists(_path): 4255 os.remove(_path) 4256 for hashed in [False, True]: 4257 self.save(hash_required=hashed) 4258 assert os.path.getsize(_path) > 0 4259 self.reset() 4260 assert self.recall(False, debug=debug) is False 4261 for hash_required in [False, True]: 4262 if debug: 4263 print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4264 self.load(hash_required=hashed and hash_required) 4265 if debug: 4266 print('[debug]', type(self.__vault)) 4267 assert self.__vault.account is not None 4268 assert old_vault == self.__vault 4269 assert old_vault_deep == self.__vault 4270 assert old_vault_dict == dataclasses.asdict(self.__vault) 4271 # corrupt the data 4272 log_ref = NO_TIME() 4273 tmp_file_ref = Time.time() 4274 for k in self.__vault.account['cave'].log: 4275 log_ref = k 4276 self.__vault.account['cave'].log[k].file[tmp_file_ref] = 'HACKED' 4277 break 4278 assert old_vault != self.__vault 4279 assert old_vault_deep != self.__vault 4280 assert old_vault_dict != dataclasses.asdict(self.__vault) 4281 # fix the data 4282 del self.__vault.account['cave'].log[log_ref].file[tmp_file_ref] 4283 assert old_vault == self.__vault 4284 assert old_vault_deep == self.__vault 4285 assert old_vault_dict == dataclasses.asdict(self.__vault) 4286 if hashed: 4287 continue 4288 failed = False 4289 try: 4290 hash_required = True 4291 if debug: 4292 print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}') 4293 self.load(hash_required=True) 4294 except: 4295 failed = True 4296 assert failed 4297 4298 # recall after zakat 4299 4300 history_size = len(self.__vault.history) 4301 if debug: 4302 print('history_size', history_size) 4303 assert history_size == 3 4304 assert not self.nolock() 4305 assert self.recall(False, debug=debug) is False 4306 self.free(lock) 4307 assert self.nolock() 4308 4309 for i in range(3, 0, -1): 4310 history_size = len(self.__vault.history) 4311 if debug: 4312 print('history_size', history_size) 4313 assert history_size == i 4314 assert self.recall(False, debug=debug) is True 4315 4316 assert self.nolock() 4317 assert self.recall(False, debug=debug) is False 4318 4319 history_size = len(self.__vault.history) 4320 if debug: 4321 print('history_size', history_size) 4322 assert history_size == 0 4323 4324 account_size = len(self.__vault.account) 4325 if debug: 4326 print('account_size', account_size) 4327 assert account_size == 0 4328 4329 report_size = len(self.__vault.report) 4330 if debug: 4331 print('report_size', report_size) 4332 assert report_size == 0 4333 4334 assert self.nolock() 4335 4336 # csv 4337 4338 csv_count = 1000 4339 4340 for with_rate, path in { 4341 False: 'test-import_csv-no-exchange', 4342 True: 'test-import_csv-with-exchange', 4343 }.items(): 4344 4345 if debug: 4346 print('test_import_csv', with_rate, path) 4347 4348 csv_path = path + '.csv' 4349 if os.path.exists(csv_path): 4350 os.remove(csv_path) 4351 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 4352 if debug: 4353 print('generate_random_csv_file', c) 4354 assert c == csv_count 4355 assert os.path.getsize(csv_path) > 0 4356 cache_path = self.import_csv_cache_path() 4357 if os.path.exists(cache_path): 4358 os.remove(cache_path) 4359 self.reset() 4360 lock = self.lock() 4361 (created, found, bad) = self.import_csv(csv_path, debug) 4362 bad_count = len(bad) 4363 if debug: 4364 print(f'csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})') 4365 print('bad', bad) 4366 # TODO: assert created + found + bad_count == csv_count 4367 # TODO: assert created == csv_count 4368 # TODO: assert bad_count == 0 4369 assert bad_count > 0 4370 tmp_size = os.path.getsize(cache_path) 4371 assert tmp_size > 0 4372 4373 (created_2, found_2, bad_2) = self.import_csv(csv_path) 4374 bad_2_count = len(bad_2) 4375 if debug: 4376 print(f'csv-imported: ({created_2}, {found_2}, {bad_2_count})') 4377 print('bad', bad) 4378 assert bad_2_count > 0 4379 # TODO: assert tmp_size == os.path.getsize(cache_path) 4380 # TODO: assert created_2 + found_2 + bad_2_count == csv_count 4381 # TODO: assert created == found_2 4382 # TODO: assert bad_count == bad_2_count 4383 # TODO: assert found_2 == csv_count 4384 # TODO: assert bad_2_count == 0 4385 # TODO: assert created_2 == 0 4386 4387 # payment parts 4388 4389 positive_parts = self.build_payment_parts(100, positive_only=True) 4390 assert self.check_payment_parts(positive_parts) != 0 4391 assert self.check_payment_parts(positive_parts) != 0 4392 all_parts = self.build_payment_parts(300, positive_only=False) 4393 assert self.check_payment_parts(all_parts) != 0 4394 assert self.check_payment_parts(all_parts) != 0 4395 if debug: 4396 pp().pprint(positive_parts) 4397 pp().pprint(all_parts) 4398 # dynamic discount 4399 suite = [] 4400 count = 3 4401 for exceed in [False, True]: 4402 case = [] 4403 for part in [positive_parts, all_parts]: 4404 #part = parts.copy() 4405 demand = part.demand 4406 if debug: 4407 print(demand, part.total) 4408 i = 0 4409 z = demand / count 4410 cp = PaymentParts( 4411 demand=demand, 4412 exceed=exceed, 4413 total=part.total, 4414 ) 4415 j = '' 4416 for x, y in part.account.items(): 4417 x_exchange = self.exchange(x) 4418 zz = self.exchange_calc(z, 1, x_exchange.rate) 4419 if exceed and zz <= demand: 4420 i += 1 4421 y.part = zz 4422 if debug: 4423 print(exceed, y) 4424 cp.account[x] = y 4425 case.append(y) 4426 elif not exceed and y.balance >= zz: 4427 i += 1 4428 y.part = zz 4429 if debug: 4430 print(exceed, y) 4431 cp.account[x] = y 4432 case.append(y) 4433 j = x 4434 if i >= count: 4435 break 4436 if debug: 4437 print('[debug]', cp.account[j]) 4438 if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0): 4439 suite.append(cp) 4440 if debug: 4441 print('suite', len(suite)) 4442 for case in suite: 4443 if debug: 4444 print('case', case) 4445 result = self.check_payment_parts(case) 4446 if debug: 4447 print('check_payment_parts', result, f'exceed: {exceed}') 4448 assert result == 0 4449 4450 report = self.check(2.17, None, debug) 4451 if debug: 4452 print('valid', report.valid) 4453 zakat_result = self.zakat(report, parts=case, debug=debug) 4454 if debug: 4455 print('zakat-result', zakat_result) 4456 assert report.valid == zakat_result 4457 4458 assert self.free(lock) 4459 4460 assert self.save(path + f'.{self.ext()}') 4461 4462 assert self.save(f'1000-transactions-test.{self.ext()}') 4463 return True 4464 except Exception as e: 4465 # pp().pprint(self.__vault) 4466 assert self.save(f'test-snapshot.{self.ext()}') 4467 raise e
Represents the name of an account.
Represents a timestamp as an integer.
205@dataclasses.dataclass 206class Box: 207 """ 208 Represents a box containing financial information. 209 210 Attributes: 211 - capital: The initial capital of the box. 212 - count: The number of zakat applied on the box. 213 - last: The timestamp of the last zakat on the box. 214 - rest: The remaining value in the box. 215 - total: The total value of zakat applied on the box. 216 """ 217 capital: int #= dataclasses.field(metadata={"frozen": True}) 218 count: int 219 last: int 220 rest: int 221 total: int
Represents a box containing financial information.
Attributes:
- capital: The initial capital of the box.
- count: The number of zakat applied on the box.
- last: The timestamp of the last zakat on the box.
- rest: The remaining value in the box.
- total: The total value of zakat applied on the box.
224@dataclasses.dataclass 225class Log: 226 """ 227 Represents a log entry for an account. 228 229 Attributes: 230 - value: The value of the log entry. 231 - desc: A description of the log entry. 232 - ref: An optional timestamp reference. 233 - file: A dictionary mapping timestamps to file paths. 234 """ 235 value: int 236 desc: str 237 ref: Optional[Timestamp] 238 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.
241@dataclasses.dataclass 242class Account: 243 """ 244 Represents a financial account. 245 246 Attributes: 247 - balance: The current balance of the account. 248 - created: The timestamp when the account was created. 249 - box: A dictionary mapping timestamps to Box objects. 250 - count: A counter for logs, initialized to 0. 251 - log: A dictionary mapping timestamps to Log objects. 252 - hide: A boolean indicating whether the account is hidden. 253 - zakatable: A boolean indicating whether the account is subject to zakat. 254 """ 255 balance: int 256 created: Timestamp 257 box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict) 258 count: int = dataclasses.field(default_factory=factory_value(0)) 259 log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict) 260 hide: bool = dataclasses.field(default_factory=factory_value(False)) 261 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.
- 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.
264@dataclasses.dataclass 265class Exchange: 266 """ 267 Represents an exchange rate and related information. 268 269 Attributes: 270 - rate: The exchange rate (optional). 271 - description: A description of the exchange (optional). 272 - time: The timestamp of the exchange (optional). 273 """ 274 rate: Optional[float] = None 275 description: Optional[str] = None 276 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).
279@dataclasses.dataclass 280class History: 281 """ 282 Represents a history entry for an account action. 283 284 Attributes: 285 - action: The action performed. 286 - account: The name of the account (optional). 287 - ref: An optional timestamp reference. 288 - file: An optional timestamp for a file. 289 - key: An optional key. 290 - value: An optional value. 291 - math: An optional math operation. 292 """ 293 action: Action 294 account: Optional[AccountName] 295 ref: Optional[Timestamp] 296 file: Optional[Timestamp] 297 key: Optional[str] 298 value: Optional[any] # !!! 299 math: Optional[MathOperation]
Represents a history entry for an account action.
Attributes:
- action: The action performed.
- account: The name 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.
362@dataclasses.dataclass 363class Vault: 364 """ 365 Represents a vault containing accounts, exchanges, and history. 366 367 Attributes: 368 - account: A dictionary mapping account names to Account objects. 369 - exchange: A dictionary mapping account names to dictionaries of timestamps and Exchange objects. 370 - history: A dictionary mapping timestamps to lists of History objects. 371 - lock: An optional timestamp for a lock. 372 - report: A dictionary mapping timestamps to tuples. 373 """ 374 account: dict[AccountName, Account] = dataclasses.field(default_factory=dict) 375 exchange: dict[AccountName, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict) 376 history: dict[Timestamp, list[History]] = dataclasses.field(default_factory=dict) 377 lock: Optional[Timestamp] = None 378 report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)
Represents a vault containing accounts, exchanges, and history.
Attributes:
- account: A dictionary mapping account names to Account objects.
- exchange: A dictionary mapping account names to dictionaries of timestamps and Exchange objects.
- history: A dictionary mapping timestamps to lists of History objects.
- lock: An optional timestamp for a lock.
- report: A dictionary mapping timestamps to tuples.
381@dataclasses.dataclass 382class AccountPaymentPart: 383 """ 384 Represents a payment part for an account. 385 386 Attributes: 387 - balance: The balance of the payment part. 388 - rate: The rate of the payment part. 389 - part: The part of the payment. 390 """ 391 balance: float 392 rate: float 393 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.
396@dataclasses.dataclass 397class PaymentParts: 398 """ 399 Represents payment parts for multiple accounts. 400 401 Attributes: 402 - exceed: A boolean indicating whether the payment exceeds a limit. 403 - demand: The demand for payment. 404 - total: The total payment. 405 - account: A dictionary mapping account names to AccountPaymentPart objects. 406 """ 407 exceed: bool 408 demand: int 409 total: float 410 account: dict[AccountName, 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 names to AccountPaymentPart objects.
413@dataclasses.dataclass 414class SubtractAge: 415 """ 416 Represents an age subtraction. 417 418 Attributes: 419 - box_ref: The timestamp reference for the box. 420 - total: The total amount to subtract. 421 """ 422 box_ref: Timestamp 423 total: int
Represents an age subtraction.
Attributes:
- box_ref: The timestamp reference for the box.
- total: The total amount to subtract.
A list of SubtractAge objects.
431@dataclasses.dataclass 432class SubtractReport: 433 """ 434 Represents a report of age subtractions. 435 436 Attributes: 437 - log_ref: The timestamp reference for the log. 438 - ages: A list of SubtractAge objects. 439 """ 440 log_ref: Timestamp 441 ages: SubtractAges
Represents a report of age subtractions.
Attributes:
- log_ref: The timestamp reference for the log.
- ages: A list of SubtractAge objects.
444@dataclasses.dataclass 445class TransferTime: 446 """ 447 Represents a transfer time. 448 449 Attributes: 450 - box_ref: The timestamp reference for the box. 451 - log_ref: The timestamp reference for the log. 452 """ 453 box_ref: Timestamp 454 log_ref: Timestamp
Represents a transfer time.
Attributes:
- box_ref: The timestamp reference for the box.
- log_ref: The timestamp reference for the log.
A list of TransferTime objects.
462@dataclasses.dataclass 463class TransferRecord: 464 """ 465 Represents a transfer record. 466 467 Attributes: 468 - box_ref: The timestamp reference for the box. 469 - times: A list of TransferTime objects. 470 """ 471 box_ref: Timestamp 472 times: TransferTimes
Represents a transfer record.
Attributes:
- box_ref: The timestamp reference for the box.
- times: A list of TransferTime objects.
A list of TransferRecord objects.
302@dataclasses.dataclass 303class BoxPlan: 304 """ 305 Represents a plan for a box. 306 307 Attributes: 308 - box: The Box object. 309 - log: The Log object. 310 - exchange: The Exchange object. 311 - below_nisab: A boolean indicating whether the value is below nisab. 312 - total: The total value. 313 - count: The count. 314 - ref: The timestamp reference. 315 """ 316 box: Box 317 log: Log 318 exchange: Exchange 319 below_nisab: bool 320 total: float 321 count: int 322 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.
325class ZakatPlan(dict[AccountName, list[BoxPlan]]): 326 """A dictionary mapping account names to lists of BoxPlan objects.""" 327 pass
A dictionary mapping account names to lists of BoxPlan objects.
330@dataclasses.dataclass 331class ZakatReportStatistics: 332 """ 333 Represents statistics for a zakat report. 334 335 Attributes: 336 - overall_wealth: The overall wealth. 337 - zakatable_transactions_count: The count of zakatable transactions. 338 - zakatable_transactions_balance: The balance of zakatable transactions. 339 - zakat_cut_balances: The zakat cut balances. 340 """ 341 overall_wealth: int = 0 342 zakatable_transactions_count: int = 0 343 zakatable_transactions_balance: int = 0 344 zakat_cut_balances: int = 0
Represents statistics for a zakat report.
Attributes:
- overall_wealth: The overall wealth.
- zakatable_transactions_count: The count of zakatable transactions.
- zakatable_transactions_balance: The balance of zakatable transactions.
- zakat_cut_balances: The zakat cut balances.
347@dataclasses.dataclass 348class ZakatReport: 349 """ 350 Represents a zakat report. 351 352 Attributes: 353 - valid: A boolean indicating whether the Zakat is available. 354 - statistics: The ZakatReportStatistics object. 355 - plan: The ZakatPlan object. 356 """ 357 valid: bool 358 statistics: ZakatReportStatistics 359 plan: ZakatPlan
Represents a zakat report.
Attributes:
- valid: A boolean indicating whether the Zakat is available.
- statistics: The ZakatReportStatistics object.
- plan: The ZakatPlan object.
4470def test(path: str = None, debug: bool = False): 4471 """ 4472 Executes a test suite for the ZakatTracker. 4473 4474 This function initializes a ZakatTracker instance, optionally using a specified 4475 database path or a temporary directory. It then runs the test suite and, if debug 4476 mode is enabled, prints detailed test results and execution time. 4477 4478 Parameters: 4479 - path (str, optional): The path to the ZakatTracker database. If None, a 4480 temporary directory is created. Defaults to None. 4481 - debug (bool, optional): Enables debug mode, which prints detailed test 4482 results and execution time. Defaults to False. 4483 4484 Returns: 4485 None. The function asserts the result of the ZakatTracker's test suite. 4486 4487 Raises: 4488 - AssertionError: If the ZakatTracker's test suite fails. 4489 4490 Examples: 4491 - `test()` Runs tests using a temporary database. 4492 - `test(debug=True)` Runs the test suite in debug mode with a temporary directory. 4493 - `test(path="/path/to/my/db")` Runs tests using a specified database path. 4494 - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path. 4495 """ 4496 no_path = path is None 4497 if no_path: 4498 path = tempfile.mkdtemp() 4499 print(f"Random database path {path}") 4500 if os.path.exists(path): 4501 shutil.rmtree(path) 4502 assert ZakatTracker(':memory:').memory_mode() 4503 ledger = ZakatTracker( 4504 db_path=path, 4505 history_mode=True, 4506 ) 4507 start = time.time_ns() 4508 assert not ledger.memory_mode() 4509 assert ledger.test(debug=debug) 4510 if no_path and os.path.exists(path): 4511 shutil.rmtree(path) 4512 if debug: 4513 print('#########################') 4514 print('######## TEST DONE ########') 4515 print('#########################') 4516 print(Time.duration_from_nanoseconds(time.time_ns() - start)) 4517 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 - TRACK: Represents the tracking action ('TRACK'). 112 - LOG: Represents the logging action ('LOG'). 113 - SUBTRACT: Represents the subtract action ('SUBTRACT'). 114 - ADD_FILE: Represents the action of adding a file ('ADD_FILE'). 115 - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE'). 116 - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER'). 117 - EXCHANGE: Represents the exchange action ('EXCHANGE'). 118 - REPORT: Represents the reporting action ('REPORT'). 119 - ZAKAT: Represents a Zakat related action ('ZAKAT'). 120 """ 121 CREATE = 'CREATE' 122 TRACK = 'TRACK' 123 LOG = 'LOG' 124 SUBTRACT = 'SUBTRACT' 125 ADD_FILE = 'ADD_FILE' 126 REMOVE_FILE = 'REMOVE_FILE' 127 BOX_TRANSFER = 'BOX_TRANSFER' 128 EXCHANGE = 'EXCHANGE' 129 REPORT = 'REPORT' 130 ZAKAT = 'ZAKAT'
Enumeration representing various actions that can be performed.
Members:
- CREATE: Represents the creation action ('CREATE').
- 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').
480class JSONEncoder(json.JSONEncoder): 481 """ 482 Custom JSON encoder to handle specific object types. 483 484 This encoder overrides the default `default` method to serialize: 485 - `Action` and `MathOperation` enums as their member names. 486 - `decimal.Decimal` instances as floats. 487 488 Example: 489 ```bash 490 >>> json.dumps(Action.CREATE, cls=JSONEncoder) 491 ''CREATE'' 492 >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder) 493 '10.5' 494 ``` 495 """ 496 def default(self, o): 497 """ 498 Overrides the default `default` method to serialize specific object types. 499 500 Parameters: 501 - o: The object to serialize. 502 503 Returns: 504 - The serialized object. 505 """ 506 if isinstance(o, (Action, MathOperation)): 507 return o.name # Serialize as the enum member's name 508 if isinstance(o, decimal.Decimal): 509 return float(o) 510 if isinstance(o, Exception): 511 return str(o) 512 if isinstance(o, Vault): 513 return dataclasses.asdict(o) 514 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'
496 def default(self, o): 497 """ 498 Overrides the default `default` method to serialize specific object types. 499 500 Parameters: 501 - o: The object to serialize. 502 503 Returns: 504 - The serialized object. 505 """ 506 if isinstance(o, (Action, MathOperation)): 507 return o.name # Serialize as the enum member's name 508 if isinstance(o, decimal.Decimal): 509 return float(o) 510 if isinstance(o, Exception): 511 return str(o) 512 if isinstance(o, Vault): 513 return dataclasses.asdict(o) 514 return super().default(o)
Overrides the default default
method to serialize specific object types.
Parameters:
- o: The object to serialize.
Returns:
- The serialized object.
516class JSONDecoder(json.JSONDecoder): 517 """ 518 Custom JSON decoder to handle specific object types. 519 520 This decoder overrides the `object_hook` method to deserialize: 521 - Strings representing enum member names back to their respective enum values. 522 - Floats back to `decimal.Decimal` instances. 523 524 Example: 525 ```bash 526 >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder) 527 {'action': <Action.CREATE: 1>} 528 >>> json.loads('{"value": 10.5}', cls=JSONDecoder) 529 {'value': Decimal('10.5')} 530 ``` 531 """ 532 def object_hook(self, obj): 533 """ 534 Overrides the default `object_hook` method to deserialize specific object types. 535 536 Parameters: 537 - obj: The object to deserialize. 538 539 Returns: 540 - The deserialized object. 541 """ 542 if isinstance(obj, str) and obj in Action.__members__: 543 return Action[obj] 544 if isinstance(obj, str) and obj in MathOperation.__members__: 545 return MathOperation[obj] 546 if isinstance(obj, float): 547 return decimal.Decimal(str(obj)) 548 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')}
532 def object_hook(self, obj): 533 """ 534 Overrides the default `object_hook` method to deserialize specific object types. 535 536 Parameters: 537 - obj: The object to deserialize. 538 539 Returns: 540 - The deserialized object. 541 """ 542 if isinstance(obj, str) and obj in Action.__members__: 543 return Action[obj] 544 if isinstance(obj, str) and obj in MathOperation.__members__: 545 return MathOperation[obj] 546 if isinstance(obj, float): 547 return decimal.Decimal(str(obj)) 548 return obj
Overrides the default object_hook
method to deserialize specific object types.
Parameters:
- obj: The object to deserialize.
Returns:
- The deserialized object.
133@enum.unique 134class MathOperation(enum.Enum): 135 """ 136 Enumeration representing mathematical operations. 137 138 Members: 139 - ADDITION: Represents the addition operation ('ADDITION'). 140 - EQUAL: Represents the equality operation ('EQUAL'). 141 - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION'). 142 """ 143 ADDITION = 'ADDITION' 144 EQUAL = 'EQUAL' 145 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).
110def start_file_server(database_path: str, database_callback: Optional[callable] = None, csv_callback: Optional[callable] = None, 111 debug: bool = False) -> tuple: 112 """ 113 Starts a multi-purpose WSGI server to manage file interactions for a Zakat application. 114 115 This server facilitates the following functionalities: 116 117 1. GET `/{file_uuid}/get`: Download the database file specified by `database_path`. 118 2. GET `/{file_uuid}/upload`: Display an HTML form for uploading files. 119 3. POST `/{file_uuid}/upload`: Handle file uploads, distinguishing between: 120 - Database File (.db): Replaces the existing database with the uploaded one. 121 - CSV File (.csv): Imports data from the CSV into the existing database. 122 123 Parameters: 124 - database_path (str): The path to the camel database file. 125 - database_callback (callable, optional): A function to call after a successful database upload. 126 It receives the uploaded database path as its argument. 127 - csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path, 128 the database path, and the debug flag as its arguments. 129 - debug (bool, optional): If True, print debugging information. Defaults to False. 130 131 Returns: 132 - Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing: 133 - file_name (str): The name of the database file. 134 - download_url (str): The URL to download the database file. 135 - upload_url (str): The URL to access the file upload form. 136 - server_thread (threading.Thread): The thread running the server. 137 - shutdown_server (Callable[[], None]): A function to gracefully shut down the server. 138 139 Example: 140 ```python 141 _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db") 142 print(f"Download database: {download_url}") 143 print(f"Upload files: {upload_url}") 144 server_thread.start() 145 # ... later ... 146 shutdown_server() 147 ``` 148 """ 149 file_uuid = uuid.uuid4() 150 file_name = os.path.basename(database_path) 151 152 port = find_available_port() 153 download_url = f"http://localhost:{port}/{file_uuid}/get" 154 upload_url = f"http://localhost:{port}/{file_uuid}/upload" 155 156 # Upload directory 157 upload_directory = "./uploads" 158 os.makedirs(upload_directory, exist_ok=True) 159 160 # HTML templates 161 upload_form = f""" 162 <html lang="en"> 163 <head> 164 <title>Zakat File Server</title> 165 </head> 166 <body> 167 <h1>Zakat File Server</h1> 168 <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3> 169 <h3>Or upload a new file to restore a database or import `CSV` file:</h3> 170 <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data"> 171 <input type="file" name="file" required><br/> 172 <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required> 173 <label for="database">Database File</label><br/> 174 <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}"> 175 <label for="csv">CSV File</label><br/> 176 <input type="submit" value="Upload"><br/> 177 </form> 178 </body></html> 179 """ 180 181 # WSGI application 182 def wsgi_app(environ, start_response): 183 path = environ.get('PATH_INFO', '') 184 method = environ.get('REQUEST_METHOD', 'GET') 185 186 if path == f"/{file_uuid}/get" and method == 'GET': 187 # GET: Serve the existing file 188 try: 189 with open(database_path, "rb") as f: 190 file_content = f.read() 191 192 start_response('200 OK', [ 193 ('Content-type', 'application/octet-stream'), 194 ('Content-Disposition', f'attachment; filename="{file_name}"'), 195 ('Content-Length', str(len(file_content))) 196 ]) 197 return [file_content] 198 except FileNotFoundError: 199 start_response('404 Not Found', [('Content-type', 'text/plain')]) 200 return [b'File not found'] 201 202 elif path == f"/{file_uuid}/upload" and method == 'GET': 203 # GET: Serve the upload form 204 start_response('200 OK', [('Content-type', 'text/html')]) 205 return [upload_form.encode()] 206 207 elif path == f"/{file_uuid}/upload" and method == 'POST': 208 # POST: Handle file uploads 209 try: 210 # Get content length 211 content_length = int(environ.get('CONTENT_LENGTH', 0)) 212 213 # Get content type and boundary 214 content_type = environ.get('CONTENT_TYPE', '') 215 216 # Read the request body 217 request_body = environ['wsgi.input'].read(content_length) 218 219 # Create a file-like object from the request body 220 # request_body_file = io.BytesIO(request_body) 221 222 # Parse the multipart form data using WSGI approach 223 # First, detect the boundary from content_type 224 boundary = None 225 for part in content_type.split(';'): 226 part = part.strip() 227 if part.startswith('boundary='): 228 boundary = part[9:] 229 if boundary.startswith('"') and boundary.endswith('"'): 230 boundary = boundary[1:-1] 231 break 232 233 if not boundary: 234 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 235 return [b"Missing boundary in multipart form data"] 236 237 # Process multipart data 238 parts = request_body.split(f'--{boundary}'.encode()) 239 240 # Initialize variables to store form data 241 upload_type = None 242 # file_item = None 243 file_data = None 244 filename = None 245 246 # Process each part 247 for part in parts: 248 if not part.strip(): 249 continue 250 251 # Split header and body 252 try: 253 headers_raw, body = part.split(b'\r\n\r\n', 1) 254 headers_text = headers_raw.decode('utf-8') 255 except ValueError: 256 continue 257 258 # Parse headers 259 headers = {} 260 for header_line in headers_text.split('\r\n'): 261 if ':' in header_line: 262 name, value = header_line.split(':', 1) 263 headers[name.strip().lower()] = value.strip() 264 265 # Get content disposition 266 content_disposition = headers.get('content-disposition', '') 267 if not content_disposition.startswith('form-data'): 268 continue 269 270 # Extract field name 271 field_name = None 272 for item in content_disposition.split(';'): 273 item = item.strip() 274 if item.startswith('name='): 275 field_name = item[5:].strip('"\'') 276 break 277 278 if not field_name: 279 continue 280 281 # Handle upload_type field 282 if field_name == 'upload_type': 283 # Remove trailing data including the boundary 284 body_end = body.find(b'\r\n--') 285 if body_end >= 0: 286 body = body[:body_end] 287 upload_type = body.decode('utf-8').strip() 288 289 # Handle file field 290 elif field_name == 'file': 291 # Extract filename 292 for item in content_disposition.split(';'): 293 item = item.strip() 294 if item.startswith('filename='): 295 filename = item[9:].strip('"\'') 296 break 297 298 if filename: 299 # Remove trailing data including the boundary 300 body_end = body.find(b'\r\n--') 301 if body_end >= 0: 302 body = body[:body_end] 303 file_data = body 304 305 if debug: 306 print('upload_type', upload_type) 307 308 if debug: 309 print('upload_type:', upload_type) 310 print('filename:', filename) 311 312 if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]: 313 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 314 return [b"Invalid upload type"] 315 316 if not filename or not file_data: 317 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 318 return [b"Missing file data"] 319 320 if debug: 321 print(f'Uploaded filename: {filename}') 322 323 # Save the file 324 file_path = os.path.join(upload_directory, upload_type) 325 with open(file_path, 'wb') as f: 326 f.write(file_data) 327 328 # Process based on file type 329 if upload_type == FileType.Database.value: 330 try: 331 # Verify database file 332 if database_callback is not None: 333 database_callback(file_path) 334 335 # Copy database into the original path 336 shutil.copy2(file_path, database_path) 337 338 start_response('200 OK', [('Content-type', 'text/plain')]) 339 return [b"Database file uploaded successfully."] 340 except Exception as e: 341 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 342 return [str(e).encode()] 343 344 elif upload_type == FileType.CSV.value: 345 try: 346 if csv_callback is not None: 347 result = csv_callback(file_path, database_path, debug) 348 if debug: 349 print(f'CSV imported: {result}') 350 if len(result[2]) != 0: 351 start_response('200 OK', [('Content-type', 'application/json')]) 352 return [json.dumps(result).encode()] 353 354 start_response('200 OK', [('Content-type', 'text/plain')]) 355 return [b"CSV file uploaded successfully."] 356 except Exception as e: 357 start_response('400 Bad Request', [('Content-type', 'text/plain')]) 358 return [str(e).encode()] 359 360 except Exception as e: 361 start_response('500 Internal Server Error', [('Content-type', 'text/plain')]) 362 return [f"Error processing upload: {str(e)}".encode()] 363 364 else: 365 # 404 for anything else 366 start_response('404 Not Found', [('Content-type', 'text/plain')]) 367 return [b'Not Found'] 368 369 # Create and start the server 370 httpd = make_server('localhost', port, wsgi_app) 371 server_thread = threading.Thread(target=httpd.serve_forever) 372 373 def shutdown_server(): 374 nonlocal httpd, server_thread 375 httpd.shutdown() 376 server_thread.join() # Wait for the thread to finish 377 378 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()
85def find_available_port() -> int: 86 """ 87 Finds and returns an available TCP port on the local machine. 88 89 This function utilizes a TCP server socket to bind to port 0, which 90 instructs the operating system to automatically assign an available 91 port. The assigned port is then extracted and returned. 92 93 Returns: 94 - int: The available TCP port number. 95 96 Raises: 97 - OSError: If an error occurs during the port binding process, such 98 as all ports being in use. 99 100 Example: 101 ```python 102 port = find_available_port() 103 print(f"Available port: {port}") 104 ``` 105 """ 106 with socketserver.TCPServer(("localhost", 0), None) as s: 107 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').