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