zakat
xxx
_____ _ _ _ _ _
|__ /__ _| | ____ _| |_ | | (_) |__ _ __ __ _ _ __ _ _
/ // _| |/ / _
| __| | | | | '_ \| '__/ _` | '__| | | |
/ /| (_| | < (_| | |_ | |___| | |_) | | | (_| | | | |_| |
/______,_|_|___,_|__| |_____|_|_.__/|_| __,_|_| __, |
|___/
"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف ... Never Trust, Always Verify ...
This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
1""" 2 _____ _ _ _ _ _ 3|__ /__ _| | ____ _| |_ | | (_) |__ _ __ __ _ _ __ _ _ 4 / // _` | |/ / _` | __| | | | | '_ \| '__/ _` | '__| | | | 5 / /| (_| | < (_| | |_ | |___| | |_) | | | (_| | | | |_| | 6/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_| \__,_|_| \__, | 7 |___/ 8 9"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف 10... Never Trust, Always Verify ... 11 12This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat. 13""" 14# Importing necessary classes and functions from the main module 15from zakat.zakat_tracker import ( 16 ZakatTracker, 17 test, 18 Action, 19 JSONEncoder, 20 MathOperation, 21) 22 23from zakat.file_server import ( 24 start_file_server, 25 find_available_port, 26 FileType, 27) 28 29# Version information for the module 30__version__ = ZakatTracker.Version() 31__all__ = [ 32 "ZakatTracker", 33 "test", 34 "Action", 35 "JSONEncoder", 36 "MathOperation", 37 "start_file_server", 38 "find_available_port", 39 "FileType", 40]
101class ZakatTracker: 102 """ 103 A class for tracking and calculating Zakat. 104 105 This class provides functionalities for recording transactions, calculating Zakat due, 106 and managing account balances. It also offers features like importing transactions from 107 CSV files, exporting data to JSON format, and saving/loading the tracker state. 108 109 The `ZakatTracker` class is designed to handle both positive and negative transactions, 110 allowing for flexible tracking of financial activities related to Zakat. It also supports 111 the concept of a "Nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due 112 based on the current silver price. 113 114 The class uses a pickle file as its database to persist the tracker state, 115 ensuring data integrity across sessions. It also provides options for enabling or 116 disabling history tracking, allowing users to choose their preferred level of detail. 117 118 In addition, the `ZakatTracker` class includes various helper methods like 119 `time`, `time_to_datetime`, `lock`, `free`, `recall`, `export_json`, 120 and more. These methods provide additional functionalities and flexibility 121 for interacting with and managing the Zakat tracker. 122 123 Attributes: 124 ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage. 125 ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat. 126 ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price. 127 ZakatTracker.Version (function): The version of the ZakatTracker class. 128 129 Data Structure: 130 The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data. 131 132 _vault (dict): 133 - account (dict): 134 - {account_number} (dict): 135 - balance (int): The current balance of the account. 136 - box (dict): A dictionary storing transaction details. 137 - {timestamp} (dict): 138 - capital (int): The initial amount of the transaction. 139 - count (int): The number of times Zakat has been calculated for this transaction. 140 - last (int): The timestamp of the last Zakat calculation. 141 - rest (int): The remaining amount after Zakat deductions and withdrawal. 142 - total (int): The total Zakat deducted from this transaction. 143 - count (int): The total number of transactions for the account. 144 - log (dict): A dictionary storing transaction logs. 145 - {timestamp} (dict): 146 - value (int): The transaction amount (positive or negative). 147 - desc (str): The description of the transaction. 148 - ref (int): The box reference (positive or None). 149 - file (dict): A dictionary storing file references associated with the transaction. 150 - hide (bool): Indicates whether the account is hidden or not. 151 - zakatable (bool): Indicates whether the account is subject to Zakat. 152 - exchange (dict): 153 - account (dict): 154 - {timestamps} (dict): 155 - rate (float): Exchange rate when compared to local currency. 156 - description (str): The description of the exchange rate. 157 - history (dict): 158 - {timestamp} (list): A list of dictionaries storing the history of actions performed. 159 - {action_dict} (dict): 160 - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT). 161 - account (str): The account number associated with the action. 162 - ref (int): The reference number of the transaction. 163 - file (int): The reference number of the file (if applicable). 164 - key (str): The key associated with the action (e.g., 'rest', 'total'). 165 - value (int): The value associated with the action. 166 - math (MathOperation): The mathematical operation performed (if applicable). 167 - lock (int or None): The timestamp indicating the current lock status (None if not locked). 168 - report (dict): 169 - {timestamp} (tuple): A tuple storing Zakat report details. 170 171 """ 172 173 @staticmethod 174 def Version(): 175 """ 176 Returns the current version of the software. 177 178 This function returns a string representing the current version of the software, 179 including major, minor, and patch version numbers in the format "X.Y.Z". 180 181 Returns: 182 str: The current version of the software. 183 """ 184 return '0.2.73' 185 186 @staticmethod 187 def ZakatCut(x: float) -> float: 188 """ 189 Calculates the Zakat amount due on an asset. 190 191 This function calculates the zakat amount due on a given asset value over one lunar year. 192 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 193 that exceeds a certain threshold (Nisab). 194 195 Parameters: 196 x: The total value of the asset on which Zakat is to be calculated. 197 198 Returns: 199 The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 200 """ 201 return 0.025 * x # Zakat Cut in one Lunar Year 202 203 @staticmethod 204 def TimeCycle(days: int = 355) -> int: 205 """ 206 Calculates the approximate duration of a lunar year in nanoseconds. 207 208 This function calculates the approximate duration of a lunar year based on the given number of days. 209 It converts the given number of days into nanoseconds for use in high-precision timing applications. 210 211 Parameters: 212 days: The number of days in a lunar year. Defaults to 355, 213 which is an approximation of the average length of a lunar year. 214 215 Returns: 216 The approximate duration of a lunar year in nanoseconds. 217 """ 218 return int(60 * 60 * 24 * days * 1e9) # Lunar Year in nanoseconds 219 220 @staticmethod 221 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 222 """ 223 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 224 225 This function calculates the Nisab value, which is the minimum threshold of wealth, 226 that makes an individual liable for paying Zakat. 227 The Nisab value is determined by the equivalent value of a specific amount 228 of gold or silver (currently 595 grams in silver) in the local currency. 229 230 Parameters: 231 - gram_price (float): The price per gram of Nisab. 232 - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver. 233 234 Returns: 235 - float: The total value of Nisab based on the given price per gram. 236 """ 237 return gram_price * gram_quantity 238 239 def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True): 240 """ 241 Initialize ZakatTracker with database path and history mode. 242 243 Parameters: 244 db_path (str): The path to the database file. Default is "zakat.pickle". 245 history_mode (bool): The mode for tracking history. Default is True. 246 247 Returns: 248 None 249 """ 250 self._vault_path = None 251 self._vault = None 252 self.reset() 253 self._history(history_mode) 254 self.path(db_path) 255 self.load() 256 257 def path(self, path: str = None) -> str: 258 """ 259 Set or get the database path. 260 261 Parameters: 262 path (str): The path to the database file. If not provided, it returns the current path. 263 264 Returns: 265 str: The current database path. 266 """ 267 if path is not None: 268 self._vault_path = path 269 return self._vault_path 270 271 def _history(self, status: bool = None) -> bool: 272 """ 273 Enable or disable history tracking. 274 275 Parameters: 276 status (bool): The status of history tracking. Default is True. 277 278 Returns: 279 None 280 """ 281 if status is not None: 282 self._history_mode = status 283 return self._history_mode 284 285 def reset(self) -> None: 286 """ 287 Reset the internal data structure to its initial state. 288 289 Parameters: 290 None 291 292 Returns: 293 None 294 """ 295 self._vault = { 296 'account': {}, 297 'exchange': {}, 298 'history': {}, 299 'lock': None, 300 'report': {}, 301 } 302 303 @staticmethod 304 def time(now: datetime = None) -> int: 305 """ 306 Generates a timestamp based on the provided datetime object or the current datetime. 307 308 Parameters: 309 now (datetime, optional): The datetime object to generate the timestamp from. 310 If not provided, the current datetime is used. 311 312 Returns: 313 int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), 314 before 1970 will return in negative until 1000AD. 315 """ 316 if now is None: 317 now = datetime.datetime.now() 318 ordinal_day = now.toordinal() 319 ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9 320 return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day) 321 322 @staticmethod 323 def time_to_datetime(ordinal_ns: int) -> datetime: 324 """ 325 Converts an ordinal number (number of days since 1000-01-01) to a datetime object. 326 327 Parameters: 328 ordinal_ns (int): The ordinal number of days since 1000-01-01. 329 330 Returns: 331 datetime: The corresponding datetime object. 332 """ 333 ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163 334 ns_in_day = ordinal_ns % 86_400_000_000_000 335 d = datetime.datetime.fromordinal(ordinal_day) 336 t = datetime.timedelta(seconds=ns_in_day // 10 ** 9) 337 return datetime.datetime.combine(d, datetime.time()) + t 338 339 def _step(self, action: Action = None, account=None, ref: int = None, file: int = None, value: float = None, 340 key: str = None, math_operation: MathOperation = None) -> int: 341 """ 342 This method is responsible for recording the actions performed on the ZakatTracker. 343 344 Parameters: 345 - action (Action): The type of action performed. 346 - account (str): The account number on which the action was performed. 347 - ref (int): The reference number of the action. 348 - file (int): The file reference number of the action. 349 - value (int): The value associated with the action. 350 - key (str): The key associated with the action. 351 - math_operation (MathOperation): The mathematical operation performed during the action. 352 353 Returns: 354 - int: The lock time of the recorded action. If no lock was performed, it returns 0. 355 """ 356 if not self._history(): 357 return 0 358 lock = self._vault['lock'] 359 if self.nolock(): 360 lock = self._vault['lock'] = self.time() 361 self._vault['history'][lock] = [] 362 if action is None: 363 return lock 364 self._vault['history'][lock].append({ 365 'action': action, 366 'account': account, 367 'ref': ref, 368 'file': file, 369 'key': key, 370 'value': value, 371 'math': math_operation, 372 }) 373 return lock 374 375 def nolock(self) -> bool: 376 """ 377 Check if the vault lock is currently not set. 378 379 Returns: 380 bool: True if the vault lock is not set, False otherwise. 381 """ 382 return self._vault['lock'] is None 383 384 def lock(self) -> int: 385 """ 386 Acquires a lock on the ZakatTracker instance. 387 388 Returns: 389 int: The lock ID. This ID can be used to release the lock later. 390 """ 391 return self._step() 392 393 def vault(self) -> dict: 394 """ 395 Returns a copy of the internal vault dictionary. 396 397 This method is used to retrieve the current state of the ZakatTracker object. 398 It provides a snapshot of the internal data structure, allowing for further 399 processing or analysis. 400 401 Returns: 402 dict: A copy of the internal vault dictionary. 403 """ 404 return self._vault.copy() 405 406 def stats(self) -> dict[str, tuple]: 407 """ 408 Calculates and returns statistics about the object's data storage. 409 410 This method determines the size of the database file on disk and the 411 size of the data currently held in RAM (likely within a dictionary). 412 Both sizes are reported in bytes and in a human-readable format 413 (e.g., KB, MB). 414 415 Returns: 416 dict[str, tuple]: A dictionary containing the following statistics: 417 418 * 'database': A tuple with two elements: 419 - The database file size in bytes (int). 420 - The database file size in human-readable format (str). 421 * 'ram': A tuple with two elements: 422 - The RAM usage (dictionary size) in bytes (int). 423 - The RAM usage in human-readable format (str). 424 425 Example: 426 >>> stats = my_object.stats() 427 >>> print(stats['database']) 428 (256000, '250.0 KB') 429 >>> print(stats['ram']) 430 (12345, '12.1 KB') 431 """ 432 ram_size = self.get_dict_size(self.vault()) 433 file_size = os.path.getsize(self.path()) 434 return { 435 'database': (file_size, self.human_readable_size(file_size)), 436 'ram': (ram_size, self.human_readable_size(ram_size)), 437 } 438 439 def steps(self) -> dict: 440 """ 441 Returns a copy of the history of steps taken in the ZakatTracker. 442 443 The history is a dictionary where each key is a unique identifier for a step, 444 and the corresponding value is a dictionary containing information about the step. 445 446 Returns: 447 dict: A copy of the history of steps taken in the ZakatTracker. 448 """ 449 return self._vault['history'].copy() 450 451 def free(self, lock: int, auto_save: bool = True) -> bool: 452 """ 453 Releases the lock on the database. 454 455 Parameters: 456 lock (int): The lock ID to be released. 457 auto_save (bool): Whether to automatically save the database after releasing the lock. 458 459 Returns: 460 bool: True if the lock is successfully released and (optionally) saved, False otherwise. 461 """ 462 if lock == self._vault['lock']: 463 self._vault['lock'] = None 464 if auto_save: 465 return self.save(self.path()) 466 return True 467 return False 468 469 def account_exists(self, account) -> bool: 470 """ 471 Check if the given account exists in the vault. 472 473 Parameters: 474 account (str): The account number to check. 475 476 Returns: 477 bool: True if the account exists, False otherwise. 478 """ 479 return account in self._vault['account'] 480 481 def box_size(self, account) -> int: 482 """ 483 Calculate the size of the box for a specific account. 484 485 Parameters: 486 account (str): The account number for which the box size needs to be calculated. 487 488 Returns: 489 int: The size of the box for the given account. If the account does not exist, -1 is returned. 490 """ 491 if self.account_exists(account): 492 return len(self._vault['account'][account]['box']) 493 return -1 494 495 def log_size(self, account) -> int: 496 """ 497 Get the size of the log for a specific account. 498 499 Parameters: 500 account (str): The account number for which the log size needs to be calculated. 501 502 Returns: 503 int: The size of the log for the given account. If the account does not exist, -1 is returned. 504 """ 505 if self.account_exists(account): 506 return len(self._vault['account'][account]['log']) 507 return -1 508 509 def recall(self, dry=True, debug=False) -> bool: 510 """ 511 Revert the last operation. 512 513 Parameters: 514 dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 515 debug (bool): If True, the function will print debug information. Default is False. 516 517 Returns: 518 bool: True if the operation was successful, False otherwise. 519 """ 520 if not self.nolock() or len(self._vault['history']) == 0: 521 return False 522 if len(self._vault['history']) <= 0: 523 return False 524 ref = sorted(self._vault['history'].keys())[-1] 525 if debug: 526 print('recall', ref) 527 memory = self._vault['history'][ref] 528 if debug: 529 print(type(memory), 'memory', memory) 530 531 limit = len(memory) + 1 532 sub_positive_log_negative = 0 533 for i in range(-1, -limit, -1): 534 x = memory[i] 535 if debug: 536 print(type(x), x) 537 match x['action']: 538 case Action.CREATE: 539 if x['account'] is not None: 540 if self.account_exists(x['account']): 541 if debug: 542 print('account', self._vault['account'][x['account']]) 543 assert len(self._vault['account'][x['account']]['box']) == 0 544 assert self._vault['account'][x['account']]['balance'] == 0 545 assert self._vault['account'][x['account']]['count'] == 0 546 if dry: 547 continue 548 del self._vault['account'][x['account']] 549 550 case Action.TRACK: 551 if x['account'] is not None: 552 if self.account_exists(x['account']): 553 if dry: 554 continue 555 self._vault['account'][x['account']]['balance'] -= x['value'] 556 self._vault['account'][x['account']]['count'] -= 1 557 del self._vault['account'][x['account']]['box'][x['ref']] 558 559 case Action.LOG: 560 if x['account'] is not None: 561 if self.account_exists(x['account']): 562 if x['ref'] in self._vault['account'][x['account']]['log']: 563 if dry: 564 continue 565 if sub_positive_log_negative == -x['value']: 566 self._vault['account'][x['account']]['count'] -= 1 567 sub_positive_log_negative = 0 568 box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref'] 569 if not box_ref is None: 570 assert self.box_exists(x['account'], box_ref) 571 box_value = self._vault['account'][x['account']]['log'][x['ref']]['value'] 572 assert box_value < 0 573 self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value 574 self._vault['account'][x['account']]['balance'] += -box_value 575 self._vault['account'][x['account']]['count'] -= 1 576 del self._vault['account'][x['account']]['log'][x['ref']] 577 578 case Action.SUB: 579 if x['account'] is not None: 580 if self.account_exists(x['account']): 581 if x['ref'] in self._vault['account'][x['account']]['box']: 582 if dry: 583 continue 584 self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value'] 585 self._vault['account'][x['account']]['balance'] += x['value'] 586 sub_positive_log_negative = x['value'] 587 588 case Action.ADD_FILE: 589 if x['account'] is not None: 590 if self.account_exists(x['account']): 591 if x['ref'] in self._vault['account'][x['account']]['log']: 592 if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']: 593 if dry: 594 continue 595 del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] 596 597 case Action.REMOVE_FILE: 598 if x['account'] is not None: 599 if self.account_exists(x['account']): 600 if x['ref'] in self._vault['account'][x['account']]['log']: 601 if dry: 602 continue 603 self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value'] 604 605 case Action.BOX_TRANSFER: 606 if x['account'] is not None: 607 if self.account_exists(x['account']): 608 if x['ref'] in self._vault['account'][x['account']]['box']: 609 if dry: 610 continue 611 self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value'] 612 613 case Action.EXCHANGE: 614 if x['account'] is not None: 615 if x['account'] in self._vault['exchange']: 616 if x['ref'] in self._vault['exchange'][x['account']]: 617 if dry: 618 continue 619 del self._vault['exchange'][x['account']][x['ref']] 620 621 case Action.REPORT: 622 if x['ref'] in self._vault['report']: 623 if dry: 624 continue 625 del self._vault['report'][x['ref']] 626 627 case Action.ZAKAT: 628 if x['account'] is not None: 629 if self.account_exists(x['account']): 630 if x['ref'] in self._vault['account'][x['account']]['box']: 631 if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]: 632 if dry: 633 continue 634 match x['math']: 635 case MathOperation.ADDITION: 636 self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[ 637 'value'] 638 case MathOperation.EQUAL: 639 self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value'] 640 case MathOperation.SUBTRACTION: 641 self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[ 642 'value'] 643 644 if not dry: 645 del self._vault['history'][ref] 646 return True 647 648 def ref_exists(self, account: str, ref_type: str, ref: int) -> bool: 649 """ 650 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 651 652 Parameters: 653 account (str): The account number for which to check the existence of the reference. 654 ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 655 ref (int): The reference (transaction) number to check for existence. 656 657 Returns: 658 bool: True if the reference exists for the given account and reference type, False otherwise. 659 """ 660 if account in self._vault['account']: 661 return ref in self._vault['account'][account][ref_type] 662 return False 663 664 def box_exists(self, account: str, ref: int) -> bool: 665 """ 666 Check if a specific box (transaction) exists in the vault for a given account and reference. 667 668 Parameters: 669 - account (str): The account number for which to check the existence of the box. 670 - ref (int): The reference (transaction) number to check for existence. 671 672 Returns: 673 - bool: True if the box exists for the given account and reference, False otherwise. 674 """ 675 return self.ref_exists(account, 'box', ref) 676 677 def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, 678 debug: bool = False) -> int: 679 """ 680 This function tracks a transaction for a specific account. 681 682 Parameters: 683 value (float): The value of the transaction. Default is 0. 684 desc (str): The description of the transaction. Default is an empty string. 685 account (str): The account for which the transaction is being tracked. Default is '1'. 686 logging (bool): Whether to log the transaction. Default is True. 687 created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. 688 debug (bool): Whether to print debug information. Default is False. 689 690 Returns: 691 int: The timestamp of the transaction. 692 693 This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box. 694 695 Raises: 696 ValueError: The log transaction happened again in the same nanosecond time. 697 ValueError: The box transaction happened again in the same nanosecond time. 698 """ 699 if debug: 700 print('track', f'debug={debug}') 701 if created is None: 702 created = self.time() 703 no_lock = self.nolock() 704 self.lock() 705 if not self.account_exists(account): 706 if debug: 707 print(f"account {account} created") 708 self._vault['account'][account] = { 709 'balance': 0, 710 'box': {}, 711 'count': 0, 712 'log': {}, 713 'hide': False, 714 'zakatable': True, 715 } 716 self._step(Action.CREATE, account) 717 if value == 0: 718 if no_lock: 719 self.free(self.lock()) 720 return 0 721 if logging: 722 self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug) 723 if debug: 724 print('create-box', created) 725 if self.box_exists(account, created): 726 raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).") 727 if debug: 728 print('created-box', created) 729 self._vault['account'][account]['box'][created] = { 730 'capital': value, 731 'count': 0, 732 'last': 0, 733 'rest': value, 734 'total': 0, 735 } 736 self._step(Action.TRACK, account, ref=created, value=value) 737 if no_lock: 738 self.free(self.lock()) 739 return created 740 741 def log_exists(self, account: str, ref: int) -> bool: 742 """ 743 Checks if a specific transaction log entry exists for a given account. 744 745 Parameters: 746 account (str): The account number associated with the transaction log. 747 ref (int): The reference to the transaction log entry. 748 749 Returns: 750 bool: True if the transaction log entry exists, False otherwise. 751 """ 752 return self.ref_exists(account, 'log', ref) 753 754 def _log(self, value: float, desc: str = '', account: str = 1, created: int = None, ref: int = None, 755 debug: bool = False) -> int: 756 """ 757 Log a transaction into the account's log. 758 759 Parameters: 760 value (float): The value of the transaction. 761 desc (str): The description of the transaction. 762 account (str): The account to log the transaction into. Default is '1'. 763 created (int): The timestamp of the transaction. If not provided, it will be generated. 764 765 Returns: 766 int: The timestamp of the logged transaction. 767 768 This method updates the account's balance, count, and log with the transaction details. 769 It also creates a step in the history of the transaction. 770 771 Raises: 772 ValueError: The log transaction happened again in the same nanosecond time. 773 """ 774 if debug: 775 print('_log', f'debug={debug}') 776 if created is None: 777 created = self.time() 778 try: 779 self._vault['account'][account]['balance'] += value 780 except TypeError: 781 self._vault['account'][account]['balance'] += Decimal(value) 782 self._vault['account'][account]['count'] += 1 783 if debug: 784 print('create-log', created) 785 if self.log_exists(account, created): 786 raise ValueError(f"The log transaction happened again in the same nanosecond time({created}).") 787 if debug: 788 print('created-log', created) 789 self._vault['account'][account]['log'][created] = { 790 'value': value, 791 'desc': desc, 792 'ref': ref, 793 'file': {}, 794 } 795 self._step(Action.LOG, account, ref=created, value=value) 796 return created 797 798 def exchange(self, account, created: int = None, rate: float = None, description: str = None, 799 debug: bool = False) -> dict: 800 """ 801 This method is used to record or retrieve exchange rates for a specific account. 802 803 Parameters: 804 - account (str): The account number for which the exchange rate is being recorded or retrieved. 805 - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 806 - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 807 - description (str): A description of the exchange rate. 808 809 Returns: 810 - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 811 it returns a dictionary with default values for the rate and description. 812 """ 813 if debug: 814 print('exchange', f'debug={debug}') 815 if created is None: 816 created = self.time() 817 no_lock = self.nolock() 818 self.lock() 819 if rate is not None: 820 if rate <= 0: 821 return dict() 822 if account not in self._vault['exchange']: 823 self._vault['exchange'][account] = {} 824 if len(self._vault['exchange'][account]) == 0 and rate <= 1: 825 return {"time": created, "rate": 1, "description": None} 826 self._vault['exchange'][account][created] = {"rate": rate, "description": description} 827 self._step(Action.EXCHANGE, account, ref=created, value=rate) 828 if no_lock: 829 self.free(self.lock()) 830 if debug: 831 print("exchange-created-1", 832 f'account: {account}, created: {created}, rate:{rate}, description:{description}') 833 834 if account in self._vault['exchange']: 835 valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created] 836 if valid_rates: 837 latest_rate = max(valid_rates, key=lambda x: x[0]) 838 if debug: 839 print("exchange-read-1", 840 f'account: {account}, created: {created}, rate:{rate}, description:{description}', 841 'latest_rate', latest_rate) 842 result = latest_rate[1] 843 result['time'] = latest_rate[0] 844 return result # إرجاع قاموس يحتوي على المعدل والوصف 845 if debug: 846 print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}') 847 return {"time": created, "rate": 1, "description": None} # إرجاع القيمة الافتراضية مع وصف فارغ 848 849 @staticmethod 850 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 851 """ 852 This function calculates the exchanged amount of a currency. 853 854 Args: 855 x (float): The original amount of the currency. 856 x_rate (float): The exchange rate of the original currency. 857 y_rate (float): The exchange rate of the target currency. 858 859 Returns: 860 float: The exchanged amount of the target currency. 861 """ 862 return (x * x_rate) / y_rate 863 864 def exchanges(self) -> dict: 865 """ 866 Retrieve the recorded exchange rates for all accounts. 867 868 Parameters: 869 None 870 871 Returns: 872 dict: A dictionary containing all recorded exchange rates. 873 The keys are account names or numbers, and the values are dictionaries containing the exchange rates. 874 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 875 """ 876 return self._vault['exchange'].copy() 877 878 def accounts(self) -> dict: 879 """ 880 Returns a dictionary containing account numbers as keys and their respective balances as values. 881 882 Parameters: 883 None 884 885 Returns: 886 dict: A dictionary where keys are account numbers and values are their respective balances. 887 """ 888 result = {} 889 for i in self._vault['account']: 890 result[i] = self._vault['account'][i]['balance'] 891 return result 892 893 def boxes(self, account) -> dict: 894 """ 895 Retrieve the boxes (transactions) associated with a specific account. 896 897 Parameters: 898 account (str): The account number for which to retrieve the boxes. 899 900 Returns: 901 dict: A dictionary containing the boxes associated with the given account. 902 If the account does not exist, an empty dictionary is returned. 903 """ 904 if self.account_exists(account): 905 return self._vault['account'][account]['box'] 906 return {} 907 908 def logs(self, account) -> dict: 909 """ 910 Retrieve the logs (transactions) associated with a specific account. 911 912 Parameters: 913 account (str): The account number for which to retrieve the logs. 914 915 Returns: 916 dict: A dictionary containing the logs associated with the given account. 917 If the account does not exist, an empty dictionary is returned. 918 """ 919 if self.account_exists(account): 920 return self._vault['account'][account]['log'] 921 return {} 922 923 def add_file(self, account: str, ref: int, path: str) -> int: 924 """ 925 Adds a file reference to a specific transaction log entry in the vault. 926 927 Parameters: 928 account (str): The account number associated with the transaction log. 929 ref (int): The reference to the transaction log entry. 930 path (str): The path of the file to be added. 931 932 Returns: 933 int: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 934 """ 935 if self.account_exists(account): 936 if ref in self._vault['account'][account]['log']: 937 file_ref = self.time() 938 self._vault['account'][account]['log'][ref]['file'][file_ref] = path 939 no_lock = self.nolock() 940 self.lock() 941 self._step(Action.ADD_FILE, account, ref=ref, file=file_ref) 942 if no_lock: 943 self.free(self.lock()) 944 return file_ref 945 return 0 946 947 def remove_file(self, account: str, ref: int, file_ref: int) -> bool: 948 """ 949 Removes a file reference from a specific transaction log entry in the vault. 950 951 Parameters: 952 account (str): The account number associated with the transaction log. 953 ref (int): The reference to the transaction log entry. 954 file_ref (int): The reference of the file to be removed. 955 956 Returns: 957 bool: True if the file reference is successfully removed, False otherwise. 958 """ 959 if self.account_exists(account): 960 if ref in self._vault['account'][account]['log']: 961 if file_ref in self._vault['account'][account]['log'][ref]['file']: 962 x = self._vault['account'][account]['log'][ref]['file'][file_ref] 963 del self._vault['account'][account]['log'][ref]['file'][file_ref] 964 no_lock = self.nolock() 965 self.lock() 966 self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 967 if no_lock: 968 self.free(self.lock()) 969 return True 970 return False 971 972 def balance(self, account: str = 1, cached: bool = True) -> int: 973 """ 974 Calculate and return the balance of a specific account. 975 976 Parameters: 977 account (str): The account number. Default is '1'. 978 cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 979 980 Returns: 981 int: The balance of the account. 982 983 Note: 984 If cached is True, the function returns the cached balance. 985 If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 986 """ 987 if cached: 988 return self._vault['account'][account]['balance'] 989 x = 0 990 return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1] 991 992 def hide(self, account, status: bool = None) -> bool: 993 """ 994 Check or set the hide status of a specific account. 995 996 Parameters: 997 account (str): The account number. 998 status (bool, optional): The new hide status. If not provided, the function will return the current status. 999 1000 Returns: 1001 bool: The current or updated hide status of the account. 1002 1003 Raises: 1004 None 1005 1006 Example: 1007 >>> tracker = ZakatTracker() 1008 >>> ref = tracker.track(51, 'desc', 'account1') 1009 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 1010 False 1011 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 1012 True 1013 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 1014 True 1015 >>> tracker.hide('account1', False) 1016 False 1017 """ 1018 if self.account_exists(account): 1019 if status is None: 1020 return self._vault['account'][account]['hide'] 1021 self._vault['account'][account]['hide'] = status 1022 return status 1023 return False 1024 1025 def zakatable(self, account, status: bool = None) -> bool: 1026 """ 1027 Check or set the zakatable status of a specific account. 1028 1029 Parameters: 1030 account (str): The account number. 1031 status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 1032 1033 Returns: 1034 bool: The current or updated zakatable status of the account. 1035 1036 Raises: 1037 None 1038 1039 Example: 1040 >>> tracker = ZakatTracker() 1041 >>> ref = tracker.track(51, 'desc', 'account1') 1042 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 1043 True 1044 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 1045 True 1046 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 1047 True 1048 >>> tracker.zakatable('account1', False) 1049 False 1050 """ 1051 if self.account_exists(account): 1052 if status is None: 1053 return self._vault['account'][account]['zakatable'] 1054 self._vault['account'][account]['zakatable'] = status 1055 return status 1056 return False 1057 1058 def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple: 1059 """ 1060 Subtracts a specified value from an account's balance. 1061 1062 Parameters: 1063 x (float): The amount to be subtracted. 1064 desc (str): A description for the transaction. Defaults to an empty string. 1065 account (str): The account from which the value will be subtracted. Defaults to '1'. 1066 created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. 1067 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1068 1069 Returns: 1070 tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 1071 1072 If the amount to subtract is greater than the account's balance, 1073 the remaining amount will be transferred to a new transaction with a negative value. 1074 1075 Raises: 1076 ValueError: The box transaction happened again in the same nanosecond time. 1077 ValueError: The log transaction happened again in the same nanosecond time. 1078 """ 1079 if debug: 1080 print('sub', f'debug={debug}') 1081 if x < 0: 1082 return tuple() 1083 if x == 0: 1084 ref = self.track(x, '', account) 1085 return ref, ref 1086 if created is None: 1087 created = self.time() 1088 no_lock = self.nolock() 1089 self.lock() 1090 self.track(0, '', account) 1091 self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug) 1092 ids = sorted(self._vault['account'][account]['box'].keys()) 1093 limit = len(ids) + 1 1094 target = x 1095 if debug: 1096 print('ids', ids) 1097 ages = [] 1098 for i in range(-1, -limit, -1): 1099 if target == 0: 1100 break 1101 j = ids[i] 1102 if debug: 1103 print('i', i, 'j', j) 1104 rest = self._vault['account'][account]['box'][j]['rest'] 1105 if rest >= target: 1106 self._vault['account'][account]['box'][j]['rest'] -= target 1107 self._step(Action.SUB, account, ref=j, value=target) 1108 ages.append((j, target)) 1109 target = 0 1110 break 1111 elif target > rest > 0: 1112 chunk = rest 1113 target -= chunk 1114 self._step(Action.SUB, account, ref=j, value=chunk) 1115 ages.append((j, chunk)) 1116 self._vault['account'][account]['box'][j]['rest'] = 0 1117 if target > 0: 1118 self.track(-target, desc, account, False, created) 1119 ages.append((created, target)) 1120 if no_lock: 1121 self.free(self.lock()) 1122 return created, ages 1123 1124 def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, 1125 debug: bool = False) -> list[int]: 1126 """ 1127 Transfers a specified value from one account to another. 1128 1129 Parameters: 1130 amount (int): The amount to be transferred. 1131 from_account (str): The account from which the value will be transferred. 1132 to_account (str): The account to which the value will be transferred. 1133 desc (str, optional): A description for the transaction. Defaults to an empty string. 1134 created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. 1135 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1136 1137 Returns: 1138 list[int]: A list of timestamps corresponding to the transactions made during the transfer. 1139 1140 Raises: 1141 ValueError: Transfer to the same account is forbidden. 1142 ValueError: The box transaction happened again in the same nanosecond time. 1143 ValueError: The log transaction happened again in the same nanosecond time. 1144 """ 1145 if debug: 1146 print('transfer', f'debug={debug}') 1147 if from_account == to_account: 1148 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 1149 if amount <= 0: 1150 return [] 1151 if created is None: 1152 created = self.time() 1153 (_, ages) = self.sub(amount, desc, from_account, created, debug=debug) 1154 times = [] 1155 source_exchange = self.exchange(from_account, created) 1156 target_exchange = self.exchange(to_account, created) 1157 1158 if debug: 1159 print('ages', ages) 1160 1161 for age, value in ages: 1162 target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']) 1163 # Perform the transfer 1164 if self.box_exists(to_account, age): 1165 if debug: 1166 print('box_exists', age) 1167 capital = self._vault['account'][to_account]['box'][age]['capital'] 1168 rest = self._vault['account'][to_account]['box'][age]['rest'] 1169 if debug: 1170 print( 1171 f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1172 selected_age = age 1173 if rest + target_amount > capital: 1174 self._vault['account'][to_account]['box'][age]['capital'] += target_amount 1175 selected_age = ZakatTracker.time() 1176 self._vault['account'][to_account]['box'][age]['rest'] += target_amount 1177 self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 1178 y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 1179 created=None, ref=None, debug=debug) 1180 times.append((age, y)) 1181 continue 1182 y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug) 1183 if debug: 1184 print( 1185 f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1186 times.append(y) 1187 return times 1188 1189 def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, 1190 cycle: float = None) -> tuple: 1191 """ 1192 Check the eligibility for Zakat based on the given parameters. 1193 1194 Parameters: 1195 silver_gram_price (float): The price of a gram of silver. 1196 nisab (float): The minimum amount of wealth required for Zakat. If not provided, 1197 it will be calculated based on the silver_gram_price. 1198 debug (bool): Flag to enable debug mode. 1199 now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). 1200 cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 1201 1202 Returns: 1203 tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, 1204 and a dictionary containing the Zakat plan. 1205 """ 1206 if debug: 1207 print('check', f'debug={debug}') 1208 if now is None: 1209 now = self.time() 1210 if cycle is None: 1211 cycle = ZakatTracker.TimeCycle() 1212 if nisab is None: 1213 nisab = ZakatTracker.Nisab(silver_gram_price) 1214 plan = {} 1215 below_nisab = 0 1216 brief = [0, 0, 0] 1217 valid = False 1218 for x in self._vault['account']: 1219 if not self.zakatable(x): 1220 continue 1221 _box = self._vault['account'][x]['box'] 1222 _log = self._vault['account'][x]['log'] 1223 limit = len(_box) + 1 1224 ids = sorted(self._vault['account'][x]['box'].keys()) 1225 for i in range(-1, -limit, -1): 1226 j = ids[i] 1227 rest = float(_box[j]['rest']) 1228 if rest <= 0: 1229 continue 1230 exchange = self.exchange(x, created=self.time()) 1231 if debug: 1232 print('exchanges', self.exchanges()) 1233 rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1) 1234 brief[0] += rest 1235 index = limit + i - 1 1236 epoch = (now - j) / cycle 1237 if debug: 1238 print(f"Epoch: {epoch}", _box[j]) 1239 if _box[j]['last'] > 0: 1240 epoch = (now - _box[j]['last']) / cycle 1241 if debug: 1242 print(f"Epoch: {epoch}") 1243 epoch = floor(epoch) 1244 if debug: 1245 print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch) 1246 if epoch == 0: 1247 continue 1248 if debug: 1249 print("Epoch - PASSED") 1250 brief[1] += rest 1251 if rest >= nisab: 1252 total = 0 1253 for _ in range(epoch): 1254 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 1255 if total > 0: 1256 if x not in plan: 1257 plan[x] = {} 1258 valid = True 1259 brief[2] += total 1260 plan[x][index] = { 1261 'total': total, 1262 'count': epoch, 1263 'box_time': j, 1264 'box_capital': _box[j]['capital'], 1265 'box_rest': _box[j]['rest'], 1266 'box_last': _box[j]['last'], 1267 'box_total': _box[j]['total'], 1268 'box_count': _box[j]['count'], 1269 'box_log': _log[j]['desc'], 1270 'exchange_rate': exchange['rate'], 1271 'exchange_time': exchange['time'], 1272 'exchange_desc': exchange['description'], 1273 } 1274 else: 1275 chunk = ZakatTracker.ZakatCut(float(rest)) 1276 if chunk > 0: 1277 if x not in plan: 1278 plan[x] = {} 1279 if j not in plan[x].keys(): 1280 plan[x][index] = {} 1281 below_nisab += rest 1282 brief[2] += chunk 1283 plan[x][index]['below_nisab'] = chunk 1284 plan[x][index]['total'] = chunk 1285 plan[x][index]['count'] = epoch 1286 plan[x][index]['box_time'] = j 1287 plan[x][index]['box_capital'] = _box[j]['capital'] 1288 plan[x][index]['box_rest'] = _box[j]['rest'] 1289 plan[x][index]['box_last'] = _box[j]['last'] 1290 plan[x][index]['box_total'] = _box[j]['total'] 1291 plan[x][index]['box_count'] = _box[j]['count'] 1292 plan[x][index]['box_log'] = _log[j]['desc'] 1293 plan[x][index]['exchange_rate'] = exchange['rate'] 1294 plan[x][index]['exchange_time'] = exchange['time'] 1295 plan[x][index]['exchange_desc'] = exchange['description'] 1296 valid = valid or below_nisab >= nisab 1297 if debug: 1298 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 1299 return valid, brief, plan 1300 1301 def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict: 1302 """ 1303 Build payment parts for the Zakat distribution. 1304 1305 Parameters: 1306 demand (float): The total demand for payment in local currency. 1307 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 1308 1309 Returns: 1310 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 1311 { 1312 'account': { 1313 'account_id': {'balance': float, 'rate': float, 'part': float}, 1314 ... 1315 }, 1316 'exceed': bool, 1317 'demand': float, 1318 'total': float, 1319 } 1320 """ 1321 total = 0 1322 parts = { 1323 'account': {}, 1324 'exceed': False, 1325 'demand': demand, 1326 } 1327 for x, y in self.accounts().items(): 1328 if positive_only and y <= 0: 1329 continue 1330 total += float(y) 1331 exchange = self.exchange(x) 1332 parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0} 1333 parts['total'] = total 1334 return parts 1335 1336 @staticmethod 1337 def check_payment_parts(parts: dict, debug: bool = False) -> int: 1338 """ 1339 Checks the validity of payment parts. 1340 1341 Parameters: 1342 parts (dict): A dictionary containing payment parts information. 1343 debug (bool): Flag to enable debug mode. 1344 1345 Returns: 1346 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 1347 1348 Error Codes: 1349 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 1350 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 1351 3: 'part' value in parts['account'][x] is less than 0. 1352 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 1353 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 1354 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 1355 """ 1356 if debug: 1357 print('check_payment_parts', f'debug={debug}') 1358 for i in ['demand', 'account', 'total', 'exceed']: 1359 if i not in parts: 1360 return 1 1361 exceed = parts['exceed'] 1362 for x in parts['account']: 1363 for j in ['balance', 'rate', 'part']: 1364 if j not in parts['account'][x]: 1365 return 2 1366 if parts['account'][x]['part'] < 0: 1367 return 3 1368 if not exceed and parts['account'][x]['balance'] <= 0: 1369 return 4 1370 demand = parts['demand'] 1371 z = 0 1372 for _, y in parts['account'].items(): 1373 if not exceed and y['part'] > y['balance']: 1374 return 5 1375 z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1) 1376 z = round(z, 2) 1377 demand = round(demand, 2) 1378 if debug: 1379 print('check_payment_parts', f'z = {z}, demand = {demand}') 1380 print('check_payment_parts', type(z), type(demand)) 1381 print('check_payment_parts', z != demand) 1382 print('check_payment_parts', str(z) != str(demand)) 1383 if z != demand and str(z) != str(demand): 1384 return 6 1385 return 0 1386 1387 def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool: 1388 """ 1389 Perform Zakat calculation based on the given report and optional parts. 1390 1391 Parameters: 1392 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 1393 parts (dict): A dictionary containing the payment parts for the zakat. 1394 debug (bool): A flag indicating whether to print debug information. 1395 1396 Returns: 1397 bool: True if the zakat calculation is successful, False otherwise. 1398 """ 1399 if debug: 1400 print('zakat', f'debug={debug}') 1401 valid, _, plan = report 1402 if not valid: 1403 return valid 1404 parts_exist = parts is not None 1405 if parts_exist: 1406 if self.check_payment_parts(parts, debug=debug) != 0: 1407 return False 1408 if debug: 1409 print('######### zakat #######') 1410 print('parts_exist', parts_exist) 1411 no_lock = self.nolock() 1412 self.lock() 1413 report_time = self.time() 1414 self._vault['report'][report_time] = report 1415 self._step(Action.REPORT, ref=report_time) 1416 created = self.time() 1417 for x in plan: 1418 target_exchange = self.exchange(x) 1419 if debug: 1420 print(plan[x]) 1421 print('-------------') 1422 print(self._vault['account'][x]['box']) 1423 ids = sorted(self._vault['account'][x]['box'].keys()) 1424 if debug: 1425 print('plan[x]', plan[x]) 1426 for i in plan[x].keys(): 1427 j = ids[i] 1428 if debug: 1429 print('i', i, 'j', j) 1430 self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'], 1431 key='last', 1432 math_operation=MathOperation.EQUAL) 1433 self._vault['account'][x]['box'][j]['last'] = created 1434 amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate'])) 1435 self._vault['account'][x]['box'][j]['total'] += amount 1436 self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 1437 math_operation=MathOperation.ADDITION) 1438 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1439 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count', 1440 math_operation=MathOperation.ADDITION) 1441 if not parts_exist: 1442 try: 1443 self._vault['account'][x]['box'][j]['rest'] -= amount 1444 except TypeError: 1445 self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount) 1446 # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 1447 # math_operation=MathOperation.SUBTRACTION) 1448 self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug) 1449 if parts_exist: 1450 for account, part in parts['account'].items(): 1451 if part['part'] == 0: 1452 continue 1453 if debug: 1454 print('zakat-part', account, part['rate']) 1455 target_exchange = self.exchange(account) 1456 amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate']) 1457 self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug) 1458 if no_lock: 1459 self.free(self.lock()) 1460 return True 1461 1462 def export_json(self, path: str = "data.json") -> bool: 1463 """ 1464 Exports the current state of the ZakatTracker object to a JSON file. 1465 1466 Parameters: 1467 path (str): The path where the JSON file will be saved. Default is "data.json". 1468 1469 Returns: 1470 bool: True if the export is successful, False otherwise. 1471 1472 Raises: 1473 No specific exceptions are raised by this method. 1474 """ 1475 with open(path, "w") as file: 1476 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1477 return True 1478 1479 def save(self, path: str = None) -> bool: 1480 """ 1481 Saves the ZakatTracker's current state to a pickle file. 1482 1483 This method serializes the internal data (`_vault`) along with metadata 1484 (Python version, pickle protocol) for future compatibility. 1485 1486 Parameters: 1487 path (str, optional): File path for saving. Defaults to a predefined location. 1488 1489 Returns: 1490 bool: True if the save operation is successful, False otherwise. 1491 """ 1492 if path is None: 1493 path = self.path() 1494 with open(path, "wb") as f: 1495 version = f'{version_info.major}.{version_info.minor}.{version_info.micro}' 1496 pickle_protocol = pickle.HIGHEST_PROTOCOL 1497 data = { 1498 'python_version': version, 1499 'pickle_protocol': pickle_protocol, 1500 'data': self._vault, 1501 } 1502 pickle.dump(data, f, protocol=pickle_protocol) 1503 return True 1504 1505 def load(self, path: str = None) -> bool: 1506 """ 1507 Load the current state of the ZakatTracker object from a pickle file. 1508 1509 Parameters: 1510 path (str): The path where the pickle file is located. If not provided, it will use the default path. 1511 1512 Returns: 1513 bool: True if the load operation is successful, False otherwise. 1514 """ 1515 if path is None: 1516 path = self.path() 1517 if os.path.exists(path): 1518 with open(path, "rb") as f: 1519 data = pickle.load(f) 1520 self._vault = data['data'] 1521 return True 1522 return False 1523 1524 def import_csv_cache_path(self): 1525 """ 1526 Generates the cache file path for imported CSV data. 1527 1528 This function constructs the file path where cached data from CSV imports 1529 will be stored. The cache file is a pickle file (.pickle extension) appended 1530 to the base path of the object. 1531 1532 Returns: 1533 str: The full path to the import CSV cache file. 1534 1535 Example: 1536 >>> obj = ZakatTracker('/data/reports') 1537 >>> obj.import_csv_cache_path() 1538 '/data/reports.import_csv.pickle' 1539 """ 1540 path = self.path() 1541 if path.endswith(".pickle"): 1542 path = path[:-7] 1543 return path + '.import_csv.pickle' 1544 1545 def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple: 1546 """ 1547 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1548 1549 Parameters: 1550 path (str): The path to the CSV file. Default is 'file.csv'. 1551 debug (bool): A flag indicating whether to print debug information. 1552 1553 Returns: 1554 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 1555 and a dictionary of bad transactions. 1556 1557 Notes: 1558 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 1559 are appropriate for the currency pairs involved in the conversions. 1560 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 1561 to 1.0 or the previous rate for that account. 1562 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 1563 transactions of the same account within the whole imported and existing dataset when doing `check` and 1564 `zakat` operations. 1565 1566 Example Usage: 1567 The CSV file should have the following format, rate is optional per transaction: 1568 account, desc, value, date, rate 1569 For example: 1570 safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1 1571 """ 1572 if debug: 1573 print('import_csv', f'debug={debug}') 1574 cache: list[int] = [] 1575 try: 1576 with open(self.import_csv_cache_path(), "rb") as f: 1577 cache = pickle.load(f) 1578 except: 1579 pass 1580 date_formats = [ 1581 "%Y-%m-%d %H:%M:%S", 1582 "%Y-%m-%dT%H:%M:%S", 1583 "%Y-%m-%dT%H%M%S", 1584 "%Y-%m-%d", 1585 ] 1586 created, found, bad = 0, 0, {} 1587 data: dict[int, list] = {} 1588 with open(path, newline='', encoding="utf-8") as f: 1589 i = 0 1590 for row in csv.reader(f, delimiter=','): 1591 i += 1 1592 hashed = hash(tuple(row)) 1593 if hashed in cache: 1594 found += 1 1595 continue 1596 account = row[0] 1597 desc = row[1] 1598 value = float(row[2]) 1599 rate = 1.0 1600 if row[4:5]: # Empty list if index is out of range 1601 rate = float(row[4]) 1602 date: int = 0 1603 for time_format in date_formats: 1604 try: 1605 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1606 break 1607 except: 1608 pass 1609 # TODO: not allowed for negative dates 1610 if date == 0 or value == 0: 1611 bad[i] = row 1612 continue 1613 if date not in data: 1614 data[date] = [] 1615 # TODO: If duplicated time with different accounts with the same amount it is an indicator of a transfer 1616 data[date].append((date, value, desc, account, rate, hashed)) 1617 1618 if debug: 1619 print('import_csv', len(data)) 1620 1621 def process(row, index=0): 1622 nonlocal created 1623 (date, value, desc, account, rate, hashed) = row 1624 date += index 1625 if rate > 1: 1626 self.exchange(account, created=date, rate=rate) 1627 if value > 0: 1628 self.track(value, desc, account, True, date) 1629 elif value < 0: 1630 self.sub(-value, desc, account, date) 1631 created += 1 1632 cache.append(hashed) 1633 1634 for date, rows in sorted(data.items()): 1635 len_rows = len(rows) 1636 if len_rows == 1: 1637 process(rows[0]) 1638 continue 1639 if debug: 1640 print('-- Duplicated time detected', date, 'len', len_rows) 1641 print(rows) 1642 print('---------------------------------') 1643 for index, row in enumerate(rows): 1644 process(row, index) 1645 with open(self.import_csv_cache_path(), "wb") as f: 1646 pickle.dump(cache, f) 1647 return created, found, bad 1648 1649 ######## 1650 # TESTS # 1651 ####### 1652 1653 @staticmethod 1654 def human_readable_size(size: float, decimal_places: int = 2) -> str: 1655 """ 1656 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 1657 1658 This function iterates through progressively larger units of information 1659 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 1660 range that can be expressed with a reasonable number before the unit. 1661 1662 Parameters: 1663 size (float): The size in bytes to convert. 1664 decimal_places (int, optional): The number of decimal places to display 1665 in the result. Defaults to 2. 1666 1667 Returns: 1668 str: A string representation of the size in a human-readable format, 1669 rounded to the specified number of decimal places. For example: 1670 - "1.50 KB" (1536 bytes) 1671 - "23.00 MB" (24117248 bytes) 1672 - "1.23 GB" (1325899906 bytes) 1673 """ 1674 if type(size) not in (float, int): 1675 raise TypeError("size must be a float or integer") 1676 if type(decimal_places) != int: 1677 raise TypeError("decimal_places must be an integer") 1678 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 1679 if size < 1024.0: 1680 break 1681 size /= 1024.0 1682 return f"{size:.{decimal_places}f} {unit}" 1683 1684 @staticmethod 1685 def get_dict_size(obj: dict, seen: set = None) -> float: 1686 """ 1687 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 1688 1689 This function traverses the dictionary structure, accounting for the size of keys, values, 1690 and any nested objects. It handles various data types commonly found in dictionaries 1691 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 1692 of circular references. 1693 1694 Parameters: 1695 obj (dict): The dictionary whose size is to be calculated. 1696 seen (set, optional): A set used internally to track visited objects 1697 and avoid circular references. Defaults to None. 1698 1699 Returns: 1700 float: An approximate size of the dictionary and its contents in bytes. 1701 1702 Note: 1703 - This function is a method of the `ZakatTracker` class and is likely used to 1704 estimate the memory footprint of data structures relevant to Zakat calculations. 1705 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 1706 not account for all memory overhead depending on the Python implementation. 1707 - Circular references are handled to prevent infinite recursion. 1708 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 1709 - String sizes are estimated based on character length and encoding. 1710 """ 1711 size = 0 1712 if seen is None: 1713 seen = set() 1714 1715 obj_id = id(obj) 1716 if obj_id in seen: 1717 return 0 1718 1719 seen.add(obj_id) 1720 size += sys.getsizeof(obj) 1721 1722 if isinstance(obj, dict): 1723 for k, v in obj.items(): 1724 size += ZakatTracker.get_dict_size(k, seen) 1725 size += ZakatTracker.get_dict_size(v, seen) 1726 elif isinstance(obj, (list, tuple, set, frozenset)): 1727 for item in obj: 1728 size += ZakatTracker.get_dict_size(item, seen) 1729 elif isinstance(obj, (int, float, complex)): # Handle numbers 1730 pass # Basic numbers have a fixed size, so nothing to add here 1731 elif isinstance(obj, str): # Handle strings 1732 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 1733 return size 1734 1735 @staticmethod 1736 def duration_from_nanoseconds(ns: int) -> tuple: 1737 """ 1738 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1739 Convert NanoSeconds to Human Readable Time Format. 1740 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1741 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1742 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1743 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1744 1745 INPUT : ms (AKA: MilliSeconds) 1746 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1747 OUTPUT Variables: time_lapsed, spoken_time 1748 1749 Example Input: duration_from_nanoseconds(ns) 1750 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 1751 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') 1752 duration_from_nanoseconds(1234567890123456789012) 1753 """ 1754 us, ns = divmod(ns, 1000) 1755 ms, us = divmod(us, 1000) 1756 s, ms = divmod(ms, 1000) 1757 m, s = divmod(s, 60) 1758 h, m = divmod(m, 60) 1759 d, h = divmod(h, 24) 1760 y, d = divmod(d, 365) 1761 c, y = divmod(y, 100) 1762 n, c = divmod(c, 10) 1763 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}" 1764 spoken_time = f"{n: 3d} Millennia, {c: 4d} Century, {y: 3d} Years, {d: 4d} Days, {h: 2d} Hours, {m: 2d} Minutes, {s: 2d} Seconds, {ms: 3d} MilliSeconds, {us: 3d} MicroSeconds, {ns: 3d} NanoSeconds" 1765 return time_lapsed, spoken_time 1766 1767 @staticmethod 1768 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 1769 """ 1770 Convert a specific day, month, and year into a timestamp. 1771 1772 Parameters: 1773 day (int): The day of the month. 1774 month (int): The month of the year. Default is 6 (June). 1775 year (int): The year. Default is 2024. 1776 1777 Returns: 1778 int: The timestamp representing the given day, month, and year. 1779 1780 Note: 1781 This method assumes the default month and year if not provided. 1782 """ 1783 return ZakatTracker.time(datetime.datetime(year, month, day)) 1784 1785 @staticmethod 1786 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 1787 """ 1788 Generate a random date between two given dates. 1789 1790 Parameters: 1791 start_date (datetime.datetime): The start date from which to generate a random date. 1792 end_date (datetime.datetime): The end date until which to generate a random date. 1793 1794 Returns: 1795 datetime.datetime: A random date between the start_date and end_date. 1796 """ 1797 time_between_dates = end_date - start_date 1798 days_between_dates = time_between_dates.days 1799 random_number_of_days = random.randrange(days_between_dates) 1800 return start_date + datetime.timedelta(days=random_number_of_days) 1801 1802 @staticmethod 1803 def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, 1804 debug: bool = False) -> int: 1805 """ 1806 Generate a random CSV file with specified parameters. 1807 1808 Parameters: 1809 path (str): The path where the CSV file will be saved. Default is "data.csv". 1810 count (int): The number of rows to generate in the CSV file. Default is 1000. 1811 with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. 1812 debug (bool): A flag indicating whether to print debug information. 1813 1814 Returns: 1815 None. The function generates a CSV file at the specified path with the given count of rows. 1816 Each row contains a randomly generated account, description, value, and date. 1817 The value is randomly generated between 1000 and 100000, 1818 and the date is randomly generated between 1950-01-01 and 2023-12-31. 1819 If the row number is not divisible by 13, the value is multiplied by -1. 1820 """ 1821 if debug: 1822 print('generate_random_csv_file', f'debug={debug}') 1823 i = 0 1824 with open(path, "w", newline="") as csvfile: 1825 writer = csv.writer(csvfile) 1826 for i in range(count): 1827 account = f"acc-{random.randint(1, 1000)}" 1828 desc = f"Some text {random.randint(1, 1000)}" 1829 value = random.randint(1000, 100000) 1830 date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), 1831 datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 1832 if not i % 13 == 0: 1833 value *= -1 1834 row = [account, desc, value, date] 1835 if with_rate: 1836 rate = random.randint(1, 100) * 0.12 1837 if debug: 1838 print('before-append', row) 1839 row.append(rate) 1840 if debug: 1841 print('after-append', row) 1842 writer.writerow(row) 1843 i = i + 1 1844 return i 1845 1846 @staticmethod 1847 def create_random_list(max_sum, min_value=0, max_value=10): 1848 """ 1849 Creates a list of random integers whose sum does not exceed the specified maximum. 1850 1851 Args: 1852 max_sum: The maximum allowed sum of the list elements. 1853 min_value: The minimum possible value for an element (inclusive). 1854 max_value: The maximum possible value for an element (inclusive). 1855 1856 Returns: 1857 A list of random integers. 1858 """ 1859 result = [] 1860 current_sum = 0 1861 1862 while current_sum < max_sum: 1863 # Calculate the remaining space for the next element 1864 remaining_sum = max_sum - current_sum 1865 # Determine the maximum possible value for the next element 1866 next_max_value = min(remaining_sum, max_value) 1867 # Generate a random element within the allowed range 1868 next_element = random.randint(min_value, next_max_value) 1869 result.append(next_element) 1870 current_sum += next_element 1871 1872 return result 1873 1874 def _test_core(self, restore=False, debug=False): 1875 1876 if debug: 1877 random.seed(1234567890) 1878 1879 # sanity check - random forward time 1880 1881 xlist = [] 1882 limit = 1000 1883 for _ in range(limit): 1884 y = ZakatTracker.time() 1885 z = '-' 1886 if y not in xlist: 1887 xlist.append(y) 1888 else: 1889 z = 'x' 1890 if debug: 1891 print(z, y) 1892 xx = len(xlist) 1893 if debug: 1894 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 1895 assert limit == xx 1896 1897 # sanity check - convert date since 1000AD 1898 1899 for year in range(1000, 9000): 1900 ns = ZakatTracker.time(datetime.datetime.strptime(f"{year}-12-30 18:30:45", "%Y-%m-%d %H:%M:%S")) 1901 date = ZakatTracker.time_to_datetime(ns) 1902 if debug: 1903 print(date) 1904 assert date.year == year 1905 assert date.month == 12 1906 assert date.day == 30 1907 assert date.hour == 18 1908 assert date.minute == 30 1909 assert date.second in [44, 45] 1910 1911 # human_readable_size 1912 1913 assert ZakatTracker.human_readable_size(0) == "0.00 B" 1914 assert ZakatTracker.human_readable_size(512) == "512.00 B" 1915 assert ZakatTracker.human_readable_size(1023) == "1023.00 B" 1916 1917 assert ZakatTracker.human_readable_size(1024) == "1.00 KB" 1918 assert ZakatTracker.human_readable_size(2048) == "2.00 KB" 1919 assert ZakatTracker.human_readable_size(5120) == "5.00 KB" 1920 1921 assert ZakatTracker.human_readable_size(1024 ** 2) == "1.00 MB" 1922 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == "2.50 MB" 1923 1924 assert ZakatTracker.human_readable_size(1024 ** 3) == "1.00 GB" 1925 assert ZakatTracker.human_readable_size(1024 ** 4) == "1.00 TB" 1926 assert ZakatTracker.human_readable_size(1024 ** 5) == "1.00 PB" 1927 1928 assert ZakatTracker.human_readable_size(1536, decimal_places=0) == "2 KB" 1929 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == "2.5 MB" 1930 assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == "1.150 GB" 1931 1932 try: 1933 ZakatTracker.human_readable_size("not a number") 1934 assert False, "Expected TypeError for invalid input" 1935 except TypeError: 1936 pass 1937 1938 try: 1939 ZakatTracker.human_readable_size(1024, decimal_places="not an int") 1940 assert False, "Expected TypeError for invalid decimal_places" 1941 except TypeError: 1942 pass 1943 1944 # get_dict_size 1945 assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), "Empty dictionary size mismatch" 1946 assert ZakatTracker.get_dict_size({"a": 1, "b": 2.5, "c": True}) != sys.getsizeof({}), "Not Empty dictionary" 1947 1948 assert self.nolock() 1949 assert self._history() is True 1950 1951 table = { 1952 1: [ 1953 (0, 10, 10, 10, 10, 1, 1), 1954 (0, 20, 30, 30, 30, 2, 2), 1955 (0, 30, 60, 60, 60, 3, 3), 1956 (1, 15, 45, 45, 45, 3, 4), 1957 (1, 50, -5, -5, -5, 4, 5), 1958 (1, 100, -105, -105, -105, 5, 6), 1959 ], 1960 'wallet': [ 1961 (1, 90, -90, -90, -90, 1, 1), 1962 (0, 100, 10, 10, 10, 2, 2), 1963 (1, 190, -180, -180, -180, 3, 3), 1964 (0, 1000, 820, 820, 820, 4, 4), 1965 ], 1966 } 1967 for x in table: 1968 for y in table[x]: 1969 self.lock() 1970 if y[0] == 0: 1971 ref = self.track(y[1], 'test-add', x, True, ZakatTracker.time(), debug) 1972 else: 1973 (ref, z) = self.sub(y[1], 'test-sub', x, ZakatTracker.time()) 1974 if debug: 1975 print('_sub', z, ZakatTracker.time()) 1976 assert ref != 0 1977 assert len(self._vault['account'][x]['log'][ref]['file']) == 0 1978 for i in range(3): 1979 file_ref = self.add_file(x, ref, 'file_' + str(i)) 1980 sleep(0.0000001) 1981 assert file_ref != 0 1982 if debug: 1983 print('ref', ref, 'file', file_ref) 1984 assert len(self._vault['account'][x]['log'][ref]['file']) == i + 1 1985 file_ref = self.add_file(x, ref, 'file_' + str(3)) 1986 assert self.remove_file(x, ref, file_ref) 1987 assert self.balance(x) == y[2] 1988 z = self.balance(x, False) 1989 if debug: 1990 print("debug-1", z, y[3]) 1991 assert z == y[3] 1992 o = self._vault['account'][x]['log'] 1993 z = 0 1994 for i in o: 1995 z += o[i]['value'] 1996 if debug: 1997 print("debug-2", z, type(z)) 1998 print("debug-2", y[4], type(y[4])) 1999 assert z == y[4] 2000 if debug: 2001 print('debug-2 - PASSED') 2002 assert self.box_size(x) == y[5] 2003 assert self.log_size(x) == y[6] 2004 assert not self.nolock() 2005 self.free(self.lock()) 2006 assert self.nolock() 2007 assert self.boxes(x) != {} 2008 assert self.logs(x) != {} 2009 2010 assert not self.hide(x) 2011 assert self.hide(x, False) is False 2012 assert self.hide(x) is False 2013 assert self.hide(x, True) 2014 assert self.hide(x) 2015 2016 assert self.zakatable(x) 2017 assert self.zakatable(x, False) is False 2018 assert self.zakatable(x) is False 2019 assert self.zakatable(x, True) 2020 assert self.zakatable(x) 2021 2022 if restore is True: 2023 count = len(self._vault['history']) 2024 if debug: 2025 print('history-count', count) 2026 assert count == 10 2027 # try mode 2028 for _ in range(count): 2029 assert self.recall(True, debug) 2030 count = len(self._vault['history']) 2031 if debug: 2032 print('history-count', count) 2033 assert count == 10 2034 _accounts = list(table.keys()) 2035 accounts_limit = len(_accounts) + 1 2036 for i in range(-1, -accounts_limit, -1): 2037 account = _accounts[i] 2038 if debug: 2039 print(account, len(table[account])) 2040 transaction_limit = len(table[account]) + 1 2041 for j in range(-1, -transaction_limit, -1): 2042 row = table[account][j] 2043 if debug: 2044 print(row, self.balance(account), self.balance(account, False)) 2045 assert self.balance(account) == self.balance(account, False) 2046 assert self.balance(account) == row[2] 2047 assert self.recall(False, debug) 2048 assert self.recall(False, debug) is False 2049 count = len(self._vault['history']) 2050 if debug: 2051 print('history-count', count) 2052 assert count == 0 2053 self.reset() 2054 2055 def test(self, debug: bool = False) -> bool: 2056 if debug: 2057 print('test', f'debug={debug}') 2058 try: 2059 2060 assert self._history() 2061 2062 # Not allowed for duplicate transactions in the same account and time 2063 2064 created = ZakatTracker.time() 2065 self.track(100, 'test-1', 'same', True, created) 2066 failed = False 2067 try: 2068 self.track(50, 'test-1', 'same', True, created) 2069 except: 2070 failed = True 2071 assert failed is True 2072 2073 self.reset() 2074 2075 # Same account transfer 2076 for x in [1, 'a', True, 1.8, None]: 2077 failed = False 2078 try: 2079 self.transfer(1, x, x, 'same-account', debug=debug) 2080 except: 2081 failed = True 2082 assert failed is True 2083 2084 # Always preserve box age during transfer 2085 2086 series: list[tuple] = [ 2087 (30, 4), 2088 (60, 3), 2089 (90, 2), 2090 ] 2091 case = { 2092 30: { 2093 'series': series, 2094 'rest': 150, 2095 }, 2096 60: { 2097 'series': series, 2098 'rest': 120, 2099 }, 2100 90: { 2101 'series': series, 2102 'rest': 90, 2103 }, 2104 180: { 2105 'series': series, 2106 'rest': 0, 2107 }, 2108 270: { 2109 'series': series, 2110 'rest': -90, 2111 }, 2112 360: { 2113 'series': series, 2114 'rest': -180, 2115 }, 2116 } 2117 2118 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 2119 2120 for total in case: 2121 for x in case[total]['series']: 2122 self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1]) 2123 2124 refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug) 2125 2126 if debug: 2127 print('refs', refs) 2128 2129 ages_cache_balance = self.balance('ages') 2130 ages_fresh_balance = self.balance('ages', False) 2131 rest = case[total]['rest'] 2132 if debug: 2133 print('source', ages_cache_balance, ages_fresh_balance, rest) 2134 assert ages_cache_balance == rest 2135 assert ages_fresh_balance == rest 2136 2137 future_cache_balance = self.balance('future') 2138 future_fresh_balance = self.balance('future', False) 2139 if debug: 2140 print('target', future_cache_balance, future_fresh_balance, total) 2141 print('refs', refs) 2142 assert future_cache_balance == total 2143 assert future_fresh_balance == total 2144 2145 for ref in self._vault['account']['ages']['box']: 2146 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 2147 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 2148 future_capital = 0 2149 if ref in self._vault['account']['future']['box']: 2150 future_capital = self._vault['account']['future']['box'][ref]['capital'] 2151 future_rest = 0 2152 if ref in self._vault['account']['future']['box']: 2153 future_rest = self._vault['account']['future']['box'][ref]['rest'] 2154 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 2155 if debug: 2156 print('================================================================') 2157 print('ages', ages_capital, ages_rest) 2158 print('future', future_capital, future_rest) 2159 if ages_rest == 0: 2160 assert ages_capital == future_capital 2161 elif ages_rest < 0: 2162 assert -ages_capital == future_capital 2163 elif ages_rest > 0: 2164 assert ages_capital == ages_rest + future_capital 2165 self.reset() 2166 assert len(self._vault['history']) == 0 2167 2168 assert self._history() 2169 assert self._history(False) is False 2170 assert self._history() is False 2171 assert self._history(True) 2172 assert self._history() 2173 2174 self._test_core(True, debug) 2175 self._test_core(False, debug) 2176 2177 transaction = [ 2178 ( 2179 20, 'wallet', 1, 800, 800, 800, 4, 5, 2180 -85, -85, -85, 6, 7, 2181 ), 2182 ( 2183 750, 'wallet', 'safe', 50, 50, 50, 4, 6, 2184 750, 750, 750, 1, 1, 2185 ), 2186 ( 2187 600, 'safe', 'bank', 150, 150, 150, 1, 2, 2188 600, 600, 600, 1, 1, 2189 ), 2190 ] 2191 for z in transaction: 2192 self.lock() 2193 x = z[1] 2194 y = z[2] 2195 self.transfer(z[0], x, y, 'test-transfer', debug=debug) 2196 assert self.balance(x) == z[3] 2197 xx = self.accounts()[x] 2198 assert xx == z[3] 2199 assert self.balance(x, False) == z[4] 2200 assert xx == z[4] 2201 2202 s = 0 2203 log = self._vault['account'][x]['log'] 2204 for i in log: 2205 s += log[i]['value'] 2206 if debug: 2207 print('s', s, 'z[5]', z[5]) 2208 assert s == z[5] 2209 2210 assert self.box_size(x) == z[6] 2211 assert self.log_size(x) == z[7] 2212 2213 yy = self.accounts()[y] 2214 assert self.balance(y) == z[8] 2215 assert yy == z[8] 2216 assert self.balance(y, False) == z[9] 2217 assert yy == z[9] 2218 2219 s = 0 2220 log = self._vault['account'][y]['log'] 2221 for i in log: 2222 s += log[i]['value'] 2223 assert s == z[10] 2224 2225 assert self.box_size(y) == z[11] 2226 assert self.log_size(y) == z[12] 2227 2228 if debug: 2229 pp().pprint(self.check(2.17)) 2230 2231 assert not self.nolock() 2232 history_count = len(self._vault['history']) 2233 if debug: 2234 print('history-count', history_count) 2235 assert history_count == 11 2236 assert not self.free(ZakatTracker.time()) 2237 assert self.free(self.lock()) 2238 assert self.nolock() 2239 assert len(self._vault['history']) == 11 2240 2241 # storage 2242 2243 _path = self.path('test.pickle') 2244 if os.path.exists(_path): 2245 os.remove(_path) 2246 self.save() 2247 assert os.path.getsize(_path) > 0 2248 self.reset() 2249 assert self.recall(False, debug) is False 2250 self.load() 2251 assert self._vault['account'] is not None 2252 2253 # recall 2254 2255 assert self.nolock() 2256 assert len(self._vault['history']) == 11 2257 assert self.recall(False, debug) is True 2258 assert len(self._vault['history']) == 10 2259 assert self.recall(False, debug) is True 2260 assert len(self._vault['history']) == 9 2261 2262 # exchange 2263 2264 self.exchange("cash", 25, 3.75, "2024-06-25") 2265 self.exchange("cash", 22, 3.73, "2024-06-22") 2266 self.exchange("cash", 15, 3.69, "2024-06-15") 2267 self.exchange("cash", 10, 3.66) 2268 2269 for i in range(1, 30): 2270 exchange = self.exchange("cash", i) 2271 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2272 if debug: 2273 print(i, rate, description, created) 2274 assert created 2275 if i < 10: 2276 assert rate == 1 2277 assert description is None 2278 elif i == 10: 2279 assert rate == 3.66 2280 assert description is None 2281 elif i < 15: 2282 assert rate == 3.66 2283 assert description is None 2284 elif i == 15: 2285 assert rate == 3.69 2286 assert description is not None 2287 elif i < 22: 2288 assert rate == 3.69 2289 assert description is not None 2290 elif i == 22: 2291 assert rate == 3.73 2292 assert description is not None 2293 elif i >= 25: 2294 assert rate == 3.75 2295 assert description is not None 2296 exchange = self.exchange("bank", i) 2297 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2298 if debug: 2299 print(i, rate, description, created) 2300 assert created 2301 assert rate == 1 2302 assert description is None 2303 2304 assert len(self._vault['exchange']) > 0 2305 assert len(self.exchanges()) > 0 2306 self._vault['exchange'].clear() 2307 assert len(self._vault['exchange']) == 0 2308 assert len(self.exchanges()) == 0 2309 2310 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 2311 self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25") 2312 self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22") 2313 self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15") 2314 self.exchange("cash", ZakatTracker.day_to_time(10), 3.66) 2315 2316 for i in [x * 0.12 for x in range(-15, 21)]: 2317 if i <= 0: 2318 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0 2319 else: 2320 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0 2321 2322 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 2323 for i in range(1, 31): 2324 timestamp_ns = ZakatTracker.day_to_time(i) 2325 exchange = self.exchange("cash", timestamp_ns) 2326 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2327 if debug: 2328 print(i, rate, description, created) 2329 assert created 2330 if i < 10: 2331 assert rate == 1 2332 assert description is None 2333 elif i == 10: 2334 assert rate == 3.66 2335 assert description is None 2336 elif i < 15: 2337 assert rate == 3.66 2338 assert description is None 2339 elif i == 15: 2340 assert rate == 3.69 2341 assert description is not None 2342 elif i < 22: 2343 assert rate == 3.69 2344 assert description is not None 2345 elif i == 22: 2346 assert rate == 3.73 2347 assert description is not None 2348 elif i >= 25: 2349 assert rate == 3.75 2350 assert description is not None 2351 exchange = self.exchange("bank", i) 2352 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2353 if debug: 2354 print(i, rate, description, created) 2355 assert created 2356 assert rate == 1 2357 assert description is None 2358 2359 # csv 2360 2361 csv_count = 1000 2362 2363 for with_rate, path in { 2364 False: 'test-import_csv-no-exchange', 2365 True: 'test-import_csv-with-exchange', 2366 }.items(): 2367 2368 if debug: 2369 print('test_import_csv', with_rate, path) 2370 2371 # csv 2372 2373 csv_path = path + '.csv' 2374 if os.path.exists(csv_path): 2375 os.remove(csv_path) 2376 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 2377 if debug: 2378 print('generate_random_csv_file', c) 2379 assert c == csv_count 2380 assert os.path.getsize(csv_path) > 0 2381 cache_path = self.import_csv_cache_path() 2382 if os.path.exists(cache_path): 2383 os.remove(cache_path) 2384 self.reset() 2385 (created, found, bad) = self.import_csv(csv_path, debug) 2386 bad_count = len(bad) 2387 if debug: 2388 print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})") 2389 tmp_size = os.path.getsize(cache_path) 2390 assert tmp_size > 0 2391 assert created + found + bad_count == csv_count 2392 assert created == csv_count 2393 assert bad_count == 0 2394 (created_2, found_2, bad_2) = self.import_csv(csv_path) 2395 bad_2_count = len(bad_2) 2396 if debug: 2397 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 2398 print(bad) 2399 assert tmp_size == os.path.getsize(cache_path) 2400 assert created_2 + found_2 + bad_2_count == csv_count 2401 assert created == found_2 2402 assert bad_count == bad_2_count 2403 assert found_2 == csv_count 2404 assert bad_2_count == 0 2405 assert created_2 == 0 2406 2407 # payment parts 2408 2409 positive_parts = self.build_payment_parts(100, positive_only=True) 2410 assert self.check_payment_parts(positive_parts) != 0 2411 assert self.check_payment_parts(positive_parts) != 0 2412 all_parts = self.build_payment_parts(300, positive_only=False) 2413 assert self.check_payment_parts(all_parts) != 0 2414 assert self.check_payment_parts(all_parts) != 0 2415 if debug: 2416 pp().pprint(positive_parts) 2417 pp().pprint(all_parts) 2418 # dynamic discount 2419 suite = [] 2420 count = 3 2421 for exceed in [False, True]: 2422 case = [] 2423 for parts in [positive_parts, all_parts]: 2424 part = parts.copy() 2425 demand = part['demand'] 2426 if debug: 2427 print(demand, part['total']) 2428 i = 0 2429 z = demand / count 2430 cp = { 2431 'account': {}, 2432 'demand': demand, 2433 'exceed': exceed, 2434 'total': part['total'], 2435 } 2436 j = '' 2437 for x, y in part['account'].items(): 2438 x_exchange = self.exchange(x) 2439 zz = self.exchange_calc(z, 1, x_exchange['rate']) 2440 if exceed and zz <= demand: 2441 i += 1 2442 y['part'] = zz 2443 if debug: 2444 print(exceed, y) 2445 cp['account'][x] = y 2446 case.append(y) 2447 elif not exceed and y['balance'] >= zz: 2448 i += 1 2449 y['part'] = zz 2450 if debug: 2451 print(exceed, y) 2452 cp['account'][x] = y 2453 case.append(y) 2454 j = x 2455 if i >= count: 2456 break 2457 if len(cp['account'][j]) > 0: 2458 suite.append(cp) 2459 if debug: 2460 print('suite', len(suite)) 2461 # vault = self._vault.copy() 2462 for case in suite: 2463 # self._vault = vault.copy() 2464 if debug: 2465 print('case', case) 2466 result = self.check_payment_parts(case) 2467 if debug: 2468 print('check_payment_parts', result, f'exceed: {exceed}') 2469 assert result == 0 2470 2471 report = self.check(2.17, None, debug) 2472 (valid, brief, plan) = report 2473 if debug: 2474 print('valid', valid) 2475 zakat_result = self.zakat(report, parts=case, debug=debug) 2476 if debug: 2477 print('zakat-result', zakat_result) 2478 assert valid == zakat_result 2479 2480 assert self.save(path + '.pickle') 2481 assert self.export_json(path + '.json') 2482 2483 assert self.export_json("1000-transactions-test.json") 2484 assert self.save("1000-transactions-test.pickle") 2485 2486 self.reset() 2487 2488 # test transfer between accounts with different exchange rate 2489 2490 a_SAR = "Bank (SAR)" 2491 b_USD = "Bank (USD)" 2492 c_SAR = "Safe (SAR)" 2493 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 2494 for case in [ 2495 (0, a_SAR, "SAR Gift", 1000, 1000), 2496 (1, a_SAR, 1), 2497 (0, b_USD, "USD Gift", 500, 500), 2498 (1, b_USD, 1), 2499 (2, b_USD, 3.75), 2500 (1, b_USD, 3.75), 2501 (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375), 2502 (0, c_SAR, "Salary", 750, 750), 2503 (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500), 2504 (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501), 2505 ]: 2506 match (case[0]): 2507 case 0: # track 2508 _, account, desc, x, balance = case 2509 self.track(value=x, desc=desc, account=account, debug=debug) 2510 2511 cached_value = self.balance(account, cached=True) 2512 fresh_value = self.balance(account, cached=False) 2513 if debug: 2514 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 2515 assert cached_value == balance 2516 assert fresh_value == balance 2517 case 1: # check-exchange 2518 _, account, expected_rate = case 2519 t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2520 if debug: 2521 print('t-exchange', t_exchange) 2522 assert t_exchange['rate'] == expected_rate 2523 case 2: # do-exchange 2524 _, account, rate = case 2525 self.exchange(account, rate=rate, debug=debug) 2526 b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2527 if debug: 2528 print('b-exchange', b_exchange) 2529 assert b_exchange['rate'] == rate 2530 case 3: # transfer 2531 _, x, a, b, desc, a_balance, b_balance = case 2532 self.transfer(x, a, b, desc, debug=debug) 2533 2534 cached_value = self.balance(a, cached=True) 2535 fresh_value = self.balance(a, cached=False) 2536 if debug: 2537 print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value) 2538 assert cached_value == a_balance 2539 assert fresh_value == a_balance 2540 2541 cached_value = self.balance(b, cached=True) 2542 fresh_value = self.balance(b, cached=False) 2543 if debug: 2544 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 2545 assert cached_value == b_balance 2546 assert fresh_value == b_balance 2547 2548 # Transfer all in many chunks randomly from B to A 2549 a_SAR_balance = 1371.25 2550 b_USD_balance = 501 2551 b_USD_exchange = self.exchange(b_USD) 2552 amounts = ZakatTracker.create_random_list(b_USD_balance) 2553 if debug: 2554 print('amounts', amounts) 2555 i = 0 2556 for x in amounts: 2557 if debug: 2558 print(f'{i} - transfer-with-exchange({x})') 2559 self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug) 2560 2561 b_USD_balance -= x 2562 cached_value = self.balance(b_USD, cached=True) 2563 fresh_value = self.balance(b_USD, cached=False) 2564 if debug: 2565 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2566 b_USD_balance) 2567 assert cached_value == b_USD_balance 2568 assert fresh_value == b_USD_balance 2569 2570 a_SAR_balance += x * b_USD_exchange['rate'] 2571 cached_value = self.balance(a_SAR, cached=True) 2572 fresh_value = self.balance(a_SAR, cached=False) 2573 if debug: 2574 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2575 a_SAR_balance, 'rate', b_USD_exchange['rate']) 2576 assert cached_value == a_SAR_balance 2577 assert fresh_value == a_SAR_balance 2578 i += 1 2579 2580 # Transfer all in many chunks randomly from C to A 2581 c_SAR_balance = 375 2582 amounts = ZakatTracker.create_random_list(c_SAR_balance) 2583 if debug: 2584 print('amounts', amounts) 2585 i = 0 2586 for x in amounts: 2587 if debug: 2588 print(f'{i} - transfer-with-exchange({x})') 2589 self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug) 2590 2591 c_SAR_balance -= x 2592 cached_value = self.balance(c_SAR, cached=True) 2593 fresh_value = self.balance(c_SAR, cached=False) 2594 if debug: 2595 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2596 c_SAR_balance) 2597 assert cached_value == c_SAR_balance 2598 assert fresh_value == c_SAR_balance 2599 2600 a_SAR_balance += x 2601 cached_value = self.balance(a_SAR, cached=True) 2602 fresh_value = self.balance(a_SAR, cached=False) 2603 if debug: 2604 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2605 a_SAR_balance) 2606 assert cached_value == a_SAR_balance 2607 assert fresh_value == a_SAR_balance 2608 i += 1 2609 2610 assert self.export_json("accounts-transfer-with-exchange-rates.json") 2611 assert self.save("accounts-transfer-with-exchange-rates.pickle") 2612 2613 # check & zakat with exchange rates for many cycles 2614 2615 for rate, values in { 2616 1: { 2617 'in': [1000, 2000, 10000], 2618 'exchanged': [1000, 2000, 10000], 2619 'out': [25, 50, 731.40625], 2620 }, 2621 3.75: { 2622 'in': [200, 1000, 5000], 2623 'exchanged': [750, 3750, 18750], 2624 'out': [18.75, 93.75, 1371.38671875], 2625 }, 2626 }.items(): 2627 a, b, c = values['in'] 2628 m, n, o = values['exchanged'] 2629 x, y, z = values['out'] 2630 if debug: 2631 print('rate', rate, 'values', values) 2632 for case in [ 2633 (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2634 {'safe': {0: {'below_nisab': x}}}, 2635 ], False, m), 2636 (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2637 {'safe': {0: {'count': 1, 'total': y}}}, 2638 ], True, n), 2639 (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [ 2640 {'cave': {0: {'count': 3, 'total': z}}}, 2641 ], True, o), 2642 ]: 2643 if debug: 2644 print(f"############# check(rate: {rate}) #############") 2645 self.reset() 2646 self.exchange(account=case[1], created=case[2], rate=rate) 2647 self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2]) 2648 2649 # assert self.nolock() 2650 # history_size = len(self._vault['history']) 2651 # print('history_size', history_size) 2652 # assert history_size == 2 2653 assert self.lock() 2654 assert not self.nolock() 2655 report = self.check(2.17, None, debug) 2656 (valid, brief, plan) = report 2657 assert valid == case[4] 2658 if debug: 2659 print('brief', brief) 2660 assert case[5] == brief[0] 2661 assert case[5] == brief[1] 2662 2663 if debug: 2664 pp().pprint(plan) 2665 2666 for x in plan: 2667 assert case[1] == x 2668 if 'total' in case[3][0][x][0].keys(): 2669 assert case[3][0][x][0]['total'] == brief[2] 2670 assert plan[x][0]['total'] == case[3][0][x][0]['total'] 2671 assert plan[x][0]['count'] == case[3][0][x][0]['count'] 2672 else: 2673 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 2674 if debug: 2675 pp().pprint(report) 2676 result = self.zakat(report, debug=debug) 2677 if debug: 2678 print('zakat-result', result, case[4]) 2679 assert result == case[4] 2680 report = self.check(2.17, None, debug) 2681 (valid, brief, plan) = report 2682 assert valid is False 2683 2684 history_size = len(self._vault['history']) 2685 if debug: 2686 print('history_size', history_size) 2687 assert history_size == 3 2688 assert not self.nolock() 2689 assert self.recall(False, debug) is False 2690 self.free(self.lock()) 2691 assert self.nolock() 2692 2693 for i in range(3, 0, -1): 2694 history_size = len(self._vault['history']) 2695 if debug: 2696 print('history_size', history_size) 2697 assert history_size == i 2698 assert self.recall(False, debug) is True 2699 2700 assert self.nolock() 2701 assert self.recall(False, debug) is False 2702 2703 history_size = len(self._vault['history']) 2704 if debug: 2705 print('history_size', history_size) 2706 assert history_size == 0 2707 2708 account_size = len(self._vault['account']) 2709 if debug: 2710 print('account_size', account_size) 2711 assert account_size == 0 2712 2713 report_size = len(self._vault['report']) 2714 if debug: 2715 print('report_size', report_size) 2716 assert report_size == 0 2717 2718 assert self.nolock() 2719 return True 2720 except: 2721 # pp().pprint(self._vault) 2722 assert self.export_json("test-snapshot.json") 2723 assert self.save("test-snapshot.pickle") 2724 raise
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 pickle 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
, export_json
,
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 dictionary structure called "_vault" to store and manage data.
_vault (dict):
- account (dict):
- {account_number} (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 (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.
239 def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True): 240 """ 241 Initialize ZakatTracker with database path and history mode. 242 243 Parameters: 244 db_path (str): The path to the database file. Default is "zakat.pickle". 245 history_mode (bool): The mode for tracking history. Default is True. 246 247 Returns: 248 None 249 """ 250 self._vault_path = None 251 self._vault = None 252 self.reset() 253 self._history(history_mode) 254 self.path(db_path) 255 self.load()
Initialize ZakatTracker with database path and history mode.
Parameters: db_path (str): The path to the database file. Default is "zakat.pickle". history_mode (bool): The mode for tracking history. Default is True.
Returns: None
173 @staticmethod 174 def Version(): 175 """ 176 Returns the current version of the software. 177 178 This function returns a string representing the current version of the software, 179 including major, minor, and patch version numbers in the format "X.Y.Z". 180 181 Returns: 182 str: The current version of the software. 183 """ 184 return '0.2.73'
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.
186 @staticmethod 187 def ZakatCut(x: float) -> float: 188 """ 189 Calculates the Zakat amount due on an asset. 190 191 This function calculates the zakat amount due on a given asset value over one lunar year. 192 Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth 193 that exceeds a certain threshold (Nisab). 194 195 Parameters: 196 x: The total value of the asset on which Zakat is to be calculated. 197 198 Returns: 199 The amount of Zakat due on the asset, calculated as 2.5% of the asset's value. 200 """ 201 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: The total value of the asset on which Zakat is to be calculated.
Returns: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
203 @staticmethod 204 def TimeCycle(days: int = 355) -> int: 205 """ 206 Calculates the approximate duration of a lunar year in nanoseconds. 207 208 This function calculates the approximate duration of a lunar year based on the given number of days. 209 It converts the given number of days into nanoseconds for use in high-precision timing applications. 210 211 Parameters: 212 days: The number of days in a lunar year. Defaults to 355, 213 which is an approximation of the average length of a lunar year. 214 215 Returns: 216 The approximate duration of a lunar year in nanoseconds. 217 """ 218 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: The number of days in a lunar year. Defaults to 355, which is an approximation of the average length of a lunar year.
Returns: The approximate duration of a lunar year in nanoseconds.
220 @staticmethod 221 def Nisab(gram_price: float, gram_quantity: float = 595) -> float: 222 """ 223 Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram. 224 225 This function calculates the Nisab value, which is the minimum threshold of wealth, 226 that makes an individual liable for paying Zakat. 227 The Nisab value is determined by the equivalent value of a specific amount 228 of gold or silver (currently 595 grams in silver) in the local currency. 229 230 Parameters: 231 - gram_price (float): The price per gram of Nisab. 232 - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver. 233 234 Returns: 235 - float: The total value of Nisab based on the given price per gram. 236 """ 237 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): 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.
257 def path(self, path: str = None) -> str: 258 """ 259 Set or get the database path. 260 261 Parameters: 262 path (str): The path to the database file. If not provided, it returns the current path. 263 264 Returns: 265 str: The current database path. 266 """ 267 if path is not None: 268 self._vault_path = path 269 return self._vault_path
Set or get the database path.
Parameters: path (str): The path to the database file. If not provided, it returns the current path.
Returns: str: The current database path.
285 def reset(self) -> None: 286 """ 287 Reset the internal data structure to its initial state. 288 289 Parameters: 290 None 291 292 Returns: 293 None 294 """ 295 self._vault = { 296 'account': {}, 297 'exchange': {}, 298 'history': {}, 299 'lock': None, 300 'report': {}, 301 }
Reset the internal data structure to its initial state.
Parameters: None
Returns: None
303 @staticmethod 304 def time(now: datetime = None) -> int: 305 """ 306 Generates a timestamp based on the provided datetime object or the current datetime. 307 308 Parameters: 309 now (datetime, optional): The datetime object to generate the timestamp from. 310 If not provided, the current datetime is used. 311 312 Returns: 313 int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), 314 before 1970 will return in negative until 1000AD. 315 """ 316 if now is None: 317 now = datetime.datetime.now() 318 ordinal_day = now.toordinal() 319 ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9 320 return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)
Generates a timestamp based on the provided datetime object or the current datetime.
Parameters: now (datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
Returns: int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), before 1970 will return in negative until 1000AD.
322 @staticmethod 323 def time_to_datetime(ordinal_ns: int) -> datetime: 324 """ 325 Converts an ordinal number (number of days since 1000-01-01) to a datetime object. 326 327 Parameters: 328 ordinal_ns (int): The ordinal number of days since 1000-01-01. 329 330 Returns: 331 datetime: The corresponding datetime object. 332 """ 333 ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163 334 ns_in_day = ordinal_ns % 86_400_000_000_000 335 d = datetime.datetime.fromordinal(ordinal_day) 336 t = datetime.timedelta(seconds=ns_in_day // 10 ** 9) 337 return datetime.datetime.combine(d, datetime.time()) + t
Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
Parameters: ordinal_ns (int): The ordinal number of days since 1000-01-01.
Returns: datetime: The corresponding datetime object.
375 def nolock(self) -> bool: 376 """ 377 Check if the vault lock is currently not set. 378 379 Returns: 380 bool: True if the vault lock is not set, False otherwise. 381 """ 382 return self._vault['lock'] is None
Check if the vault lock is currently not set.
Returns: bool: True if the vault lock is not set, False otherwise.
384 def lock(self) -> int: 385 """ 386 Acquires a lock on the ZakatTracker instance. 387 388 Returns: 389 int: The lock ID. This ID can be used to release the lock later. 390 """ 391 return self._step()
Acquires a lock on the ZakatTracker instance.
Returns: int: The lock ID. This ID can be used to release the lock later.
393 def vault(self) -> dict: 394 """ 395 Returns a copy of the internal vault dictionary. 396 397 This method is used to retrieve the current state of the ZakatTracker object. 398 It provides a snapshot of the internal data structure, allowing for further 399 processing or analysis. 400 401 Returns: 402 dict: A copy of the internal vault dictionary. 403 """ 404 return self._vault.copy()
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.
Returns: dict: A copy of the internal vault dictionary.
406 def stats(self) -> dict[str, tuple]: 407 """ 408 Calculates and returns statistics about the object's data storage. 409 410 This method determines the size of the database file on disk and the 411 size of the data currently held in RAM (likely within a dictionary). 412 Both sizes are reported in bytes and in a human-readable format 413 (e.g., KB, MB). 414 415 Returns: 416 dict[str, tuple]: A dictionary containing the following statistics: 417 418 * 'database': A tuple with two elements: 419 - The database file size in bytes (int). 420 - The database file size in human-readable format (str). 421 * 'ram': A tuple with two elements: 422 - The RAM usage (dictionary size) in bytes (int). 423 - The RAM usage in human-readable format (str). 424 425 Example: 426 >>> stats = my_object.stats() 427 >>> print(stats['database']) 428 (256000, '250.0 KB') 429 >>> print(stats['ram']) 430 (12345, '12.1 KB') 431 """ 432 ram_size = self.get_dict_size(self.vault()) 433 file_size = os.path.getsize(self.path()) 434 return { 435 'database': (file_size, self.human_readable_size(file_size)), 436 'ram': (ram_size, self.human_readable_size(ram_size)), 437 }
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).
Returns: dict[str, tuple]: A dictionary containing the following statistics:
* 'database': A tuple with two elements:
- The database file size in bytes (int).
- The database file size in human-readable format (str).
* 'ram': A tuple with two elements:
- The RAM usage (dictionary size) in bytes (int).
- The RAM usage in human-readable format (str).
Example:
>>> stats = my_object.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
439 def steps(self) -> dict: 440 """ 441 Returns a copy of the history of steps taken in the ZakatTracker. 442 443 The history is a dictionary where each key is a unique identifier for a step, 444 and the corresponding value is a dictionary containing information about the step. 445 446 Returns: 447 dict: A copy of the history of steps taken in the ZakatTracker. 448 """ 449 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.
Returns: dict: A copy of the history of steps taken in the ZakatTracker.
451 def free(self, lock: int, auto_save: bool = True) -> bool: 452 """ 453 Releases the lock on the database. 454 455 Parameters: 456 lock (int): The lock ID to be released. 457 auto_save (bool): Whether to automatically save the database after releasing the lock. 458 459 Returns: 460 bool: True if the lock is successfully released and (optionally) saved, False otherwise. 461 """ 462 if lock == self._vault['lock']: 463 self._vault['lock'] = None 464 if auto_save: 465 return self.save(self.path()) 466 return True 467 return False
Releases the lock on the database.
Parameters: lock (int): The lock ID to be released. auto_save (bool): Whether to automatically save the database after releasing the lock.
Returns: bool: True if the lock is successfully released and (optionally) saved, False otherwise.
469 def account_exists(self, account) -> bool: 470 """ 471 Check if the given account exists in the vault. 472 473 Parameters: 474 account (str): The account number to check. 475 476 Returns: 477 bool: True if the account exists, False otherwise. 478 """ 479 return account in self._vault['account']
Check if the given account exists in the vault.
Parameters: account (str): The account number to check.
Returns: bool: True if the account exists, False otherwise.
481 def box_size(self, account) -> int: 482 """ 483 Calculate the size of the box for a specific account. 484 485 Parameters: 486 account (str): The account number for which the box size needs to be calculated. 487 488 Returns: 489 int: The size of the box for the given account. If the account does not exist, -1 is returned. 490 """ 491 if self.account_exists(account): 492 return len(self._vault['account'][account]['box']) 493 return -1
Calculate the size of the box for a specific account.
Parameters: account (str): 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.
495 def log_size(self, account) -> int: 496 """ 497 Get the size of the log for a specific account. 498 499 Parameters: 500 account (str): The account number for which the log size needs to be calculated. 501 502 Returns: 503 int: The size of the log for the given account. If the account does not exist, -1 is returned. 504 """ 505 if self.account_exists(account): 506 return len(self._vault['account'][account]['log']) 507 return -1
Get the size of the log for a specific account.
Parameters: account (str): 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.
509 def recall(self, dry=True, debug=False) -> bool: 510 """ 511 Revert the last operation. 512 513 Parameters: 514 dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 515 debug (bool): If True, the function will print debug information. Default is False. 516 517 Returns: 518 bool: True if the operation was successful, False otherwise. 519 """ 520 if not self.nolock() or len(self._vault['history']) == 0: 521 return False 522 if len(self._vault['history']) <= 0: 523 return False 524 ref = sorted(self._vault['history'].keys())[-1] 525 if debug: 526 print('recall', ref) 527 memory = self._vault['history'][ref] 528 if debug: 529 print(type(memory), 'memory', memory) 530 531 limit = len(memory) + 1 532 sub_positive_log_negative = 0 533 for i in range(-1, -limit, -1): 534 x = memory[i] 535 if debug: 536 print(type(x), x) 537 match x['action']: 538 case Action.CREATE: 539 if x['account'] is not None: 540 if self.account_exists(x['account']): 541 if debug: 542 print('account', self._vault['account'][x['account']]) 543 assert len(self._vault['account'][x['account']]['box']) == 0 544 assert self._vault['account'][x['account']]['balance'] == 0 545 assert self._vault['account'][x['account']]['count'] == 0 546 if dry: 547 continue 548 del self._vault['account'][x['account']] 549 550 case Action.TRACK: 551 if x['account'] is not None: 552 if self.account_exists(x['account']): 553 if dry: 554 continue 555 self._vault['account'][x['account']]['balance'] -= x['value'] 556 self._vault['account'][x['account']]['count'] -= 1 557 del self._vault['account'][x['account']]['box'][x['ref']] 558 559 case Action.LOG: 560 if x['account'] is not None: 561 if self.account_exists(x['account']): 562 if x['ref'] in self._vault['account'][x['account']]['log']: 563 if dry: 564 continue 565 if sub_positive_log_negative == -x['value']: 566 self._vault['account'][x['account']]['count'] -= 1 567 sub_positive_log_negative = 0 568 box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref'] 569 if not box_ref is None: 570 assert self.box_exists(x['account'], box_ref) 571 box_value = self._vault['account'][x['account']]['log'][x['ref']]['value'] 572 assert box_value < 0 573 self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value 574 self._vault['account'][x['account']]['balance'] += -box_value 575 self._vault['account'][x['account']]['count'] -= 1 576 del self._vault['account'][x['account']]['log'][x['ref']] 577 578 case Action.SUB: 579 if x['account'] is not None: 580 if self.account_exists(x['account']): 581 if x['ref'] in self._vault['account'][x['account']]['box']: 582 if dry: 583 continue 584 self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value'] 585 self._vault['account'][x['account']]['balance'] += x['value'] 586 sub_positive_log_negative = x['value'] 587 588 case Action.ADD_FILE: 589 if x['account'] is not None: 590 if self.account_exists(x['account']): 591 if x['ref'] in self._vault['account'][x['account']]['log']: 592 if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']: 593 if dry: 594 continue 595 del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] 596 597 case Action.REMOVE_FILE: 598 if x['account'] is not None: 599 if self.account_exists(x['account']): 600 if x['ref'] in self._vault['account'][x['account']]['log']: 601 if dry: 602 continue 603 self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value'] 604 605 case Action.BOX_TRANSFER: 606 if x['account'] is not None: 607 if self.account_exists(x['account']): 608 if x['ref'] in self._vault['account'][x['account']]['box']: 609 if dry: 610 continue 611 self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value'] 612 613 case Action.EXCHANGE: 614 if x['account'] is not None: 615 if x['account'] in self._vault['exchange']: 616 if x['ref'] in self._vault['exchange'][x['account']]: 617 if dry: 618 continue 619 del self._vault['exchange'][x['account']][x['ref']] 620 621 case Action.REPORT: 622 if x['ref'] in self._vault['report']: 623 if dry: 624 continue 625 del self._vault['report'][x['ref']] 626 627 case Action.ZAKAT: 628 if x['account'] is not None: 629 if self.account_exists(x['account']): 630 if x['ref'] in self._vault['account'][x['account']]['box']: 631 if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]: 632 if dry: 633 continue 634 match x['math']: 635 case MathOperation.ADDITION: 636 self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[ 637 'value'] 638 case MathOperation.EQUAL: 639 self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value'] 640 case MathOperation.SUBTRACTION: 641 self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[ 642 'value'] 643 644 if not dry: 645 del self._vault['history'][ref] 646 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. debug (bool): If True, the function will print debug information. Default is False.
Returns: bool: True if the operation was successful, False otherwise.
648 def ref_exists(self, account: str, ref_type: str, ref: int) -> bool: 649 """ 650 Check if a specific reference (transaction) exists in the vault for a given account and reference type. 651 652 Parameters: 653 account (str): The account number for which to check the existence of the reference. 654 ref_type (str): The type of reference (e.g., 'box', 'log', etc.). 655 ref (int): The reference (transaction) number to check for existence. 656 657 Returns: 658 bool: True if the reference exists for the given account and reference type, False otherwise. 659 """ 660 if account in self._vault['account']: 661 return ref in self._vault['account'][account][ref_type] 662 return False
Check if a specific reference (transaction) exists in the vault for a given account and reference type.
Parameters: account (str): 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 (int): The reference (transaction) number to check for existence.
Returns: bool: True if the reference exists for the given account and reference type, False otherwise.
664 def box_exists(self, account: str, ref: int) -> bool: 665 """ 666 Check if a specific box (transaction) exists in the vault for a given account and reference. 667 668 Parameters: 669 - account (str): The account number for which to check the existence of the box. 670 - ref (int): The reference (transaction) number to check for existence. 671 672 Returns: 673 - bool: True if the box exists for the given account and reference, False otherwise. 674 """ 675 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 (str): The account number for which to check the existence of the box.
- ref (int): The reference (transaction) number to check for existence.
Returns:
- bool: True if the box exists for the given account and reference, False otherwise.
677 def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, 678 debug: bool = False) -> int: 679 """ 680 This function tracks a transaction for a specific account. 681 682 Parameters: 683 value (float): The value of the transaction. Default is 0. 684 desc (str): The description of the transaction. Default is an empty string. 685 account (str): The account for which the transaction is being tracked. Default is '1'. 686 logging (bool): Whether to log the transaction. Default is True. 687 created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. 688 debug (bool): Whether to print debug information. Default is False. 689 690 Returns: 691 int: The timestamp of the transaction. 692 693 This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box. 694 695 Raises: 696 ValueError: The log transaction happened again in the same nanosecond time. 697 ValueError: The box transaction happened again in the same nanosecond time. 698 """ 699 if debug: 700 print('track', f'debug={debug}') 701 if created is None: 702 created = self.time() 703 no_lock = self.nolock() 704 self.lock() 705 if not self.account_exists(account): 706 if debug: 707 print(f"account {account} created") 708 self._vault['account'][account] = { 709 'balance': 0, 710 'box': {}, 711 'count': 0, 712 'log': {}, 713 'hide': False, 714 'zakatable': True, 715 } 716 self._step(Action.CREATE, account) 717 if value == 0: 718 if no_lock: 719 self.free(self.lock()) 720 return 0 721 if logging: 722 self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug) 723 if debug: 724 print('create-box', created) 725 if self.box_exists(account, created): 726 raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).") 727 if debug: 728 print('created-box', created) 729 self._vault['account'][account]['box'][created] = { 730 'capital': value, 731 'count': 0, 732 'last': 0, 733 'rest': value, 734 'total': 0, 735 } 736 self._step(Action.TRACK, account, ref=created, value=value) 737 if no_lock: 738 self.free(self.lock()) 739 return created
This function tracks a transaction for a specific account.
Parameters: value (float): The value of the transaction. Default is 0. desc (str): The description of the transaction. Default is an empty string. account (str): The account for which the transaction is being tracked. Default is '1'. logging (bool): Whether to log the transaction. Default is True. created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. debug (bool): Whether to print debug information. Default is False.
Returns: int: The timestamp of the transaction.
This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
Raises: ValueError: The log transaction happened again in the same nanosecond time. ValueError: The box transaction happened again in the same nanosecond time.
741 def log_exists(self, account: str, ref: int) -> bool: 742 """ 743 Checks if a specific transaction log entry exists for a given account. 744 745 Parameters: 746 account (str): The account number associated with the transaction log. 747 ref (int): The reference to the transaction log entry. 748 749 Returns: 750 bool: True if the transaction log entry exists, False otherwise. 751 """ 752 return self.ref_exists(account, 'log', ref)
Checks if a specific transaction log entry exists for a given account.
Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry.
Returns: bool: True if the transaction log entry exists, False otherwise.
798 def exchange(self, account, created: int = None, rate: float = None, description: str = None, 799 debug: bool = False) -> dict: 800 """ 801 This method is used to record or retrieve exchange rates for a specific account. 802 803 Parameters: 804 - account (str): The account number for which the exchange rate is being recorded or retrieved. 805 - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 806 - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 807 - description (str): A description of the exchange rate. 808 809 Returns: 810 - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 811 it returns a dictionary with default values for the rate and description. 812 """ 813 if debug: 814 print('exchange', f'debug={debug}') 815 if created is None: 816 created = self.time() 817 no_lock = self.nolock() 818 self.lock() 819 if rate is not None: 820 if rate <= 0: 821 return dict() 822 if account not in self._vault['exchange']: 823 self._vault['exchange'][account] = {} 824 if len(self._vault['exchange'][account]) == 0 and rate <= 1: 825 return {"time": created, "rate": 1, "description": None} 826 self._vault['exchange'][account][created] = {"rate": rate, "description": description} 827 self._step(Action.EXCHANGE, account, ref=created, value=rate) 828 if no_lock: 829 self.free(self.lock()) 830 if debug: 831 print("exchange-created-1", 832 f'account: {account}, created: {created}, rate:{rate}, description:{description}') 833 834 if account in self._vault['exchange']: 835 valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created] 836 if valid_rates: 837 latest_rate = max(valid_rates, key=lambda x: x[0]) 838 if debug: 839 print("exchange-read-1", 840 f'account: {account}, created: {created}, rate:{rate}, description:{description}', 841 'latest_rate', latest_rate) 842 result = latest_rate[1] 843 result['time'] = latest_rate[0] 844 return result # إرجاع قاموس يحتوي على المعدل والوصف 845 if debug: 846 print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}') 847 return {"time": created, "rate": 1, "description": None} # إرجاع القيمة الافتراضية مع وصف فارغ
This method is used to record or retrieve exchange rates for a specific account.
Parameters:
- account (str): The account number for which the exchange rate is being recorded or retrieved.
- created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
- rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
- description (str): A description of the exchange rate.
Returns:
- dict: 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.
849 @staticmethod 850 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 851 """ 852 This function calculates the exchanged amount of a currency. 853 854 Args: 855 x (float): The original amount of the currency. 856 x_rate (float): The exchange rate of the original currency. 857 y_rate (float): The exchange rate of the target currency. 858 859 Returns: 860 float: The exchanged amount of the target currency. 861 """ 862 return (x * x_rate) / y_rate
This function calculates the exchanged amount of a currency.
Args: 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.
864 def exchanges(self) -> dict: 865 """ 866 Retrieve the recorded exchange rates for all accounts. 867 868 Parameters: 869 None 870 871 Returns: 872 dict: A dictionary containing all recorded exchange rates. 873 The keys are account names or numbers, and the values are dictionaries containing the exchange rates. 874 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 875 """ 876 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.
878 def accounts(self) -> dict: 879 """ 880 Returns a dictionary containing account numbers as keys and their respective balances as values. 881 882 Parameters: 883 None 884 885 Returns: 886 dict: A dictionary where keys are account numbers and values are their respective balances. 887 """ 888 result = {} 889 for i in self._vault['account']: 890 result[i] = self._vault['account'][i]['balance'] 891 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.
893 def boxes(self, account) -> dict: 894 """ 895 Retrieve the boxes (transactions) associated with a specific account. 896 897 Parameters: 898 account (str): The account number for which to retrieve the boxes. 899 900 Returns: 901 dict: A dictionary containing the boxes associated with the given account. 902 If the account does not exist, an empty dictionary is returned. 903 """ 904 if self.account_exists(account): 905 return self._vault['account'][account]['box'] 906 return {}
Retrieve the boxes (transactions) associated with a specific account.
Parameters: account (str): 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.
908 def logs(self, account) -> dict: 909 """ 910 Retrieve the logs (transactions) associated with a specific account. 911 912 Parameters: 913 account (str): The account number for which to retrieve the logs. 914 915 Returns: 916 dict: A dictionary containing the logs associated with the given account. 917 If the account does not exist, an empty dictionary is returned. 918 """ 919 if self.account_exists(account): 920 return self._vault['account'][account]['log'] 921 return {}
Retrieve the logs (transactions) associated with a specific account.
Parameters: account (str): The account number for which to retrieve the logs.
Returns: dict: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.
923 def add_file(self, account: str, ref: int, path: str) -> int: 924 """ 925 Adds a file reference to a specific transaction log entry in the vault. 926 927 Parameters: 928 account (str): The account number associated with the transaction log. 929 ref (int): The reference to the transaction log entry. 930 path (str): The path of the file to be added. 931 932 Returns: 933 int: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 934 """ 935 if self.account_exists(account): 936 if ref in self._vault['account'][account]['log']: 937 file_ref = self.time() 938 self._vault['account'][account]['log'][ref]['file'][file_ref] = path 939 no_lock = self.nolock() 940 self.lock() 941 self._step(Action.ADD_FILE, account, ref=ref, file=file_ref) 942 if no_lock: 943 self.free(self.lock()) 944 return file_ref 945 return 0
Adds a file reference to a specific transaction log entry in the vault.
Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry. path (str): The path of the file to be added.
Returns: int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
947 def remove_file(self, account: str, ref: int, file_ref: int) -> bool: 948 """ 949 Removes a file reference from a specific transaction log entry in the vault. 950 951 Parameters: 952 account (str): The account number associated with the transaction log. 953 ref (int): The reference to the transaction log entry. 954 file_ref (int): The reference of the file to be removed. 955 956 Returns: 957 bool: True if the file reference is successfully removed, False otherwise. 958 """ 959 if self.account_exists(account): 960 if ref in self._vault['account'][account]['log']: 961 if file_ref in self._vault['account'][account]['log'][ref]['file']: 962 x = self._vault['account'][account]['log'][ref]['file'][file_ref] 963 del self._vault['account'][account]['log'][ref]['file'][file_ref] 964 no_lock = self.nolock() 965 self.lock() 966 self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 967 if no_lock: 968 self.free(self.lock()) 969 return True 970 return False
Removes a file reference from a specific transaction log entry in the vault.
Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry. file_ref (int): The reference of the file to be removed.
Returns: bool: True if the file reference is successfully removed, False otherwise.
972 def balance(self, account: str = 1, cached: bool = True) -> int: 973 """ 974 Calculate and return the balance of a specific account. 975 976 Parameters: 977 account (str): The account number. Default is '1'. 978 cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 979 980 Returns: 981 int: The balance of the account. 982 983 Note: 984 If cached is True, the function returns the cached balance. 985 If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 986 """ 987 if cached: 988 return self._vault['account'][account]['balance'] 989 x = 0 990 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 (str): The account number. Default is '1'. cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
Returns: int: The balance of the account.
Note: 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.
992 def hide(self, account, status: bool = None) -> bool: 993 """ 994 Check or set the hide status of a specific account. 995 996 Parameters: 997 account (str): The account number. 998 status (bool, optional): The new hide status. If not provided, the function will return the current status. 999 1000 Returns: 1001 bool: The current or updated hide status of the account. 1002 1003 Raises: 1004 None 1005 1006 Example: 1007 >>> tracker = ZakatTracker() 1008 >>> ref = tracker.track(51, 'desc', 'account1') 1009 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 1010 False 1011 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 1012 True 1013 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 1014 True 1015 >>> tracker.hide('account1', False) 1016 False 1017 """ 1018 if self.account_exists(account): 1019 if status is None: 1020 return self._vault['account'][account]['hide'] 1021 self._vault['account'][account]['hide'] = status 1022 return status 1023 return False
Check or set the hide status of a specific account.
Parameters: account (str): 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
1025 def zakatable(self, account, status: bool = None) -> bool: 1026 """ 1027 Check or set the zakatable status of a specific account. 1028 1029 Parameters: 1030 account (str): The account number. 1031 status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 1032 1033 Returns: 1034 bool: The current or updated zakatable status of the account. 1035 1036 Raises: 1037 None 1038 1039 Example: 1040 >>> tracker = ZakatTracker() 1041 >>> ref = tracker.track(51, 'desc', 'account1') 1042 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 1043 True 1044 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 1045 True 1046 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 1047 True 1048 >>> tracker.zakatable('account1', False) 1049 False 1050 """ 1051 if self.account_exists(account): 1052 if status is None: 1053 return self._vault['account'][account]['zakatable'] 1054 self._vault['account'][account]['zakatable'] = status 1055 return status 1056 return False
Check or set the zakatable status of a specific account.
Parameters: account (str): 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
1058 def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple: 1059 """ 1060 Subtracts a specified value from an account's balance. 1061 1062 Parameters: 1063 x (float): The amount to be subtracted. 1064 desc (str): A description for the transaction. Defaults to an empty string. 1065 account (str): The account from which the value will be subtracted. Defaults to '1'. 1066 created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. 1067 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1068 1069 Returns: 1070 tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 1071 1072 If the amount to subtract is greater than the account's balance, 1073 the remaining amount will be transferred to a new transaction with a negative value. 1074 1075 Raises: 1076 ValueError: The box transaction happened again in the same nanosecond time. 1077 ValueError: The log transaction happened again in the same nanosecond time. 1078 """ 1079 if debug: 1080 print('sub', f'debug={debug}') 1081 if x < 0: 1082 return tuple() 1083 if x == 0: 1084 ref = self.track(x, '', account) 1085 return ref, ref 1086 if created is None: 1087 created = self.time() 1088 no_lock = self.nolock() 1089 self.lock() 1090 self.track(0, '', account) 1091 self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug) 1092 ids = sorted(self._vault['account'][account]['box'].keys()) 1093 limit = len(ids) + 1 1094 target = x 1095 if debug: 1096 print('ids', ids) 1097 ages = [] 1098 for i in range(-1, -limit, -1): 1099 if target == 0: 1100 break 1101 j = ids[i] 1102 if debug: 1103 print('i', i, 'j', j) 1104 rest = self._vault['account'][account]['box'][j]['rest'] 1105 if rest >= target: 1106 self._vault['account'][account]['box'][j]['rest'] -= target 1107 self._step(Action.SUB, account, ref=j, value=target) 1108 ages.append((j, target)) 1109 target = 0 1110 break 1111 elif target > rest > 0: 1112 chunk = rest 1113 target -= chunk 1114 self._step(Action.SUB, account, ref=j, value=chunk) 1115 ages.append((j, chunk)) 1116 self._vault['account'][account]['box'][j]['rest'] = 0 1117 if target > 0: 1118 self.track(-target, desc, account, False, created) 1119 ages.append((created, target)) 1120 if no_lock: 1121 self.free(self.lock()) 1122 return created, ages
Subtracts a specified value from an account's balance.
Parameters: x (float): The amount to be subtracted. desc (str): A description for the transaction. Defaults to an empty string. account (str): The account from which the value will be subtracted. Defaults to '1'. created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.
Returns: tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
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.
Raises: ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.
1124 def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, 1125 debug: bool = False) -> list[int]: 1126 """ 1127 Transfers a specified value from one account to another. 1128 1129 Parameters: 1130 amount (int): The amount to be transferred. 1131 from_account (str): The account from which the value will be transferred. 1132 to_account (str): The account to which the value will be transferred. 1133 desc (str, optional): A description for the transaction. Defaults to an empty string. 1134 created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. 1135 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1136 1137 Returns: 1138 list[int]: A list of timestamps corresponding to the transactions made during the transfer. 1139 1140 Raises: 1141 ValueError: Transfer to the same account is forbidden. 1142 ValueError: The box transaction happened again in the same nanosecond time. 1143 ValueError: The log transaction happened again in the same nanosecond time. 1144 """ 1145 if debug: 1146 print('transfer', f'debug={debug}') 1147 if from_account == to_account: 1148 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 1149 if amount <= 0: 1150 return [] 1151 if created is None: 1152 created = self.time() 1153 (_, ages) = self.sub(amount, desc, from_account, created, debug=debug) 1154 times = [] 1155 source_exchange = self.exchange(from_account, created) 1156 target_exchange = self.exchange(to_account, created) 1157 1158 if debug: 1159 print('ages', ages) 1160 1161 for age, value in ages: 1162 target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']) 1163 # Perform the transfer 1164 if self.box_exists(to_account, age): 1165 if debug: 1166 print('box_exists', age) 1167 capital = self._vault['account'][to_account]['box'][age]['capital'] 1168 rest = self._vault['account'][to_account]['box'][age]['rest'] 1169 if debug: 1170 print( 1171 f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1172 selected_age = age 1173 if rest + target_amount > capital: 1174 self._vault['account'][to_account]['box'][age]['capital'] += target_amount 1175 selected_age = ZakatTracker.time() 1176 self._vault['account'][to_account]['box'][age]['rest'] += target_amount 1177 self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 1178 y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 1179 created=None, ref=None, debug=debug) 1180 times.append((age, y)) 1181 continue 1182 y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug) 1183 if debug: 1184 print( 1185 f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1186 times.append(y) 1187 return times
Transfers a specified value from one account to another.
Parameters: amount (int): The amount to be transferred. from_account (str): The account from which the value will be transferred. to_account (str): The account to which the value will be transferred. desc (str, optional): A description for the transaction. Defaults to an empty string. created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.
Returns: list[int]: A list of timestamps corresponding to the transactions made during the transfer.
Raises: ValueError: Transfer to the same account is forbidden. ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.
1189 def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, 1190 cycle: float = None) -> tuple: 1191 """ 1192 Check the eligibility for Zakat based on the given parameters. 1193 1194 Parameters: 1195 silver_gram_price (float): The price of a gram of silver. 1196 nisab (float): The minimum amount of wealth required for Zakat. If not provided, 1197 it will be calculated based on the silver_gram_price. 1198 debug (bool): Flag to enable debug mode. 1199 now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). 1200 cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 1201 1202 Returns: 1203 tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, 1204 and a dictionary containing the Zakat plan. 1205 """ 1206 if debug: 1207 print('check', f'debug={debug}') 1208 if now is None: 1209 now = self.time() 1210 if cycle is None: 1211 cycle = ZakatTracker.TimeCycle() 1212 if nisab is None: 1213 nisab = ZakatTracker.Nisab(silver_gram_price) 1214 plan = {} 1215 below_nisab = 0 1216 brief = [0, 0, 0] 1217 valid = False 1218 for x in self._vault['account']: 1219 if not self.zakatable(x): 1220 continue 1221 _box = self._vault['account'][x]['box'] 1222 _log = self._vault['account'][x]['log'] 1223 limit = len(_box) + 1 1224 ids = sorted(self._vault['account'][x]['box'].keys()) 1225 for i in range(-1, -limit, -1): 1226 j = ids[i] 1227 rest = float(_box[j]['rest']) 1228 if rest <= 0: 1229 continue 1230 exchange = self.exchange(x, created=self.time()) 1231 if debug: 1232 print('exchanges', self.exchanges()) 1233 rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1) 1234 brief[0] += rest 1235 index = limit + i - 1 1236 epoch = (now - j) / cycle 1237 if debug: 1238 print(f"Epoch: {epoch}", _box[j]) 1239 if _box[j]['last'] > 0: 1240 epoch = (now - _box[j]['last']) / cycle 1241 if debug: 1242 print(f"Epoch: {epoch}") 1243 epoch = floor(epoch) 1244 if debug: 1245 print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch) 1246 if epoch == 0: 1247 continue 1248 if debug: 1249 print("Epoch - PASSED") 1250 brief[1] += rest 1251 if rest >= nisab: 1252 total = 0 1253 for _ in range(epoch): 1254 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 1255 if total > 0: 1256 if x not in plan: 1257 plan[x] = {} 1258 valid = True 1259 brief[2] += total 1260 plan[x][index] = { 1261 'total': total, 1262 'count': epoch, 1263 'box_time': j, 1264 'box_capital': _box[j]['capital'], 1265 'box_rest': _box[j]['rest'], 1266 'box_last': _box[j]['last'], 1267 'box_total': _box[j]['total'], 1268 'box_count': _box[j]['count'], 1269 'box_log': _log[j]['desc'], 1270 'exchange_rate': exchange['rate'], 1271 'exchange_time': exchange['time'], 1272 'exchange_desc': exchange['description'], 1273 } 1274 else: 1275 chunk = ZakatTracker.ZakatCut(float(rest)) 1276 if chunk > 0: 1277 if x not in plan: 1278 plan[x] = {} 1279 if j not in plan[x].keys(): 1280 plan[x][index] = {} 1281 below_nisab += rest 1282 brief[2] += chunk 1283 plan[x][index]['below_nisab'] = chunk 1284 plan[x][index]['total'] = chunk 1285 plan[x][index]['count'] = epoch 1286 plan[x][index]['box_time'] = j 1287 plan[x][index]['box_capital'] = _box[j]['capital'] 1288 plan[x][index]['box_rest'] = _box[j]['rest'] 1289 plan[x][index]['box_last'] = _box[j]['last'] 1290 plan[x][index]['box_total'] = _box[j]['total'] 1291 plan[x][index]['box_count'] = _box[j]['count'] 1292 plan[x][index]['box_log'] = _log[j]['desc'] 1293 plan[x][index]['exchange_rate'] = exchange['rate'] 1294 plan[x][index]['exchange_time'] = exchange['time'] 1295 plan[x][index]['exchange_desc'] = exchange['description'] 1296 valid = valid or below_nisab >= nisab 1297 if debug: 1298 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 1299 return valid, brief, plan
Check the eligibility for Zakat based on the given parameters.
Parameters: silver_gram_price (float): The price of a gram of silver. nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price. debug (bool): Flag to enable debug mode. now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
Returns: tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
1301 def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict: 1302 """ 1303 Build payment parts for the Zakat distribution. 1304 1305 Parameters: 1306 demand (float): The total demand for payment in local currency. 1307 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 1308 1309 Returns: 1310 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 1311 { 1312 'account': { 1313 'account_id': {'balance': float, 'rate': float, 'part': float}, 1314 ... 1315 }, 1316 'exceed': bool, 1317 'demand': float, 1318 'total': float, 1319 } 1320 """ 1321 total = 0 1322 parts = { 1323 'account': {}, 1324 'exceed': False, 1325 'demand': demand, 1326 } 1327 for x, y in self.accounts().items(): 1328 if positive_only and y <= 0: 1329 continue 1330 total += float(y) 1331 exchange = self.exchange(x) 1332 parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0} 1333 parts['total'] = total 1334 return parts
Build payment parts for the Zakat distribution.
Parameters: demand (float): The total demand for payment in local currency. positive_only (bool): If True, only consider accounts with positive balance. Default is True.
Returns: dict: 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': float, 'total': float, }
1336 @staticmethod 1337 def check_payment_parts(parts: dict, debug: bool = False) -> int: 1338 """ 1339 Checks the validity of payment parts. 1340 1341 Parameters: 1342 parts (dict): A dictionary containing payment parts information. 1343 debug (bool): Flag to enable debug mode. 1344 1345 Returns: 1346 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 1347 1348 Error Codes: 1349 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 1350 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 1351 3: 'part' value in parts['account'][x] is less than 0. 1352 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 1353 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 1354 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 1355 """ 1356 if debug: 1357 print('check_payment_parts', f'debug={debug}') 1358 for i in ['demand', 'account', 'total', 'exceed']: 1359 if i not in parts: 1360 return 1 1361 exceed = parts['exceed'] 1362 for x in parts['account']: 1363 for j in ['balance', 'rate', 'part']: 1364 if j not in parts['account'][x]: 1365 return 2 1366 if parts['account'][x]['part'] < 0: 1367 return 3 1368 if not exceed and parts['account'][x]['balance'] <= 0: 1369 return 4 1370 demand = parts['demand'] 1371 z = 0 1372 for _, y in parts['account'].items(): 1373 if not exceed and y['part'] > y['balance']: 1374 return 5 1375 z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1) 1376 z = round(z, 2) 1377 demand = round(demand, 2) 1378 if debug: 1379 print('check_payment_parts', f'z = {z}, demand = {demand}') 1380 print('check_payment_parts', type(z), type(demand)) 1381 print('check_payment_parts', z != demand) 1382 print('check_payment_parts', str(z) != str(demand)) 1383 if z != demand and str(z) != str(demand): 1384 return 6 1385 return 0
Checks the validity of payment parts.
Parameters: parts (dict): A dictionary containing payment parts information. debug (bool): 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.
1387 def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool: 1388 """ 1389 Perform Zakat calculation based on the given report and optional parts. 1390 1391 Parameters: 1392 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 1393 parts (dict): A dictionary containing the payment parts for the zakat. 1394 debug (bool): A flag indicating whether to print debug information. 1395 1396 Returns: 1397 bool: True if the zakat calculation is successful, False otherwise. 1398 """ 1399 if debug: 1400 print('zakat', f'debug={debug}') 1401 valid, _, plan = report 1402 if not valid: 1403 return valid 1404 parts_exist = parts is not None 1405 if parts_exist: 1406 if self.check_payment_parts(parts, debug=debug) != 0: 1407 return False 1408 if debug: 1409 print('######### zakat #######') 1410 print('parts_exist', parts_exist) 1411 no_lock = self.nolock() 1412 self.lock() 1413 report_time = self.time() 1414 self._vault['report'][report_time] = report 1415 self._step(Action.REPORT, ref=report_time) 1416 created = self.time() 1417 for x in plan: 1418 target_exchange = self.exchange(x) 1419 if debug: 1420 print(plan[x]) 1421 print('-------------') 1422 print(self._vault['account'][x]['box']) 1423 ids = sorted(self._vault['account'][x]['box'].keys()) 1424 if debug: 1425 print('plan[x]', plan[x]) 1426 for i in plan[x].keys(): 1427 j = ids[i] 1428 if debug: 1429 print('i', i, 'j', j) 1430 self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'], 1431 key='last', 1432 math_operation=MathOperation.EQUAL) 1433 self._vault['account'][x]['box'][j]['last'] = created 1434 amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate'])) 1435 self._vault['account'][x]['box'][j]['total'] += amount 1436 self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 1437 math_operation=MathOperation.ADDITION) 1438 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1439 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count', 1440 math_operation=MathOperation.ADDITION) 1441 if not parts_exist: 1442 try: 1443 self._vault['account'][x]['box'][j]['rest'] -= amount 1444 except TypeError: 1445 self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount) 1446 # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 1447 # math_operation=MathOperation.SUBTRACTION) 1448 self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug) 1449 if parts_exist: 1450 for account, part in parts['account'].items(): 1451 if part['part'] == 0: 1452 continue 1453 if debug: 1454 print('zakat-part', account, part['rate']) 1455 target_exchange = self.exchange(account) 1456 amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate']) 1457 self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug) 1458 if no_lock: 1459 self.free(self.lock()) 1460 return True
Perform Zakat calculation based on the given report and optional parts.
Parameters: report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. parts (dict): A dictionary containing the payment parts for the zakat. debug (bool): A flag indicating whether to print debug information.
Returns: bool: True if the zakat calculation is successful, False otherwise.
1462 def export_json(self, path: str = "data.json") -> bool: 1463 """ 1464 Exports the current state of the ZakatTracker object to a JSON file. 1465 1466 Parameters: 1467 path (str): The path where the JSON file will be saved. Default is "data.json". 1468 1469 Returns: 1470 bool: True if the export is successful, False otherwise. 1471 1472 Raises: 1473 No specific exceptions are raised by this method. 1474 """ 1475 with open(path, "w") as file: 1476 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1477 return True
Exports the current state of the ZakatTracker object to a JSON file.
Parameters: path (str): The path where the JSON file will be saved. Default is "data.json".
Returns: bool: True if the export is successful, False otherwise.
Raises: No specific exceptions are raised by this method.
1479 def save(self, path: str = None) -> bool: 1480 """ 1481 Saves the ZakatTracker's current state to a pickle file. 1482 1483 This method serializes the internal data (`_vault`) along with metadata 1484 (Python version, pickle protocol) for future compatibility. 1485 1486 Parameters: 1487 path (str, optional): File path for saving. Defaults to a predefined location. 1488 1489 Returns: 1490 bool: True if the save operation is successful, False otherwise. 1491 """ 1492 if path is None: 1493 path = self.path() 1494 with open(path, "wb") as f: 1495 version = f'{version_info.major}.{version_info.minor}.{version_info.micro}' 1496 pickle_protocol = pickle.HIGHEST_PROTOCOL 1497 data = { 1498 'python_version': version, 1499 'pickle_protocol': pickle_protocol, 1500 'data': self._vault, 1501 } 1502 pickle.dump(data, f, protocol=pickle_protocol) 1503 return True
Saves the ZakatTracker's current state to a pickle file.
This method serializes the internal data (_vault
) along with metadata
(Python version, pickle protocol) for future compatibility.
Parameters: path (str, optional): File path for saving. Defaults to a predefined location.
Returns: bool: True if the save operation is successful, False otherwise.
1505 def load(self, path: str = None) -> bool: 1506 """ 1507 Load the current state of the ZakatTracker object from a pickle file. 1508 1509 Parameters: 1510 path (str): The path where the pickle file is located. If not provided, it will use the default path. 1511 1512 Returns: 1513 bool: True if the load operation is successful, False otherwise. 1514 """ 1515 if path is None: 1516 path = self.path() 1517 if os.path.exists(path): 1518 with open(path, "rb") as f: 1519 data = pickle.load(f) 1520 self._vault = data['data'] 1521 return True 1522 return False
Load the current state of the ZakatTracker object from a pickle file.
Parameters: path (str): The path where the pickle file is located. If not provided, it will use the default path.
Returns: bool: True if the load operation is successful, False otherwise.
1524 def import_csv_cache_path(self): 1525 """ 1526 Generates the cache file path for imported CSV data. 1527 1528 This function constructs the file path where cached data from CSV imports 1529 will be stored. The cache file is a pickle file (.pickle extension) appended 1530 to the base path of the object. 1531 1532 Returns: 1533 str: The full path to the import CSV cache file. 1534 1535 Example: 1536 >>> obj = ZakatTracker('/data/reports') 1537 >>> obj.import_csv_cache_path() 1538 '/data/reports.import_csv.pickle' 1539 """ 1540 path = self.path() 1541 if path.endswith(".pickle"): 1542 path = path[:-7] 1543 return path + '.import_csv.pickle'
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 pickle file (.pickle extension) appended to the base path of the object.
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.pickle'
1545 def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple: 1546 """ 1547 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1548 1549 Parameters: 1550 path (str): The path to the CSV file. Default is 'file.csv'. 1551 debug (bool): A flag indicating whether to print debug information. 1552 1553 Returns: 1554 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 1555 and a dictionary of bad transactions. 1556 1557 Notes: 1558 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 1559 are appropriate for the currency pairs involved in the conversions. 1560 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 1561 to 1.0 or the previous rate for that account. 1562 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 1563 transactions of the same account within the whole imported and existing dataset when doing `check` and 1564 `zakat` operations. 1565 1566 Example Usage: 1567 The CSV file should have the following format, rate is optional per transaction: 1568 account, desc, value, date, rate 1569 For example: 1570 safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1 1571 """ 1572 if debug: 1573 print('import_csv', f'debug={debug}') 1574 cache: list[int] = [] 1575 try: 1576 with open(self.import_csv_cache_path(), "rb") as f: 1577 cache = pickle.load(f) 1578 except: 1579 pass 1580 date_formats = [ 1581 "%Y-%m-%d %H:%M:%S", 1582 "%Y-%m-%dT%H:%M:%S", 1583 "%Y-%m-%dT%H%M%S", 1584 "%Y-%m-%d", 1585 ] 1586 created, found, bad = 0, 0, {} 1587 data: dict[int, list] = {} 1588 with open(path, newline='', encoding="utf-8") as f: 1589 i = 0 1590 for row in csv.reader(f, delimiter=','): 1591 i += 1 1592 hashed = hash(tuple(row)) 1593 if hashed in cache: 1594 found += 1 1595 continue 1596 account = row[0] 1597 desc = row[1] 1598 value = float(row[2]) 1599 rate = 1.0 1600 if row[4:5]: # Empty list if index is out of range 1601 rate = float(row[4]) 1602 date: int = 0 1603 for time_format in date_formats: 1604 try: 1605 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1606 break 1607 except: 1608 pass 1609 # TODO: not allowed for negative dates 1610 if date == 0 or value == 0: 1611 bad[i] = row 1612 continue 1613 if date not in data: 1614 data[date] = [] 1615 # TODO: If duplicated time with different accounts with the same amount it is an indicator of a transfer 1616 data[date].append((date, value, desc, account, rate, hashed)) 1617 1618 if debug: 1619 print('import_csv', len(data)) 1620 1621 def process(row, index=0): 1622 nonlocal created 1623 (date, value, desc, account, rate, hashed) = row 1624 date += index 1625 if rate > 1: 1626 self.exchange(account, created=date, rate=rate) 1627 if value > 0: 1628 self.track(value, desc, account, True, date) 1629 elif value < 0: 1630 self.sub(-value, desc, account, date) 1631 created += 1 1632 cache.append(hashed) 1633 1634 for date, rows in sorted(data.items()): 1635 len_rows = len(rows) 1636 if len_rows == 1: 1637 process(rows[0]) 1638 continue 1639 if debug: 1640 print('-- Duplicated time detected', date, 'len', len_rows) 1641 print(rows) 1642 print('---------------------------------') 1643 for index, row in enumerate(rows): 1644 process(row, index) 1645 with open(self.import_csv_cache_path(), "wb") as f: 1646 pickle.dump(cache, f) 1647 return created, found, bad
The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
Parameters: path (str): The path to the CSV file. Default is 'file.csv'. debug (bool): 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
and
zakat
operations.
Example Usage: 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
1653 @staticmethod 1654 def human_readable_size(size: float, decimal_places: int = 2) -> str: 1655 """ 1656 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 1657 1658 This function iterates through progressively larger units of information 1659 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 1660 range that can be expressed with a reasonable number before the unit. 1661 1662 Parameters: 1663 size (float): The size in bytes to convert. 1664 decimal_places (int, optional): The number of decimal places to display 1665 in the result. Defaults to 2. 1666 1667 Returns: 1668 str: A string representation of the size in a human-readable format, 1669 rounded to the specified number of decimal places. For example: 1670 - "1.50 KB" (1536 bytes) 1671 - "23.00 MB" (24117248 bytes) 1672 - "1.23 GB" (1325899906 bytes) 1673 """ 1674 if type(size) not in (float, int): 1675 raise TypeError("size must be a float or integer") 1676 if type(decimal_places) != int: 1677 raise TypeError("decimal_places must be an integer") 1678 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 1679 if size < 1024.0: 1680 break 1681 size /= 1024.0 1682 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)
1684 @staticmethod 1685 def get_dict_size(obj: dict, seen: set = None) -> float: 1686 """ 1687 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 1688 1689 This function traverses the dictionary structure, accounting for the size of keys, values, 1690 and any nested objects. It handles various data types commonly found in dictionaries 1691 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 1692 of circular references. 1693 1694 Parameters: 1695 obj (dict): The dictionary whose size is to be calculated. 1696 seen (set, optional): A set used internally to track visited objects 1697 and avoid circular references. Defaults to None. 1698 1699 Returns: 1700 float: An approximate size of the dictionary and its contents in bytes. 1701 1702 Note: 1703 - This function is a method of the `ZakatTracker` class and is likely used to 1704 estimate the memory footprint of data structures relevant to Zakat calculations. 1705 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 1706 not account for all memory overhead depending on the Python implementation. 1707 - Circular references are handled to prevent infinite recursion. 1708 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 1709 - String sizes are estimated based on character length and encoding. 1710 """ 1711 size = 0 1712 if seen is None: 1713 seen = set() 1714 1715 obj_id = id(obj) 1716 if obj_id in seen: 1717 return 0 1718 1719 seen.add(obj_id) 1720 size += sys.getsizeof(obj) 1721 1722 if isinstance(obj, dict): 1723 for k, v in obj.items(): 1724 size += ZakatTracker.get_dict_size(k, seen) 1725 size += ZakatTracker.get_dict_size(v, seen) 1726 elif isinstance(obj, (list, tuple, set, frozenset)): 1727 for item in obj: 1728 size += ZakatTracker.get_dict_size(item, seen) 1729 elif isinstance(obj, (int, float, complex)): # Handle numbers 1730 pass # Basic numbers have a fixed size, so nothing to add here 1731 elif isinstance(obj, str): # Handle strings 1732 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 1733 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.
Note:
- 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.
1735 @staticmethod 1736 def duration_from_nanoseconds(ns: int) -> tuple: 1737 """ 1738 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1739 Convert NanoSeconds to Human Readable Time Format. 1740 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1741 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1742 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1743 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1744 1745 INPUT : ms (AKA: MilliSeconds) 1746 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1747 OUTPUT Variables: time_lapsed, spoken_time 1748 1749 Example Input: duration_from_nanoseconds(ns) 1750 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 1751 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') 1752 duration_from_nanoseconds(1234567890123456789012) 1753 """ 1754 us, ns = divmod(ns, 1000) 1755 ms, us = divmod(us, 1000) 1756 s, ms = divmod(ms, 1000) 1757 m, s = divmod(s, 60) 1758 h, m = divmod(m, 60) 1759 d, h = divmod(h, 24) 1760 y, d = divmod(d, 365) 1761 c, y = divmod(y, 100) 1762 n, c = divmod(c, 10) 1763 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}" 1764 spoken_time = f"{n: 3d} Millennia, {c: 4d} Century, {y: 3d} Years, {d: 4d} Days, {h: 2d} Hours, {m: 2d} Minutes, {s: 2d} Seconds, {ms: 3d} MilliSeconds, {us: 3d} MicroSeconds, {ns: 3d} NanoSeconds" 1765 return time_lapsed, spoken_time
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)
1767 @staticmethod 1768 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 1769 """ 1770 Convert a specific day, month, and year into a timestamp. 1771 1772 Parameters: 1773 day (int): The day of the month. 1774 month (int): The month of the year. Default is 6 (June). 1775 year (int): The year. Default is 2024. 1776 1777 Returns: 1778 int: The timestamp representing the given day, month, and year. 1779 1780 Note: 1781 This method assumes the default month and year if not provided. 1782 """ 1783 return ZakatTracker.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): The month of the year. Default is 6 (June). year (int): 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.
1785 @staticmethod 1786 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 1787 """ 1788 Generate a random date between two given dates. 1789 1790 Parameters: 1791 start_date (datetime.datetime): The start date from which to generate a random date. 1792 end_date (datetime.datetime): The end date until which to generate a random date. 1793 1794 Returns: 1795 datetime.datetime: A random date between the start_date and end_date. 1796 """ 1797 time_between_dates = end_date - start_date 1798 days_between_dates = time_between_dates.days 1799 random_number_of_days = random.randrange(days_between_dates) 1800 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.
1802 @staticmethod 1803 def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, 1804 debug: bool = False) -> int: 1805 """ 1806 Generate a random CSV file with specified parameters. 1807 1808 Parameters: 1809 path (str): The path where the CSV file will be saved. Default is "data.csv". 1810 count (int): The number of rows to generate in the CSV file. Default is 1000. 1811 with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. 1812 debug (bool): A flag indicating whether to print debug information. 1813 1814 Returns: 1815 None. The function generates a CSV file at the specified path with the given count of rows. 1816 Each row contains a randomly generated account, description, value, and date. 1817 The value is randomly generated between 1000 and 100000, 1818 and the date is randomly generated between 1950-01-01 and 2023-12-31. 1819 If the row number is not divisible by 13, the value is multiplied by -1. 1820 """ 1821 if debug: 1822 print('generate_random_csv_file', f'debug={debug}') 1823 i = 0 1824 with open(path, "w", newline="") as csvfile: 1825 writer = csv.writer(csvfile) 1826 for i in range(count): 1827 account = f"acc-{random.randint(1, 1000)}" 1828 desc = f"Some text {random.randint(1, 1000)}" 1829 value = random.randint(1000, 100000) 1830 date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), 1831 datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 1832 if not i % 13 == 0: 1833 value *= -1 1834 row = [account, desc, value, date] 1835 if with_rate: 1836 rate = random.randint(1, 100) * 0.12 1837 if debug: 1838 print('before-append', row) 1839 row.append(rate) 1840 if debug: 1841 print('after-append', row) 1842 writer.writerow(row) 1843 i = i + 1 1844 return i
Generate a random CSV file with specified parameters.
Parameters: path (str): The path where the CSV file will be saved. Default is "data.csv". count (int): The number of rows to generate in the CSV file. Default is 1000. with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. debug (bool): A flag indicating whether to print debug information.
Returns: None. 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.
1846 @staticmethod 1847 def create_random_list(max_sum, min_value=0, max_value=10): 1848 """ 1849 Creates a list of random integers whose sum does not exceed the specified maximum. 1850 1851 Args: 1852 max_sum: The maximum allowed sum of the list elements. 1853 min_value: The minimum possible value for an element (inclusive). 1854 max_value: The maximum possible value for an element (inclusive). 1855 1856 Returns: 1857 A list of random integers. 1858 """ 1859 result = [] 1860 current_sum = 0 1861 1862 while current_sum < max_sum: 1863 # Calculate the remaining space for the next element 1864 remaining_sum = max_sum - current_sum 1865 # Determine the maximum possible value for the next element 1866 next_max_value = min(remaining_sum, max_value) 1867 # Generate a random element within the allowed range 1868 next_element = random.randint(min_value, next_max_value) 1869 result.append(next_element) 1870 current_sum += next_element 1871 1872 return result
Creates a list of random integers whose sum does not exceed the specified maximum.
Args: max_sum: The maximum allowed sum of the list elements. min_value: The minimum possible value for an element (inclusive). max_value: The maximum possible value for an element (inclusive).
Returns: A list of random integers.
2055 def test(self, debug: bool = False) -> bool: 2056 if debug: 2057 print('test', f'debug={debug}') 2058 try: 2059 2060 assert self._history() 2061 2062 # Not allowed for duplicate transactions in the same account and time 2063 2064 created = ZakatTracker.time() 2065 self.track(100, 'test-1', 'same', True, created) 2066 failed = False 2067 try: 2068 self.track(50, 'test-1', 'same', True, created) 2069 except: 2070 failed = True 2071 assert failed is True 2072 2073 self.reset() 2074 2075 # Same account transfer 2076 for x in [1, 'a', True, 1.8, None]: 2077 failed = False 2078 try: 2079 self.transfer(1, x, x, 'same-account', debug=debug) 2080 except: 2081 failed = True 2082 assert failed is True 2083 2084 # Always preserve box age during transfer 2085 2086 series: list[tuple] = [ 2087 (30, 4), 2088 (60, 3), 2089 (90, 2), 2090 ] 2091 case = { 2092 30: { 2093 'series': series, 2094 'rest': 150, 2095 }, 2096 60: { 2097 'series': series, 2098 'rest': 120, 2099 }, 2100 90: { 2101 'series': series, 2102 'rest': 90, 2103 }, 2104 180: { 2105 'series': series, 2106 'rest': 0, 2107 }, 2108 270: { 2109 'series': series, 2110 'rest': -90, 2111 }, 2112 360: { 2113 'series': series, 2114 'rest': -180, 2115 }, 2116 } 2117 2118 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 2119 2120 for total in case: 2121 for x in case[total]['series']: 2122 self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1]) 2123 2124 refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug) 2125 2126 if debug: 2127 print('refs', refs) 2128 2129 ages_cache_balance = self.balance('ages') 2130 ages_fresh_balance = self.balance('ages', False) 2131 rest = case[total]['rest'] 2132 if debug: 2133 print('source', ages_cache_balance, ages_fresh_balance, rest) 2134 assert ages_cache_balance == rest 2135 assert ages_fresh_balance == rest 2136 2137 future_cache_balance = self.balance('future') 2138 future_fresh_balance = self.balance('future', False) 2139 if debug: 2140 print('target', future_cache_balance, future_fresh_balance, total) 2141 print('refs', refs) 2142 assert future_cache_balance == total 2143 assert future_fresh_balance == total 2144 2145 for ref in self._vault['account']['ages']['box']: 2146 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 2147 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 2148 future_capital = 0 2149 if ref in self._vault['account']['future']['box']: 2150 future_capital = self._vault['account']['future']['box'][ref]['capital'] 2151 future_rest = 0 2152 if ref in self._vault['account']['future']['box']: 2153 future_rest = self._vault['account']['future']['box'][ref]['rest'] 2154 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 2155 if debug: 2156 print('================================================================') 2157 print('ages', ages_capital, ages_rest) 2158 print('future', future_capital, future_rest) 2159 if ages_rest == 0: 2160 assert ages_capital == future_capital 2161 elif ages_rest < 0: 2162 assert -ages_capital == future_capital 2163 elif ages_rest > 0: 2164 assert ages_capital == ages_rest + future_capital 2165 self.reset() 2166 assert len(self._vault['history']) == 0 2167 2168 assert self._history() 2169 assert self._history(False) is False 2170 assert self._history() is False 2171 assert self._history(True) 2172 assert self._history() 2173 2174 self._test_core(True, debug) 2175 self._test_core(False, debug) 2176 2177 transaction = [ 2178 ( 2179 20, 'wallet', 1, 800, 800, 800, 4, 5, 2180 -85, -85, -85, 6, 7, 2181 ), 2182 ( 2183 750, 'wallet', 'safe', 50, 50, 50, 4, 6, 2184 750, 750, 750, 1, 1, 2185 ), 2186 ( 2187 600, 'safe', 'bank', 150, 150, 150, 1, 2, 2188 600, 600, 600, 1, 1, 2189 ), 2190 ] 2191 for z in transaction: 2192 self.lock() 2193 x = z[1] 2194 y = z[2] 2195 self.transfer(z[0], x, y, 'test-transfer', debug=debug) 2196 assert self.balance(x) == z[3] 2197 xx = self.accounts()[x] 2198 assert xx == z[3] 2199 assert self.balance(x, False) == z[4] 2200 assert xx == z[4] 2201 2202 s = 0 2203 log = self._vault['account'][x]['log'] 2204 for i in log: 2205 s += log[i]['value'] 2206 if debug: 2207 print('s', s, 'z[5]', z[5]) 2208 assert s == z[5] 2209 2210 assert self.box_size(x) == z[6] 2211 assert self.log_size(x) == z[7] 2212 2213 yy = self.accounts()[y] 2214 assert self.balance(y) == z[8] 2215 assert yy == z[8] 2216 assert self.balance(y, False) == z[9] 2217 assert yy == z[9] 2218 2219 s = 0 2220 log = self._vault['account'][y]['log'] 2221 for i in log: 2222 s += log[i]['value'] 2223 assert s == z[10] 2224 2225 assert self.box_size(y) == z[11] 2226 assert self.log_size(y) == z[12] 2227 2228 if debug: 2229 pp().pprint(self.check(2.17)) 2230 2231 assert not self.nolock() 2232 history_count = len(self._vault['history']) 2233 if debug: 2234 print('history-count', history_count) 2235 assert history_count == 11 2236 assert not self.free(ZakatTracker.time()) 2237 assert self.free(self.lock()) 2238 assert self.nolock() 2239 assert len(self._vault['history']) == 11 2240 2241 # storage 2242 2243 _path = self.path('test.pickle') 2244 if os.path.exists(_path): 2245 os.remove(_path) 2246 self.save() 2247 assert os.path.getsize(_path) > 0 2248 self.reset() 2249 assert self.recall(False, debug) is False 2250 self.load() 2251 assert self._vault['account'] is not None 2252 2253 # recall 2254 2255 assert self.nolock() 2256 assert len(self._vault['history']) == 11 2257 assert self.recall(False, debug) is True 2258 assert len(self._vault['history']) == 10 2259 assert self.recall(False, debug) is True 2260 assert len(self._vault['history']) == 9 2261 2262 # exchange 2263 2264 self.exchange("cash", 25, 3.75, "2024-06-25") 2265 self.exchange("cash", 22, 3.73, "2024-06-22") 2266 self.exchange("cash", 15, 3.69, "2024-06-15") 2267 self.exchange("cash", 10, 3.66) 2268 2269 for i in range(1, 30): 2270 exchange = self.exchange("cash", i) 2271 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2272 if debug: 2273 print(i, rate, description, created) 2274 assert created 2275 if i < 10: 2276 assert rate == 1 2277 assert description is None 2278 elif i == 10: 2279 assert rate == 3.66 2280 assert description is None 2281 elif i < 15: 2282 assert rate == 3.66 2283 assert description is None 2284 elif i == 15: 2285 assert rate == 3.69 2286 assert description is not None 2287 elif i < 22: 2288 assert rate == 3.69 2289 assert description is not None 2290 elif i == 22: 2291 assert rate == 3.73 2292 assert description is not None 2293 elif i >= 25: 2294 assert rate == 3.75 2295 assert description is not None 2296 exchange = self.exchange("bank", i) 2297 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2298 if debug: 2299 print(i, rate, description, created) 2300 assert created 2301 assert rate == 1 2302 assert description is None 2303 2304 assert len(self._vault['exchange']) > 0 2305 assert len(self.exchanges()) > 0 2306 self._vault['exchange'].clear() 2307 assert len(self._vault['exchange']) == 0 2308 assert len(self.exchanges()) == 0 2309 2310 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 2311 self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25") 2312 self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22") 2313 self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15") 2314 self.exchange("cash", ZakatTracker.day_to_time(10), 3.66) 2315 2316 for i in [x * 0.12 for x in range(-15, 21)]: 2317 if i <= 0: 2318 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0 2319 else: 2320 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0 2321 2322 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 2323 for i in range(1, 31): 2324 timestamp_ns = ZakatTracker.day_to_time(i) 2325 exchange = self.exchange("cash", timestamp_ns) 2326 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2327 if debug: 2328 print(i, rate, description, created) 2329 assert created 2330 if i < 10: 2331 assert rate == 1 2332 assert description is None 2333 elif i == 10: 2334 assert rate == 3.66 2335 assert description is None 2336 elif i < 15: 2337 assert rate == 3.66 2338 assert description is None 2339 elif i == 15: 2340 assert rate == 3.69 2341 assert description is not None 2342 elif i < 22: 2343 assert rate == 3.69 2344 assert description is not None 2345 elif i == 22: 2346 assert rate == 3.73 2347 assert description is not None 2348 elif i >= 25: 2349 assert rate == 3.75 2350 assert description is not None 2351 exchange = self.exchange("bank", i) 2352 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2353 if debug: 2354 print(i, rate, description, created) 2355 assert created 2356 assert rate == 1 2357 assert description is None 2358 2359 # csv 2360 2361 csv_count = 1000 2362 2363 for with_rate, path in { 2364 False: 'test-import_csv-no-exchange', 2365 True: 'test-import_csv-with-exchange', 2366 }.items(): 2367 2368 if debug: 2369 print('test_import_csv', with_rate, path) 2370 2371 # csv 2372 2373 csv_path = path + '.csv' 2374 if os.path.exists(csv_path): 2375 os.remove(csv_path) 2376 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 2377 if debug: 2378 print('generate_random_csv_file', c) 2379 assert c == csv_count 2380 assert os.path.getsize(csv_path) > 0 2381 cache_path = self.import_csv_cache_path() 2382 if os.path.exists(cache_path): 2383 os.remove(cache_path) 2384 self.reset() 2385 (created, found, bad) = self.import_csv(csv_path, debug) 2386 bad_count = len(bad) 2387 if debug: 2388 print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})") 2389 tmp_size = os.path.getsize(cache_path) 2390 assert tmp_size > 0 2391 assert created + found + bad_count == csv_count 2392 assert created == csv_count 2393 assert bad_count == 0 2394 (created_2, found_2, bad_2) = self.import_csv(csv_path) 2395 bad_2_count = len(bad_2) 2396 if debug: 2397 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 2398 print(bad) 2399 assert tmp_size == os.path.getsize(cache_path) 2400 assert created_2 + found_2 + bad_2_count == csv_count 2401 assert created == found_2 2402 assert bad_count == bad_2_count 2403 assert found_2 == csv_count 2404 assert bad_2_count == 0 2405 assert created_2 == 0 2406 2407 # payment parts 2408 2409 positive_parts = self.build_payment_parts(100, positive_only=True) 2410 assert self.check_payment_parts(positive_parts) != 0 2411 assert self.check_payment_parts(positive_parts) != 0 2412 all_parts = self.build_payment_parts(300, positive_only=False) 2413 assert self.check_payment_parts(all_parts) != 0 2414 assert self.check_payment_parts(all_parts) != 0 2415 if debug: 2416 pp().pprint(positive_parts) 2417 pp().pprint(all_parts) 2418 # dynamic discount 2419 suite = [] 2420 count = 3 2421 for exceed in [False, True]: 2422 case = [] 2423 for parts in [positive_parts, all_parts]: 2424 part = parts.copy() 2425 demand = part['demand'] 2426 if debug: 2427 print(demand, part['total']) 2428 i = 0 2429 z = demand / count 2430 cp = { 2431 'account': {}, 2432 'demand': demand, 2433 'exceed': exceed, 2434 'total': part['total'], 2435 } 2436 j = '' 2437 for x, y in part['account'].items(): 2438 x_exchange = self.exchange(x) 2439 zz = self.exchange_calc(z, 1, x_exchange['rate']) 2440 if exceed and zz <= demand: 2441 i += 1 2442 y['part'] = zz 2443 if debug: 2444 print(exceed, y) 2445 cp['account'][x] = y 2446 case.append(y) 2447 elif not exceed and y['balance'] >= zz: 2448 i += 1 2449 y['part'] = zz 2450 if debug: 2451 print(exceed, y) 2452 cp['account'][x] = y 2453 case.append(y) 2454 j = x 2455 if i >= count: 2456 break 2457 if len(cp['account'][j]) > 0: 2458 suite.append(cp) 2459 if debug: 2460 print('suite', len(suite)) 2461 # vault = self._vault.copy() 2462 for case in suite: 2463 # self._vault = vault.copy() 2464 if debug: 2465 print('case', case) 2466 result = self.check_payment_parts(case) 2467 if debug: 2468 print('check_payment_parts', result, f'exceed: {exceed}') 2469 assert result == 0 2470 2471 report = self.check(2.17, None, debug) 2472 (valid, brief, plan) = report 2473 if debug: 2474 print('valid', valid) 2475 zakat_result = self.zakat(report, parts=case, debug=debug) 2476 if debug: 2477 print('zakat-result', zakat_result) 2478 assert valid == zakat_result 2479 2480 assert self.save(path + '.pickle') 2481 assert self.export_json(path + '.json') 2482 2483 assert self.export_json("1000-transactions-test.json") 2484 assert self.save("1000-transactions-test.pickle") 2485 2486 self.reset() 2487 2488 # test transfer between accounts with different exchange rate 2489 2490 a_SAR = "Bank (SAR)" 2491 b_USD = "Bank (USD)" 2492 c_SAR = "Safe (SAR)" 2493 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 2494 for case in [ 2495 (0, a_SAR, "SAR Gift", 1000, 1000), 2496 (1, a_SAR, 1), 2497 (0, b_USD, "USD Gift", 500, 500), 2498 (1, b_USD, 1), 2499 (2, b_USD, 3.75), 2500 (1, b_USD, 3.75), 2501 (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375), 2502 (0, c_SAR, "Salary", 750, 750), 2503 (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500), 2504 (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501), 2505 ]: 2506 match (case[0]): 2507 case 0: # track 2508 _, account, desc, x, balance = case 2509 self.track(value=x, desc=desc, account=account, debug=debug) 2510 2511 cached_value = self.balance(account, cached=True) 2512 fresh_value = self.balance(account, cached=False) 2513 if debug: 2514 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 2515 assert cached_value == balance 2516 assert fresh_value == balance 2517 case 1: # check-exchange 2518 _, account, expected_rate = case 2519 t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2520 if debug: 2521 print('t-exchange', t_exchange) 2522 assert t_exchange['rate'] == expected_rate 2523 case 2: # do-exchange 2524 _, account, rate = case 2525 self.exchange(account, rate=rate, debug=debug) 2526 b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2527 if debug: 2528 print('b-exchange', b_exchange) 2529 assert b_exchange['rate'] == rate 2530 case 3: # transfer 2531 _, x, a, b, desc, a_balance, b_balance = case 2532 self.transfer(x, a, b, desc, debug=debug) 2533 2534 cached_value = self.balance(a, cached=True) 2535 fresh_value = self.balance(a, cached=False) 2536 if debug: 2537 print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value) 2538 assert cached_value == a_balance 2539 assert fresh_value == a_balance 2540 2541 cached_value = self.balance(b, cached=True) 2542 fresh_value = self.balance(b, cached=False) 2543 if debug: 2544 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 2545 assert cached_value == b_balance 2546 assert fresh_value == b_balance 2547 2548 # Transfer all in many chunks randomly from B to A 2549 a_SAR_balance = 1371.25 2550 b_USD_balance = 501 2551 b_USD_exchange = self.exchange(b_USD) 2552 amounts = ZakatTracker.create_random_list(b_USD_balance) 2553 if debug: 2554 print('amounts', amounts) 2555 i = 0 2556 for x in amounts: 2557 if debug: 2558 print(f'{i} - transfer-with-exchange({x})') 2559 self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug) 2560 2561 b_USD_balance -= x 2562 cached_value = self.balance(b_USD, cached=True) 2563 fresh_value = self.balance(b_USD, cached=False) 2564 if debug: 2565 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2566 b_USD_balance) 2567 assert cached_value == b_USD_balance 2568 assert fresh_value == b_USD_balance 2569 2570 a_SAR_balance += x * b_USD_exchange['rate'] 2571 cached_value = self.balance(a_SAR, cached=True) 2572 fresh_value = self.balance(a_SAR, cached=False) 2573 if debug: 2574 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2575 a_SAR_balance, 'rate', b_USD_exchange['rate']) 2576 assert cached_value == a_SAR_balance 2577 assert fresh_value == a_SAR_balance 2578 i += 1 2579 2580 # Transfer all in many chunks randomly from C to A 2581 c_SAR_balance = 375 2582 amounts = ZakatTracker.create_random_list(c_SAR_balance) 2583 if debug: 2584 print('amounts', amounts) 2585 i = 0 2586 for x in amounts: 2587 if debug: 2588 print(f'{i} - transfer-with-exchange({x})') 2589 self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug) 2590 2591 c_SAR_balance -= x 2592 cached_value = self.balance(c_SAR, cached=True) 2593 fresh_value = self.balance(c_SAR, cached=False) 2594 if debug: 2595 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2596 c_SAR_balance) 2597 assert cached_value == c_SAR_balance 2598 assert fresh_value == c_SAR_balance 2599 2600 a_SAR_balance += x 2601 cached_value = self.balance(a_SAR, cached=True) 2602 fresh_value = self.balance(a_SAR, cached=False) 2603 if debug: 2604 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2605 a_SAR_balance) 2606 assert cached_value == a_SAR_balance 2607 assert fresh_value == a_SAR_balance 2608 i += 1 2609 2610 assert self.export_json("accounts-transfer-with-exchange-rates.json") 2611 assert self.save("accounts-transfer-with-exchange-rates.pickle") 2612 2613 # check & zakat with exchange rates for many cycles 2614 2615 for rate, values in { 2616 1: { 2617 'in': [1000, 2000, 10000], 2618 'exchanged': [1000, 2000, 10000], 2619 'out': [25, 50, 731.40625], 2620 }, 2621 3.75: { 2622 'in': [200, 1000, 5000], 2623 'exchanged': [750, 3750, 18750], 2624 'out': [18.75, 93.75, 1371.38671875], 2625 }, 2626 }.items(): 2627 a, b, c = values['in'] 2628 m, n, o = values['exchanged'] 2629 x, y, z = values['out'] 2630 if debug: 2631 print('rate', rate, 'values', values) 2632 for case in [ 2633 (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2634 {'safe': {0: {'below_nisab': x}}}, 2635 ], False, m), 2636 (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2637 {'safe': {0: {'count': 1, 'total': y}}}, 2638 ], True, n), 2639 (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [ 2640 {'cave': {0: {'count': 3, 'total': z}}}, 2641 ], True, o), 2642 ]: 2643 if debug: 2644 print(f"############# check(rate: {rate}) #############") 2645 self.reset() 2646 self.exchange(account=case[1], created=case[2], rate=rate) 2647 self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2]) 2648 2649 # assert self.nolock() 2650 # history_size = len(self._vault['history']) 2651 # print('history_size', history_size) 2652 # assert history_size == 2 2653 assert self.lock() 2654 assert not self.nolock() 2655 report = self.check(2.17, None, debug) 2656 (valid, brief, plan) = report 2657 assert valid == case[4] 2658 if debug: 2659 print('brief', brief) 2660 assert case[5] == brief[0] 2661 assert case[5] == brief[1] 2662 2663 if debug: 2664 pp().pprint(plan) 2665 2666 for x in plan: 2667 assert case[1] == x 2668 if 'total' in case[3][0][x][0].keys(): 2669 assert case[3][0][x][0]['total'] == brief[2] 2670 assert plan[x][0]['total'] == case[3][0][x][0]['total'] 2671 assert plan[x][0]['count'] == case[3][0][x][0]['count'] 2672 else: 2673 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 2674 if debug: 2675 pp().pprint(report) 2676 result = self.zakat(report, debug=debug) 2677 if debug: 2678 print('zakat-result', result, case[4]) 2679 assert result == case[4] 2680 report = self.check(2.17, None, debug) 2681 (valid, brief, plan) = report 2682 assert valid is False 2683 2684 history_size = len(self._vault['history']) 2685 if debug: 2686 print('history_size', history_size) 2687 assert history_size == 3 2688 assert not self.nolock() 2689 assert self.recall(False, debug) is False 2690 self.free(self.lock()) 2691 assert self.nolock() 2692 2693 for i in range(3, 0, -1): 2694 history_size = len(self._vault['history']) 2695 if debug: 2696 print('history_size', history_size) 2697 assert history_size == i 2698 assert self.recall(False, debug) is True 2699 2700 assert self.nolock() 2701 assert self.recall(False, debug) is False 2702 2703 history_size = len(self._vault['history']) 2704 if debug: 2705 print('history_size', history_size) 2706 assert history_size == 0 2707 2708 account_size = len(self._vault['account']) 2709 if debug: 2710 print('account_size', account_size) 2711 assert account_size == 0 2712 2713 report_size = len(self._vault['report']) 2714 if debug: 2715 print('report_size', report_size) 2716 assert report_size == 0 2717 2718 assert self.nolock() 2719 return True 2720 except: 2721 # pp().pprint(self._vault) 2722 assert self.export_json("test-snapshot.json") 2723 assert self.save("test-snapshot.pickle") 2724 raise
2727def test(debug: bool = False): 2728 ledger = ZakatTracker() 2729 start = ZakatTracker.time() 2730 assert ledger.test(debug=debug) 2731 if debug: 2732 print("#########################") 2733 print("######## TEST DONE ########") 2734 print("#########################") 2735 print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start)) 2736 print("#########################")
73class Action(Enum): 74 CREATE = auto() 75 TRACK = auto() 76 LOG = auto() 77 SUB = auto() 78 ADD_FILE = auto() 79 REMOVE_FILE = auto() 80 BOX_TRANSFER = auto() 81 EXCHANGE = auto() 82 REPORT = auto() 83 ZAKAT = auto()
86class JSONEncoder(json.JSONEncoder): 87 def default(self, obj): 88 if isinstance(obj, Action) or isinstance(obj, MathOperation): 89 return obj.name # Serialize as the enum member's name 90 elif isinstance(obj, Decimal): 91 return float(obj) 92 return super().default(obj)
Extensible JSON https://json.org encoder for Python data structures.
Supports the following objects and types by default:
+-------------------+---------------+ | Python | JSON | +===================+===============+ | dict | object | +-------------------+---------------+ | list, tuple | array | +-------------------+---------------+ | str | string | +-------------------+---------------+ | int, float | number | +-------------------+---------------+ | True | true | +-------------------+---------------+ | False | false | +-------------------+---------------+ | None | null | +-------------------+---------------+
To extend this to recognize other objects, subclass and implement a
.default()
method with another method that returns a serializable
object for o
if possible, otherwise it should call the superclass
implementation (to raise TypeError
).
87 def default(self, obj): 88 if isinstance(obj, Action) or isinstance(obj, MathOperation): 89 return obj.name # Serialize as the enum member's name 90 elif isinstance(obj, Decimal): 91 return float(obj) 92 return super().default(obj)
Implement this method in a subclass such that it returns
a serializable object for o
, or calls the base implementation
(to raise a TypeError
).
For example, to support arbitrary iterators, you could implement default like this::
def default(self, o):
try:
iterable = iter(o)
except TypeError:
pass
else:
return list(iterable)
# Let the base class default method raise the TypeError
return super().default(o)
55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None, 56 debug: bool = False) -> tuple: 57 """ 58 Starts a multi-purpose HTTP server to manage file interactions for a Zakat application. 59 60 This server facilitates the following functionalities: 61 62 1. GET /{file_uuid}/get: Download the database file specified by `database_path`. 63 2. GET /{file_uuid}/upload: Display an HTML form for uploading files. 64 3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between: 65 - Database File (.db): Replaces the existing database with the uploaded one. 66 - CSV File (.csv): Imports data from the CSV into the existing database. 67 68 Args: 69 database_path (str): The path to the pickle database file. 70 database_callback (callable, optional): A function to call after a successful database upload. 71 It receives the uploaded database path as its argument. 72 csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path, 73 the database path, and the debug flag as its arguments. 74 debug (bool, optional): If True, print debugging information. Defaults to False. 75 76 Returns: 77 Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing: 78 - file_name (str): The name of the database file. 79 - download_url (str): The URL to download the database file. 80 - upload_url (str): The URL to access the file upload form. 81 - server_thread (threading.Thread): The thread running the server. 82 - shutdown_server (Callable[[], None]): A function to gracefully shut down the server. 83 84 Example: 85 _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db") 86 print(f"Download database: {download_url}") 87 print(f"Upload files: {upload_url}") 88 server_thread.start() 89 # ... later ... 90 shutdown_server() 91 """ 92 file_uuid = uuid.uuid4() 93 file_name = os.path.basename(database_path) 94 95 port = find_available_port() 96 download_url = f"http://localhost:{port}/{file_uuid}/get" 97 upload_url = f"http://localhost:{port}/{file_uuid}/upload" 98 99 class Handler(http.server.SimpleHTTPRequestHandler): 100 def do_GET(self): 101 if self.path == f"/{file_uuid}/get": 102 # GET: Serve the existing file 103 try: 104 with open(database_path, "rb") as f: 105 self.send_response(200) 106 self.send_header("Content-type", "application/octet-stream") 107 self.send_header("Content-Disposition", f'attachment; filename="{file_name}"') 108 self.end_headers() 109 self.wfile.write(f.read()) 110 except FileNotFoundError: 111 self.send_error(404, "File not found") 112 elif self.path == f"/{file_uuid}/upload": 113 # GET: Serve the upload form 114 self.send_response(200) 115 self.send_header("Content-type", "text/html") 116 self.end_headers() 117 self.wfile.write(f""" 118 <html lang="en"> 119 <head> 120 <title>Zakat File Server</title> 121 </head> 122 <body> 123 <h1>Zakat File Server</h1> 124 <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3> 125 <h3>Or upload a new file to restore a database or import `CSV` file:</h3> 126 <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data"> 127 <input type="file" name="file" required><br/> 128 <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required> 129 <label for="database">Database File</label><br/> 130 <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}"> 131 <label for="csv">CSV File</label><br/> 132 <input type="submit" value="Upload"><br/> 133 </form> 134 </body></html> 135 """.encode()) 136 else: 137 self.send_error(404) 138 139 def do_POST(self): 140 if self.path == f"/{file_uuid}/upload": 141 # POST: Handle request 142 # 1. Get the Form Data 143 form_data = cgi.FieldStorage( 144 fp=self.rfile, 145 headers=self.headers, 146 environ={'REQUEST_METHOD': 'POST'} 147 ) 148 upload_type = form_data.getvalue("upload_type") 149 150 if debug: 151 print('upload_type', upload_type) 152 153 if upload_type not in [FileType.Database.value, FileType.CSV.value]: 154 self.send_error(400, "Invalid upload type") 155 return 156 157 # 2. Extract File Data 158 file_item = form_data['file'] # Assuming 'file' is your file input name 159 160 # 3. Get File Details 161 filename = file_item.filename 162 file_data = file_item.file.read() # Read the file's content 163 164 if debug: 165 print(f'Uploaded filename: {filename}') 166 167 # 4. Define Storage Path for CSV 168 upload_directory = "./uploads" # Create this directory if it doesn't exist 169 os.makedirs(upload_directory, exist_ok=True) 170 file_path = os.path.join(upload_directory, upload_type) 171 172 # 5. Write to Disk 173 with open(file_path, 'wb') as f: 174 f.write(file_data) 175 176 match upload_type: 177 case FileType.Database.value: 178 179 try: 180 # 6. Verify database file 181 # ZakatTracker(db_path=file_path) # FATAL, Circular Imports Error 182 if database_callback is not None: 183 database_callback(file_path) 184 185 # 7. Copy database into the original path 186 shutil.copy2(file_path, database_path) 187 except Exception as e: 188 self.send_error(400, str(e)) 189 return 190 191 case FileType.CSV.value: 192 # 6. Verify CSV file 193 try: 194 # x = ZakatTracker(db_path=database_path) # FATAL, Circular Imports Error 195 # result = x.import_csv(file_path, debug=debug) 196 if csv_callback is not None: 197 result = csv_callback(file_path, database_path, debug) 198 if debug: 199 print(f'CSV imported: {result}') 200 if len(result[2]) != 0: 201 self.send_response(200) 202 self.end_headers() 203 self.wfile.write(json.dumps(result).encode()) 204 return 205 except Exception as e: 206 self.send_error(400, str(e)) 207 return 208 209 self.send_response(200) 210 self.end_headers() 211 self.wfile.write(b"File uploaded successfully.") 212 213 httpd = socketserver.TCPServer(("localhost", port), Handler) 214 server_thread = threading.Thread(target=httpd.serve_forever) 215 216 def shutdown_server(): 217 nonlocal httpd, server_thread 218 httpd.shutdown() 219 httpd.server_close() # Close the socket 220 server_thread.join() # Wait for the thread to finish 221 222 return file_name, download_url, upload_url, server_thread, shutdown_server
Starts a multi-purpose HTTP server to manage file interactions for a Zakat application.
This server facilitates the following functionalities:
- GET /{file_uuid}/get: Download the database file specified by
database_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.
Args: database_path (str): The path to the pickle 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()
32def find_available_port() -> int: 33 """ 34 Finds and returns an available TCP port on the local machine. 35 36 This function utilizes a TCP server socket to bind to port 0, which 37 instructs the operating system to automatically assign an available 38 port. The assigned port is then extracted and returned. 39 40 Returns: 41 int: The available TCP port number. 42 43 Raises: 44 OSError: If an error occurs during the port binding process, such 45 as all ports being in use. 46 47 Example: 48 port = find_available_port() 49 print(f"Available port: {port}") 50 """ 51 with socketserver.TCPServer(("localhost", 0), None) as s: 52 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}")