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