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