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