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