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