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