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.90' 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 ref (int): The reference of the object. 1061 debug (bool): Whether to print debug information. Default is False. 1062 1063 Returns: 1064 int: The timestamp of the logged transaction. 1065 1066 This method updates the account's balance, count, and log with the transaction details. 1067 It also creates a step in the history of the transaction. 1068 1069 Raises: 1070 ValueError: The log transaction happened again in the same nanosecond time. 1071 """ 1072 if debug: 1073 print('_log', f'debug={debug}') 1074 if created is None: 1075 created = self.time() 1076 try: 1077 self._vault['account'][account]['balance'] += value 1078 except TypeError: 1079 self._vault['account'][account]['balance'] += Decimal(value) 1080 self._vault['account'][account]['count'] += 1 1081 if debug: 1082 print('create-log', created) 1083 if self.log_exists(account, created): 1084 raise ValueError(f"The log transaction happened again in the same nanosecond time({created}).") 1085 if debug: 1086 print('created-log', created) 1087 self._vault['account'][account]['log'][created] = { 1088 'value': value, 1089 'desc': desc, 1090 'ref': ref, 1091 'file': {}, 1092 } 1093 self._step(Action.LOG, account, ref=created, value=value) 1094 return created 1095 1096 def exchange(self, account, created: int = None, rate: float = None, description: str = None, 1097 debug: bool = False) -> dict: 1098 """ 1099 This method is used to record or retrieve exchange rates for a specific account. 1100 1101 Parameters: 1102 - account (str): The account number for which the exchange rate is being recorded or retrieved. 1103 - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 1104 - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 1105 - description (str): A description of the exchange rate. 1106 - debug (bool): Whether to print debug information. Default is False. 1107 1108 Returns: 1109 - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 1110 it returns a dictionary with default values for the rate and description. 1111 """ 1112 if debug: 1113 print('exchange', f'debug={debug}') 1114 if created is None: 1115 created = self.time() 1116 no_lock = self.nolock() 1117 self.lock() 1118 if rate is not None: 1119 if rate <= 0: 1120 return dict() 1121 if account not in self._vault['exchange']: 1122 self._vault['exchange'][account] = {} 1123 if len(self._vault['exchange'][account]) == 0 and rate <= 1: 1124 return {"time": created, "rate": 1, "description": None} 1125 self._vault['exchange'][account][created] = {"rate": rate, "description": description} 1126 self._step(Action.EXCHANGE, account, ref=created, value=rate) 1127 if no_lock: 1128 self.free(self.lock()) 1129 if debug: 1130 print("exchange-created-1", 1131 f'account: {account}, created: {created}, rate:{rate}, description:{description}') 1132 1133 if account in self._vault['exchange']: 1134 valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created] 1135 if valid_rates: 1136 latest_rate = max(valid_rates, key=lambda x: x[0]) 1137 if debug: 1138 print("exchange-read-1", 1139 f'account: {account}, created: {created}, rate:{rate}, description:{description}', 1140 'latest_rate', latest_rate) 1141 result = latest_rate[1] 1142 result['time'] = latest_rate[0] 1143 return result # إرجاع قاموس يحتوي على المعدل والوصف 1144 if debug: 1145 print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}') 1146 return {"time": created, "rate": 1, "description": None} # إرجاع القيمة الافتراضية مع وصف فارغ 1147 1148 @staticmethod 1149 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 1150 """ 1151 This function calculates the exchanged amount of a currency. 1152 1153 Args: 1154 x (float): The original amount of the currency. 1155 x_rate (float): The exchange rate of the original currency. 1156 y_rate (float): The exchange rate of the target currency. 1157 1158 Returns: 1159 float: The exchanged amount of the target currency. 1160 """ 1161 return (x * x_rate) / y_rate 1162 1163 def exchanges(self) -> dict: 1164 """ 1165 Retrieve the recorded exchange rates for all accounts. 1166 1167 Parameters: 1168 None 1169 1170 Returns: 1171 dict: A dictionary containing all recorded exchange rates. 1172 The keys are account names or numbers, and the values are dictionaries containing the exchange rates. 1173 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 1174 """ 1175 return self._vault['exchange'].copy() 1176 1177 def accounts(self) -> dict: 1178 """ 1179 Returns a dictionary containing account numbers as keys and their respective balances as values. 1180 1181 Parameters: 1182 None 1183 1184 Returns: 1185 dict: A dictionary where keys are account numbers and values are their respective balances. 1186 """ 1187 result = {} 1188 for i in self._vault['account']: 1189 result[i] = self._vault['account'][i]['balance'] 1190 return result 1191 1192 def boxes(self, account) -> dict: 1193 """ 1194 Retrieve the boxes (transactions) associated with a specific account. 1195 1196 Parameters: 1197 account (str): The account number for which to retrieve the boxes. 1198 1199 Returns: 1200 dict: A dictionary containing the boxes associated with the given account. 1201 If the account does not exist, an empty dictionary is returned. 1202 """ 1203 if self.account_exists(account): 1204 return self._vault['account'][account]['box'] 1205 return {} 1206 1207 def logs(self, account) -> dict: 1208 """ 1209 Retrieve the logs (transactions) associated with a specific account. 1210 1211 Parameters: 1212 account (str): The account number for which to retrieve the logs. 1213 1214 Returns: 1215 dict: A dictionary containing the logs associated with the given account. 1216 If the account does not exist, an empty dictionary is returned. 1217 """ 1218 if self.account_exists(account): 1219 return self._vault['account'][account]['log'] 1220 return {} 1221 1222 def daily_logs(self, debug: bool = False): 1223 """ 1224 Retrieve the daily logs (transactions) from all accounts. 1225 1226 The function groups the logs by day, month, and year, and calculates the total value for each group. 1227 It returns a dictionary where the keys are the timestamps of the daily groups, 1228 and the values are dictionaries containing the total value and the logs for that group. 1229 1230 Parameters: 1231 debug (bool): Whether to print debug information. Default is False. 1232 1233 Returns: 1234 dict: A dictionary containing the daily logs. 1235 1236 Example: 1237 >>> tracker = ZakatTracker() 1238 >>> tracker.track(51, 'desc', 'account1') 1239 >>> tracker.track(100, 'desc', 'account2') 1240 >>> tracker.daily_logs() 1241 { 1242 1632057600: { 1243 'total': 151, 1244 'transfer': False, 1245 'rows': [ 1246 {'value': 51, 'account': 'account1', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'}, 1247 {'value': 100, 'account': 'account2', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'} 1248 ] 1249 } 1250 } 1251 """ 1252 logs = {} 1253 for account in self.accounts(): 1254 for k, v in self.logs(account).items(): 1255 v['time'] = k 1256 v['account'] = account 1257 if k not in logs: 1258 logs[k] = [] 1259 logs[k].append(v) 1260 if debug: 1261 print('logs', logs) 1262 y = {} 1263 for i in sorted(logs, reverse=True): 1264 dt = self.time_to_datetime(i) 1265 group = self.day_to_time(dt.day, dt.month, dt.year) 1266 if group not in y: 1267 y[group] = { 1268 'total': 0, 1269 'rows': [], 1270 } 1271 y[group]['transfer'] = len(logs[i]) > 1 1272 if debug: 1273 print('logs[i]', logs[i]) 1274 for z in logs[i]: 1275 print('z', z) 1276 y[group]['total'] += z['value'] 1277 y[group]['rows'].append(z) 1278 if debug: 1279 print('y', y) 1280 return y 1281 1282 def add_file(self, account: str, ref: int, path: str) -> int: 1283 """ 1284 Adds a file reference to a specific transaction log entry in the vault. 1285 1286 Parameters: 1287 account (str): The account number associated with the transaction log. 1288 ref (int): The reference to the transaction log entry. 1289 path (str): The path of the file to be added. 1290 1291 Returns: 1292 int: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 1293 """ 1294 if self.account_exists(account): 1295 if ref in self._vault['account'][account]['log']: 1296 file_ref = self.time() 1297 self._vault['account'][account]['log'][ref]['file'][file_ref] = path 1298 no_lock = self.nolock() 1299 self.lock() 1300 self._step(Action.ADD_FILE, account, ref=ref, file=file_ref) 1301 if no_lock: 1302 self.free(self.lock()) 1303 return file_ref 1304 return 0 1305 1306 def remove_file(self, account: str, ref: int, file_ref: int) -> bool: 1307 """ 1308 Removes a file reference from a specific transaction log entry in the vault. 1309 1310 Parameters: 1311 account (str): The account number associated with the transaction log. 1312 ref (int): The reference to the transaction log entry. 1313 file_ref (int): The reference of the file to be removed. 1314 1315 Returns: 1316 bool: True if the file reference is successfully removed, False otherwise. 1317 """ 1318 if self.account_exists(account): 1319 if ref in self._vault['account'][account]['log']: 1320 if file_ref in self._vault['account'][account]['log'][ref]['file']: 1321 x = self._vault['account'][account]['log'][ref]['file'][file_ref] 1322 del self._vault['account'][account]['log'][ref]['file'][file_ref] 1323 no_lock = self.nolock() 1324 self.lock() 1325 self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 1326 if no_lock: 1327 self.free(self.lock()) 1328 return True 1329 return False 1330 1331 def balance(self, account: str = 1, cached: bool = True) -> int: 1332 """ 1333 Calculate and return the balance of a specific account. 1334 1335 Parameters: 1336 account (str): The account number. Default is '1'. 1337 cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 1338 1339 Returns: 1340 int: The balance of the account. 1341 1342 Note: 1343 If cached is True, the function returns the cached balance. 1344 If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 1345 """ 1346 if cached: 1347 return self._vault['account'][account]['balance'] 1348 x = 0 1349 return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1] 1350 1351 def hide(self, account, status: bool = None) -> bool: 1352 """ 1353 Check or set the hide status of a specific account. 1354 1355 Parameters: 1356 account (str): The account number. 1357 status (bool, optional): The new hide status. If not provided, the function will return the current status. 1358 1359 Returns: 1360 bool: The current or updated hide status of the account. 1361 1362 Raises: 1363 None 1364 1365 Example: 1366 >>> tracker = ZakatTracker() 1367 >>> ref = tracker.track(51, 'desc', 'account1') 1368 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 1369 False 1370 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 1371 True 1372 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 1373 True 1374 >>> tracker.hide('account1', False) 1375 False 1376 """ 1377 if self.account_exists(account): 1378 if status is None: 1379 return self._vault['account'][account]['hide'] 1380 self._vault['account'][account]['hide'] = status 1381 return status 1382 return False 1383 1384 def zakatable(self, account, status: bool = None) -> bool: 1385 """ 1386 Check or set the zakatable status of a specific account. 1387 1388 Parameters: 1389 account (str): The account number. 1390 status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 1391 1392 Returns: 1393 bool: The current or updated zakatable status of the account. 1394 1395 Raises: 1396 None 1397 1398 Example: 1399 >>> tracker = ZakatTracker() 1400 >>> ref = tracker.track(51, 'desc', 'account1') 1401 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 1402 True 1403 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 1404 True 1405 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 1406 True 1407 >>> tracker.zakatable('account1', False) 1408 False 1409 """ 1410 if self.account_exists(account): 1411 if status is None: 1412 return self._vault['account'][account]['zakatable'] 1413 self._vault['account'][account]['zakatable'] = status 1414 return status 1415 return False 1416 1417 def sub(self, unscaled_value: float | int | Decimal, desc: str = '', account: str = 1, created: int = None, 1418 debug: bool = False) \ 1419 -> tuple[ 1420 int, 1421 list[ 1422 tuple[int, int], 1423 ], 1424 ] | tuple: 1425 """ 1426 Subtracts a specified value from an account's balance. 1427 1428 Parameters: 1429 unscaled_value (float | int | Decimal): The amount to be subtracted. 1430 desc (str): A description for the transaction. Defaults to an empty string. 1431 account (str): The account from which the value will be subtracted. Defaults to '1'. 1432 created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. 1433 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1434 1435 Returns: 1436 tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 1437 1438 If the amount to subtract is greater than the account's balance, 1439 the remaining amount will be transferred to a new transaction with a negative value. 1440 1441 Raises: 1442 ValueError: The box transaction happened again in the same nanosecond time. 1443 ValueError: The log transaction happened again in the same nanosecond time. 1444 """ 1445 if debug: 1446 print('sub', f'debug={debug}') 1447 if unscaled_value < 0: 1448 return tuple() 1449 if unscaled_value == 0: 1450 ref = self.track(unscaled_value, '', account) 1451 return ref, ref 1452 if created is None: 1453 created = self.time() 1454 no_lock = self.nolock() 1455 self.lock() 1456 self.track(0, '', account) 1457 value = self.scale(unscaled_value) 1458 self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug) 1459 ids = sorted(self._vault['account'][account]['box'].keys()) 1460 limit = len(ids) + 1 1461 target = value 1462 if debug: 1463 print('ids', ids) 1464 ages = [] 1465 for i in range(-1, -limit, -1): 1466 if target == 0: 1467 break 1468 j = ids[i] 1469 if debug: 1470 print('i', i, 'j', j) 1471 rest = self._vault['account'][account]['box'][j]['rest'] 1472 if rest >= target: 1473 self._vault['account'][account]['box'][j]['rest'] -= target 1474 self._step(Action.SUB, account, ref=j, value=target) 1475 ages.append((j, target)) 1476 target = 0 1477 break 1478 elif target > rest > 0: 1479 chunk = rest 1480 target -= chunk 1481 self._step(Action.SUB, account, ref=j, value=chunk) 1482 ages.append((j, chunk)) 1483 self._vault['account'][account]['box'][j]['rest'] = 0 1484 if target > 0: 1485 self.track( 1486 unscaled_value=self.unscale(-target), 1487 desc=desc, 1488 account=account, 1489 logging=False, 1490 created=created, 1491 ) 1492 ages.append((created, target)) 1493 if no_lock: 1494 self.free(self.lock()) 1495 return created, ages 1496 1497 def transfer(self, unscaled_amount: float | int | Decimal, from_account: str, to_account: str, desc: str = '', 1498 created: int = None, 1499 debug: bool = False) -> list[int]: 1500 """ 1501 Transfers a specified value from one account to another. 1502 1503 Parameters: 1504 unscaled_amount (float | int | Decimal): The amount to be transferred. 1505 from_account (str): The account from which the value will be transferred. 1506 to_account (str): The account to which the value will be transferred. 1507 desc (str, optional): A description for the transaction. Defaults to an empty string. 1508 created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. 1509 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1510 1511 Returns: 1512 list[int]: A list of timestamps corresponding to the transactions made during the transfer. 1513 1514 Raises: 1515 ValueError: Transfer to the same account is forbidden. 1516 ValueError: The box transaction happened again in the same nanosecond time. 1517 ValueError: The log transaction happened again in the same nanosecond time. 1518 """ 1519 if debug: 1520 print('transfer', f'debug={debug}') 1521 if from_account == to_account: 1522 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 1523 if unscaled_amount <= 0: 1524 return [] 1525 if created is None: 1526 created = self.time() 1527 (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug) 1528 times = [] 1529 source_exchange = self.exchange(from_account, created) 1530 target_exchange = self.exchange(to_account, created) 1531 1532 if debug: 1533 print('ages', ages) 1534 1535 for age, value in ages: 1536 target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])) 1537 if debug: 1538 print('target_amount', target_amount) 1539 # Perform the transfer 1540 if self.box_exists(to_account, age): 1541 if debug: 1542 print('box_exists', age) 1543 capital = self._vault['account'][to_account]['box'][age]['capital'] 1544 rest = self._vault['account'][to_account]['box'][age]['rest'] 1545 if debug: 1546 print( 1547 f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1548 selected_age = age 1549 if rest + target_amount > capital: 1550 self._vault['account'][to_account]['box'][age]['capital'] += target_amount 1551 selected_age = ZakatTracker.time() 1552 self._vault['account'][to_account]['box'][age]['rest'] += target_amount 1553 self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 1554 y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 1555 created=None, ref=None, debug=debug) 1556 times.append((age, y)) 1557 continue 1558 if debug: 1559 print( 1560 f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1561 y = self.track( 1562 unscaled_value=self.unscale(int(target_amount)), 1563 desc=desc, 1564 account=to_account, 1565 logging=True, 1566 created=age, 1567 debug=debug, 1568 ) 1569 times.append(y) 1570 return times 1571 1572 def check(self, silver_gram_price: float, unscaled_nisab: float | int | Decimal = None, debug: bool = False, now: int = None, 1573 cycle: float = None) -> tuple: 1574 """ 1575 Check the eligibility for Zakat based on the given parameters. 1576 1577 Parameters: 1578 silver_gram_price (float): The price of a gram of silver. 1579 unscaled_nisab (float | int | Decimal): The minimum amount of wealth required for Zakat. If not provided, 1580 it will be calculated based on the silver_gram_price. 1581 debug (bool): Flag to enable debug mode. 1582 now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). 1583 cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 1584 1585 Returns: 1586 tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, 1587 and a dictionary containing the Zakat plan. 1588 """ 1589 if debug: 1590 print('check', f'debug={debug}') 1591 if now is None: 1592 now = self.time() 1593 if cycle is None: 1594 cycle = ZakatTracker.TimeCycle() 1595 if unscaled_nisab is None: 1596 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 1597 nisab = self.scale(unscaled_nisab) 1598 plan = {} 1599 below_nisab = 0 1600 brief = [0, 0, 0] 1601 valid = False 1602 if debug: 1603 print('exchanges', self.exchanges()) 1604 for x in self._vault['account']: 1605 if not self.zakatable(x): 1606 continue 1607 _box = self._vault['account'][x]['box'] 1608 _log = self._vault['account'][x]['log'] 1609 limit = len(_box) + 1 1610 ids = sorted(self._vault['account'][x]['box'].keys()) 1611 for i in range(-1, -limit, -1): 1612 j = ids[i] 1613 rest = float(_box[j]['rest']) 1614 if rest <= 0: 1615 continue 1616 exchange = self.exchange(x, created=self.time()) 1617 rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1) 1618 brief[0] += rest 1619 index = limit + i - 1 1620 epoch = (now - j) / cycle 1621 if debug: 1622 print(f"Epoch: {epoch}", _box[j]) 1623 if _box[j]['last'] > 0: 1624 epoch = (now - _box[j]['last']) / cycle 1625 if debug: 1626 print(f"Epoch: {epoch}") 1627 epoch = floor(epoch) 1628 if debug: 1629 print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch) 1630 if epoch == 0: 1631 continue 1632 if debug: 1633 print("Epoch - PASSED") 1634 brief[1] += rest 1635 if rest >= nisab: 1636 total = 0 1637 for _ in range(epoch): 1638 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 1639 if total > 0: 1640 if x not in plan: 1641 plan[x] = {} 1642 valid = True 1643 brief[2] += total 1644 plan[x][index] = { 1645 'total': total, 1646 'count': epoch, 1647 'box_time': j, 1648 'box_capital': _box[j]['capital'], 1649 'box_rest': _box[j]['rest'], 1650 'box_last': _box[j]['last'], 1651 'box_total': _box[j]['total'], 1652 'box_count': _box[j]['count'], 1653 'box_log': _log[j]['desc'], 1654 'exchange_rate': exchange['rate'], 1655 'exchange_time': exchange['time'], 1656 'exchange_desc': exchange['description'], 1657 } 1658 else: 1659 chunk = ZakatTracker.ZakatCut(float(rest)) 1660 if chunk > 0: 1661 if x not in plan: 1662 plan[x] = {} 1663 if j not in plan[x].keys(): 1664 plan[x][index] = {} 1665 below_nisab += rest 1666 brief[2] += chunk 1667 plan[x][index]['below_nisab'] = chunk 1668 plan[x][index]['total'] = chunk 1669 plan[x][index]['count'] = epoch 1670 plan[x][index]['box_time'] = j 1671 plan[x][index]['box_capital'] = _box[j]['capital'] 1672 plan[x][index]['box_rest'] = _box[j]['rest'] 1673 plan[x][index]['box_last'] = _box[j]['last'] 1674 plan[x][index]['box_total'] = _box[j]['total'] 1675 plan[x][index]['box_count'] = _box[j]['count'] 1676 plan[x][index]['box_log'] = _log[j]['desc'] 1677 plan[x][index]['exchange_rate'] = exchange['rate'] 1678 plan[x][index]['exchange_time'] = exchange['time'] 1679 plan[x][index]['exchange_desc'] = exchange['description'] 1680 valid = valid or below_nisab >= nisab 1681 if debug: 1682 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 1683 return valid, brief, plan 1684 1685 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict: 1686 """ 1687 Build payment parts for the Zakat distribution. 1688 1689 Parameters: 1690 scaled_demand (int): The total demand for payment in local currency. 1691 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 1692 1693 Returns: 1694 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 1695 { 1696 'account': { 1697 'account_id': {'balance': float, 'rate': float, 'part': float}, 1698 ... 1699 }, 1700 'exceed': bool, 1701 'demand': int, 1702 'total': float, 1703 } 1704 """ 1705 total = 0 1706 parts = { 1707 'account': {}, 1708 'exceed': False, 1709 'demand': int(round(scaled_demand)), 1710 } 1711 for x, y in self.accounts().items(): 1712 if positive_only and y <= 0: 1713 continue 1714 total += float(y) 1715 exchange = self.exchange(x) 1716 parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0} 1717 parts['total'] = total 1718 return parts 1719 1720 @staticmethod 1721 def check_payment_parts(parts: dict, debug: bool = False) -> int: 1722 """ 1723 Checks the validity of payment parts. 1724 1725 Parameters: 1726 parts (dict): A dictionary containing payment parts information. 1727 debug (bool): Flag to enable debug mode. 1728 1729 Returns: 1730 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 1731 1732 Error Codes: 1733 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 1734 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 1735 3: 'part' value in parts['account'][x] is less than 0. 1736 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 1737 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 1738 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 1739 """ 1740 if debug: 1741 print('check_payment_parts', f'debug={debug}') 1742 for i in ['demand', 'account', 'total', 'exceed']: 1743 if i not in parts: 1744 return 1 1745 exceed = parts['exceed'] 1746 for x in parts['account']: 1747 for j in ['balance', 'rate', 'part']: 1748 if j not in parts['account'][x]: 1749 return 2 1750 if parts['account'][x]['part'] < 0: 1751 return 3 1752 if not exceed and parts['account'][x]['balance'] <= 0: 1753 return 4 1754 demand = parts['demand'] 1755 z = 0 1756 for _, y in parts['account'].items(): 1757 if not exceed and y['part'] > y['balance']: 1758 return 5 1759 z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1) 1760 z = round(z, 2) 1761 demand = round(demand, 2) 1762 if debug: 1763 print('check_payment_parts', f'z = {z}, demand = {demand}') 1764 print('check_payment_parts', type(z), type(demand)) 1765 print('check_payment_parts', z != demand) 1766 print('check_payment_parts', str(z) != str(demand)) 1767 if z != demand and str(z) != str(demand): 1768 return 6 1769 return 0 1770 1771 def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool: 1772 """ 1773 Perform Zakat calculation based on the given report and optional parts. 1774 1775 Parameters: 1776 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 1777 parts (dict): A dictionary containing the payment parts for the zakat. 1778 debug (bool): A flag indicating whether to print debug information. 1779 1780 Returns: 1781 bool: True if the zakat calculation is successful, False otherwise. 1782 """ 1783 if debug: 1784 print('zakat', f'debug={debug}') 1785 valid, _, plan = report 1786 if not valid: 1787 return valid 1788 parts_exist = parts is not None 1789 if parts_exist: 1790 if self.check_payment_parts(parts, debug=debug) != 0: 1791 return False 1792 if debug: 1793 print('######### zakat #######') 1794 print('parts_exist', parts_exist) 1795 no_lock = self.nolock() 1796 self.lock() 1797 report_time = self.time() 1798 self._vault['report'][report_time] = report 1799 self._step(Action.REPORT, ref=report_time) 1800 created = self.time() 1801 for x in plan: 1802 target_exchange = self.exchange(x) 1803 if debug: 1804 print(plan[x]) 1805 print('-------------') 1806 print(self._vault['account'][x]['box']) 1807 ids = sorted(self._vault['account'][x]['box'].keys()) 1808 if debug: 1809 print('plan[x]', plan[x]) 1810 for i in plan[x].keys(): 1811 j = ids[i] 1812 if debug: 1813 print('i', i, 'j', j) 1814 self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'], 1815 key='last', 1816 math_operation=MathOperation.EQUAL) 1817 self._vault['account'][x]['box'][j]['last'] = created 1818 amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate'])) 1819 self._vault['account'][x]['box'][j]['total'] += amount 1820 self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 1821 math_operation=MathOperation.ADDITION) 1822 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1823 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count', 1824 math_operation=MathOperation.ADDITION) 1825 if not parts_exist: 1826 try: 1827 self._vault['account'][x]['box'][j]['rest'] -= amount 1828 except TypeError: 1829 self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount) 1830 # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 1831 # math_operation=MathOperation.SUBTRACTION) 1832 self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug) 1833 if parts_exist: 1834 for account, part in parts['account'].items(): 1835 if part['part'] == 0: 1836 continue 1837 if debug: 1838 print('zakat-part', account, part['rate']) 1839 target_exchange = self.exchange(account) 1840 amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate']) 1841 self.sub( 1842 unscaled_value=self.unscale(amount), 1843 desc='zakat-part-دفعة-زكاة', 1844 account=account, 1845 debug=debug, 1846 ) 1847 if no_lock: 1848 self.free(self.lock()) 1849 return True 1850 1851 def export_json(self, path: str = "data.json") -> bool: 1852 """ 1853 Exports the current state of the ZakatTracker object to a JSON file. 1854 1855 Parameters: 1856 path (str): The path where the JSON file will be saved. Default is "data.json". 1857 1858 Returns: 1859 bool: True if the export is successful, False otherwise. 1860 1861 Raises: 1862 No specific exceptions are raised by this method. 1863 """ 1864 with open(path, "w") as file: 1865 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1866 return True 1867 1868 def save(self, path: str = None) -> bool: 1869 """ 1870 Saves the ZakatTracker's current state to a camel file. 1871 1872 This method serializes the internal data (`_vault`). 1873 1874 Parameters: 1875 path (str, optional): File path for saving. Defaults to a predefined location. 1876 1877 Returns: 1878 bool: True if the save operation is successful, False otherwise. 1879 """ 1880 if path is None: 1881 path = self.path() 1882 with open(f'{path}.tmp', 'w') as stream: 1883 # first save in tmp file 1884 stream.write(camel.dump(self._vault)) 1885 # then move tmp file to original location 1886 shutil.move(f'{path}.tmp', path) 1887 return True 1888 1889 def load(self, path: str = None) -> bool: 1890 """ 1891 Load the current state of the ZakatTracker object from a camel file. 1892 1893 Parameters: 1894 path (str): The path where the camel file is located. If not provided, it will use the default path. 1895 1896 Returns: 1897 bool: True if the load operation is successful, False otherwise. 1898 """ 1899 if path is None: 1900 path = self.path() 1901 if os.path.exists(path): 1902 with open(path, 'r') as stream: 1903 self._vault = camel.load(stream.read()) 1904 return True 1905 return False 1906 1907 def import_csv_cache_path(self): 1908 """ 1909 Generates the cache file path for imported CSV data. 1910 1911 This function constructs the file path where cached data from CSV imports 1912 will be stored. The cache file is a camel file (.camel extension) appended 1913 to the base path of the object. 1914 1915 Returns: 1916 str: The full path to the import CSV cache file. 1917 1918 Example: 1919 >>> obj = ZakatTracker('/data/reports') 1920 >>> obj.import_csv_cache_path() 1921 '/data/reports.import_csv.camel' 1922 """ 1923 path = str(self.path()) 1924 ext = self.ext() 1925 ext_len = len(ext) 1926 if path.endswith(f'.{ext}'): 1927 path = path[:-ext_len-1] 1928 _, filename = os.path.split(path + f'.import_csv.{ext}') 1929 return self.base_path(filename) 1930 1931 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple: 1932 """ 1933 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1934 1935 Parameters: 1936 path (str): The path to the CSV file. Default is 'file.csv'. 1937 scale_decimal_places (int): The number of decimal places to scale the value. Default is 0. 1938 debug (bool): A flag indicating whether to print debug information. 1939 1940 Returns: 1941 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 1942 and a dictionary of bad transactions. 1943 1944 Notes: 1945 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 1946 are appropriate for the currency pairs involved in the conversions. 1947 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 1948 to 1.0 or the previous rate for that account. 1949 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 1950 transactions of the same account within the whole imported and existing dataset when doing `check` and 1951 `zakat` operations. 1952 1953 Example Usage: 1954 The CSV file should have the following format, rate is optional per transaction: 1955 account, desc, value, date, rate 1956 For example: 1957 safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1 1958 """ 1959 if debug: 1960 print('import_csv', f'debug={debug}') 1961 cache: list[int] = [] 1962 try: 1963 with open(self.import_csv_cache_path(), 'r') as stream: 1964 cache = camel.load(stream.read()) 1965 except: 1966 pass 1967 date_formats = [ 1968 "%Y-%m-%d %H:%M:%S", 1969 "%Y-%m-%dT%H:%M:%S", 1970 "%Y-%m-%dT%H%M%S", 1971 "%Y-%m-%d", 1972 ] 1973 created, found, bad = 0, 0, {} 1974 data: dict[int, list] = {} 1975 with open(path, newline='', encoding="utf-8") as f: 1976 i = 0 1977 for row in csv.reader(f, delimiter=','): 1978 i += 1 1979 hashed = hash(tuple(row)) 1980 if hashed in cache: 1981 found += 1 1982 continue 1983 account = row[0] 1984 desc = row[1] 1985 value = float(row[2]) 1986 rate = 1.0 1987 if row[4:5]: # Empty list if index is out of range 1988 rate = float(row[4]) 1989 date: int = 0 1990 for time_format in date_formats: 1991 try: 1992 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1993 break 1994 except: 1995 pass 1996 # TODO: not allowed for negative dates in the future after enhance time functions 1997 if date == 0: 1998 bad[i] = row + ['invalid date'] 1999 if value == 0: 2000 bad[i] = row + ['invalid value'] 2001 continue 2002 if date not in data: 2003 data[date] = [] 2004 data[date].append((i, account, desc, value, date, rate, hashed)) 2005 2006 if debug: 2007 print('import_csv', len(data)) 2008 2009 if bad: 2010 return created, found, bad 2011 2012 for date, rows in sorted(data.items()): 2013 try: 2014 len_rows = len(rows) 2015 if len_rows == 1: 2016 (_, account, desc, unscaled_value, date, rate, hashed) = rows[0] 2017 value = self.unscale(unscaled_value, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value 2018 if rate > 0: 2019 self.exchange(account=account, created=date, rate=rate) 2020 if value > 0: 2021 self.track(unscaled_value=value, desc=desc, account=account, logging=True, created=date) 2022 elif value < 0: 2023 self.sub(unscaled_value=-value, desc=desc, account=account, created=date) 2024 created += 1 2025 cache.append(hashed) 2026 continue 2027 if debug: 2028 print('-- Duplicated time detected', date, 'len', len_rows) 2029 print(rows) 2030 print('---------------------------------') 2031 # If records are found at the same time with different accounts in the same amount 2032 # (one positive and the other negative), this indicates it is a transfer. 2033 if len_rows != 2: 2034 raise Exception(f'more than two transactions({len_rows}) at the same time') 2035 (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0] 2036 (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1] 2037 if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(unscaled_value2) or date1 != date2: 2038 raise Exception('invalid transfer') 2039 if rate1 > 0: 2040 self.exchange(account1, created=date1, rate=rate1) 2041 if rate2 > 0: 2042 self.exchange(account2, created=date2, rate=rate2) 2043 value1 = self.unscale(unscaled_value1, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value1 2044 value2 = self.unscale(unscaled_value2, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value2 2045 values = { 2046 value1: account1, 2047 value2: account2, 2048 } 2049 self.transfer( 2050 unscaled_amount=abs(value1), 2051 from_account=values[min(values.keys())], 2052 to_account=values[max(values.keys())], 2053 desc=desc1, 2054 created=date1, 2055 ) 2056 except Exception as e: 2057 for (i, account, desc, value, date, rate, _) in rows: 2058 bad[i] = (account, desc, value, date, rate, e) 2059 break 2060 with open(self.import_csv_cache_path(), 'w') as stream: 2061 stream.write(camel.dump(cache)) 2062 return created, found, bad 2063 2064 ######## 2065 # TESTS # 2066 ####### 2067 2068 @staticmethod 2069 def human_readable_size(size: float, decimal_places: int = 2) -> str: 2070 """ 2071 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 2072 2073 This function iterates through progressively larger units of information 2074 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 2075 range that can be expressed with a reasonable number before the unit. 2076 2077 Parameters: 2078 size (float): The size in bytes to convert. 2079 decimal_places (int, optional): The number of decimal places to display 2080 in the result. Defaults to 2. 2081 2082 Returns: 2083 str: A string representation of the size in a human-readable format, 2084 rounded to the specified number of decimal places. For example: 2085 - "1.50 KB" (1536 bytes) 2086 - "23.00 MB" (24117248 bytes) 2087 - "1.23 GB" (1325899906 bytes) 2088 """ 2089 if type(size) not in (float, int): 2090 raise TypeError("size must be a float or integer") 2091 if type(decimal_places) != int: 2092 raise TypeError("decimal_places must be an integer") 2093 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 2094 if size < 1024.0: 2095 break 2096 size /= 1024.0 2097 return f"{size:.{decimal_places}f} {unit}" 2098 2099 @staticmethod 2100 def get_dict_size(obj: dict, seen: set = None) -> float: 2101 """ 2102 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 2103 2104 This function traverses the dictionary structure, accounting for the size of keys, values, 2105 and any nested objects. It handles various data types commonly found in dictionaries 2106 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 2107 of circular references. 2108 2109 Parameters: 2110 obj (dict): The dictionary whose size is to be calculated. 2111 seen (set, optional): A set used internally to track visited objects 2112 and avoid circular references. Defaults to None. 2113 2114 Returns: 2115 float: An approximate size of the dictionary and its contents in bytes. 2116 2117 Note: 2118 - This function is a method of the `ZakatTracker` class and is likely used to 2119 estimate the memory footprint of data structures relevant to Zakat calculations. 2120 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 2121 not account for all memory overhead depending on the Python implementation. 2122 - Circular references are handled to prevent infinite recursion. 2123 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 2124 - String sizes are estimated based on character length and encoding. 2125 """ 2126 size = 0 2127 if seen is None: 2128 seen = set() 2129 2130 obj_id = id(obj) 2131 if obj_id in seen: 2132 return 0 2133 2134 seen.add(obj_id) 2135 size += sys.getsizeof(obj) 2136 2137 if isinstance(obj, dict): 2138 for k, v in obj.items(): 2139 size += ZakatTracker.get_dict_size(k, seen) 2140 size += ZakatTracker.get_dict_size(v, seen) 2141 elif isinstance(obj, (list, tuple, set, frozenset)): 2142 for item in obj: 2143 size += ZakatTracker.get_dict_size(item, seen) 2144 elif isinstance(obj, (int, float, complex)): # Handle numbers 2145 pass # Basic numbers have a fixed size, so nothing to add here 2146 elif isinstance(obj, str): # Handle strings 2147 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 2148 return size 2149 2150 @staticmethod 2151 def duration_from_nanoseconds(ns: int, 2152 show_zeros_in_spoken_time: bool = False, 2153 spoken_time_separator=',', 2154 millennia: str = 'Millennia', 2155 century: str = 'Century', 2156 years: str = 'Years', 2157 days: str = 'Days', 2158 hours: str = 'Hours', 2159 minutes: str = 'Minutes', 2160 seconds: str = 'Seconds', 2161 milli_seconds: str = 'MilliSeconds', 2162 micro_seconds: str = 'MicroSeconds', 2163 nano_seconds: str = 'NanoSeconds', 2164 ) -> tuple: 2165 """ 2166 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 2167 Convert NanoSeconds to Human Readable Time Format. 2168 A NanoSeconds is a unit of time in the International System of Units (SI) equal 2169 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 2170 Its symbol is μs, sometimes simplified to us when Unicode is not available. 2171 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 2172 2173 INPUT : ms (AKA: MilliSeconds) 2174 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 2175 OUTPUT Variables: time_lapsed, spoken_time 2176 2177 Example Input: duration_from_nanoseconds(ns) 2178 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 2179 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') 2180 duration_from_nanoseconds(1234567890123456789012) 2181 """ 2182 us, ns = divmod(ns, 1000) 2183 ms, us = divmod(us, 1000) 2184 s, ms = divmod(ms, 1000) 2185 m, s = divmod(s, 60) 2186 h, m = divmod(m, 60) 2187 d, h = divmod(h, 24) 2188 y, d = divmod(d, 365) 2189 c, y = divmod(y, 100) 2190 n, c = divmod(c, 10) 2191 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}" 2192 spoken_time_part = [] 2193 if n > 0 or show_zeros_in_spoken_time: 2194 spoken_time_part.append(f"{n: 3d} {millennia}") 2195 if c > 0 or show_zeros_in_spoken_time: 2196 spoken_time_part.append(f"{c: 4d} {century}") 2197 if y > 0 or show_zeros_in_spoken_time: 2198 spoken_time_part.append(f"{y: 3d} {years}") 2199 if d > 0 or show_zeros_in_spoken_time: 2200 spoken_time_part.append(f"{d: 4d} {days}") 2201 if h > 0 or show_zeros_in_spoken_time: 2202 spoken_time_part.append(f"{h: 2d} {hours}") 2203 if m > 0 or show_zeros_in_spoken_time: 2204 spoken_time_part.append(f"{m: 2d} {minutes}") 2205 if s > 0 or show_zeros_in_spoken_time: 2206 spoken_time_part.append(f"{s: 2d} {seconds}") 2207 if ms > 0 or show_zeros_in_spoken_time: 2208 spoken_time_part.append(f"{ms: 3d} {milli_seconds}") 2209 if us > 0 or show_zeros_in_spoken_time: 2210 spoken_time_part.append(f"{us: 3d} {micro_seconds}") 2211 if ns > 0 or show_zeros_in_spoken_time: 2212 spoken_time_part.append(f"{ns: 3d} {nano_seconds}") 2213 return time_lapsed, spoken_time_separator.join(spoken_time_part) 2214 2215 @staticmethod 2216 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 2217 """ 2218 Convert a specific day, month, and year into a timestamp. 2219 2220 Parameters: 2221 day (int): The day of the month. 2222 month (int): The month of the year. Default is 6 (June). 2223 year (int): The year. Default is 2024. 2224 2225 Returns: 2226 int: The timestamp representing the given day, month, and year. 2227 2228 Note: 2229 This method assumes the default month and year if not provided. 2230 """ 2231 return ZakatTracker.time(datetime.datetime(year, month, day)) 2232 2233 @staticmethod 2234 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 2235 """ 2236 Generate a random date between two given dates. 2237 2238 Parameters: 2239 start_date (datetime.datetime): The start date from which to generate a random date. 2240 end_date (datetime.datetime): The end date until which to generate a random date. 2241 2242 Returns: 2243 datetime.datetime: A random date between the start_date and end_date. 2244 """ 2245 time_between_dates = end_date - start_date 2246 days_between_dates = time_between_dates.days 2247 random_number_of_days = random.randrange(days_between_dates) 2248 return start_date + datetime.timedelta(days=random_number_of_days) 2249 2250 @staticmethod 2251 def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, 2252 debug: bool = False) -> int: 2253 """ 2254 Generate a random CSV file with specified parameters. 2255 2256 Parameters: 2257 path (str): The path where the CSV file will be saved. Default is "data.csv". 2258 count (int): The number of rows to generate in the CSV file. Default is 1000. 2259 with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. 2260 debug (bool): A flag indicating whether to print debug information. 2261 2262 Returns: 2263 None. The function generates a CSV file at the specified path with the given count of rows. 2264 Each row contains a randomly generated account, description, value, and date. 2265 The value is randomly generated between 1000 and 100000, 2266 and the date is randomly generated between 1950-01-01 and 2023-12-31. 2267 If the row number is not divisible by 13, the value is multiplied by -1. 2268 """ 2269 if debug: 2270 print('generate_random_csv_file', f'debug={debug}') 2271 i = 0 2272 with open(path, "w", newline="") as csvfile: 2273 writer = csv.writer(csvfile) 2274 for i in range(count): 2275 account = f"acc-{random.randint(1, 1000)}" 2276 desc = f"Some text {random.randint(1, 1000)}" 2277 value = random.randint(1000, 100000) 2278 date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1), 2279 datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 2280 if not i % 13 == 0: 2281 value *= -1 2282 row = [account, desc, value, date] 2283 if with_rate: 2284 rate = random.randint(1, 100) * 0.12 2285 if debug: 2286 print('before-append', row) 2287 row.append(rate) 2288 if debug: 2289 print('after-append', row) 2290 writer.writerow(row) 2291 i = i + 1 2292 return i 2293 2294 @staticmethod 2295 def create_random_list(max_sum, min_value=0, max_value=10): 2296 """ 2297 Creates a list of random integers whose sum does not exceed the specified maximum. 2298 2299 Args: 2300 max_sum: The maximum allowed sum of the list elements. 2301 min_value: The minimum possible value for an element (inclusive). 2302 max_value: The maximum possible value for an element (inclusive). 2303 2304 Returns: 2305 A list of random integers. 2306 """ 2307 result = [] 2308 current_sum = 0 2309 2310 while current_sum < max_sum: 2311 # Calculate the remaining space for the next element 2312 remaining_sum = max_sum - current_sum 2313 # Determine the maximum possible value for the next element 2314 next_max_value = min(remaining_sum, max_value) 2315 # Generate a random element within the allowed range 2316 next_element = random.randint(min_value, next_max_value) 2317 result.append(next_element) 2318 current_sum += next_element 2319 2320 return result 2321 2322 def _test_core(self, restore=False, debug=False): 2323 2324 if debug: 2325 random.seed(1234567890) 2326 2327 # sanity check - random forward time 2328 2329 xlist = [] 2330 limit = 1000 2331 for _ in range(limit): 2332 y = ZakatTracker.time() 2333 z = '-' 2334 if y not in xlist: 2335 xlist.append(y) 2336 else: 2337 z = 'x' 2338 if debug: 2339 print(z, y) 2340 xx = len(xlist) 2341 if debug: 2342 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 2343 assert limit == xx 2344 2345 # sanity check - convert date since 1000AD 2346 2347 for year in range(1000, 9000): 2348 ns = ZakatTracker.time(datetime.datetime.strptime(f"{year}-12-30 18:30:45", "%Y-%m-%d %H:%M:%S")) 2349 date = ZakatTracker.time_to_datetime(ns) 2350 if debug: 2351 print(date) 2352 assert date.year == year 2353 assert date.month == 12 2354 assert date.day == 30 2355 assert date.hour == 18 2356 assert date.minute == 30 2357 assert date.second in [44, 45] 2358 2359 # human_readable_size 2360 2361 assert ZakatTracker.human_readable_size(0) == "0.00 B" 2362 assert ZakatTracker.human_readable_size(512) == "512.00 B" 2363 assert ZakatTracker.human_readable_size(1023) == "1023.00 B" 2364 2365 assert ZakatTracker.human_readable_size(1024) == "1.00 KB" 2366 assert ZakatTracker.human_readable_size(2048) == "2.00 KB" 2367 assert ZakatTracker.human_readable_size(5120) == "5.00 KB" 2368 2369 assert ZakatTracker.human_readable_size(1024 ** 2) == "1.00 MB" 2370 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == "2.50 MB" 2371 2372 assert ZakatTracker.human_readable_size(1024 ** 3) == "1.00 GB" 2373 assert ZakatTracker.human_readable_size(1024 ** 4) == "1.00 TB" 2374 assert ZakatTracker.human_readable_size(1024 ** 5) == "1.00 PB" 2375 2376 assert ZakatTracker.human_readable_size(1536, decimal_places=0) == "2 KB" 2377 assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == "2.5 MB" 2378 assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == "1.150 GB" 2379 2380 try: 2381 # noinspection PyTypeChecker 2382 ZakatTracker.human_readable_size("not a number") 2383 assert False, "Expected TypeError for invalid input" 2384 except TypeError: 2385 pass 2386 2387 try: 2388 # noinspection PyTypeChecker 2389 ZakatTracker.human_readable_size(1024, decimal_places="not an int") 2390 assert False, "Expected TypeError for invalid decimal_places" 2391 except TypeError: 2392 pass 2393 2394 # get_dict_size 2395 assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), "Empty dictionary size mismatch" 2396 assert ZakatTracker.get_dict_size({"a": 1, "b": 2.5, "c": True}) != sys.getsizeof({}), "Not Empty dictionary" 2397 2398 # number scale 2399 error = 0 2400 total = 0 2401 for sign in ['', '-']: 2402 for max_i, max_j, decimal_places in [ 2403 (101, 101, 2), # fiat currency minimum unit took 2 decimal places 2404 (1, 1_000, 8), # cryptocurrency like Satoshi in Bitcoin took 8 decimal places 2405 (1, 1_000, 18) # cryptocurrency like Wei in Ethereum took 18 decimal places 2406 ]: 2407 for return_type in ( 2408 float, 2409 Decimal, 2410 ): 2411 for i in range(max_i): 2412 for j in range(max_j): 2413 total += 1 2414 num_str = f'{sign}{i}.{j:0{decimal_places}d}' 2415 num = return_type(num_str) 2416 scaled = self.scale(num, decimal_places=decimal_places) 2417 unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places) 2418 if debug: 2419 print( 2420 f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}') 2421 if unscaled != num: 2422 if debug: 2423 print('***** SCALE ERROR *****') 2424 error += 1 2425 if debug: 2426 print(f'total: {total}, error({error}): {100 * error / total}%') 2427 assert error == 0 2428 2429 assert self.nolock() 2430 assert self._history() is True 2431 2432 table = { 2433 1: [ 2434 (0, 10, 1000, 1000, 1000, 1, 1), 2435 (0, 20, 3000, 3000, 3000, 2, 2), 2436 (0, 30, 6000, 6000, 6000, 3, 3), 2437 (1, 15, 4500, 4500, 4500, 3, 4), 2438 (1, 50, -500, -500, -500, 4, 5), 2439 (1, 100, -10500, -10500, -10500, 5, 6), 2440 ], 2441 'wallet': [ 2442 (1, 90, -9000, -9000, -9000, 1, 1), 2443 (0, 100, 1000, 1000, 1000, 2, 2), 2444 (1, 190, -18000, -18000, -18000, 3, 3), 2445 (0, 1000, 82000, 82000, 82000, 4, 4), 2446 ], 2447 } 2448 for x in table: 2449 for y in table[x]: 2450 self.lock() 2451 if y[0] == 0: 2452 ref = self.track( 2453 unscaled_value=y[1], 2454 desc='test-add', 2455 account=x, 2456 logging=True, 2457 created=ZakatTracker.time(), 2458 debug=debug, 2459 ) 2460 else: 2461 (ref, z) = self.sub( 2462 unscaled_value=y[1], 2463 desc='test-sub', 2464 account=x, 2465 created=ZakatTracker.time(), 2466 ) 2467 if debug: 2468 print('_sub', z, ZakatTracker.time()) 2469 assert ref != 0 2470 assert len(self._vault['account'][x]['log'][ref]['file']) == 0 2471 for i in range(3): 2472 file_ref = self.add_file(x, ref, 'file_' + str(i)) 2473 sleep(0.0000001) 2474 assert file_ref != 0 2475 if debug: 2476 print('ref', ref, 'file', file_ref) 2477 assert len(self._vault['account'][x]['log'][ref]['file']) == i + 1 2478 file_ref = self.add_file(x, ref, 'file_' + str(3)) 2479 assert self.remove_file(x, ref, file_ref) 2480 z = self.balance(x) 2481 if debug: 2482 print("debug-0", z, y) 2483 assert z == y[2] 2484 z = self.balance(x, False) 2485 if debug: 2486 print("debug-1", z, y[3]) 2487 assert z == y[3] 2488 o = self._vault['account'][x]['log'] 2489 z = 0 2490 for i in o: 2491 z += o[i]['value'] 2492 if debug: 2493 print("debug-2", z, type(z)) 2494 print("debug-2", y[4], type(y[4])) 2495 assert z == y[4] 2496 if debug: 2497 print('debug-2 - PASSED') 2498 assert self.box_size(x) == y[5] 2499 assert self.log_size(x) == y[6] 2500 assert not self.nolock() 2501 self.free(self.lock()) 2502 assert self.nolock() 2503 assert self.boxes(x) != {} 2504 assert self.logs(x) != {} 2505 2506 assert not self.hide(x) 2507 assert self.hide(x, False) is False 2508 assert self.hide(x) is False 2509 assert self.hide(x, True) 2510 assert self.hide(x) 2511 2512 assert self.zakatable(x) 2513 assert self.zakatable(x, False) is False 2514 assert self.zakatable(x) is False 2515 assert self.zakatable(x, True) 2516 assert self.zakatable(x) 2517 2518 if restore is True: 2519 count = len(self._vault['history']) 2520 if debug: 2521 print('history-count', count) 2522 assert count == 10 2523 # try mode 2524 for _ in range(count): 2525 assert self.recall(True, debug) 2526 count = len(self._vault['history']) 2527 if debug: 2528 print('history-count', count) 2529 assert count == 10 2530 _accounts = list(table.keys()) 2531 accounts_limit = len(_accounts) + 1 2532 for i in range(-1, -accounts_limit, -1): 2533 account = _accounts[i] 2534 if debug: 2535 print(account, len(table[account])) 2536 transaction_limit = len(table[account]) + 1 2537 for j in range(-1, -transaction_limit, -1): 2538 row = table[account][j] 2539 if debug: 2540 print(row, self.balance(account), self.balance(account, False)) 2541 assert self.balance(account) == self.balance(account, False) 2542 assert self.balance(account) == row[2] 2543 assert self.recall(False, debug) 2544 assert self.recall(False, debug) is False 2545 count = len(self._vault['history']) 2546 if debug: 2547 print('history-count', count) 2548 assert count == 0 2549 self.reset() 2550 2551 def test(self, debug: bool = False) -> bool: 2552 if debug: 2553 print('test', f'debug={debug}') 2554 try: 2555 2556 self._test_core(True, debug) 2557 self._test_core(False, debug) 2558 2559 assert self._history() 2560 2561 # Not allowed for duplicate transactions in the same account and time 2562 2563 created = ZakatTracker.time() 2564 self.track(100, 'test-1', 'same', True, created) 2565 failed = False 2566 try: 2567 self.track(50, 'test-1', 'same', True, created) 2568 except: 2569 failed = True 2570 assert failed is True 2571 2572 self.reset() 2573 2574 # Same account transfer 2575 for x in [1, 'a', True, 1.8, None]: 2576 failed = False 2577 try: 2578 self.transfer(1, x, x, 'same-account', debug=debug) 2579 except: 2580 failed = True 2581 assert failed is True 2582 2583 # Always preserve box age during transfer 2584 2585 series: list[tuple] = [ 2586 (30, 4), 2587 (60, 3), 2588 (90, 2), 2589 ] 2590 case = { 2591 3000: { 2592 'series': series, 2593 'rest': 15000, 2594 }, 2595 6000: { 2596 'series': series, 2597 'rest': 12000, 2598 }, 2599 9000: { 2600 'series': series, 2601 'rest': 9000, 2602 }, 2603 18000: { 2604 'series': series, 2605 'rest': 0, 2606 }, 2607 27000: { 2608 'series': series, 2609 'rest': -9000, 2610 }, 2611 36000: { 2612 'series': series, 2613 'rest': -18000, 2614 }, 2615 } 2616 2617 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 2618 2619 for total in case: 2620 if debug: 2621 print('--------------------------------------------------------') 2622 print(f'case[{total}]', case[total]) 2623 for x in case[total]['series']: 2624 self.track( 2625 unscaled_value=x[0], 2626 desc=f"test-{x} ages", 2627 account='ages', 2628 logging=True, 2629 created=selected_time * x[1], 2630 ) 2631 2632 unscaled_total = self.unscale(total) 2633 if debug: 2634 print('unscaled_total', unscaled_total) 2635 refs = self.transfer( 2636 unscaled_amount=unscaled_total, 2637 from_account='ages', 2638 to_account='future', 2639 desc='Zakat Movement', 2640 debug=debug, 2641 ) 2642 2643 if debug: 2644 print('refs', refs) 2645 2646 ages_cache_balance = self.balance('ages') 2647 ages_fresh_balance = self.balance('ages', False) 2648 rest = case[total]['rest'] 2649 if debug: 2650 print('source', ages_cache_balance, ages_fresh_balance, rest) 2651 assert ages_cache_balance == rest 2652 assert ages_fresh_balance == rest 2653 2654 future_cache_balance = self.balance('future') 2655 future_fresh_balance = self.balance('future', False) 2656 if debug: 2657 print('target', future_cache_balance, future_fresh_balance, total) 2658 print('refs', refs) 2659 assert future_cache_balance == total 2660 assert future_fresh_balance == total 2661 2662 # TODO: check boxes times for `ages` should equal box times in `future` 2663 for ref in self._vault['account']['ages']['box']: 2664 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 2665 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 2666 future_capital = 0 2667 if ref in self._vault['account']['future']['box']: 2668 future_capital = self._vault['account']['future']['box'][ref]['capital'] 2669 future_rest = 0 2670 if ref in self._vault['account']['future']['box']: 2671 future_rest = self._vault['account']['future']['box'][ref]['rest'] 2672 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 2673 if debug: 2674 print('================================================================') 2675 print('ages', ages_capital, ages_rest) 2676 print('future', future_capital, future_rest) 2677 if ages_rest == 0: 2678 assert ages_capital == future_capital 2679 elif ages_rest < 0: 2680 assert -ages_capital == future_capital 2681 elif ages_rest > 0: 2682 assert ages_capital == ages_rest + future_capital 2683 self.reset() 2684 assert len(self._vault['history']) == 0 2685 2686 assert self._history() 2687 assert self._history(False) is False 2688 assert self._history() is False 2689 assert self._history(True) 2690 assert self._history() 2691 if debug: 2692 print('####################################################################') 2693 2694 transaction = [ 2695 ( 2696 20, 'wallet', 1, -2000, -2000, -2000, 1, 1, 2697 2000, 2000, 2000, 1, 1, 2698 ), 2699 ( 2700 750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2, 2701 75000, 75000, 75000, 1, 1, 2702 ), 2703 ( 2704 600, 'safe', 'bank', 15000, 15000, 15000, 1, 2, 2705 60000, 60000, 60000, 1, 1, 2706 ), 2707 ] 2708 for z in transaction: 2709 self.lock() 2710 x = z[1] 2711 y = z[2] 2712 self.transfer( 2713 unscaled_amount=z[0], 2714 from_account=x, 2715 to_account=y, 2716 desc='test-transfer', 2717 debug=debug, 2718 ) 2719 zz = self.balance(x) 2720 if debug: 2721 print(zz, z) 2722 assert zz == z[3] 2723 xx = self.accounts()[x] 2724 assert xx == z[3] 2725 assert self.balance(x, False) == z[4] 2726 assert xx == z[4] 2727 2728 s = 0 2729 log = self._vault['account'][x]['log'] 2730 for i in log: 2731 s += log[i]['value'] 2732 if debug: 2733 print('s', s, 'z[5]', z[5]) 2734 assert s == z[5] 2735 2736 assert self.box_size(x) == z[6] 2737 assert self.log_size(x) == z[7] 2738 2739 yy = self.accounts()[y] 2740 assert self.balance(y) == z[8] 2741 assert yy == z[8] 2742 assert self.balance(y, False) == z[9] 2743 assert yy == z[9] 2744 2745 s = 0 2746 log = self._vault['account'][y]['log'] 2747 for i in log: 2748 s += log[i]['value'] 2749 assert s == z[10] 2750 2751 assert self.box_size(y) == z[11] 2752 assert self.log_size(y) == z[12] 2753 assert self.free(self.lock()) 2754 2755 if debug: 2756 pp().pprint(self.check(2.17)) 2757 2758 assert not self.nolock() 2759 history_count = len(self._vault['history']) 2760 if debug: 2761 print('history-count', history_count) 2762 assert history_count == 4 2763 assert not self.free(ZakatTracker.time()) 2764 assert self.free(self.lock()) 2765 assert self.nolock() 2766 assert len(self._vault['history']) == 3 2767 2768 # storage 2769 2770 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 2771 if os.path.exists(_path): 2772 os.remove(_path) 2773 self.save() 2774 assert os.path.getsize(_path) > 0 2775 self.reset() 2776 assert self.recall(False, debug) is False 2777 self.load() 2778 assert self._vault['account'] is not None 2779 2780 # recall 2781 2782 assert self.nolock() 2783 assert len(self._vault['history']) == 3 2784 assert self.recall(False, debug) is True 2785 assert len(self._vault['history']) == 2 2786 assert self.recall(False, debug) is True 2787 assert len(self._vault['history']) == 1 2788 assert self.recall(False, debug) is True 2789 assert len(self._vault['history']) == 0 2790 assert self.recall(False, debug) is False 2791 assert len(self._vault['history']) == 0 2792 2793 # exchange 2794 2795 self.exchange("cash", 25, 3.75, "2024-06-25") 2796 self.exchange("cash", 22, 3.73, "2024-06-22") 2797 self.exchange("cash", 15, 3.69, "2024-06-15") 2798 self.exchange("cash", 10, 3.66) 2799 2800 for i in range(1, 30): 2801 exchange = self.exchange("cash", i) 2802 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2803 if debug: 2804 print(i, rate, description, created) 2805 assert created 2806 if i < 10: 2807 assert rate == 1 2808 assert description is None 2809 elif i == 10: 2810 assert rate == 3.66 2811 assert description is None 2812 elif i < 15: 2813 assert rate == 3.66 2814 assert description is None 2815 elif i == 15: 2816 assert rate == 3.69 2817 assert description is not None 2818 elif i < 22: 2819 assert rate == 3.69 2820 assert description is not None 2821 elif i == 22: 2822 assert rate == 3.73 2823 assert description is not None 2824 elif i >= 25: 2825 assert rate == 3.75 2826 assert description is not None 2827 exchange = self.exchange("bank", i) 2828 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2829 if debug: 2830 print(i, rate, description, created) 2831 assert created 2832 assert rate == 1 2833 assert description is None 2834 2835 assert len(self._vault['exchange']) > 0 2836 assert len(self.exchanges()) > 0 2837 self._vault['exchange'].clear() 2838 assert len(self._vault['exchange']) == 0 2839 assert len(self.exchanges()) == 0 2840 2841 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 2842 self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25") 2843 self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22") 2844 self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15") 2845 self.exchange("cash", ZakatTracker.day_to_time(10), 3.66) 2846 2847 for i in [x * 0.12 for x in range(-15, 21)]: 2848 if i <= 0: 2849 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0 2850 else: 2851 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0 2852 2853 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 2854 for i in range(1, 31): 2855 timestamp_ns = ZakatTracker.day_to_time(i) 2856 exchange = self.exchange("cash", timestamp_ns) 2857 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2858 if debug: 2859 print(i, rate, description, created) 2860 assert created 2861 if i < 10: 2862 assert rate == 1 2863 assert description is None 2864 elif i == 10: 2865 assert rate == 3.66 2866 assert description is None 2867 elif i < 15: 2868 assert rate == 3.66 2869 assert description is None 2870 elif i == 15: 2871 assert rate == 3.69 2872 assert description is not None 2873 elif i < 22: 2874 assert rate == 3.69 2875 assert description is not None 2876 elif i == 22: 2877 assert rate == 3.73 2878 assert description is not None 2879 elif i >= 25: 2880 assert rate == 3.75 2881 assert description is not None 2882 exchange = self.exchange("bank", i) 2883 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2884 if debug: 2885 print(i, rate, description, created) 2886 assert created 2887 assert rate == 1 2888 assert description is None 2889 2890 # csv 2891 2892 csv_count = 1000 2893 2894 for with_rate, path in { 2895 False: 'test-import_csv-no-exchange', 2896 True: 'test-import_csv-with-exchange', 2897 }.items(): 2898 2899 if debug: 2900 print('test_import_csv', with_rate, path) 2901 2902 csv_path = path + '.csv' 2903 if os.path.exists(csv_path): 2904 os.remove(csv_path) 2905 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 2906 if debug: 2907 print('generate_random_csv_file', c) 2908 assert c == csv_count 2909 assert os.path.getsize(csv_path) > 0 2910 cache_path = self.import_csv_cache_path() 2911 if os.path.exists(cache_path): 2912 os.remove(cache_path) 2913 self.reset() 2914 (created, found, bad) = self.import_csv(csv_path, debug) 2915 bad_count = len(bad) 2916 assert bad_count > 0 2917 if debug: 2918 print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})") 2919 print('bad', bad) 2920 tmp_size = os.path.getsize(cache_path) 2921 assert tmp_size > 0 2922 # TODO: assert created + found + bad_count == csv_count 2923 # TODO: assert created == csv_count 2924 # TODO: assert bad_count == 0 2925 (created_2, found_2, bad_2) = self.import_csv(csv_path) 2926 bad_2_count = len(bad_2) 2927 if debug: 2928 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 2929 print('bad', bad) 2930 assert bad_2_count > 0 2931 # TODO: assert tmp_size == os.path.getsize(cache_path) 2932 # TODO: assert created_2 + found_2 + bad_2_count == csv_count 2933 # TODO: assert created == found_2 2934 # TODO: assert bad_count == bad_2_count 2935 # TODO: assert found_2 == csv_count 2936 # TODO: assert bad_2_count == 0 2937 # TODO: assert created_2 == 0 2938 2939 # payment parts 2940 2941 positive_parts = self.build_payment_parts(100, positive_only=True) 2942 assert self.check_payment_parts(positive_parts) != 0 2943 assert self.check_payment_parts(positive_parts) != 0 2944 all_parts = self.build_payment_parts(300, positive_only=False) 2945 assert self.check_payment_parts(all_parts) != 0 2946 assert self.check_payment_parts(all_parts) != 0 2947 if debug: 2948 pp().pprint(positive_parts) 2949 pp().pprint(all_parts) 2950 # dynamic discount 2951 suite = [] 2952 count = 3 2953 for exceed in [False, True]: 2954 case = [] 2955 for parts in [positive_parts, all_parts]: 2956 part = parts.copy() 2957 demand = part['demand'] 2958 if debug: 2959 print(demand, part['total']) 2960 i = 0 2961 z = demand / count 2962 cp = { 2963 'account': {}, 2964 'demand': demand, 2965 'exceed': exceed, 2966 'total': part['total'], 2967 } 2968 j = '' 2969 for x, y in part['account'].items(): 2970 x_exchange = self.exchange(x) 2971 zz = self.exchange_calc(z, 1, x_exchange['rate']) 2972 if exceed and zz <= demand: 2973 i += 1 2974 y['part'] = zz 2975 if debug: 2976 print(exceed, y) 2977 cp['account'][x] = y 2978 case.append(y) 2979 elif not exceed and y['balance'] >= zz: 2980 i += 1 2981 y['part'] = zz 2982 if debug: 2983 print(exceed, y) 2984 cp['account'][x] = y 2985 case.append(y) 2986 j = x 2987 if i >= count: 2988 break 2989 if len(cp['account'][j]) > 0: 2990 suite.append(cp) 2991 if debug: 2992 print('suite', len(suite)) 2993 # vault = self._vault.copy() 2994 for case in suite: 2995 # self._vault = vault.copy() 2996 if debug: 2997 print('case', case) 2998 result = self.check_payment_parts(case) 2999 if debug: 3000 print('check_payment_parts', result, f'exceed: {exceed}') 3001 assert result == 0 3002 3003 report = self.check(2.17, None, debug) 3004 (valid, brief, plan) = report 3005 if debug: 3006 print('valid', valid) 3007 zakat_result = self.zakat(report, parts=case, debug=debug) 3008 if debug: 3009 print('zakat-result', zakat_result) 3010 assert valid == zakat_result 3011 3012 assert self.save(path + f'.{self.ext()}') 3013 assert self.export_json(path + '.json') 3014 3015 assert self.export_json("1000-transactions-test.json") 3016 assert self.save(f"1000-transactions-test.{self.ext()}") 3017 3018 self.reset() 3019 3020 # test transfer between accounts with different exchange rate 3021 3022 a_SAR = "Bank (SAR)" 3023 b_USD = "Bank (USD)" 3024 c_SAR = "Safe (SAR)" 3025 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 3026 for case in [ 3027 (0, a_SAR, "SAR Gift", 1000, 100000), 3028 (1, a_SAR, 1), 3029 (0, b_USD, "USD Gift", 500, 50000), 3030 (1, b_USD, 1), 3031 (2, b_USD, 3.75), 3032 (1, b_USD, 3.75), 3033 (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500), 3034 (0, c_SAR, "Salary", 750, 75000), 3035 (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000), 3036 (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100), 3037 ]: 3038 if debug: 3039 print('case', case) 3040 match (case[0]): 3041 case 0: # track 3042 _, account, desc, x, balance = case 3043 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 3044 3045 cached_value = self.balance(account, cached=True) 3046 fresh_value = self.balance(account, cached=False) 3047 if debug: 3048 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 3049 assert cached_value == balance 3050 assert fresh_value == balance 3051 case 1: # check-exchange 3052 _, account, expected_rate = case 3053 t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 3054 if debug: 3055 print('t-exchange', t_exchange) 3056 assert t_exchange['rate'] == expected_rate 3057 case 2: # do-exchange 3058 _, account, rate = case 3059 self.exchange(account, rate=rate, debug=debug) 3060 b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 3061 if debug: 3062 print('b-exchange', b_exchange) 3063 assert b_exchange['rate'] == rate 3064 case 3: # transfer 3065 _, x, a, b, desc, a_balance, b_balance = case 3066 self.transfer(x, a, b, desc, debug=debug) 3067 3068 cached_value = self.balance(a, cached=True) 3069 fresh_value = self.balance(a, cached=False) 3070 if debug: 3071 print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value, 'a_balance', a_balance) 3072 assert cached_value == a_balance 3073 assert fresh_value == a_balance 3074 3075 cached_value = self.balance(b, cached=True) 3076 fresh_value = self.balance(b, cached=False) 3077 if debug: 3078 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 3079 assert cached_value == b_balance 3080 assert fresh_value == b_balance 3081 3082 # Transfer all in many chunks randomly from B to A 3083 a_SAR_balance = 137125 3084 b_USD_balance = 50100 3085 b_USD_exchange = self.exchange(b_USD) 3086 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 3087 if debug: 3088 print('amounts', amounts) 3089 i = 0 3090 for x in amounts: 3091 if debug: 3092 print(f'{i} - transfer-with-exchange({x})') 3093 self.transfer( 3094 unscaled_amount=self.unscale(x), 3095 from_account=b_USD, 3096 to_account=a_SAR, 3097 desc=f"{x} USD -> SAR", 3098 debug=debug, 3099 ) 3100 3101 b_USD_balance -= x 3102 cached_value = self.balance(b_USD, cached=True) 3103 fresh_value = self.balance(b_USD, cached=False) 3104 if debug: 3105 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 3106 b_USD_balance) 3107 assert cached_value == b_USD_balance 3108 assert fresh_value == b_USD_balance 3109 3110 a_SAR_balance += int(x * b_USD_exchange['rate']) 3111 cached_value = self.balance(a_SAR, cached=True) 3112 fresh_value = self.balance(a_SAR, cached=False) 3113 if debug: 3114 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 3115 a_SAR_balance, 'rate', b_USD_exchange['rate']) 3116 assert cached_value == a_SAR_balance 3117 assert fresh_value == a_SAR_balance 3118 i += 1 3119 3120 # Transfer all in many chunks randomly from C to A 3121 c_SAR_balance = 37500 3122 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 3123 if debug: 3124 print('amounts', amounts) 3125 i = 0 3126 for x in amounts: 3127 if debug: 3128 print(f'{i} - transfer-with-exchange({x})') 3129 self.transfer( 3130 unscaled_amount=self.unscale(x), 3131 from_account=c_SAR, 3132 to_account=a_SAR, 3133 desc=f"{x} SAR -> a_SAR", 3134 debug=debug, 3135 ) 3136 3137 c_SAR_balance -= x 3138 cached_value = self.balance(c_SAR, cached=True) 3139 fresh_value = self.balance(c_SAR, cached=False) 3140 if debug: 3141 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 3142 c_SAR_balance) 3143 assert cached_value == c_SAR_balance 3144 assert fresh_value == c_SAR_balance 3145 3146 a_SAR_balance += x 3147 cached_value = self.balance(a_SAR, cached=True) 3148 fresh_value = self.balance(a_SAR, cached=False) 3149 if debug: 3150 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 3151 a_SAR_balance) 3152 assert cached_value == a_SAR_balance 3153 assert fresh_value == a_SAR_balance 3154 i += 1 3155 3156 assert self.export_json("accounts-transfer-with-exchange-rates.json") 3157 assert self.save(f"accounts-transfer-with-exchange-rates.{self.ext()}") 3158 3159 # check & zakat with exchange rates for many cycles 3160 3161 for rate, values in { 3162 1: { 3163 'in': [1000, 2000, 10000], 3164 'exchanged': [100000, 200000, 1000000], 3165 'out': [2500, 5000, 73140], 3166 }, 3167 3.75: { 3168 'in': [200, 1000, 5000], 3169 'exchanged': [75000, 375000, 1875000], 3170 'out': [1875, 9375, 137138], 3171 }, 3172 }.items(): 3173 a, b, c = values['in'] 3174 m, n, o = values['exchanged'] 3175 x, y, z = values['out'] 3176 if debug: 3177 print('rate', rate, 'values', values) 3178 for case in [ 3179 (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 3180 {'safe': {0: {'below_nisab': x}}}, 3181 ], False, m), 3182 (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 3183 {'safe': {0: {'count': 1, 'total': y}}}, 3184 ], True, n), 3185 (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [ 3186 {'cave': {0: {'count': 3, 'total': z}}}, 3187 ], True, o), 3188 ]: 3189 if debug: 3190 print(f"############# check(rate: {rate}) #############") 3191 print('case', case) 3192 self.reset() 3193 self.exchange(account=case[1], created=case[2], rate=rate) 3194 self.track(unscaled_value=case[0], desc='test-check', account=case[1], logging=True, created=case[2]) 3195 assert self.snapshot() 3196 3197 # assert self.nolock() 3198 # history_size = len(self._vault['history']) 3199 # print('history_size', history_size) 3200 # assert history_size == 2 3201 assert self.lock() 3202 assert not self.nolock() 3203 report = self.check(2.17, None, debug) 3204 (valid, brief, plan) = report 3205 if debug: 3206 print('brief', brief) 3207 assert valid == case[4] 3208 assert case[5] == brief[0] 3209 assert case[5] == brief[1] 3210 3211 if debug: 3212 pp().pprint(plan) 3213 3214 for x in plan: 3215 assert case[1] == x 3216 if 'total' in case[3][0][x][0].keys(): 3217 assert case[3][0][x][0]['total'] == int(brief[2]) 3218 assert int(plan[x][0]['total']) == case[3][0][x][0]['total'] 3219 assert int(plan[x][0]['count']) == case[3][0][x][0]['count'] 3220 else: 3221 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 3222 if debug: 3223 pp().pprint(report) 3224 result = self.zakat(report, debug=debug) 3225 if debug: 3226 print('zakat-result', result, case[4]) 3227 assert result == case[4] 3228 report = self.check(2.17, None, debug) 3229 (valid, brief, plan) = report 3230 assert valid is False 3231 3232 history_size = len(self._vault['history']) 3233 if debug: 3234 print('history_size', history_size) 3235 assert history_size == 3 3236 assert not self.nolock() 3237 assert self.recall(False, debug) is False 3238 self.free(self.lock()) 3239 assert self.nolock() 3240 3241 for i in range(3, 0, -1): 3242 history_size = len(self._vault['history']) 3243 if debug: 3244 print('history_size', history_size) 3245 assert history_size == i 3246 assert self.recall(False, debug) is True 3247 3248 assert self.nolock() 3249 assert self.recall(False, debug) is False 3250 3251 history_size = len(self._vault['history']) 3252 if debug: 3253 print('history_size', history_size) 3254 assert history_size == 0 3255 3256 account_size = len(self._vault['account']) 3257 if debug: 3258 print('account_size', account_size) 3259 assert account_size == 0 3260 3261 report_size = len(self._vault['report']) 3262 if debug: 3263 print('report_size', report_size) 3264 assert report_size == 0 3265 3266 assert self.nolock() 3267 return True 3268 except Exception as e: 3269 # pp().pprint(self._vault) 3270 assert self.export_json("test-snapshot.json") 3271 assert self.save(f"test-snapshot.{self.ext()}") 3272 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.90'
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.
1096 def exchange(self, account, created: int = None, rate: float = None, description: str = None, 1097 debug: bool = False) -> dict: 1098 """ 1099 This method is used to record or retrieve exchange rates for a specific account. 1100 1101 Parameters: 1102 - account (str): The account number for which the exchange rate is being recorded or retrieved. 1103 - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used. 1104 - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate. 1105 - description (str): A description of the exchange rate. 1106 - debug (bool): Whether to print debug information. Default is False. 1107 1108 Returns: 1109 - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, 1110 it returns a dictionary with default values for the rate and description. 1111 """ 1112 if debug: 1113 print('exchange', f'debug={debug}') 1114 if created is None: 1115 created = self.time() 1116 no_lock = self.nolock() 1117 self.lock() 1118 if rate is not None: 1119 if rate <= 0: 1120 return dict() 1121 if account not in self._vault['exchange']: 1122 self._vault['exchange'][account] = {} 1123 if len(self._vault['exchange'][account]) == 0 and rate <= 1: 1124 return {"time": created, "rate": 1, "description": None} 1125 self._vault['exchange'][account][created] = {"rate": rate, "description": description} 1126 self._step(Action.EXCHANGE, account, ref=created, value=rate) 1127 if no_lock: 1128 self.free(self.lock()) 1129 if debug: 1130 print("exchange-created-1", 1131 f'account: {account}, created: {created}, rate:{rate}, description:{description}') 1132 1133 if account in self._vault['exchange']: 1134 valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created] 1135 if valid_rates: 1136 latest_rate = max(valid_rates, key=lambda x: x[0]) 1137 if debug: 1138 print("exchange-read-1", 1139 f'account: {account}, created: {created}, rate:{rate}, description:{description}', 1140 'latest_rate', latest_rate) 1141 result = latest_rate[1] 1142 result['time'] = latest_rate[0] 1143 return result # إرجاع قاموس يحتوي على المعدل والوصف 1144 if debug: 1145 print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}') 1146 return {"time": created, "rate": 1, "description": None} # إرجاع القيمة الافتراضية مع وصف فارغ
This method is used to record or retrieve exchange rates for a specific account.
Parameters:
- account (str): The account number for which the exchange rate is being recorded or retrieved.
- created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
- rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
- description (str): A description of the exchange rate.
- debug (bool): Whether to print debug information. Default is False.
Returns:
- dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
1148 @staticmethod 1149 def exchange_calc(x: float, x_rate: float, y_rate: float) -> float: 1150 """ 1151 This function calculates the exchanged amount of a currency. 1152 1153 Args: 1154 x (float): The original amount of the currency. 1155 x_rate (float): The exchange rate of the original currency. 1156 y_rate (float): The exchange rate of the target currency. 1157 1158 Returns: 1159 float: The exchanged amount of the target currency. 1160 """ 1161 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.
1163 def exchanges(self) -> dict: 1164 """ 1165 Retrieve the recorded exchange rates for all accounts. 1166 1167 Parameters: 1168 None 1169 1170 Returns: 1171 dict: A dictionary containing all recorded exchange rates. 1172 The keys are account names or numbers, and the values are dictionaries containing the exchange rates. 1173 Each exchange rate dictionary has timestamps as keys and exchange rate details as values. 1174 """ 1175 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.
1177 def accounts(self) -> dict: 1178 """ 1179 Returns a dictionary containing account numbers as keys and their respective balances as values. 1180 1181 Parameters: 1182 None 1183 1184 Returns: 1185 dict: A dictionary where keys are account numbers and values are their respective balances. 1186 """ 1187 result = {} 1188 for i in self._vault['account']: 1189 result[i] = self._vault['account'][i]['balance'] 1190 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.
1192 def boxes(self, account) -> dict: 1193 """ 1194 Retrieve the boxes (transactions) associated with a specific account. 1195 1196 Parameters: 1197 account (str): The account number for which to retrieve the boxes. 1198 1199 Returns: 1200 dict: A dictionary containing the boxes associated with the given account. 1201 If the account does not exist, an empty dictionary is returned. 1202 """ 1203 if self.account_exists(account): 1204 return self._vault['account'][account]['box'] 1205 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.
1207 def logs(self, account) -> dict: 1208 """ 1209 Retrieve the logs (transactions) associated with a specific account. 1210 1211 Parameters: 1212 account (str): The account number for which to retrieve the logs. 1213 1214 Returns: 1215 dict: A dictionary containing the logs associated with the given account. 1216 If the account does not exist, an empty dictionary is returned. 1217 """ 1218 if self.account_exists(account): 1219 return self._vault['account'][account]['log'] 1220 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.
1222 def daily_logs(self, debug: bool = False): 1223 """ 1224 Retrieve the daily logs (transactions) from all accounts. 1225 1226 The function groups the logs by day, month, and year, and calculates the total value for each group. 1227 It returns a dictionary where the keys are the timestamps of the daily groups, 1228 and the values are dictionaries containing the total value and the logs for that group. 1229 1230 Parameters: 1231 debug (bool): Whether to print debug information. Default is False. 1232 1233 Returns: 1234 dict: A dictionary containing the daily logs. 1235 1236 Example: 1237 >>> tracker = ZakatTracker() 1238 >>> tracker.track(51, 'desc', 'account1') 1239 >>> tracker.track(100, 'desc', 'account2') 1240 >>> tracker.daily_logs() 1241 { 1242 1632057600: { 1243 'total': 151, 1244 'transfer': False, 1245 'rows': [ 1246 {'value': 51, 'account': 'account1', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'}, 1247 {'value': 100, 'account': 'account2', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'} 1248 ] 1249 } 1250 } 1251 """ 1252 logs = {} 1253 for account in self.accounts(): 1254 for k, v in self.logs(account).items(): 1255 v['time'] = k 1256 v['account'] = account 1257 if k not in logs: 1258 logs[k] = [] 1259 logs[k].append(v) 1260 if debug: 1261 print('logs', logs) 1262 y = {} 1263 for i in sorted(logs, reverse=True): 1264 dt = self.time_to_datetime(i) 1265 group = self.day_to_time(dt.day, dt.month, dt.year) 1266 if group not in y: 1267 y[group] = { 1268 'total': 0, 1269 'rows': [], 1270 } 1271 y[group]['transfer'] = len(logs[i]) > 1 1272 if debug: 1273 print('logs[i]', logs[i]) 1274 for z in logs[i]: 1275 print('z', z) 1276 y[group]['total'] += z['value'] 1277 y[group]['rows'].append(z) 1278 if debug: 1279 print('y', y) 1280 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: debug (bool): Whether to print debug information. Default is False.
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,
'transfer': False,
'rows': [
{'value': 51, 'account': 'account1', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'},
{'value': 100, 'account': 'account2', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'}
]
}
}
1282 def add_file(self, account: str, ref: int, path: str) -> int: 1283 """ 1284 Adds a file reference to a specific transaction log entry in the vault. 1285 1286 Parameters: 1287 account (str): The account number associated with the transaction log. 1288 ref (int): The reference to the transaction log entry. 1289 path (str): The path of the file to be added. 1290 1291 Returns: 1292 int: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 1293 """ 1294 if self.account_exists(account): 1295 if ref in self._vault['account'][account]['log']: 1296 file_ref = self.time() 1297 self._vault['account'][account]['log'][ref]['file'][file_ref] = path 1298 no_lock = self.nolock() 1299 self.lock() 1300 self._step(Action.ADD_FILE, account, ref=ref, file=file_ref) 1301 if no_lock: 1302 self.free(self.lock()) 1303 return file_ref 1304 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.
1306 def remove_file(self, account: str, ref: int, file_ref: int) -> bool: 1307 """ 1308 Removes a file reference from a specific transaction log entry in the vault. 1309 1310 Parameters: 1311 account (str): The account number associated with the transaction log. 1312 ref (int): The reference to the transaction log entry. 1313 file_ref (int): The reference of the file to be removed. 1314 1315 Returns: 1316 bool: True if the file reference is successfully removed, False otherwise. 1317 """ 1318 if self.account_exists(account): 1319 if ref in self._vault['account'][account]['log']: 1320 if file_ref in self._vault['account'][account]['log'][ref]['file']: 1321 x = self._vault['account'][account]['log'][ref]['file'][file_ref] 1322 del self._vault['account'][account]['log'][ref]['file'][file_ref] 1323 no_lock = self.nolock() 1324 self.lock() 1325 self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 1326 if no_lock: 1327 self.free(self.lock()) 1328 return True 1329 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.
1331 def balance(self, account: str = 1, cached: bool = True) -> int: 1332 """ 1333 Calculate and return the balance of a specific account. 1334 1335 Parameters: 1336 account (str): The account number. Default is '1'. 1337 cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 1338 1339 Returns: 1340 int: The balance of the account. 1341 1342 Note: 1343 If cached is True, the function returns the cached balance. 1344 If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 1345 """ 1346 if cached: 1347 return self._vault['account'][account]['balance'] 1348 x = 0 1349 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.
1351 def hide(self, account, status: bool = None) -> bool: 1352 """ 1353 Check or set the hide status of a specific account. 1354 1355 Parameters: 1356 account (str): The account number. 1357 status (bool, optional): The new hide status. If not provided, the function will return the current status. 1358 1359 Returns: 1360 bool: The current or updated hide status of the account. 1361 1362 Raises: 1363 None 1364 1365 Example: 1366 >>> tracker = ZakatTracker() 1367 >>> ref = tracker.track(51, 'desc', 'account1') 1368 >>> tracker.hide('account1') # Set the hide status of 'account1' to True 1369 False 1370 >>> tracker.hide('account1', True) # Set the hide status of 'account1' to True 1371 True 1372 >>> tracker.hide('account1') # Get the hide status of 'account1' by default 1373 True 1374 >>> tracker.hide('account1', False) 1375 False 1376 """ 1377 if self.account_exists(account): 1378 if status is None: 1379 return self._vault['account'][account]['hide'] 1380 self._vault['account'][account]['hide'] = status 1381 return status 1382 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
1384 def zakatable(self, account, status: bool = None) -> bool: 1385 """ 1386 Check or set the zakatable status of a specific account. 1387 1388 Parameters: 1389 account (str): The account number. 1390 status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 1391 1392 Returns: 1393 bool: The current or updated zakatable status of the account. 1394 1395 Raises: 1396 None 1397 1398 Example: 1399 >>> tracker = ZakatTracker() 1400 >>> ref = tracker.track(51, 'desc', 'account1') 1401 >>> tracker.zakatable('account1') # Set the zakatable status of 'account1' to True 1402 True 1403 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 1404 True 1405 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' by default 1406 True 1407 >>> tracker.zakatable('account1', False) 1408 False 1409 """ 1410 if self.account_exists(account): 1411 if status is None: 1412 return self._vault['account'][account]['zakatable'] 1413 self._vault['account'][account]['zakatable'] = status 1414 return status 1415 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
1417 def sub(self, unscaled_value: float | int | Decimal, desc: str = '', account: str = 1, created: int = None, 1418 debug: bool = False) \ 1419 -> tuple[ 1420 int, 1421 list[ 1422 tuple[int, int], 1423 ], 1424 ] | tuple: 1425 """ 1426 Subtracts a specified value from an account's balance. 1427 1428 Parameters: 1429 unscaled_value (float | int | Decimal): The amount to be subtracted. 1430 desc (str): A description for the transaction. Defaults to an empty string. 1431 account (str): The account from which the value will be subtracted. Defaults to '1'. 1432 created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. 1433 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1434 1435 Returns: 1436 tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction. 1437 1438 If the amount to subtract is greater than the account's balance, 1439 the remaining amount will be transferred to a new transaction with a negative value. 1440 1441 Raises: 1442 ValueError: The box transaction happened again in the same nanosecond time. 1443 ValueError: The log transaction happened again in the same nanosecond time. 1444 """ 1445 if debug: 1446 print('sub', f'debug={debug}') 1447 if unscaled_value < 0: 1448 return tuple() 1449 if unscaled_value == 0: 1450 ref = self.track(unscaled_value, '', account) 1451 return ref, ref 1452 if created is None: 1453 created = self.time() 1454 no_lock = self.nolock() 1455 self.lock() 1456 self.track(0, '', account) 1457 value = self.scale(unscaled_value) 1458 self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug) 1459 ids = sorted(self._vault['account'][account]['box'].keys()) 1460 limit = len(ids) + 1 1461 target = value 1462 if debug: 1463 print('ids', ids) 1464 ages = [] 1465 for i in range(-1, -limit, -1): 1466 if target == 0: 1467 break 1468 j = ids[i] 1469 if debug: 1470 print('i', i, 'j', j) 1471 rest = self._vault['account'][account]['box'][j]['rest'] 1472 if rest >= target: 1473 self._vault['account'][account]['box'][j]['rest'] -= target 1474 self._step(Action.SUB, account, ref=j, value=target) 1475 ages.append((j, target)) 1476 target = 0 1477 break 1478 elif target > rest > 0: 1479 chunk = rest 1480 target -= chunk 1481 self._step(Action.SUB, account, ref=j, value=chunk) 1482 ages.append((j, chunk)) 1483 self._vault['account'][account]['box'][j]['rest'] = 0 1484 if target > 0: 1485 self.track( 1486 unscaled_value=self.unscale(-target), 1487 desc=desc, 1488 account=account, 1489 logging=False, 1490 created=created, 1491 ) 1492 ages.append((created, target)) 1493 if no_lock: 1494 self.free(self.lock()) 1495 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.
1497 def transfer(self, unscaled_amount: float | int | Decimal, from_account: str, to_account: str, desc: str = '', 1498 created: int = None, 1499 debug: bool = False) -> list[int]: 1500 """ 1501 Transfers a specified value from one account to another. 1502 1503 Parameters: 1504 unscaled_amount (float | int | Decimal): The amount to be transferred. 1505 from_account (str): The account from which the value will be transferred. 1506 to_account (str): The account to which the value will be transferred. 1507 desc (str, optional): A description for the transaction. Defaults to an empty string. 1508 created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. 1509 debug (bool): A flag indicating whether to print debug information. Defaults to False. 1510 1511 Returns: 1512 list[int]: A list of timestamps corresponding to the transactions made during the transfer. 1513 1514 Raises: 1515 ValueError: Transfer to the same account is forbidden. 1516 ValueError: The box transaction happened again in the same nanosecond time. 1517 ValueError: The log transaction happened again in the same nanosecond time. 1518 """ 1519 if debug: 1520 print('transfer', f'debug={debug}') 1521 if from_account == to_account: 1522 raise ValueError(f'Transfer to the same account is forbidden. {to_account}') 1523 if unscaled_amount <= 0: 1524 return [] 1525 if created is None: 1526 created = self.time() 1527 (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug) 1528 times = [] 1529 source_exchange = self.exchange(from_account, created) 1530 target_exchange = self.exchange(to_account, created) 1531 1532 if debug: 1533 print('ages', ages) 1534 1535 for age, value in ages: 1536 target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])) 1537 if debug: 1538 print('target_amount', target_amount) 1539 # Perform the transfer 1540 if self.box_exists(to_account, age): 1541 if debug: 1542 print('box_exists', age) 1543 capital = self._vault['account'][to_account]['box'][age]['capital'] 1544 rest = self._vault['account'][to_account]['box'][age]['rest'] 1545 if debug: 1546 print( 1547 f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1548 selected_age = age 1549 if rest + target_amount > capital: 1550 self._vault['account'][to_account]['box'][age]['capital'] += target_amount 1551 selected_age = ZakatTracker.time() 1552 self._vault['account'][to_account]['box'][age]['rest'] += target_amount 1553 self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount) 1554 y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, 1555 created=None, ref=None, debug=debug) 1556 times.append((age, y)) 1557 continue 1558 if debug: 1559 print( 1560 f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).") 1561 y = self.track( 1562 unscaled_value=self.unscale(int(target_amount)), 1563 desc=desc, 1564 account=to_account, 1565 logging=True, 1566 created=age, 1567 debug=debug, 1568 ) 1569 times.append(y) 1570 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.
1572 def check(self, silver_gram_price: float, unscaled_nisab: float | int | Decimal = None, debug: bool = False, now: int = None, 1573 cycle: float = None) -> tuple: 1574 """ 1575 Check the eligibility for Zakat based on the given parameters. 1576 1577 Parameters: 1578 silver_gram_price (float): The price of a gram of silver. 1579 unscaled_nisab (float | int | Decimal): The minimum amount of wealth required for Zakat. If not provided, 1580 it will be calculated based on the silver_gram_price. 1581 debug (bool): Flag to enable debug mode. 1582 now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). 1583 cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 1584 1585 Returns: 1586 tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, 1587 and a dictionary containing the Zakat plan. 1588 """ 1589 if debug: 1590 print('check', f'debug={debug}') 1591 if now is None: 1592 now = self.time() 1593 if cycle is None: 1594 cycle = ZakatTracker.TimeCycle() 1595 if unscaled_nisab is None: 1596 unscaled_nisab = ZakatTracker.Nisab(silver_gram_price) 1597 nisab = self.scale(unscaled_nisab) 1598 plan = {} 1599 below_nisab = 0 1600 brief = [0, 0, 0] 1601 valid = False 1602 if debug: 1603 print('exchanges', self.exchanges()) 1604 for x in self._vault['account']: 1605 if not self.zakatable(x): 1606 continue 1607 _box = self._vault['account'][x]['box'] 1608 _log = self._vault['account'][x]['log'] 1609 limit = len(_box) + 1 1610 ids = sorted(self._vault['account'][x]['box'].keys()) 1611 for i in range(-1, -limit, -1): 1612 j = ids[i] 1613 rest = float(_box[j]['rest']) 1614 if rest <= 0: 1615 continue 1616 exchange = self.exchange(x, created=self.time()) 1617 rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1) 1618 brief[0] += rest 1619 index = limit + i - 1 1620 epoch = (now - j) / cycle 1621 if debug: 1622 print(f"Epoch: {epoch}", _box[j]) 1623 if _box[j]['last'] > 0: 1624 epoch = (now - _box[j]['last']) / cycle 1625 if debug: 1626 print(f"Epoch: {epoch}") 1627 epoch = floor(epoch) 1628 if debug: 1629 print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch) 1630 if epoch == 0: 1631 continue 1632 if debug: 1633 print("Epoch - PASSED") 1634 brief[1] += rest 1635 if rest >= nisab: 1636 total = 0 1637 for _ in range(epoch): 1638 total += ZakatTracker.ZakatCut(float(rest) - float(total)) 1639 if total > 0: 1640 if x not in plan: 1641 plan[x] = {} 1642 valid = True 1643 brief[2] += total 1644 plan[x][index] = { 1645 'total': total, 1646 'count': epoch, 1647 'box_time': j, 1648 'box_capital': _box[j]['capital'], 1649 'box_rest': _box[j]['rest'], 1650 'box_last': _box[j]['last'], 1651 'box_total': _box[j]['total'], 1652 'box_count': _box[j]['count'], 1653 'box_log': _log[j]['desc'], 1654 'exchange_rate': exchange['rate'], 1655 'exchange_time': exchange['time'], 1656 'exchange_desc': exchange['description'], 1657 } 1658 else: 1659 chunk = ZakatTracker.ZakatCut(float(rest)) 1660 if chunk > 0: 1661 if x not in plan: 1662 plan[x] = {} 1663 if j not in plan[x].keys(): 1664 plan[x][index] = {} 1665 below_nisab += rest 1666 brief[2] += chunk 1667 plan[x][index]['below_nisab'] = chunk 1668 plan[x][index]['total'] = chunk 1669 plan[x][index]['count'] = epoch 1670 plan[x][index]['box_time'] = j 1671 plan[x][index]['box_capital'] = _box[j]['capital'] 1672 plan[x][index]['box_rest'] = _box[j]['rest'] 1673 plan[x][index]['box_last'] = _box[j]['last'] 1674 plan[x][index]['box_total'] = _box[j]['total'] 1675 plan[x][index]['box_count'] = _box[j]['count'] 1676 plan[x][index]['box_log'] = _log[j]['desc'] 1677 plan[x][index]['exchange_rate'] = exchange['rate'] 1678 plan[x][index]['exchange_time'] = exchange['time'] 1679 plan[x][index]['exchange_desc'] = exchange['description'] 1680 valid = valid or below_nisab >= nisab 1681 if debug: 1682 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 1683 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.
1685 def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict: 1686 """ 1687 Build payment parts for the Zakat distribution. 1688 1689 Parameters: 1690 scaled_demand (int): The total demand for payment in local currency. 1691 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 1692 1693 Returns: 1694 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 1695 { 1696 'account': { 1697 'account_id': {'balance': float, 'rate': float, 'part': float}, 1698 ... 1699 }, 1700 'exceed': bool, 1701 'demand': int, 1702 'total': float, 1703 } 1704 """ 1705 total = 0 1706 parts = { 1707 'account': {}, 1708 'exceed': False, 1709 'demand': int(round(scaled_demand)), 1710 } 1711 for x, y in self.accounts().items(): 1712 if positive_only and y <= 0: 1713 continue 1714 total += float(y) 1715 exchange = self.exchange(x) 1716 parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0} 1717 parts['total'] = total 1718 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, }
1720 @staticmethod 1721 def check_payment_parts(parts: dict, debug: bool = False) -> int: 1722 """ 1723 Checks the validity of payment parts. 1724 1725 Parameters: 1726 parts (dict): A dictionary containing payment parts information. 1727 debug (bool): Flag to enable debug mode. 1728 1729 Returns: 1730 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 1731 1732 Error Codes: 1733 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 1734 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 1735 3: 'part' value in parts['account'][x] is less than 0. 1736 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 1737 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 1738 6: The sum of 'part' values in parts['account'] does not match with 'demand' value. 1739 """ 1740 if debug: 1741 print('check_payment_parts', f'debug={debug}') 1742 for i in ['demand', 'account', 'total', 'exceed']: 1743 if i not in parts: 1744 return 1 1745 exceed = parts['exceed'] 1746 for x in parts['account']: 1747 for j in ['balance', 'rate', 'part']: 1748 if j not in parts['account'][x]: 1749 return 2 1750 if parts['account'][x]['part'] < 0: 1751 return 3 1752 if not exceed and parts['account'][x]['balance'] <= 0: 1753 return 4 1754 demand = parts['demand'] 1755 z = 0 1756 for _, y in parts['account'].items(): 1757 if not exceed and y['part'] > y['balance']: 1758 return 5 1759 z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1) 1760 z = round(z, 2) 1761 demand = round(demand, 2) 1762 if debug: 1763 print('check_payment_parts', f'z = {z}, demand = {demand}') 1764 print('check_payment_parts', type(z), type(demand)) 1765 print('check_payment_parts', z != demand) 1766 print('check_payment_parts', str(z) != str(demand)) 1767 if z != demand and str(z) != str(demand): 1768 return 6 1769 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.
1771 def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool: 1772 """ 1773 Perform Zakat calculation based on the given report and optional parts. 1774 1775 Parameters: 1776 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 1777 parts (dict): A dictionary containing the payment parts for the zakat. 1778 debug (bool): A flag indicating whether to print debug information. 1779 1780 Returns: 1781 bool: True if the zakat calculation is successful, False otherwise. 1782 """ 1783 if debug: 1784 print('zakat', f'debug={debug}') 1785 valid, _, plan = report 1786 if not valid: 1787 return valid 1788 parts_exist = parts is not None 1789 if parts_exist: 1790 if self.check_payment_parts(parts, debug=debug) != 0: 1791 return False 1792 if debug: 1793 print('######### zakat #######') 1794 print('parts_exist', parts_exist) 1795 no_lock = self.nolock() 1796 self.lock() 1797 report_time = self.time() 1798 self._vault['report'][report_time] = report 1799 self._step(Action.REPORT, ref=report_time) 1800 created = self.time() 1801 for x in plan: 1802 target_exchange = self.exchange(x) 1803 if debug: 1804 print(plan[x]) 1805 print('-------------') 1806 print(self._vault['account'][x]['box']) 1807 ids = sorted(self._vault['account'][x]['box'].keys()) 1808 if debug: 1809 print('plan[x]', plan[x]) 1810 for i in plan[x].keys(): 1811 j = ids[i] 1812 if debug: 1813 print('i', i, 'j', j) 1814 self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'], 1815 key='last', 1816 math_operation=MathOperation.EQUAL) 1817 self._vault['account'][x]['box'][j]['last'] = created 1818 amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate'])) 1819 self._vault['account'][x]['box'][j]['total'] += amount 1820 self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total', 1821 math_operation=MathOperation.ADDITION) 1822 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1823 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count', 1824 math_operation=MathOperation.ADDITION) 1825 if not parts_exist: 1826 try: 1827 self._vault['account'][x]['box'][j]['rest'] -= amount 1828 except TypeError: 1829 self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount) 1830 # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest', 1831 # math_operation=MathOperation.SUBTRACTION) 1832 self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug) 1833 if parts_exist: 1834 for account, part in parts['account'].items(): 1835 if part['part'] == 0: 1836 continue 1837 if debug: 1838 print('zakat-part', account, part['rate']) 1839 target_exchange = self.exchange(account) 1840 amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate']) 1841 self.sub( 1842 unscaled_value=self.unscale(amount), 1843 desc='zakat-part-دفعة-زكاة', 1844 account=account, 1845 debug=debug, 1846 ) 1847 if no_lock: 1848 self.free(self.lock()) 1849 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.
1851 def export_json(self, path: str = "data.json") -> bool: 1852 """ 1853 Exports the current state of the ZakatTracker object to a JSON file. 1854 1855 Parameters: 1856 path (str): The path where the JSON file will be saved. Default is "data.json". 1857 1858 Returns: 1859 bool: True if the export is successful, False otherwise. 1860 1861 Raises: 1862 No specific exceptions are raised by this method. 1863 """ 1864 with open(path, "w") as file: 1865 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1866 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.
1868 def save(self, path: str = None) -> bool: 1869 """ 1870 Saves the ZakatTracker's current state to a camel file. 1871 1872 This method serializes the internal data (`_vault`). 1873 1874 Parameters: 1875 path (str, optional): File path for saving. Defaults to a predefined location. 1876 1877 Returns: 1878 bool: True if the save operation is successful, False otherwise. 1879 """ 1880 if path is None: 1881 path = self.path() 1882 with open(f'{path}.tmp', 'w') as stream: 1883 # first save in tmp file 1884 stream.write(camel.dump(self._vault)) 1885 # then move tmp file to original location 1886 shutil.move(f'{path}.tmp', path) 1887 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.
1889 def load(self, path: str = None) -> bool: 1890 """ 1891 Load the current state of the ZakatTracker object from a camel file. 1892 1893 Parameters: 1894 path (str): The path where the camel file is located. If not provided, it will use the default path. 1895 1896 Returns: 1897 bool: True if the load operation is successful, False otherwise. 1898 """ 1899 if path is None: 1900 path = self.path() 1901 if os.path.exists(path): 1902 with open(path, 'r') as stream: 1903 self._vault = camel.load(stream.read()) 1904 return True 1905 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.
1907 def import_csv_cache_path(self): 1908 """ 1909 Generates the cache file path for imported CSV data. 1910 1911 This function constructs the file path where cached data from CSV imports 1912 will be stored. The cache file is a camel file (.camel extension) appended 1913 to the base path of the object. 1914 1915 Returns: 1916 str: The full path to the import CSV cache file. 1917 1918 Example: 1919 >>> obj = ZakatTracker('/data/reports') 1920 >>> obj.import_csv_cache_path() 1921 '/data/reports.import_csv.camel' 1922 """ 1923 path = str(self.path()) 1924 ext = self.ext() 1925 ext_len = len(ext) 1926 if path.endswith(f'.{ext}'): 1927 path = path[:-ext_len-1] 1928 _, filename = os.path.split(path + f'.import_csv.{ext}') 1929 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'
1931 def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple: 1932 """ 1933 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1934 1935 Parameters: 1936 path (str): The path to the CSV file. Default is 'file.csv'. 1937 scale_decimal_places (int): The number of decimal places to scale the value. Default is 0. 1938 debug (bool): A flag indicating whether to print debug information. 1939 1940 Returns: 1941 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 1942 and a dictionary of bad transactions. 1943 1944 Notes: 1945 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 1946 are appropriate for the currency pairs involved in the conversions. 1947 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 1948 to 1.0 or the previous rate for that account. 1949 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 1950 transactions of the same account within the whole imported and existing dataset when doing `check` and 1951 `zakat` operations. 1952 1953 Example Usage: 1954 The CSV file should have the following format, rate is optional per transaction: 1955 account, desc, value, date, rate 1956 For example: 1957 safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1 1958 """ 1959 if debug: 1960 print('import_csv', f'debug={debug}') 1961 cache: list[int] = [] 1962 try: 1963 with open(self.import_csv_cache_path(), 'r') as stream: 1964 cache = camel.load(stream.read()) 1965 except: 1966 pass 1967 date_formats = [ 1968 "%Y-%m-%d %H:%M:%S", 1969 "%Y-%m-%dT%H:%M:%S", 1970 "%Y-%m-%dT%H%M%S", 1971 "%Y-%m-%d", 1972 ] 1973 created, found, bad = 0, 0, {} 1974 data: dict[int, list] = {} 1975 with open(path, newline='', encoding="utf-8") as f: 1976 i = 0 1977 for row in csv.reader(f, delimiter=','): 1978 i += 1 1979 hashed = hash(tuple(row)) 1980 if hashed in cache: 1981 found += 1 1982 continue 1983 account = row[0] 1984 desc = row[1] 1985 value = float(row[2]) 1986 rate = 1.0 1987 if row[4:5]: # Empty list if index is out of range 1988 rate = float(row[4]) 1989 date: int = 0 1990 for time_format in date_formats: 1991 try: 1992 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1993 break 1994 except: 1995 pass 1996 # TODO: not allowed for negative dates in the future after enhance time functions 1997 if date == 0: 1998 bad[i] = row + ['invalid date'] 1999 if value == 0: 2000 bad[i] = row + ['invalid value'] 2001 continue 2002 if date not in data: 2003 data[date] = [] 2004 data[date].append((i, account, desc, value, date, rate, hashed)) 2005 2006 if debug: 2007 print('import_csv', len(data)) 2008 2009 if bad: 2010 return created, found, bad 2011 2012 for date, rows in sorted(data.items()): 2013 try: 2014 len_rows = len(rows) 2015 if len_rows == 1: 2016 (_, account, desc, unscaled_value, date, rate, hashed) = rows[0] 2017 value = self.unscale(unscaled_value, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value 2018 if rate > 0: 2019 self.exchange(account=account, created=date, rate=rate) 2020 if value > 0: 2021 self.track(unscaled_value=value, desc=desc, account=account, logging=True, created=date) 2022 elif value < 0: 2023 self.sub(unscaled_value=-value, desc=desc, account=account, created=date) 2024 created += 1 2025 cache.append(hashed) 2026 continue 2027 if debug: 2028 print('-- Duplicated time detected', date, 'len', len_rows) 2029 print(rows) 2030 print('---------------------------------') 2031 # If records are found at the same time with different accounts in the same amount 2032 # (one positive and the other negative), this indicates it is a transfer. 2033 if len_rows != 2: 2034 raise Exception(f'more than two transactions({len_rows}) at the same time') 2035 (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0] 2036 (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1] 2037 if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(unscaled_value2) or date1 != date2: 2038 raise Exception('invalid transfer') 2039 if rate1 > 0: 2040 self.exchange(account1, created=date1, rate=rate1) 2041 if rate2 > 0: 2042 self.exchange(account2, created=date2, rate=rate2) 2043 value1 = self.unscale(unscaled_value1, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value1 2044 value2 = self.unscale(unscaled_value2, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value2 2045 values = { 2046 value1: account1, 2047 value2: account2, 2048 } 2049 self.transfer( 2050 unscaled_amount=abs(value1), 2051 from_account=values[min(values.keys())], 2052 to_account=values[max(values.keys())], 2053 desc=desc1, 2054 created=date1, 2055 ) 2056 except Exception as e: 2057 for (i, account, desc, value, date, rate, _) in rows: 2058 bad[i] = (account, desc, value, date, rate, e) 2059 break 2060 with open(self.import_csv_cache_path(), 'w') as stream: 2061 stream.write(camel.dump(cache)) 2062 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
2068 @staticmethod 2069 def human_readable_size(size: float, decimal_places: int = 2) -> str: 2070 """ 2071 Converts a size in bytes to a human-readable format (e.g., KB, MB, GB). 2072 2073 This function iterates through progressively larger units of information 2074 (B, KB, MB, GB, etc.) and divides the input size until it fits within a 2075 range that can be expressed with a reasonable number before the unit. 2076 2077 Parameters: 2078 size (float): The size in bytes to convert. 2079 decimal_places (int, optional): The number of decimal places to display 2080 in the result. Defaults to 2. 2081 2082 Returns: 2083 str: A string representation of the size in a human-readable format, 2084 rounded to the specified number of decimal places. For example: 2085 - "1.50 KB" (1536 bytes) 2086 - "23.00 MB" (24117248 bytes) 2087 - "1.23 GB" (1325899906 bytes) 2088 """ 2089 if type(size) not in (float, int): 2090 raise TypeError("size must be a float or integer") 2091 if type(decimal_places) != int: 2092 raise TypeError("decimal_places must be an integer") 2093 for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: 2094 if size < 1024.0: 2095 break 2096 size /= 1024.0 2097 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)
2099 @staticmethod 2100 def get_dict_size(obj: dict, seen: set = None) -> float: 2101 """ 2102 Recursively calculates the approximate memory size of a dictionary and its contents in bytes. 2103 2104 This function traverses the dictionary structure, accounting for the size of keys, values, 2105 and any nested objects. It handles various data types commonly found in dictionaries 2106 (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case 2107 of circular references. 2108 2109 Parameters: 2110 obj (dict): The dictionary whose size is to be calculated. 2111 seen (set, optional): A set used internally to track visited objects 2112 and avoid circular references. Defaults to None. 2113 2114 Returns: 2115 float: An approximate size of the dictionary and its contents in bytes. 2116 2117 Note: 2118 - This function is a method of the `ZakatTracker` class and is likely used to 2119 estimate the memory footprint of data structures relevant to Zakat calculations. 2120 - The size calculation is approximate as it relies on `sys.getsizeof()`, which might 2121 not account for all memory overhead depending on the Python implementation. 2122 - Circular references are handled to prevent infinite recursion. 2123 - Basic numeric types (int, float, complex) are assumed to have fixed sizes. 2124 - String sizes are estimated based on character length and encoding. 2125 """ 2126 size = 0 2127 if seen is None: 2128 seen = set() 2129 2130 obj_id = id(obj) 2131 if obj_id in seen: 2132 return 0 2133 2134 seen.add(obj_id) 2135 size += sys.getsizeof(obj) 2136 2137 if isinstance(obj, dict): 2138 for k, v in obj.items(): 2139 size += ZakatTracker.get_dict_size(k, seen) 2140 size += ZakatTracker.get_dict_size(v, seen) 2141 elif isinstance(obj, (list, tuple, set, frozenset)): 2142 for item in obj: 2143 size += ZakatTracker.get_dict_size(item, seen) 2144 elif isinstance(obj, (int, float, complex)): # Handle numbers 2145 pass # Basic numbers have a fixed size, so nothing to add here 2146 elif isinstance(obj, str): # Handle strings 2147 size += len(obj) * sys.getsizeof(str().encode()) # Size per character in bytes 2148 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.
2150 @staticmethod 2151 def duration_from_nanoseconds(ns: int, 2152 show_zeros_in_spoken_time: bool = False, 2153 spoken_time_separator=',', 2154 millennia: str = 'Millennia', 2155 century: str = 'Century', 2156 years: str = 'Years', 2157 days: str = 'Days', 2158 hours: str = 'Hours', 2159 minutes: str = 'Minutes', 2160 seconds: str = 'Seconds', 2161 milli_seconds: str = 'MilliSeconds', 2162 micro_seconds: str = 'MicroSeconds', 2163 nano_seconds: str = 'NanoSeconds', 2164 ) -> tuple: 2165 """ 2166 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 2167 Convert NanoSeconds to Human Readable Time Format. 2168 A NanoSeconds is a unit of time in the International System of Units (SI) equal 2169 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 2170 Its symbol is μs, sometimes simplified to us when Unicode is not available. 2171 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 2172 2173 INPUT : ms (AKA: MilliSeconds) 2174 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 2175 OUTPUT Variables: time_lapsed, spoken_time 2176 2177 Example Input: duration_from_nanoseconds(ns) 2178 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 2179 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') 2180 duration_from_nanoseconds(1234567890123456789012) 2181 """ 2182 us, ns = divmod(ns, 1000) 2183 ms, us = divmod(us, 1000) 2184 s, ms = divmod(ms, 1000) 2185 m, s = divmod(s, 60) 2186 h, m = divmod(m, 60) 2187 d, h = divmod(h, 24) 2188 y, d = divmod(d, 365) 2189 c, y = divmod(y, 100) 2190 n, c = divmod(c, 10) 2191 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}" 2192 spoken_time_part = [] 2193 if n > 0 or show_zeros_in_spoken_time: 2194 spoken_time_part.append(f"{n: 3d} {millennia}") 2195 if c > 0 or show_zeros_in_spoken_time: 2196 spoken_time_part.append(f"{c: 4d} {century}") 2197 if y > 0 or show_zeros_in_spoken_time: 2198 spoken_time_part.append(f"{y: 3d} {years}") 2199 if d > 0 or show_zeros_in_spoken_time: 2200 spoken_time_part.append(f"{d: 4d} {days}") 2201 if h > 0 or show_zeros_in_spoken_time: 2202 spoken_time_part.append(f"{h: 2d} {hours}") 2203 if m > 0 or show_zeros_in_spoken_time: 2204 spoken_time_part.append(f"{m: 2d} {minutes}") 2205 if s > 0 or show_zeros_in_spoken_time: 2206 spoken_time_part.append(f"{s: 2d} {seconds}") 2207 if ms > 0 or show_zeros_in_spoken_time: 2208 spoken_time_part.append(f"{ms: 3d} {milli_seconds}") 2209 if us > 0 or show_zeros_in_spoken_time: 2210 spoken_time_part.append(f"{us: 3d} {micro_seconds}") 2211 if ns > 0 or show_zeros_in_spoken_time: 2212 spoken_time_part.append(f"{ns: 3d} {nano_seconds}") 2213 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)
2215 @staticmethod 2216 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 2217 """ 2218 Convert a specific day, month, and year into a timestamp. 2219 2220 Parameters: 2221 day (int): The day of the month. 2222 month (int): The month of the year. Default is 6 (June). 2223 year (int): The year. Default is 2024. 2224 2225 Returns: 2226 int: The timestamp representing the given day, month, and year. 2227 2228 Note: 2229 This method assumes the default month and year if not provided. 2230 """ 2231 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.
2233 @staticmethod 2234 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 2235 """ 2236 Generate a random date between two given dates. 2237 2238 Parameters: 2239 start_date (datetime.datetime): The start date from which to generate a random date. 2240 end_date (datetime.datetime): The end date until which to generate a random date. 2241 2242 Returns: 2243 datetime.datetime: A random date between the start_date and end_date. 2244 """ 2245 time_between_dates = end_date - start_date 2246 days_between_dates = time_between_dates.days 2247 random_number_of_days = random.randrange(days_between_dates) 2248 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.
2250 @staticmethod 2251 def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, 2252 debug: bool = False) -> int: 2253 """ 2254 Generate a random CSV file with specified parameters. 2255 2256 Parameters: 2257 path (str): The path where the CSV file will be saved. Default is "data.csv". 2258 count (int): The number of rows to generate in the CSV file. Default is 1000. 2259 with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. 2260 debug (bool): A flag indicating whether to print debug information. 2261 2262 Returns: 2263 None. The function generates a CSV file at the specified path with the given count of rows. 2264 Each row contains a randomly generated account, description, value, and date. 2265 The value is randomly generated between 1000 and 100000, 2266 and the date is randomly generated between 1950-01-01 and 2023-12-31. 2267 If the row number is not divisible by 13, the value is multiplied by -1. 2268 """ 2269 if debug: 2270 print('generate_random_csv_file', f'debug={debug}') 2271 i = 0 2272 with open(path, "w", newline="") as csvfile: 2273 writer = csv.writer(csvfile) 2274 for i in range(count): 2275 account = f"acc-{random.randint(1, 1000)}" 2276 desc = f"Some text {random.randint(1, 1000)}" 2277 value = random.randint(1000, 100000) 2278 date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1), 2279 datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 2280 if not i % 13 == 0: 2281 value *= -1 2282 row = [account, desc, value, date] 2283 if with_rate: 2284 rate = random.randint(1, 100) * 0.12 2285 if debug: 2286 print('before-append', row) 2287 row.append(rate) 2288 if debug: 2289 print('after-append', row) 2290 writer.writerow(row) 2291 i = i + 1 2292 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.
2294 @staticmethod 2295 def create_random_list(max_sum, min_value=0, max_value=10): 2296 """ 2297 Creates a list of random integers whose sum does not exceed the specified maximum. 2298 2299 Args: 2300 max_sum: The maximum allowed sum of the list elements. 2301 min_value: The minimum possible value for an element (inclusive). 2302 max_value: The maximum possible value for an element (inclusive). 2303 2304 Returns: 2305 A list of random integers. 2306 """ 2307 result = [] 2308 current_sum = 0 2309 2310 while current_sum < max_sum: 2311 # Calculate the remaining space for the next element 2312 remaining_sum = max_sum - current_sum 2313 # Determine the maximum possible value for the next element 2314 next_max_value = min(remaining_sum, max_value) 2315 # Generate a random element within the allowed range 2316 next_element = random.randint(min_value, next_max_value) 2317 result.append(next_element) 2318 current_sum += next_element 2319 2320 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.
2551 def test(self, debug: bool = False) -> bool: 2552 if debug: 2553 print('test', f'debug={debug}') 2554 try: 2555 2556 self._test_core(True, debug) 2557 self._test_core(False, debug) 2558 2559 assert self._history() 2560 2561 # Not allowed for duplicate transactions in the same account and time 2562 2563 created = ZakatTracker.time() 2564 self.track(100, 'test-1', 'same', True, created) 2565 failed = False 2566 try: 2567 self.track(50, 'test-1', 'same', True, created) 2568 except: 2569 failed = True 2570 assert failed is True 2571 2572 self.reset() 2573 2574 # Same account transfer 2575 for x in [1, 'a', True, 1.8, None]: 2576 failed = False 2577 try: 2578 self.transfer(1, x, x, 'same-account', debug=debug) 2579 except: 2580 failed = True 2581 assert failed is True 2582 2583 # Always preserve box age during transfer 2584 2585 series: list[tuple] = [ 2586 (30, 4), 2587 (60, 3), 2588 (90, 2), 2589 ] 2590 case = { 2591 3000: { 2592 'series': series, 2593 'rest': 15000, 2594 }, 2595 6000: { 2596 'series': series, 2597 'rest': 12000, 2598 }, 2599 9000: { 2600 'series': series, 2601 'rest': 9000, 2602 }, 2603 18000: { 2604 'series': series, 2605 'rest': 0, 2606 }, 2607 27000: { 2608 'series': series, 2609 'rest': -9000, 2610 }, 2611 36000: { 2612 'series': series, 2613 'rest': -18000, 2614 }, 2615 } 2616 2617 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 2618 2619 for total in case: 2620 if debug: 2621 print('--------------------------------------------------------') 2622 print(f'case[{total}]', case[total]) 2623 for x in case[total]['series']: 2624 self.track( 2625 unscaled_value=x[0], 2626 desc=f"test-{x} ages", 2627 account='ages', 2628 logging=True, 2629 created=selected_time * x[1], 2630 ) 2631 2632 unscaled_total = self.unscale(total) 2633 if debug: 2634 print('unscaled_total', unscaled_total) 2635 refs = self.transfer( 2636 unscaled_amount=unscaled_total, 2637 from_account='ages', 2638 to_account='future', 2639 desc='Zakat Movement', 2640 debug=debug, 2641 ) 2642 2643 if debug: 2644 print('refs', refs) 2645 2646 ages_cache_balance = self.balance('ages') 2647 ages_fresh_balance = self.balance('ages', False) 2648 rest = case[total]['rest'] 2649 if debug: 2650 print('source', ages_cache_balance, ages_fresh_balance, rest) 2651 assert ages_cache_balance == rest 2652 assert ages_fresh_balance == rest 2653 2654 future_cache_balance = self.balance('future') 2655 future_fresh_balance = self.balance('future', False) 2656 if debug: 2657 print('target', future_cache_balance, future_fresh_balance, total) 2658 print('refs', refs) 2659 assert future_cache_balance == total 2660 assert future_fresh_balance == total 2661 2662 # TODO: check boxes times for `ages` should equal box times in `future` 2663 for ref in self._vault['account']['ages']['box']: 2664 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 2665 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 2666 future_capital = 0 2667 if ref in self._vault['account']['future']['box']: 2668 future_capital = self._vault['account']['future']['box'][ref]['capital'] 2669 future_rest = 0 2670 if ref in self._vault['account']['future']['box']: 2671 future_rest = self._vault['account']['future']['box'][ref]['rest'] 2672 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 2673 if debug: 2674 print('================================================================') 2675 print('ages', ages_capital, ages_rest) 2676 print('future', future_capital, future_rest) 2677 if ages_rest == 0: 2678 assert ages_capital == future_capital 2679 elif ages_rest < 0: 2680 assert -ages_capital == future_capital 2681 elif ages_rest > 0: 2682 assert ages_capital == ages_rest + future_capital 2683 self.reset() 2684 assert len(self._vault['history']) == 0 2685 2686 assert self._history() 2687 assert self._history(False) is False 2688 assert self._history() is False 2689 assert self._history(True) 2690 assert self._history() 2691 if debug: 2692 print('####################################################################') 2693 2694 transaction = [ 2695 ( 2696 20, 'wallet', 1, -2000, -2000, -2000, 1, 1, 2697 2000, 2000, 2000, 1, 1, 2698 ), 2699 ( 2700 750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2, 2701 75000, 75000, 75000, 1, 1, 2702 ), 2703 ( 2704 600, 'safe', 'bank', 15000, 15000, 15000, 1, 2, 2705 60000, 60000, 60000, 1, 1, 2706 ), 2707 ] 2708 for z in transaction: 2709 self.lock() 2710 x = z[1] 2711 y = z[2] 2712 self.transfer( 2713 unscaled_amount=z[0], 2714 from_account=x, 2715 to_account=y, 2716 desc='test-transfer', 2717 debug=debug, 2718 ) 2719 zz = self.balance(x) 2720 if debug: 2721 print(zz, z) 2722 assert zz == z[3] 2723 xx = self.accounts()[x] 2724 assert xx == z[3] 2725 assert self.balance(x, False) == z[4] 2726 assert xx == z[4] 2727 2728 s = 0 2729 log = self._vault['account'][x]['log'] 2730 for i in log: 2731 s += log[i]['value'] 2732 if debug: 2733 print('s', s, 'z[5]', z[5]) 2734 assert s == z[5] 2735 2736 assert self.box_size(x) == z[6] 2737 assert self.log_size(x) == z[7] 2738 2739 yy = self.accounts()[y] 2740 assert self.balance(y) == z[8] 2741 assert yy == z[8] 2742 assert self.balance(y, False) == z[9] 2743 assert yy == z[9] 2744 2745 s = 0 2746 log = self._vault['account'][y]['log'] 2747 for i in log: 2748 s += log[i]['value'] 2749 assert s == z[10] 2750 2751 assert self.box_size(y) == z[11] 2752 assert self.log_size(y) == z[12] 2753 assert self.free(self.lock()) 2754 2755 if debug: 2756 pp().pprint(self.check(2.17)) 2757 2758 assert not self.nolock() 2759 history_count = len(self._vault['history']) 2760 if debug: 2761 print('history-count', history_count) 2762 assert history_count == 4 2763 assert not self.free(ZakatTracker.time()) 2764 assert self.free(self.lock()) 2765 assert self.nolock() 2766 assert len(self._vault['history']) == 3 2767 2768 # storage 2769 2770 _path = self.path(f'./zakat_test_db/test.{self.ext()}') 2771 if os.path.exists(_path): 2772 os.remove(_path) 2773 self.save() 2774 assert os.path.getsize(_path) > 0 2775 self.reset() 2776 assert self.recall(False, debug) is False 2777 self.load() 2778 assert self._vault['account'] is not None 2779 2780 # recall 2781 2782 assert self.nolock() 2783 assert len(self._vault['history']) == 3 2784 assert self.recall(False, debug) is True 2785 assert len(self._vault['history']) == 2 2786 assert self.recall(False, debug) is True 2787 assert len(self._vault['history']) == 1 2788 assert self.recall(False, debug) is True 2789 assert len(self._vault['history']) == 0 2790 assert self.recall(False, debug) is False 2791 assert len(self._vault['history']) == 0 2792 2793 # exchange 2794 2795 self.exchange("cash", 25, 3.75, "2024-06-25") 2796 self.exchange("cash", 22, 3.73, "2024-06-22") 2797 self.exchange("cash", 15, 3.69, "2024-06-15") 2798 self.exchange("cash", 10, 3.66) 2799 2800 for i in range(1, 30): 2801 exchange = self.exchange("cash", i) 2802 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2803 if debug: 2804 print(i, rate, description, created) 2805 assert created 2806 if i < 10: 2807 assert rate == 1 2808 assert description is None 2809 elif i == 10: 2810 assert rate == 3.66 2811 assert description is None 2812 elif i < 15: 2813 assert rate == 3.66 2814 assert description is None 2815 elif i == 15: 2816 assert rate == 3.69 2817 assert description is not None 2818 elif i < 22: 2819 assert rate == 3.69 2820 assert description is not None 2821 elif i == 22: 2822 assert rate == 3.73 2823 assert description is not None 2824 elif i >= 25: 2825 assert rate == 3.75 2826 assert description is not None 2827 exchange = self.exchange("bank", i) 2828 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2829 if debug: 2830 print(i, rate, description, created) 2831 assert created 2832 assert rate == 1 2833 assert description is None 2834 2835 assert len(self._vault['exchange']) > 0 2836 assert len(self.exchanges()) > 0 2837 self._vault['exchange'].clear() 2838 assert len(self._vault['exchange']) == 0 2839 assert len(self.exchanges()) == 0 2840 2841 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 2842 self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25") 2843 self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22") 2844 self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15") 2845 self.exchange("cash", ZakatTracker.day_to_time(10), 3.66) 2846 2847 for i in [x * 0.12 for x in range(-15, 21)]: 2848 if i <= 0: 2849 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0 2850 else: 2851 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0 2852 2853 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 2854 for i in range(1, 31): 2855 timestamp_ns = ZakatTracker.day_to_time(i) 2856 exchange = self.exchange("cash", timestamp_ns) 2857 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2858 if debug: 2859 print(i, rate, description, created) 2860 assert created 2861 if i < 10: 2862 assert rate == 1 2863 assert description is None 2864 elif i == 10: 2865 assert rate == 3.66 2866 assert description is None 2867 elif i < 15: 2868 assert rate == 3.66 2869 assert description is None 2870 elif i == 15: 2871 assert rate == 3.69 2872 assert description is not None 2873 elif i < 22: 2874 assert rate == 3.69 2875 assert description is not None 2876 elif i == 22: 2877 assert rate == 3.73 2878 assert description is not None 2879 elif i >= 25: 2880 assert rate == 3.75 2881 assert description is not None 2882 exchange = self.exchange("bank", i) 2883 rate, description, created = exchange['rate'], exchange['description'], exchange['time'] 2884 if debug: 2885 print(i, rate, description, created) 2886 assert created 2887 assert rate == 1 2888 assert description is None 2889 2890 # csv 2891 2892 csv_count = 1000 2893 2894 for with_rate, path in { 2895 False: 'test-import_csv-no-exchange', 2896 True: 'test-import_csv-with-exchange', 2897 }.items(): 2898 2899 if debug: 2900 print('test_import_csv', with_rate, path) 2901 2902 csv_path = path + '.csv' 2903 if os.path.exists(csv_path): 2904 os.remove(csv_path) 2905 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 2906 if debug: 2907 print('generate_random_csv_file', c) 2908 assert c == csv_count 2909 assert os.path.getsize(csv_path) > 0 2910 cache_path = self.import_csv_cache_path() 2911 if os.path.exists(cache_path): 2912 os.remove(cache_path) 2913 self.reset() 2914 (created, found, bad) = self.import_csv(csv_path, debug) 2915 bad_count = len(bad) 2916 assert bad_count > 0 2917 if debug: 2918 print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})") 2919 print('bad', bad) 2920 tmp_size = os.path.getsize(cache_path) 2921 assert tmp_size > 0 2922 # TODO: assert created + found + bad_count == csv_count 2923 # TODO: assert created == csv_count 2924 # TODO: assert bad_count == 0 2925 (created_2, found_2, bad_2) = self.import_csv(csv_path) 2926 bad_2_count = len(bad_2) 2927 if debug: 2928 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 2929 print('bad', bad) 2930 assert bad_2_count > 0 2931 # TODO: assert tmp_size == os.path.getsize(cache_path) 2932 # TODO: assert created_2 + found_2 + bad_2_count == csv_count 2933 # TODO: assert created == found_2 2934 # TODO: assert bad_count == bad_2_count 2935 # TODO: assert found_2 == csv_count 2936 # TODO: assert bad_2_count == 0 2937 # TODO: assert created_2 == 0 2938 2939 # payment parts 2940 2941 positive_parts = self.build_payment_parts(100, positive_only=True) 2942 assert self.check_payment_parts(positive_parts) != 0 2943 assert self.check_payment_parts(positive_parts) != 0 2944 all_parts = self.build_payment_parts(300, positive_only=False) 2945 assert self.check_payment_parts(all_parts) != 0 2946 assert self.check_payment_parts(all_parts) != 0 2947 if debug: 2948 pp().pprint(positive_parts) 2949 pp().pprint(all_parts) 2950 # dynamic discount 2951 suite = [] 2952 count = 3 2953 for exceed in [False, True]: 2954 case = [] 2955 for parts in [positive_parts, all_parts]: 2956 part = parts.copy() 2957 demand = part['demand'] 2958 if debug: 2959 print(demand, part['total']) 2960 i = 0 2961 z = demand / count 2962 cp = { 2963 'account': {}, 2964 'demand': demand, 2965 'exceed': exceed, 2966 'total': part['total'], 2967 } 2968 j = '' 2969 for x, y in part['account'].items(): 2970 x_exchange = self.exchange(x) 2971 zz = self.exchange_calc(z, 1, x_exchange['rate']) 2972 if exceed and zz <= demand: 2973 i += 1 2974 y['part'] = zz 2975 if debug: 2976 print(exceed, y) 2977 cp['account'][x] = y 2978 case.append(y) 2979 elif not exceed and y['balance'] >= zz: 2980 i += 1 2981 y['part'] = zz 2982 if debug: 2983 print(exceed, y) 2984 cp['account'][x] = y 2985 case.append(y) 2986 j = x 2987 if i >= count: 2988 break 2989 if len(cp['account'][j]) > 0: 2990 suite.append(cp) 2991 if debug: 2992 print('suite', len(suite)) 2993 # vault = self._vault.copy() 2994 for case in suite: 2995 # self._vault = vault.copy() 2996 if debug: 2997 print('case', case) 2998 result = self.check_payment_parts(case) 2999 if debug: 3000 print('check_payment_parts', result, f'exceed: {exceed}') 3001 assert result == 0 3002 3003 report = self.check(2.17, None, debug) 3004 (valid, brief, plan) = report 3005 if debug: 3006 print('valid', valid) 3007 zakat_result = self.zakat(report, parts=case, debug=debug) 3008 if debug: 3009 print('zakat-result', zakat_result) 3010 assert valid == zakat_result 3011 3012 assert self.save(path + f'.{self.ext()}') 3013 assert self.export_json(path + '.json') 3014 3015 assert self.export_json("1000-transactions-test.json") 3016 assert self.save(f"1000-transactions-test.{self.ext()}") 3017 3018 self.reset() 3019 3020 # test transfer between accounts with different exchange rate 3021 3022 a_SAR = "Bank (SAR)" 3023 b_USD = "Bank (USD)" 3024 c_SAR = "Safe (SAR)" 3025 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 3026 for case in [ 3027 (0, a_SAR, "SAR Gift", 1000, 100000), 3028 (1, a_SAR, 1), 3029 (0, b_USD, "USD Gift", 500, 50000), 3030 (1, b_USD, 1), 3031 (2, b_USD, 3.75), 3032 (1, b_USD, 3.75), 3033 (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500), 3034 (0, c_SAR, "Salary", 750, 75000), 3035 (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000), 3036 (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100), 3037 ]: 3038 if debug: 3039 print('case', case) 3040 match (case[0]): 3041 case 0: # track 3042 _, account, desc, x, balance = case 3043 self.track(unscaled_value=x, desc=desc, account=account, debug=debug) 3044 3045 cached_value = self.balance(account, cached=True) 3046 fresh_value = self.balance(account, cached=False) 3047 if debug: 3048 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 3049 assert cached_value == balance 3050 assert fresh_value == balance 3051 case 1: # check-exchange 3052 _, account, expected_rate = case 3053 t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 3054 if debug: 3055 print('t-exchange', t_exchange) 3056 assert t_exchange['rate'] == expected_rate 3057 case 2: # do-exchange 3058 _, account, rate = case 3059 self.exchange(account, rate=rate, debug=debug) 3060 b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 3061 if debug: 3062 print('b-exchange', b_exchange) 3063 assert b_exchange['rate'] == rate 3064 case 3: # transfer 3065 _, x, a, b, desc, a_balance, b_balance = case 3066 self.transfer(x, a, b, desc, debug=debug) 3067 3068 cached_value = self.balance(a, cached=True) 3069 fresh_value = self.balance(a, cached=False) 3070 if debug: 3071 print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value, 'a_balance', a_balance) 3072 assert cached_value == a_balance 3073 assert fresh_value == a_balance 3074 3075 cached_value = self.balance(b, cached=True) 3076 fresh_value = self.balance(b, cached=False) 3077 if debug: 3078 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 3079 assert cached_value == b_balance 3080 assert fresh_value == b_balance 3081 3082 # Transfer all in many chunks randomly from B to A 3083 a_SAR_balance = 137125 3084 b_USD_balance = 50100 3085 b_USD_exchange = self.exchange(b_USD) 3086 amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000) 3087 if debug: 3088 print('amounts', amounts) 3089 i = 0 3090 for x in amounts: 3091 if debug: 3092 print(f'{i} - transfer-with-exchange({x})') 3093 self.transfer( 3094 unscaled_amount=self.unscale(x), 3095 from_account=b_USD, 3096 to_account=a_SAR, 3097 desc=f"{x} USD -> SAR", 3098 debug=debug, 3099 ) 3100 3101 b_USD_balance -= x 3102 cached_value = self.balance(b_USD, cached=True) 3103 fresh_value = self.balance(b_USD, cached=False) 3104 if debug: 3105 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 3106 b_USD_balance) 3107 assert cached_value == b_USD_balance 3108 assert fresh_value == b_USD_balance 3109 3110 a_SAR_balance += int(x * b_USD_exchange['rate']) 3111 cached_value = self.balance(a_SAR, cached=True) 3112 fresh_value = self.balance(a_SAR, cached=False) 3113 if debug: 3114 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 3115 a_SAR_balance, 'rate', b_USD_exchange['rate']) 3116 assert cached_value == a_SAR_balance 3117 assert fresh_value == a_SAR_balance 3118 i += 1 3119 3120 # Transfer all in many chunks randomly from C to A 3121 c_SAR_balance = 37500 3122 amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000) 3123 if debug: 3124 print('amounts', amounts) 3125 i = 0 3126 for x in amounts: 3127 if debug: 3128 print(f'{i} - transfer-with-exchange({x})') 3129 self.transfer( 3130 unscaled_amount=self.unscale(x), 3131 from_account=c_SAR, 3132 to_account=a_SAR, 3133 desc=f"{x} SAR -> a_SAR", 3134 debug=debug, 3135 ) 3136 3137 c_SAR_balance -= x 3138 cached_value = self.balance(c_SAR, cached=True) 3139 fresh_value = self.balance(c_SAR, cached=False) 3140 if debug: 3141 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 3142 c_SAR_balance) 3143 assert cached_value == c_SAR_balance 3144 assert fresh_value == c_SAR_balance 3145 3146 a_SAR_balance += x 3147 cached_value = self.balance(a_SAR, cached=True) 3148 fresh_value = self.balance(a_SAR, cached=False) 3149 if debug: 3150 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 3151 a_SAR_balance) 3152 assert cached_value == a_SAR_balance 3153 assert fresh_value == a_SAR_balance 3154 i += 1 3155 3156 assert self.export_json("accounts-transfer-with-exchange-rates.json") 3157 assert self.save(f"accounts-transfer-with-exchange-rates.{self.ext()}") 3158 3159 # check & zakat with exchange rates for many cycles 3160 3161 for rate, values in { 3162 1: { 3163 'in': [1000, 2000, 10000], 3164 'exchanged': [100000, 200000, 1000000], 3165 'out': [2500, 5000, 73140], 3166 }, 3167 3.75: { 3168 'in': [200, 1000, 5000], 3169 'exchanged': [75000, 375000, 1875000], 3170 'out': [1875, 9375, 137138], 3171 }, 3172 }.items(): 3173 a, b, c = values['in'] 3174 m, n, o = values['exchanged'] 3175 x, y, z = values['out'] 3176 if debug: 3177 print('rate', rate, 'values', values) 3178 for case in [ 3179 (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 3180 {'safe': {0: {'below_nisab': x}}}, 3181 ], False, m), 3182 (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 3183 {'safe': {0: {'count': 1, 'total': y}}}, 3184 ], True, n), 3185 (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [ 3186 {'cave': {0: {'count': 3, 'total': z}}}, 3187 ], True, o), 3188 ]: 3189 if debug: 3190 print(f"############# check(rate: {rate}) #############") 3191 print('case', case) 3192 self.reset() 3193 self.exchange(account=case[1], created=case[2], rate=rate) 3194 self.track(unscaled_value=case[0], desc='test-check', account=case[1], logging=True, created=case[2]) 3195 assert self.snapshot() 3196 3197 # assert self.nolock() 3198 # history_size = len(self._vault['history']) 3199 # print('history_size', history_size) 3200 # assert history_size == 2 3201 assert self.lock() 3202 assert not self.nolock() 3203 report = self.check(2.17, None, debug) 3204 (valid, brief, plan) = report 3205 if debug: 3206 print('brief', brief) 3207 assert valid == case[4] 3208 assert case[5] == brief[0] 3209 assert case[5] == brief[1] 3210 3211 if debug: 3212 pp().pprint(plan) 3213 3214 for x in plan: 3215 assert case[1] == x 3216 if 'total' in case[3][0][x][0].keys(): 3217 assert case[3][0][x][0]['total'] == int(brief[2]) 3218 assert int(plan[x][0]['total']) == case[3][0][x][0]['total'] 3219 assert int(plan[x][0]['count']) == case[3][0][x][0]['count'] 3220 else: 3221 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 3222 if debug: 3223 pp().pprint(report) 3224 result = self.zakat(report, debug=debug) 3225 if debug: 3226 print('zakat-result', result, case[4]) 3227 assert result == case[4] 3228 report = self.check(2.17, None, debug) 3229 (valid, brief, plan) = report 3230 assert valid is False 3231 3232 history_size = len(self._vault['history']) 3233 if debug: 3234 print('history_size', history_size) 3235 assert history_size == 3 3236 assert not self.nolock() 3237 assert self.recall(False, debug) is False 3238 self.free(self.lock()) 3239 assert self.nolock() 3240 3241 for i in range(3, 0, -1): 3242 history_size = len(self._vault['history']) 3243 if debug: 3244 print('history_size', history_size) 3245 assert history_size == i 3246 assert self.recall(False, debug) is True 3247 3248 assert self.nolock() 3249 assert self.recall(False, debug) is False 3250 3251 history_size = len(self._vault['history']) 3252 if debug: 3253 print('history_size', history_size) 3254 assert history_size == 0 3255 3256 account_size = len(self._vault['account']) 3257 if debug: 3258 print('account_size', account_size) 3259 assert account_size == 0 3260 3261 report_size = len(self._vault['report']) 3262 if debug: 3263 print('report_size', report_size) 3264 assert report_size == 0 3265 3266 assert self.nolock() 3267 return True 3268 except Exception as e: 3269 # pp().pprint(self._vault) 3270 assert self.export_json("test-snapshot.json") 3271 assert self.save(f"test-snapshot.{self.ext()}") 3272 raise e
3275def test(debug: bool = False): 3276 ledger = ZakatTracker("./zakat_test_db/zakat.camel") 3277 start = ZakatTracker.time() 3278 assert ledger.test(debug=debug) 3279 if debug: 3280 print("#########################") 3281 print("######## TEST DONE ########") 3282 print("#########################") 3283 print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start)) 3284 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}")