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