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