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