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