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