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

A class for tracking and calculating Zakat.

This class provides functionalities for recording transactions, calculating Zakat due, and managing account balances. It also offers features like importing transactions from CSV files, exporting data to JSON format, and saving/loading the tracker state.

The ZakatTracker class is designed to handle both positive and negative transactions, allowing for flexible tracking of financial activities related to Zakat. It also supports the concept of a "Nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due based on the current silver price.

The class uses a pickle file as its database to persist the tracker state, ensuring data integrity across sessions. It also provides options for enabling or disabling history tracking, allowing users to choose their preferred level of detail.

In addition, the ZakatTracker class includes various helper methods like time, time_to_datetime, lock, free, recall, export_json, and more. These methods provide additional functionalities and flexibility for interacting with and managing the Zakat tracker.

Attributes: ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage. ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat. ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price. ZakatTracker.Version (function): The version of the ZakatTracker class.

Data Structure: The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data.

_vault (dict):
    - account (dict):
        - {account_number} (dict):
            - balance (int): The current balance of the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - count (int): The number of times Zakat has been calculated for this transaction.
                    - last (int): The timestamp of the last Zakat calculation.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - total (int): The total Zakat deducted from this transaction.
            - count (int): The total number of transactions for the account.
            - log (dict): A dictionary storing transaction logs.
                - {timestamp} (dict):
                    - value (int): The transaction amount (positive or negative).
                    - desc (str): The description of the transaction.
                    - ref (int): The box reference (positive or None).
                    - file (dict): A dictionary storing file references associated with the transaction.
            - hide (bool): Indicates whether the account is hidden or not.
            - zakatable (bool): Indicates whether the account is subject to Zakat.
    - exchange (dict):
        - account (dict):
            - {timestamps} (dict):
                - rate (float): Exchange rate when compared to local currency.
                - description (str): The description of the exchange rate.
    - history (dict):
        - {timestamp} (list): A list of dictionaries storing the history of actions performed.
            - {action_dict} (dict):
                - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
                - account (str): The account number associated with the action.
                - ref (int): The reference number of the transaction.
                - file (int): The reference number of the file (if applicable).
                - key (str): The key associated with the action (e.g., 'rest', 'total').
                - value (int): The value associated with the action.
                - math (MathOperation): The mathematical operation performed (if applicable).
    - lock (int or None): The timestamp indicating the current lock status (None if not locked).
    - report (dict):
        - {timestamp} (tuple): A tuple storing Zakat report details.
ZakatTracker(db_path: str = 'zakat.pickle', history_mode: bool = True)
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

@staticmethod
def Version() -> str:
176    @staticmethod
177    def Version() -> str:
178        """
179        Returns the current version of the software.
180
181        This function returns a string representing the current version of the software,
182        including major, minor, and patch version numbers in the format "X.Y.Z".
183
184        Returns:
185        str: The current version of the software.
186        """
187        return '0.2.79'

Returns the current version of the software.

This function returns a string representing the current version of the software, including major, minor, and patch version numbers in the format "X.Y.Z".

Returns: str: The current version of the software.

@staticmethod
def ZakatCut(x: float) -> float:
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.

@staticmethod
def TimeCycle(days: int = 355) -> int:
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.

@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
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.
def path(self, path: str = None) -> str:
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.

def base_path(self, *args) -> str:
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.

@staticmethod
def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
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
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
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.

def reset(self) -> None:
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

@staticmethod
def time( now: <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'> = None) -> int:
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.

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
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.

def clean_history(self, lock: int | None = None) -> int:
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.

def nolock(self) -> bool:
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.

def lock(self) -> int:
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.

def vault(self) -> dict:
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.

def stats(self) -> dict[str, tuple]:
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')
def files(self) -> list[dict[str, str | int]]:
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']}")
def steps(self) -> dict:
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.

def free(self, lock: int, auto_save: bool = True) -> bool:
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.

def account_exists(self, account) -> bool:
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.

def box_size(self, account) -> int:
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.

def log_size(self, account) -> int:
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.

@staticmethod
def file_hash(file_path: str, algorithm: str = 'blake2b') -> str:
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.

def snapshot_cache_path(self):
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.

def snapshot(self) -> bool:
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.

def snapshots( self, hide_missing: bool = True, verified_hash_only: bool = False) -> dict[int, tuple[str, str, bool]]:
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.
def recall(self, dry=True, debug=False) -> bool:
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.

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
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.

def box_exists(self, account: str, ref: int) -> bool:
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.
def track( self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
 941    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
 942              debug: bool = False) -> int:
 943        """
 944        This function tracks a transaction for a specific account.
 945
 946        Parameters:
 947        value (float): The value of the transaction. Default is 0.
 948        desc (str): The description of the transaction. Default is an empty string.
 949        account (str): The account for which the transaction is being tracked. Default is '1'.
 950        logging (bool): Whether to log the transaction. Default is True.
 951        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
 952        debug (bool): Whether to print debug information. Default is False.
 953
 954        Returns:
 955        int: The timestamp of the transaction.
 956
 957        This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
 958
 959        Raises:
 960        ValueError: The log transaction happened again in the same nanosecond time.
 961        ValueError: The box transaction happened again in the same nanosecond time.
 962        """
 963        if debug:
 964            print('track', f'debug={debug}')
 965        if created is None:
 966            created = self.time()
 967        no_lock = self.nolock()
 968        self.lock()
 969        if not self.account_exists(account):
 970            if debug:
 971                print(f"account {account} created")
 972            self._vault['account'][account] = {
 973                'balance': 0,
 974                'box': {},
 975                'count': 0,
 976                'log': {},
 977                'hide': False,
 978                'zakatable': True,
 979            }
 980            self._step(Action.CREATE, account)
 981        if value == 0:
 982            if no_lock:
 983                self.free(self.lock())
 984            return 0
 985        if logging:
 986            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
 987        if debug:
 988            print('create-box', created)
 989        if self.box_exists(account, created):
 990            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
 991        if debug:
 992            print('created-box', created)
 993        self._vault['account'][account]['box'][created] = {
 994            'capital': value,
 995            'count': 0,
 996            'last': 0,
 997            'rest': value,
 998            'total': 0,
 999        }
1000        self._step(Action.TRACK, account, ref=created, value=value)
1001        if no_lock:
1002            self.free(self.lock())
1003        return created

This function tracks a transaction for a specific account.

Parameters: value (float): The value of the transaction. Default is 0. desc (str): The description of the transaction. Default is an empty string. account (str): The account for which the transaction is being tracked. Default is '1'. logging (bool): Whether to log the transaction. Default is True. created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. debug (bool): Whether to print debug information. Default is False.

Returns: int: The timestamp of the transaction.

This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.

Raises: ValueError: The log transaction happened again in the same nanosecond time. ValueError: The box transaction happened again in the same nanosecond time.

def log_exists(self, account: str, ref: int) -> bool:
1005    def log_exists(self, account: str, ref: int) -> bool:
1006        """
1007        Checks if a specific transaction log entry exists for a given account.
1008
1009        Parameters:
1010        account (str): The account number associated with the transaction log.
1011        ref (int): The reference to the transaction log entry.
1012
1013        Returns:
1014        bool: True if the transaction log entry exists, False otherwise.
1015        """
1016        return self.ref_exists(account, 'log', ref)

Checks if a specific transaction log entry exists for a given account.

Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry.

Returns: bool: True if the transaction log entry exists, False otherwise.

def exchange( self, account, created: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
1062    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1063                 debug: bool = False) -> dict:
1064        """
1065        This method is used to record or retrieve exchange rates for a specific account.
1066
1067        Parameters:
1068        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1069        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1070        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1071        - description (str): A description of the exchange rate.
1072
1073        Returns:
1074        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1075        it returns a dictionary with default values for the rate and description.
1076        """
1077        if debug:
1078            print('exchange', f'debug={debug}')
1079        if created is None:
1080            created = self.time()
1081        no_lock = self.nolock()
1082        self.lock()
1083        if rate is not None:
1084            if rate <= 0:
1085                return dict()
1086            if account not in self._vault['exchange']:
1087                self._vault['exchange'][account] = {}
1088            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1089                return {"time": created, "rate": 1, "description": None}
1090            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1091            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1092            if no_lock:
1093                self.free(self.lock())
1094            if debug:
1095                print("exchange-created-1",
1096                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1097
1098        if account in self._vault['exchange']:
1099            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1100            if valid_rates:
1101                latest_rate = max(valid_rates, key=lambda x: x[0])
1102                if debug:
1103                    print("exchange-read-1",
1104                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1105                          'latest_rate', latest_rate)
1106                result = latest_rate[1]
1107                result['time'] = latest_rate[0]
1108                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1109        if debug:
1110            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1111        return {"time": created, "rate": 1, "description": None}  # إرجاع القيمة الافتراضية مع وصف فارغ

This method is used to record or retrieve exchange rates for a specific account.

Parameters:

  • account (str): The account number for which the exchange rate is being recorded or retrieved.
  • created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str): A description of the exchange rate.

Returns:

  • dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1113    @staticmethod
1114    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1115        """
1116        This function calculates the exchanged amount of a currency.
1117
1118        Args:
1119            x (float): The original amount of the currency.
1120            x_rate (float): The exchange rate of the original currency.
1121            y_rate (float): The exchange rate of the target currency.
1122
1123        Returns:
1124            float: The exchanged amount of the target currency.
1125        """
1126        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

Args: x (float): The original amount of the currency. x_rate (float): The exchange rate of the original currency. y_rate (float): The exchange rate of the target currency.

Returns: float: The exchanged amount of the target currency.

def exchanges(self) -> dict:
1128    def exchanges(self) -> dict:
1129        """
1130        Retrieve the recorded exchange rates for all accounts.
1131
1132        Parameters:
1133        None
1134
1135        Returns:
1136        dict: A dictionary containing all recorded exchange rates.
1137        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1138        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1139        """
1140        return self._vault['exchange'].copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

Returns: dict: A dictionary containing all recorded exchange rates. The keys are account names or numbers, and the values are dictionaries containing the exchange rates. Each exchange rate dictionary has timestamps as keys and exchange rate details as values.

def accounts(self) -> dict:
1142    def accounts(self) -> dict:
1143        """
1144        Returns a dictionary containing account numbers as keys and their respective balances as values.
1145
1146        Parameters:
1147        None
1148
1149        Returns:
1150        dict: A dictionary where keys are account numbers and values are their respective balances.
1151        """
1152        result = {}
1153        for i in self._vault['account']:
1154            result[i] = self._vault['account'][i]['balance']
1155        return result

Returns a dictionary containing account numbers as keys and their respective balances as values.

Parameters: None

Returns: dict: A dictionary where keys are account numbers and values are their respective balances.

def boxes(self, account) -> dict:
1157    def boxes(self, account) -> dict:
1158        """
1159        Retrieve the boxes (transactions) associated with a specific account.
1160
1161        Parameters:
1162        account (str): The account number for which to retrieve the boxes.
1163
1164        Returns:
1165        dict: A dictionary containing the boxes associated with the given account.
1166        If the account does not exist, an empty dictionary is returned.
1167        """
1168        if self.account_exists(account):
1169            return self._vault['account'][account]['box']
1170        return {}

Retrieve the boxes (transactions) associated with a specific account.

Parameters: account (str): The account number for which to retrieve the boxes.

Returns: dict: A dictionary containing the boxes associated with the given account. If the account does not exist, an empty dictionary is returned.

def logs(self, account) -> dict:
1172    def logs(self, account) -> dict:
1173        """
1174        Retrieve the logs (transactions) associated with a specific account.
1175
1176        Parameters:
1177        account (str): The account number for which to retrieve the logs.
1178
1179        Returns:
1180        dict: A dictionary containing the logs associated with the given account.
1181        If the account does not exist, an empty dictionary is returned.
1182        """
1183        if self.account_exists(account):
1184            return self._vault['account'][account]['log']
1185        return {}

Retrieve the logs (transactions) associated with a specific account.

Parameters: account (str): The account number for which to retrieve the logs.

Returns: dict: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.

def add_file(self, account: str, ref: int, path: str) -> int:
1187    def add_file(self, account: str, ref: int, path: str) -> int:
1188        """
1189        Adds a file reference to a specific transaction log entry in the vault.
1190
1191        Parameters:
1192        account (str): The account number associated with the transaction log.
1193        ref (int): The reference to the transaction log entry.
1194        path (str): The path of the file to be added.
1195
1196        Returns:
1197        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1198        """
1199        if self.account_exists(account):
1200            if ref in self._vault['account'][account]['log']:
1201                file_ref = self.time()
1202                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1203                no_lock = self.nolock()
1204                self.lock()
1205                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1206                if no_lock:
1207                    self.free(self.lock())
1208                return file_ref
1209        return 0

Adds a file reference to a specific transaction log entry in the vault.

Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry. path (str): The path of the file to be added.

Returns: int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.

def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1211    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1212        """
1213        Removes a file reference from a specific transaction log entry in the vault.
1214
1215        Parameters:
1216        account (str): The account number associated with the transaction log.
1217        ref (int): The reference to the transaction log entry.
1218        file_ref (int): The reference of the file to be removed.
1219
1220        Returns:
1221        bool: True if the file reference is successfully removed, False otherwise.
1222        """
1223        if self.account_exists(account):
1224            if ref in self._vault['account'][account]['log']:
1225                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1226                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1227                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1228                    no_lock = self.nolock()
1229                    self.lock()
1230                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1231                    if no_lock:
1232                        self.free(self.lock())
1233                    return True
1234        return False

Removes a file reference from a specific transaction log entry in the vault.

Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry. file_ref (int): The reference of the file to be removed.

Returns: bool: True if the file reference is successfully removed, False otherwise.

def balance(self, account: str = 1, cached: bool = True) -> int:
1236    def balance(self, account: str = 1, cached: bool = True) -> int:
1237        """
1238        Calculate and return the balance of a specific account.
1239
1240        Parameters:
1241        account (str): The account number. Default is '1'.
1242        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1243
1244        Returns:
1245        int: The balance of the account.
1246
1247        Note:
1248        If cached is True, the function returns the cached balance.
1249        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1250        """
1251        if cached:
1252            return self._vault['account'][account]['balance']
1253        x = 0
1254        return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]

Calculate and return the balance of a specific account.

Parameters: account (str): The account number. Default is '1'. cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns: int: The balance of the account.

Note: If cached is True, the function returns the cached balance. If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.

def hide(self, account, status: bool = None) -> bool:
1256    def hide(self, account, status: bool = None) -> bool:
1257        """
1258        Check or set the hide status of a specific account.
1259
1260        Parameters:
1261        account (str): The account number.
1262        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1263
1264        Returns:
1265        bool: The current or updated hide status of the account.
1266
1267        Raises:
1268        None
1269
1270        Example:
1271        >>> tracker = ZakatTracker()
1272        >>> ref = tracker.track(51, 'desc', 'account1')
1273        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1274        False
1275        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1276        True
1277        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1278        True
1279        >>> tracker.hide('account1', False)
1280        False
1281        """
1282        if self.account_exists(account):
1283            if status is None:
1284                return self._vault['account'][account]['hide']
1285            self._vault['account'][account]['hide'] = status
1286            return status
1287        return False

Check or set the hide status of a specific account.

Parameters: account (str): The account number. status (bool, optional): The new hide status. If not provided, the function will return the current status.

Returns: bool: The current or updated hide status of the account.

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.hide('account1')  # Set the hide status of 'account1' to True
False
>>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
True
>>> tracker.hide('account1')  # Get the hide status of 'account1' by default
True
>>> tracker.hide('account1', False)
False
def zakatable(self, account, status: bool = None) -> bool:
1289    def zakatable(self, account, status: bool = None) -> bool:
1290        """
1291        Check or set the zakatable status of a specific account.
1292
1293        Parameters:
1294        account (str): The account number.
1295        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1296
1297        Returns:
1298        bool: The current or updated zakatable status of the account.
1299
1300        Raises:
1301        None
1302
1303        Example:
1304        >>> tracker = ZakatTracker()
1305        >>> ref = tracker.track(51, 'desc', 'account1')
1306        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1307        True
1308        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1309        True
1310        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1311        True
1312        >>> tracker.zakatable('account1', False)
1313        False
1314        """
1315        if self.account_exists(account):
1316            if status is None:
1317                return self._vault['account'][account]['zakatable']
1318            self._vault['account'][account]['zakatable'] = status
1319            return status
1320        return False

Check or set the zakatable status of a specific account.

Parameters: account (str): The account number. status (bool, optional): The new zakatable status. If not provided, the function will return the current status.

Returns: bool: The current or updated zakatable status of the account.

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
def sub( self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1322    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1323        """
1324        Subtracts a specified value from an account's balance.
1325
1326        Parameters:
1327        x (float): The amount to be subtracted.
1328        desc (str): A description for the transaction. Defaults to an empty string.
1329        account (str): The account from which the value will be subtracted. Defaults to '1'.
1330        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1331        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1332
1333        Returns:
1334        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1335
1336        If the amount to subtract is greater than the account's balance,
1337        the remaining amount will be transferred to a new transaction with a negative value.
1338
1339        Raises:
1340        ValueError: The box transaction happened again in the same nanosecond time.
1341        ValueError: The log transaction happened again in the same nanosecond time.
1342        """
1343        if debug:
1344            print('sub', f'debug={debug}')
1345        if x < 0:
1346            return tuple()
1347        if x == 0:
1348            ref = self.track(x, '', account)
1349            return ref, ref
1350        if created is None:
1351            created = self.time()
1352        no_lock = self.nolock()
1353        self.lock()
1354        self.track(0, '', account)
1355        self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug)
1356        ids = sorted(self._vault['account'][account]['box'].keys())
1357        limit = len(ids) + 1
1358        target = x
1359        if debug:
1360            print('ids', ids)
1361        ages = []
1362        for i in range(-1, -limit, -1):
1363            if target == 0:
1364                break
1365            j = ids[i]
1366            if debug:
1367                print('i', i, 'j', j)
1368            rest = self._vault['account'][account]['box'][j]['rest']
1369            if rest >= target:
1370                self._vault['account'][account]['box'][j]['rest'] -= target
1371                self._step(Action.SUB, account, ref=j, value=target)
1372                ages.append((j, target))
1373                target = 0
1374                break
1375            elif target > rest > 0:
1376                chunk = rest
1377                target -= chunk
1378                self._step(Action.SUB, account, ref=j, value=chunk)
1379                ages.append((j, chunk))
1380                self._vault['account'][account]['box'][j]['rest'] = 0
1381        if target > 0:
1382            self.track(-target, desc, account, False, created)
1383            ages.append((created, target))
1384        if no_lock:
1385            self.free(self.lock())
1386        return created, ages

Subtracts a specified value from an account's balance.

Parameters: x (float): The amount to be subtracted. desc (str): A description for the transaction. Defaults to an empty string. account (str): The account from which the value will be subtracted. Defaults to '1'. created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.

Returns: tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.

If the amount to subtract is greater than the account's balance, the remaining amount will be transferred to a new transaction with a negative value.

Raises: ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.

def transfer( self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1388    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1389                 debug: bool = False) -> list[int]:
1390        """
1391        Transfers a specified value from one account to another.
1392
1393        Parameters:
1394        amount (int): The amount to be transferred.
1395        from_account (str): The account from which the value will be transferred.
1396        to_account (str): The account to which the value will be transferred.
1397        desc (str, optional): A description for the transaction. Defaults to an empty string.
1398        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1399        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1400
1401        Returns:
1402        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1403
1404        Raises:
1405        ValueError: Transfer to the same account is forbidden.
1406        ValueError: The box transaction happened again in the same nanosecond time.
1407        ValueError: The log transaction happened again in the same nanosecond time.
1408        """
1409        if debug:
1410            print('transfer', f'debug={debug}')
1411        if from_account == to_account:
1412            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1413        if amount <= 0:
1414            return []
1415        if created is None:
1416            created = self.time()
1417        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1418        times = []
1419        source_exchange = self.exchange(from_account, created)
1420        target_exchange = self.exchange(to_account, created)
1421
1422        if debug:
1423            print('ages', ages)
1424
1425        for age, value in ages:
1426            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1427            # Perform the transfer
1428            if self.box_exists(to_account, age):
1429                if debug:
1430                    print('box_exists', age)
1431                capital = self._vault['account'][to_account]['box'][age]['capital']
1432                rest = self._vault['account'][to_account]['box'][age]['rest']
1433                if debug:
1434                    print(
1435                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1436                selected_age = age
1437                if rest + target_amount > capital:
1438                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1439                    selected_age = ZakatTracker.time()
1440                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1441                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1442                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1443                              created=None, ref=None, debug=debug)
1444                times.append((age, y))
1445                continue
1446            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1447            if debug:
1448                print(
1449                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1450            times.append(y)
1451        return times

Transfers a specified value from one account to another.

Parameters: amount (int): The amount to be transferred. from_account (str): The account from which the value will be transferred. to_account (str): The account to which the value will be transferred. desc (str, optional): A description for the transaction. Defaults to an empty string. created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.

Returns: list[int]: A list of timestamps corresponding to the transactions made during the transfer.

Raises: ValueError: Transfer to the same account is forbidden. ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.

def check( self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1453    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1454              cycle: float = None) -> tuple:
1455        """
1456        Check the eligibility for Zakat based on the given parameters.
1457
1458        Parameters:
1459        silver_gram_price (float): The price of a gram of silver.
1460        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1461                        it will be calculated based on the silver_gram_price.
1462        debug (bool): Flag to enable debug mode.
1463        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1464        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1465
1466        Returns:
1467        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1468        and a dictionary containing the Zakat plan.
1469        """
1470        if debug:
1471            print('check', f'debug={debug}')
1472        if now is None:
1473            now = self.time()
1474        if cycle is None:
1475            cycle = ZakatTracker.TimeCycle()
1476        if nisab is None:
1477            nisab = ZakatTracker.Nisab(silver_gram_price)
1478        plan = {}
1479        below_nisab = 0
1480        brief = [0, 0, 0]
1481        valid = False
1482        if debug:
1483            print('exchanges', self.exchanges())
1484        for x in self._vault['account']:
1485            if not self.zakatable(x):
1486                continue
1487            _box = self._vault['account'][x]['box']
1488            _log = self._vault['account'][x]['log']
1489            limit = len(_box) + 1
1490            ids = sorted(self._vault['account'][x]['box'].keys())
1491            for i in range(-1, -limit, -1):
1492                j = ids[i]
1493                rest = float(_box[j]['rest'])
1494                if rest <= 0:
1495                    continue
1496                exchange = self.exchange(x, created=self.time())
1497                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1498                brief[0] += rest
1499                index = limit + i - 1
1500                epoch = (now - j) / cycle
1501                if debug:
1502                    print(f"Epoch: {epoch}", _box[j])
1503                if _box[j]['last'] > 0:
1504                    epoch = (now - _box[j]['last']) / cycle
1505                if debug:
1506                    print(f"Epoch: {epoch}")
1507                epoch = floor(epoch)
1508                if debug:
1509                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1510                if epoch == 0:
1511                    continue
1512                if debug:
1513                    print("Epoch - PASSED")
1514                brief[1] += rest
1515                if rest >= nisab:
1516                    total = 0
1517                    for _ in range(epoch):
1518                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1519                    if total > 0:
1520                        if x not in plan:
1521                            plan[x] = {}
1522                        valid = True
1523                        brief[2] += total
1524                        plan[x][index] = {
1525                            'total': total,
1526                            'count': epoch,
1527                            'box_time': j,
1528                            'box_capital': _box[j]['capital'],
1529                            'box_rest': _box[j]['rest'],
1530                            'box_last': _box[j]['last'],
1531                            'box_total': _box[j]['total'],
1532                            'box_count': _box[j]['count'],
1533                            'box_log': _log[j]['desc'],
1534                            'exchange_rate': exchange['rate'],
1535                            'exchange_time': exchange['time'],
1536                            'exchange_desc': exchange['description'],
1537                        }
1538                else:
1539                    chunk = ZakatTracker.ZakatCut(float(rest))
1540                    if chunk > 0:
1541                        if x not in plan:
1542                            plan[x] = {}
1543                        if j not in plan[x].keys():
1544                            plan[x][index] = {}
1545                        below_nisab += rest
1546                        brief[2] += chunk
1547                        plan[x][index]['below_nisab'] = chunk
1548                        plan[x][index]['total'] = chunk
1549                        plan[x][index]['count'] = epoch
1550                        plan[x][index]['box_time'] = j
1551                        plan[x][index]['box_capital'] = _box[j]['capital']
1552                        plan[x][index]['box_rest'] = _box[j]['rest']
1553                        plan[x][index]['box_last'] = _box[j]['last']
1554                        plan[x][index]['box_total'] = _box[j]['total']
1555                        plan[x][index]['box_count'] = _box[j]['count']
1556                        plan[x][index]['box_log'] = _log[j]['desc']
1557                        plan[x][index]['exchange_rate'] = exchange['rate']
1558                        plan[x][index]['exchange_time'] = exchange['time']
1559                        plan[x][index]['exchange_desc'] = exchange['description']
1560        valid = valid or below_nisab >= nisab
1561        if debug:
1562            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1563        return valid, brief, plan

Check the eligibility for Zakat based on the given parameters.

Parameters: silver_gram_price (float): The price of a gram of silver. nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price. debug (bool): Flag to enable debug mode. now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().

Returns: tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.

def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1565    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1566        """
1567        Build payment parts for the Zakat distribution.
1568
1569        Parameters:
1570        demand (float): The total demand for payment in local currency.
1571        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1572
1573        Returns:
1574        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1575        {
1576            'account': {
1577                'account_id': {'balance': float, 'rate': float, 'part': float},
1578                ...
1579            },
1580            'exceed': bool,
1581            'demand': float,
1582            'total': float,
1583        }
1584        """
1585        total = 0
1586        parts = {
1587            'account': {},
1588            'exceed': False,
1589            'demand': demand,
1590        }
1591        for x, y in self.accounts().items():
1592            if positive_only and y <= 0:
1593                continue
1594            total += float(y)
1595            exchange = self.exchange(x)
1596            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1597        parts['total'] = total
1598        return parts

Build payment parts for the Zakat distribution.

Parameters: demand (float): The total demand for payment in local currency. positive_only (bool): If True, only consider accounts with positive balance. Default is True.

Returns: dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: { 'account': { 'account_id': {'balance': float, 'rate': float, 'part': float}, ... }, 'exceed': bool, 'demand': float, 'total': float, }

@staticmethod
def check_payment_parts(parts: dict, debug: bool = False) -> int:
1600    @staticmethod
1601    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1602        """
1603        Checks the validity of payment parts.
1604
1605        Parameters:
1606        parts (dict): A dictionary containing payment parts information.
1607        debug (bool): Flag to enable debug mode.
1608
1609        Returns:
1610        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1611
1612        Error Codes:
1613        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1614        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1615        3: 'part' value in parts['account'][x] is less than 0.
1616        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1617        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1618        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1619        """
1620        if debug:
1621            print('check_payment_parts', f'debug={debug}')
1622        for i in ['demand', 'account', 'total', 'exceed']:
1623            if i not in parts:
1624                return 1
1625        exceed = parts['exceed']
1626        for x in parts['account']:
1627            for j in ['balance', 'rate', 'part']:
1628                if j not in parts['account'][x]:
1629                    return 2
1630                if parts['account'][x]['part'] < 0:
1631                    return 3
1632                if not exceed and parts['account'][x]['balance'] <= 0:
1633                    return 4
1634        demand = parts['demand']
1635        z = 0
1636        for _, y in parts['account'].items():
1637            if not exceed and y['part'] > y['balance']:
1638                return 5
1639            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1640        z = round(z, 2)
1641        demand = round(demand, 2)
1642        if debug:
1643            print('check_payment_parts', f'z = {z}, demand = {demand}')
1644            print('check_payment_parts', type(z), type(demand))
1645            print('check_payment_parts', z != demand)
1646            print('check_payment_parts', str(z) != str(demand))
1647        if z != demand and str(z) != str(demand):
1648            return 6
1649        return 0

Checks the validity of payment parts.

Parameters: parts (dict): A dictionary containing payment parts information. debug (bool): Flag to enable debug mode.

Returns: int: Returns 0 if the payment parts are valid, otherwise returns the error code.

Error Codes: 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3: 'part' value in parts['account'][x] is less than 0. 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 6: The sum of 'part' values in parts['account'] does not match with 'demand' value.

def zakat( self, report: tuple, parts: Dict[str, Union[Dict, bool, Any]] = None, debug: bool = False) -> bool:
1651    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1652        """
1653        Perform Zakat calculation based on the given report and optional parts.
1654
1655        Parameters:
1656        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1657        parts (dict): A dictionary containing the payment parts for the zakat.
1658        debug (bool): A flag indicating whether to print debug information.
1659
1660        Returns:
1661        bool: True if the zakat calculation is successful, False otherwise.
1662        """
1663        if debug:
1664            print('zakat', f'debug={debug}')
1665        valid, _, plan = report
1666        if not valid:
1667            return valid
1668        parts_exist = parts is not None
1669        if parts_exist:
1670            if self.check_payment_parts(parts, debug=debug) != 0:
1671                return False
1672        if debug:
1673            print('######### zakat #######')
1674            print('parts_exist', parts_exist)
1675        no_lock = self.nolock()
1676        self.lock()
1677        report_time = self.time()
1678        self._vault['report'][report_time] = report
1679        self._step(Action.REPORT, ref=report_time)
1680        created = self.time()
1681        for x in plan:
1682            target_exchange = self.exchange(x)
1683            if debug:
1684                print(plan[x])
1685                print('-------------')
1686                print(self._vault['account'][x]['box'])
1687            ids = sorted(self._vault['account'][x]['box'].keys())
1688            if debug:
1689                print('plan[x]', plan[x])
1690            for i in plan[x].keys():
1691                j = ids[i]
1692                if debug:
1693                    print('i', i, 'j', j)
1694                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1695                           key='last',
1696                           math_operation=MathOperation.EQUAL)
1697                self._vault['account'][x]['box'][j]['last'] = created
1698                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1699                self._vault['account'][x]['box'][j]['total'] += amount
1700                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1701                           math_operation=MathOperation.ADDITION)
1702                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1703                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1704                           math_operation=MathOperation.ADDITION)
1705                if not parts_exist:
1706                    try:
1707                        self._vault['account'][x]['box'][j]['rest'] -= amount
1708                    except TypeError:
1709                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1710                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1711                    #            math_operation=MathOperation.SUBTRACTION)
1712                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1713        if parts_exist:
1714            for account, part in parts['account'].items():
1715                if part['part'] == 0:
1716                    continue
1717                if debug:
1718                    print('zakat-part', account, part['rate'])
1719                target_exchange = self.exchange(account)
1720                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1721                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1722        if no_lock:
1723            self.free(self.lock())
1724        return True

Perform Zakat calculation based on the given report and optional parts.

Parameters: report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. parts (dict): A dictionary containing the payment parts for the zakat. debug (bool): A flag indicating whether to print debug information.

Returns: bool: True if the zakat calculation is successful, False otherwise.

def export_json(self, path: str = 'data.json') -> bool:
1726    def export_json(self, path: str = "data.json") -> bool:
1727        """
1728        Exports the current state of the ZakatTracker object to a JSON file.
1729
1730        Parameters:
1731        path (str): The path where the JSON file will be saved. Default is "data.json".
1732
1733        Returns:
1734        bool: True if the export is successful, False otherwise.
1735
1736        Raises:
1737        No specific exceptions are raised by this method.
1738        """
1739        with open(path, "w") as file:
1740            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1741            return True

Exports the current state of the ZakatTracker object to a JSON file.

Parameters: path (str): The path where the JSON file will be saved. Default is "data.json".

Returns: bool: True if the export is successful, False otherwise.

Raises: No specific exceptions are raised by this method.

def save(self, path: str = None) -> bool:
1743    def save(self, path: str = None) -> bool:
1744        """
1745        Saves the ZakatTracker's current state to a pickle file.
1746
1747        This method serializes the internal data (`_vault`) along with metadata
1748        (Python version, pickle protocol) for future compatibility.
1749
1750        Parameters:
1751        path (str, optional): File path for saving. Defaults to a predefined location.
1752
1753        Returns:
1754        bool: True if the save operation is successful, False otherwise.
1755        """
1756        if path is None:
1757            path = self.path()
1758        with open(path, "wb") as f:
1759            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1760            pickle_protocol = pickle.HIGHEST_PROTOCOL
1761            data = {
1762                'python_version': version,
1763                'pickle_protocol': pickle_protocol,
1764                'data': self._vault,
1765            }
1766            pickle.dump(data, f, protocol=pickle_protocol)
1767            return True

Saves the ZakatTracker's current state to a pickle file.

This method serializes the internal data (_vault) along with metadata (Python version, pickle protocol) for future compatibility.

Parameters: path (str, optional): File path for saving. Defaults to a predefined location.

Returns: bool: True if the save operation is successful, False otherwise.

def load(self, path: str = None) -> bool:
1769    def load(self, path: str = None) -> bool:
1770        """
1771        Load the current state of the ZakatTracker object from a pickle file.
1772
1773        Parameters:
1774        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1775
1776        Returns:
1777        bool: True if the load operation is successful, False otherwise.
1778        """
1779        if path is None:
1780            path = self.path()
1781        if os.path.exists(path):
1782            with open(path, "rb") as f:
1783                data = pickle.load(f)
1784                self._vault = data['data']
1785                return True
1786        return False

Load the current state of the ZakatTracker object from a pickle file.

Parameters: path (str): The path where the pickle file is located. If not provided, it will use the default path.

Returns: bool: True if the load operation is successful, False otherwise.

def import_csv_cache_path(self):
1788    def import_csv_cache_path(self):
1789        """
1790        Generates the cache file path for imported CSV data.
1791
1792        This function constructs the file path where cached data from CSV imports
1793        will be stored. The cache file is a pickle file (.pickle extension) appended
1794        to the base path of the object.
1795
1796        Returns:
1797        str: The full path to the import CSV cache file.
1798
1799        Example:
1800            >>> obj = ZakatTracker('/data/reports')
1801            >>> obj.import_csv_cache_path()
1802            '/data/reports.import_csv.pickle'
1803        """
1804        path = str(self.path())
1805        if path.endswith(".pickle"):
1806            path = path[:-7]
1807        return path + '.import_csv.pickle'

Generates the cache file path for imported CSV data.

This function constructs the file path where cached data from CSV imports will be stored. The cache file is a pickle file (.pickle extension) appended to the base path of the object.

Returns: str: The full path to the import CSV cache file.

Example:

obj = ZakatTracker('/data/reports') obj.import_csv_cache_path() '/data/reports.import_csv.pickle'

def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1809    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1810        """
1811        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1812
1813        Parameters:
1814        path (str): The path to the CSV file. Default is 'file.csv'.
1815        debug (bool): A flag indicating whether to print debug information.
1816
1817        Returns:
1818        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1819                and a dictionary of bad transactions.
1820
1821        Notes:
1822            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1823                                        are appropriate for the currency pairs involved in the conversions.
1824            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1825                to 1.0 or the previous rate for that account.
1826            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1827              transactions of the same account within the whole imported and existing dataset when doing `check` and
1828              `zakat` operations.
1829
1830        Example Usage:
1831            The CSV file should have the following format, rate is optional per transaction:
1832            account, desc, value, date, rate
1833            For example:
1834            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1835        """
1836        if debug:
1837            print('import_csv', f'debug={debug}')
1838        cache: list[int] = []
1839        try:
1840            with open(self.import_csv_cache_path(), "rb") as f:
1841                cache = pickle.load(f)
1842        except:
1843            pass
1844        date_formats = [
1845            "%Y-%m-%d %H:%M:%S",
1846            "%Y-%m-%dT%H:%M:%S",
1847            "%Y-%m-%dT%H%M%S",
1848            "%Y-%m-%d",
1849        ]
1850        created, found, bad = 0, 0, {}
1851        data: dict[int, list] = {}
1852        with open(path, newline='', encoding="utf-8") as f:
1853            i = 0
1854            for row in csv.reader(f, delimiter=','):
1855                i += 1
1856                hashed = hash(tuple(row))
1857                if hashed in cache:
1858                    found += 1
1859                    continue
1860                account = row[0]
1861                desc = row[1]
1862                value = float(row[2])
1863                rate = 1.0
1864                if row[4:5]:  # Empty list if index is out of range
1865                    rate = float(row[4])
1866                date: int = 0
1867                for time_format in date_formats:
1868                    try:
1869                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1870                        break
1871                    except:
1872                        pass
1873                # TODO: not allowed for negative dates
1874                if date == 0 or value == 0:
1875                    bad[i] = row
1876                    continue
1877                if date not in data:
1878                    data[date] = []
1879                # TODO: If duplicated time with different accounts with the same amount it is an indicator of a transfer
1880                data[date].append((date, value, desc, account, rate, hashed))
1881
1882        if debug:
1883            print('import_csv', len(data))
1884
1885        def process(row, index=0):
1886            nonlocal created
1887            (date, value, desc, account, rate, hashed) = row
1888            date += index
1889            if rate > 1:
1890                self.exchange(account, created=date, rate=rate)
1891            if value > 0:
1892                self.track(value, desc, account, True, date)
1893            elif value < 0:
1894                self.sub(-value, desc, account, date)
1895            created += 1
1896            cache.append(hashed)
1897
1898        for date, rows in sorted(data.items()):
1899            len_rows = len(rows)
1900            if len_rows == 1:
1901                process(rows[0])
1902                continue
1903            if debug:
1904                print('-- Duplicated time detected', date, 'len', len_rows)
1905                print(rows)
1906                print('---------------------------------')
1907            for index, row in enumerate(rows):
1908                process(row, index)
1909        with open(self.import_csv_cache_path(), "wb") as f:
1910            pickle.dump(cache, f)
1911        return created, found, bad

The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.

Parameters: path (str): The path to the CSV file. Default is 'file.csv'. debug (bool): A flag indicating whether to print debug information.

Returns: tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.

Notes: * Currency Pair Assumption: This function assumes that the exchange rates stored for each account are appropriate for the currency pairs involved in the conversions. * The exchange rate for each account is based on the last encountered transaction rate that is not equal to 1.0 or the previous rate for that account. * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent transactions of the same account within the whole imported and existing dataset when doing check and zakat operations.

Example Usage: The CSV file should have the following format, rate is optional per transaction: account, desc, value, date, rate For example: safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1

@staticmethod
def human_readable_size(size: float, decimal_places: int = 2) -> str:
1917    @staticmethod
1918    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1919        """
1920        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1921
1922        This function iterates through progressively larger units of information
1923        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1924        range that can be expressed with a reasonable number before the unit.
1925
1926        Parameters:
1927        size (float): The size in bytes to convert.
1928        decimal_places (int, optional): The number of decimal places to display
1929            in the result. Defaults to 2.
1930
1931        Returns:
1932        str: A string representation of the size in a human-readable format,
1933            rounded to the specified number of decimal places. For example:
1934                - "1.50 KB" (1536 bytes)
1935                - "23.00 MB" (24117248 bytes)
1936                - "1.23 GB" (1325899906 bytes)
1937        """
1938        if type(size) not in (float, int):
1939            raise TypeError("size must be a float or integer")
1940        if type(decimal_places) != int:
1941            raise TypeError("decimal_places must be an integer")
1942        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1943            if size < 1024.0:
1944                break
1945            size /= 1024.0
1946        return f"{size:.{decimal_places}f} {unit}"

Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).

This function iterates through progressively larger units of information (B, KB, MB, GB, etc.) and divides the input size until it fits within a range that can be expressed with a reasonable number before the unit.

Parameters: size (float): The size in bytes to convert. decimal_places (int, optional): The number of decimal places to display in the result. Defaults to 2.

Returns: str: A string representation of the size in a human-readable format, rounded to the specified number of decimal places. For example: - "1.50 KB" (1536 bytes) - "23.00 MB" (24117248 bytes) - "1.23 GB" (1325899906 bytes)

@staticmethod
def get_dict_size(obj: dict, seen: set = None) -> float:
1948    @staticmethod
1949    def get_dict_size(obj: dict, seen: set = None) -> float:
1950        """
1951        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
1952
1953        This function traverses the dictionary structure, accounting for the size of keys, values,
1954        and any nested objects. It handles various data types commonly found in dictionaries
1955        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
1956        of circular references.
1957
1958        Parameters:
1959        obj (dict): The dictionary whose size is to be calculated.
1960        seen (set, optional): A set used internally to track visited objects
1961                             and avoid circular references. Defaults to None.
1962
1963        Returns:
1964            float: An approximate size of the dictionary and its contents in bytes.
1965
1966        Note:
1967        - This function is a method of the `ZakatTracker` class and is likely used to
1968          estimate the memory footprint of data structures relevant to Zakat calculations.
1969        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
1970          not account for all memory overhead depending on the Python implementation.
1971        - Circular references are handled to prevent infinite recursion.
1972        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
1973        - String sizes are estimated based on character length and encoding.
1974        """
1975        size = 0
1976        if seen is None:
1977            seen = set()
1978
1979        obj_id = id(obj)
1980        if obj_id in seen:
1981            return 0
1982
1983        seen.add(obj_id)
1984        size += sys.getsizeof(obj)
1985
1986        if isinstance(obj, dict):
1987            for k, v in obj.items():
1988                size += ZakatTracker.get_dict_size(k, seen)
1989                size += ZakatTracker.get_dict_size(v, seen)
1990        elif isinstance(obj, (list, tuple, set, frozenset)):
1991            for item in obj:
1992                size += ZakatTracker.get_dict_size(item, seen)
1993        elif isinstance(obj, (int, float, complex)):  # Handle numbers
1994            pass  # Basic numbers have a fixed size, so nothing to add here
1995        elif isinstance(obj, str):  # Handle strings
1996            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
1997        return size

Recursively calculates the approximate memory size of a dictionary and its contents in bytes.

This function traverses the dictionary structure, accounting for the size of keys, values, and any nested objects. It handles various data types commonly found in dictionaries (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case of circular references.

Parameters: obj (dict): The dictionary whose size is to be calculated. seen (set, optional): A set used internally to track visited objects and avoid circular references. Defaults to None.

Returns: float: An approximate size of the dictionary and its contents in bytes.

Note:

  • This function is a method of the ZakatTracker class and is likely used to estimate the memory footprint of data structures relevant to Zakat calculations.
  • The size calculation is approximate as it relies on sys.getsizeof(), which might not account for all memory overhead depending on the Python implementation.
  • Circular references are handled to prevent infinite recursion.
  • Basic numeric types (int, float, complex) are assumed to have fixed sizes.
  • String sizes are estimated based on character length and encoding.
@staticmethod
def duration_from_nanoseconds( ns: int, show_zeros_in_spoken_time: bool = False, spoken_time_separator=',', millennia: str = 'Millennia', century: str = 'Century', years: str = 'Years', days: str = 'Days', hours: str = 'Hours', minutes: str = 'Minutes', seconds: str = 'Seconds', milli_seconds: str = 'MilliSeconds', micro_seconds: str = 'MicroSeconds', nano_seconds: str = 'NanoSeconds') -> tuple:
1999    @staticmethod
2000    def duration_from_nanoseconds(ns: int,
2001                                  show_zeros_in_spoken_time: bool = False,
2002                                  spoken_time_separator=',',
2003                                  millennia: str = 'Millennia',
2004                                  century: str = 'Century',
2005                                  years: str = 'Years',
2006                                  days: str = 'Days',
2007                                  hours: str = 'Hours',
2008                                  minutes: str = 'Minutes',
2009                                  seconds: str = 'Seconds',
2010                                  milli_seconds: str = 'MilliSeconds',
2011                                  micro_seconds: str = 'MicroSeconds',
2012                                  nano_seconds: str = 'NanoSeconds',
2013                                  ) -> tuple:
2014        """
2015        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2016        Convert NanoSeconds to Human Readable Time Format.
2017        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2018        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2019        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2020        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2021
2022        INPUT : ms (AKA: MilliSeconds)
2023        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2024        OUTPUT Variables: time_lapsed, spoken_time
2025
2026        Example  Input: duration_from_nanoseconds(ns)
2027        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2028        Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia,    1 Century,  47 Years,  325 Days,  5 Hours,  2 Minutes,  3 Seconds,  456 MilliSeconds,  789 MicroSeconds,  12 NanoSeconds')
2029        duration_from_nanoseconds(1234567890123456789012)
2030        """
2031        us, ns = divmod(ns, 1000)
2032        ms, us = divmod(us, 1000)
2033        s, ms = divmod(ms, 1000)
2034        m, s = divmod(s, 60)
2035        h, m = divmod(m, 60)
2036        d, h = divmod(h, 24)
2037        y, d = divmod(d, 365)
2038        c, y = divmod(y, 100)
2039        n, c = divmod(c, 10)
2040        time_lapsed = f"{n:03.0f}:{c:04.0f}:{y:03.0f}:{d:03.0f}:{h:02.0f}:{m:02.0f}:{s:02.0f}::{ms:03.0f}::{us:03.0f}::{ns:03.0f}"
2041        spoken_time_part = []
2042        if n > 0 or show_zeros_in_spoken_time:
2043            spoken_time_part.append(f"{n: 3d} {millennia}")
2044        if c > 0 or show_zeros_in_spoken_time:
2045            spoken_time_part.append(f"{c: 4d} {century}")
2046        if y > 0 or show_zeros_in_spoken_time:
2047            spoken_time_part.append(f"{y: 3d} {years}")
2048        if d > 0 or show_zeros_in_spoken_time:
2049            spoken_time_part.append(f"{d: 4d} {days}")
2050        if h > 0 or show_zeros_in_spoken_time:
2051            spoken_time_part.append(f"{h: 2d} {hours}")
2052        if m > 0 or show_zeros_in_spoken_time:
2053            spoken_time_part.append(f"{m: 2d} {minutes}")
2054        if s > 0 or show_zeros_in_spoken_time:
2055            spoken_time_part.append(f"{s: 2d} {seconds}")
2056        if ms > 0 or show_zeros_in_spoken_time:
2057            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2058        if us > 0 or show_zeros_in_spoken_time:
2059            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2060        if ns > 0 or show_zeros_in_spoken_time:
2061            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2062        return time_lapsed, spoken_time_separator.join(spoken_time_part)

REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 Convert NanoSeconds to Human Readable Time Format. A NanoSeconds is a unit of time in the International System of Units (SI) equal to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. Its symbol is μs, sometimes simplified to us when Unicode is not available. A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.

INPUT : ms (AKA: MilliSeconds) OUTPUT: tuple(string time_lapsed, string spoken_time) like format. OUTPUT Variables: time_lapsed, spoken_time

Example Input: duration_from_nanoseconds(ns) "Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds" Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia, 1 Century, 47 Years, 325 Days, 5 Hours, 2 Minutes, 3 Seconds, 456 MilliSeconds, 789 MicroSeconds, 12 NanoSeconds') duration_from_nanoseconds(1234567890123456789012)

@staticmethod
def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:
2064    @staticmethod
2065    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2066        """
2067        Convert a specific day, month, and year into a timestamp.
2068
2069        Parameters:
2070        day (int): The day of the month.
2071        month (int): The month of the year. Default is 6 (June).
2072        year (int): The year. Default is 2024.
2073
2074        Returns:
2075        int: The timestamp representing the given day, month, and year.
2076
2077        Note:
2078        This method assumes the default month and year if not provided.
2079        """
2080        return ZakatTracker.time(datetime.datetime(year, month, day))

Convert a specific day, month, and year into a timestamp.

Parameters: day (int): The day of the month. month (int): The month of the year. Default is 6 (June). year (int): The year. Default is 2024.

Returns: int: The timestamp representing the given day, month, and year.

Note: This method assumes the default month and year if not provided.

@staticmethod
def generate_random_date( start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2082    @staticmethod
2083    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2084        """
2085        Generate a random date between two given dates.
2086
2087        Parameters:
2088        start_date (datetime.datetime): The start date from which to generate a random date.
2089        end_date (datetime.datetime): The end date until which to generate a random date.
2090
2091        Returns:
2092        datetime.datetime: A random date between the start_date and end_date.
2093        """
2094        time_between_dates = end_date - start_date
2095        days_between_dates = time_between_dates.days
2096        random_number_of_days = random.randrange(days_between_dates)
2097        return start_date + datetime.timedelta(days=random_number_of_days)

Generate a random date between two given dates.

Parameters: start_date (datetime.datetime): The start date from which to generate a random date. end_date (datetime.datetime): The end date until which to generate a random date.

Returns: datetime.datetime: A random date between the start_date and end_date.

@staticmethod
def generate_random_csv_file( path: str = 'data.csv', count: int = 1000, with_rate: bool = False, debug: bool = False) -> int:
2099    @staticmethod
2100    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2101                                 debug: bool = False) -> int:
2102        """
2103        Generate a random CSV file with specified parameters.
2104
2105        Parameters:
2106        path (str): The path where the CSV file will be saved. Default is "data.csv".
2107        count (int): The number of rows to generate in the CSV file. Default is 1000.
2108        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2109        debug (bool): A flag indicating whether to print debug information.
2110
2111        Returns:
2112        None. The function generates a CSV file at the specified path with the given count of rows.
2113        Each row contains a randomly generated account, description, value, and date.
2114        The value is randomly generated between 1000 and 100000,
2115        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2116        If the row number is not divisible by 13, the value is multiplied by -1.
2117        """
2118        if debug:
2119            print('generate_random_csv_file', f'debug={debug}')
2120        i = 0
2121        with open(path, "w", newline="") as csvfile:
2122            writer = csv.writer(csvfile)
2123            for i in range(count):
2124                account = f"acc-{random.randint(1, 1000)}"
2125                desc = f"Some text {random.randint(1, 1000)}"
2126                value = random.randint(1000, 100000)
2127                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
2128                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2129                if not i % 13 == 0:
2130                    value *= -1
2131                row = [account, desc, value, date]
2132                if with_rate:
2133                    rate = random.randint(1, 100) * 0.12
2134                    if debug:
2135                        print('before-append', row)
2136                    row.append(rate)
2137                    if debug:
2138                        print('after-append', row)
2139                writer.writerow(row)
2140                i = i + 1
2141        return i

Generate a random CSV file with specified parameters.

Parameters: path (str): The path where the CSV file will be saved. Default is "data.csv". count (int): The number of rows to generate in the CSV file. Default is 1000. with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. debug (bool): A flag indicating whether to print debug information.

Returns: None. The function generates a CSV file at the specified path with the given count of rows. Each row contains a randomly generated account, description, value, and date. The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. If the row number is not divisible by 13, the value is multiplied by -1.

@staticmethod
def create_random_list(max_sum, min_value=0, max_value=10):
2143    @staticmethod
2144    def create_random_list(max_sum, min_value=0, max_value=10):
2145        """
2146        Creates a list of random integers whose sum does not exceed the specified maximum.
2147
2148        Args:
2149            max_sum: The maximum allowed sum of the list elements.
2150            min_value: The minimum possible value for an element (inclusive).
2151            max_value: The maximum possible value for an element (inclusive).
2152
2153        Returns:
2154            A list of random integers.
2155        """
2156        result = []
2157        current_sum = 0
2158
2159        while current_sum < max_sum:
2160            # Calculate the remaining space for the next element
2161            remaining_sum = max_sum - current_sum
2162            # Determine the maximum possible value for the next element
2163            next_max_value = min(remaining_sum, max_value)
2164            # Generate a random element within the allowed range
2165            next_element = random.randint(min_value, next_max_value)
2166            result.append(next_element)
2167            current_sum += next_element
2168
2169        return result

Creates a list of random integers whose sum does not exceed the specified maximum.

Args: max_sum: The maximum allowed sum of the list elements. min_value: The minimum possible value for an element (inclusive). max_value: The maximum possible value for an element (inclusive).

Returns: A list of random integers.

def test(self, debug: bool = False) -> bool:
2382    def test(self, debug: bool = False) -> bool:
2383        if debug:
2384            print('test', f'debug={debug}')
2385        try:
2386
2387            assert self._history()
2388
2389            # Not allowed for duplicate transactions in the same account and time
2390
2391            created = ZakatTracker.time()
2392            self.track(100, 'test-1', 'same', True, created)
2393            failed = False
2394            try:
2395                self.track(50, 'test-1', 'same', True, created)
2396            except:
2397                failed = True
2398            assert failed is True
2399
2400            self.reset()
2401
2402            # Same account transfer
2403            for x in [1, 'a', True, 1.8, None]:
2404                failed = False
2405                try:
2406                    self.transfer(1, x, x, 'same-account', debug=debug)
2407                except:
2408                    failed = True
2409                assert failed is True
2410
2411            # Always preserve box age during transfer
2412
2413            series: list[tuple] = [
2414                (30, 4),
2415                (60, 3),
2416                (90, 2),
2417            ]
2418            case = {
2419                30: {
2420                    'series': series,
2421                    'rest': 150,
2422                },
2423                60: {
2424                    'series': series,
2425                    'rest': 120,
2426                },
2427                90: {
2428                    'series': series,
2429                    'rest': 90,
2430                },
2431                180: {
2432                    'series': series,
2433                    'rest': 0,
2434                },
2435                270: {
2436                    'series': series,
2437                    'rest': -90,
2438                },
2439                360: {
2440                    'series': series,
2441                    'rest': -180,
2442                },
2443            }
2444
2445            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2446
2447            for total in case:
2448                for x in case[total]['series']:
2449                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
2450
2451                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
2452
2453                if debug:
2454                    print('refs', refs)
2455
2456                ages_cache_balance = self.balance('ages')
2457                ages_fresh_balance = self.balance('ages', False)
2458                rest = case[total]['rest']
2459                if debug:
2460                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2461                assert ages_cache_balance == rest
2462                assert ages_fresh_balance == rest
2463
2464                future_cache_balance = self.balance('future')
2465                future_fresh_balance = self.balance('future', False)
2466                if debug:
2467                    print('target', future_cache_balance, future_fresh_balance, total)
2468                    print('refs', refs)
2469                assert future_cache_balance == total
2470                assert future_fresh_balance == total
2471
2472                for ref in self._vault['account']['ages']['box']:
2473                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2474                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2475                    future_capital = 0
2476                    if ref in self._vault['account']['future']['box']:
2477                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2478                    future_rest = 0
2479                    if ref in self._vault['account']['future']['box']:
2480                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2481                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2482                        if debug:
2483                            print('================================================================')
2484                            print('ages', ages_capital, ages_rest)
2485                            print('future', future_capital, future_rest)
2486                        if ages_rest == 0:
2487                            assert ages_capital == future_capital
2488                        elif ages_rest < 0:
2489                            assert -ages_capital == future_capital
2490                        elif ages_rest > 0:
2491                            assert ages_capital == ages_rest + future_capital
2492                self.reset()
2493                assert len(self._vault['history']) == 0
2494
2495            assert self._history()
2496            assert self._history(False) is False
2497            assert self._history() is False
2498            assert self._history(True)
2499            assert self._history()
2500
2501            self._test_core(True, debug)
2502            self._test_core(False, debug)
2503
2504            transaction = [
2505                (
2506                    20, 'wallet', 1, 800, 800, 800, 4, 5,
2507                    -85, -85, -85, 6, 7,
2508                ),
2509                (
2510                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
2511                    750, 750, 750, 1, 1,
2512                ),
2513                (
2514                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
2515                    600, 600, 600, 1, 1,
2516                ),
2517            ]
2518            for z in transaction:
2519                self.lock()
2520                x = z[1]
2521                y = z[2]
2522                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
2523                assert self.balance(x) == z[3]
2524                xx = self.accounts()[x]
2525                assert xx == z[3]
2526                assert self.balance(x, False) == z[4]
2527                assert xx == z[4]
2528
2529                s = 0
2530                log = self._vault['account'][x]['log']
2531                for i in log:
2532                    s += log[i]['value']
2533                if debug:
2534                    print('s', s, 'z[5]', z[5])
2535                assert s == z[5]
2536
2537                assert self.box_size(x) == z[6]
2538                assert self.log_size(x) == z[7]
2539
2540                yy = self.accounts()[y]
2541                assert self.balance(y) == z[8]
2542                assert yy == z[8]
2543                assert self.balance(y, False) == z[9]
2544                assert yy == z[9]
2545
2546                s = 0
2547                log = self._vault['account'][y]['log']
2548                for i in log:
2549                    s += log[i]['value']
2550                assert s == z[10]
2551
2552                assert self.box_size(y) == z[11]
2553                assert self.log_size(y) == z[12]
2554
2555            if debug:
2556                pp().pprint(self.check(2.17))
2557
2558            assert not self.nolock()
2559            history_count = len(self._vault['history'])
2560            if debug:
2561                print('history-count', history_count)
2562            assert history_count == 11
2563            assert not self.free(ZakatTracker.time())
2564            assert self.free(self.lock())
2565            assert self.nolock()
2566            assert len(self._vault['history']) == 11
2567
2568            # storage
2569
2570            _path = self.path('test.pickle')
2571            if os.path.exists(_path):
2572                os.remove(_path)
2573            self.save()
2574            assert os.path.getsize(_path) > 0
2575            self.reset()
2576            assert self.recall(False, debug) is False
2577            self.load()
2578            assert self._vault['account'] is not None
2579
2580            # recall
2581
2582            assert self.nolock()
2583            assert len(self._vault['history']) == 11
2584            assert self.recall(False, debug) is True
2585            assert len(self._vault['history']) == 10
2586            assert self.recall(False, debug) is True
2587            assert len(self._vault['history']) == 9
2588
2589            # exchange
2590
2591            self.exchange("cash", 25, 3.75, "2024-06-25")
2592            self.exchange("cash", 22, 3.73, "2024-06-22")
2593            self.exchange("cash", 15, 3.69, "2024-06-15")
2594            self.exchange("cash", 10, 3.66)
2595
2596            for i in range(1, 30):
2597                exchange = self.exchange("cash", i)
2598                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2599                if debug:
2600                    print(i, rate, description, created)
2601                assert created
2602                if i < 10:
2603                    assert rate == 1
2604                    assert description is None
2605                elif i == 10:
2606                    assert rate == 3.66
2607                    assert description is None
2608                elif i < 15:
2609                    assert rate == 3.66
2610                    assert description is None
2611                elif i == 15:
2612                    assert rate == 3.69
2613                    assert description is not None
2614                elif i < 22:
2615                    assert rate == 3.69
2616                    assert description is not None
2617                elif i == 22:
2618                    assert rate == 3.73
2619                    assert description is not None
2620                elif i >= 25:
2621                    assert rate == 3.75
2622                    assert description is not None
2623                exchange = self.exchange("bank", i)
2624                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2625                if debug:
2626                    print(i, rate, description, created)
2627                assert created
2628                assert rate == 1
2629                assert description is None
2630
2631            assert len(self._vault['exchange']) > 0
2632            assert len(self.exchanges()) > 0
2633            self._vault['exchange'].clear()
2634            assert len(self._vault['exchange']) == 0
2635            assert len(self.exchanges()) == 0
2636
2637            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2638            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2639            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2640            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2641            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2642
2643            for i in [x * 0.12 for x in range(-15, 21)]:
2644                if i <= 0:
2645                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2646                else:
2647                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2648
2649            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2650            for i in range(1, 31):
2651                timestamp_ns = ZakatTracker.day_to_time(i)
2652                exchange = self.exchange("cash", timestamp_ns)
2653                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2654                if debug:
2655                    print(i, rate, description, created)
2656                assert created
2657                if i < 10:
2658                    assert rate == 1
2659                    assert description is None
2660                elif i == 10:
2661                    assert rate == 3.66
2662                    assert description is None
2663                elif i < 15:
2664                    assert rate == 3.66
2665                    assert description is None
2666                elif i == 15:
2667                    assert rate == 3.69
2668                    assert description is not None
2669                elif i < 22:
2670                    assert rate == 3.69
2671                    assert description is not None
2672                elif i == 22:
2673                    assert rate == 3.73
2674                    assert description is not None
2675                elif i >= 25:
2676                    assert rate == 3.75
2677                    assert description is not None
2678                exchange = self.exchange("bank", i)
2679                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2680                if debug:
2681                    print(i, rate, description, created)
2682                assert created
2683                assert rate == 1
2684                assert description is None
2685
2686            # csv
2687
2688            csv_count = 1000
2689
2690            for with_rate, path in {
2691                False: 'test-import_csv-no-exchange',
2692                True: 'test-import_csv-with-exchange',
2693            }.items():
2694
2695                if debug:
2696                    print('test_import_csv', with_rate, path)
2697
2698                # csv
2699
2700                csv_path = path + '.csv'
2701                if os.path.exists(csv_path):
2702                    os.remove(csv_path)
2703                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2704                if debug:
2705                    print('generate_random_csv_file', c)
2706                assert c == csv_count
2707                assert os.path.getsize(csv_path) > 0
2708                cache_path = self.import_csv_cache_path()
2709                if os.path.exists(cache_path):
2710                    os.remove(cache_path)
2711                self.reset()
2712                (created, found, bad) = self.import_csv(csv_path, debug)
2713                bad_count = len(bad)
2714                if debug:
2715                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2716                tmp_size = os.path.getsize(cache_path)
2717                assert tmp_size > 0
2718                assert created + found + bad_count == csv_count
2719                assert created == csv_count
2720                assert bad_count == 0
2721                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2722                bad_2_count = len(bad_2)
2723                if debug:
2724                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2725                    print(bad)
2726                assert tmp_size == os.path.getsize(cache_path)
2727                assert created_2 + found_2 + bad_2_count == csv_count
2728                assert created == found_2
2729                assert bad_count == bad_2_count
2730                assert found_2 == csv_count
2731                assert bad_2_count == 0
2732                assert created_2 == 0
2733
2734                # payment parts
2735
2736                positive_parts = self.build_payment_parts(100, positive_only=True)
2737                assert self.check_payment_parts(positive_parts) != 0
2738                assert self.check_payment_parts(positive_parts) != 0
2739                all_parts = self.build_payment_parts(300, positive_only=False)
2740                assert self.check_payment_parts(all_parts) != 0
2741                assert self.check_payment_parts(all_parts) != 0
2742                if debug:
2743                    pp().pprint(positive_parts)
2744                    pp().pprint(all_parts)
2745                # dynamic discount
2746                suite = []
2747                count = 3
2748                for exceed in [False, True]:
2749                    case = []
2750                    for parts in [positive_parts, all_parts]:
2751                        part = parts.copy()
2752                        demand = part['demand']
2753                        if debug:
2754                            print(demand, part['total'])
2755                        i = 0
2756                        z = demand / count
2757                        cp = {
2758                            'account': {},
2759                            'demand': demand,
2760                            'exceed': exceed,
2761                            'total': part['total'],
2762                        }
2763                        j = ''
2764                        for x, y in part['account'].items():
2765                            x_exchange = self.exchange(x)
2766                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2767                            if exceed and zz <= demand:
2768                                i += 1
2769                                y['part'] = zz
2770                                if debug:
2771                                    print(exceed, y)
2772                                cp['account'][x] = y
2773                                case.append(y)
2774                            elif not exceed and y['balance'] >= zz:
2775                                i += 1
2776                                y['part'] = zz
2777                                if debug:
2778                                    print(exceed, y)
2779                                cp['account'][x] = y
2780                                case.append(y)
2781                            j = x
2782                            if i >= count:
2783                                break
2784                        if len(cp['account'][j]) > 0:
2785                            suite.append(cp)
2786                if debug:
2787                    print('suite', len(suite))
2788                # vault = self._vault.copy()
2789                for case in suite:
2790                    # self._vault = vault.copy()
2791                    if debug:
2792                        print('case', case)
2793                    result = self.check_payment_parts(case)
2794                    if debug:
2795                        print('check_payment_parts', result, f'exceed: {exceed}')
2796                    assert result == 0
2797
2798                    report = self.check(2.17, None, debug)
2799                    (valid, brief, plan) = report
2800                    if debug:
2801                        print('valid', valid)
2802                    zakat_result = self.zakat(report, parts=case, debug=debug)
2803                    if debug:
2804                        print('zakat-result', zakat_result)
2805                    assert valid == zakat_result
2806
2807            assert self.save(path + '.pickle')
2808            assert self.export_json(path + '.json')
2809
2810            assert self.export_json("1000-transactions-test.json")
2811            assert self.save("1000-transactions-test.pickle")
2812
2813            self.reset()
2814
2815            # test transfer between accounts with different exchange rate
2816
2817            a_SAR = "Bank (SAR)"
2818            b_USD = "Bank (USD)"
2819            c_SAR = "Safe (SAR)"
2820            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2821            for case in [
2822                (0, a_SAR, "SAR Gift", 1000, 1000),
2823                (1, a_SAR, 1),
2824                (0, b_USD, "USD Gift", 500, 500),
2825                (1, b_USD, 1),
2826                (2, b_USD, 3.75),
2827                (1, b_USD, 3.75),
2828                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2829                (0, c_SAR, "Salary", 750, 750),
2830                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2831                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2832            ]:
2833                match (case[0]):
2834                    case 0:  # track
2835                        _, account, desc, x, balance = case
2836                        self.track(value=x, desc=desc, account=account, debug=debug)
2837
2838                        cached_value = self.balance(account, cached=True)
2839                        fresh_value = self.balance(account, cached=False)
2840                        if debug:
2841                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2842                        assert cached_value == balance
2843                        assert fresh_value == balance
2844                    case 1:  # check-exchange
2845                        _, account, expected_rate = case
2846                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2847                        if debug:
2848                            print('t-exchange', t_exchange)
2849                        assert t_exchange['rate'] == expected_rate
2850                    case 2:  # do-exchange
2851                        _, account, rate = case
2852                        self.exchange(account, rate=rate, debug=debug)
2853                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2854                        if debug:
2855                            print('b-exchange', b_exchange)
2856                        assert b_exchange['rate'] == rate
2857                    case 3:  # transfer
2858                        _, x, a, b, desc, a_balance, b_balance = case
2859                        self.transfer(x, a, b, desc, debug=debug)
2860
2861                        cached_value = self.balance(a, cached=True)
2862                        fresh_value = self.balance(a, cached=False)
2863                        if debug:
2864                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2865                        assert cached_value == a_balance
2866                        assert fresh_value == a_balance
2867
2868                        cached_value = self.balance(b, cached=True)
2869                        fresh_value = self.balance(b, cached=False)
2870                        if debug:
2871                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2872                        assert cached_value == b_balance
2873                        assert fresh_value == b_balance
2874
2875            # Transfer all in many chunks randomly from B to A
2876            a_SAR_balance = 1371.25
2877            b_USD_balance = 501
2878            b_USD_exchange = self.exchange(b_USD)
2879            amounts = ZakatTracker.create_random_list(b_USD_balance)
2880            if debug:
2881                print('amounts', amounts)
2882            i = 0
2883            for x in amounts:
2884                if debug:
2885                    print(f'{i} - transfer-with-exchange({x})')
2886                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2887
2888                b_USD_balance -= x
2889                cached_value = self.balance(b_USD, cached=True)
2890                fresh_value = self.balance(b_USD, cached=False)
2891                if debug:
2892                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2893                          b_USD_balance)
2894                assert cached_value == b_USD_balance
2895                assert fresh_value == b_USD_balance
2896
2897                a_SAR_balance += x * b_USD_exchange['rate']
2898                cached_value = self.balance(a_SAR, cached=True)
2899                fresh_value = self.balance(a_SAR, cached=False)
2900                if debug:
2901                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2902                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2903                assert cached_value == a_SAR_balance
2904                assert fresh_value == a_SAR_balance
2905                i += 1
2906
2907            # Transfer all in many chunks randomly from C to A
2908            c_SAR_balance = 375
2909            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2910            if debug:
2911                print('amounts', amounts)
2912            i = 0
2913            for x in amounts:
2914                if debug:
2915                    print(f'{i} - transfer-with-exchange({x})')
2916                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2917
2918                c_SAR_balance -= x
2919                cached_value = self.balance(c_SAR, cached=True)
2920                fresh_value = self.balance(c_SAR, cached=False)
2921                if debug:
2922                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2923                          c_SAR_balance)
2924                assert cached_value == c_SAR_balance
2925                assert fresh_value == c_SAR_balance
2926
2927                a_SAR_balance += x
2928                cached_value = self.balance(a_SAR, cached=True)
2929                fresh_value = self.balance(a_SAR, cached=False)
2930                if debug:
2931                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2932                          a_SAR_balance)
2933                assert cached_value == a_SAR_balance
2934                assert fresh_value == a_SAR_balance
2935                i += 1
2936
2937            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2938            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2939
2940            # check & zakat with exchange rates for many cycles
2941
2942            for rate, values in {
2943                1: {
2944                    'in': [1000, 2000, 10000],
2945                    'exchanged': [1000, 2000, 10000],
2946                    'out': [25, 50, 731.40625],
2947                },
2948                3.75: {
2949                    'in': [200, 1000, 5000],
2950                    'exchanged': [750, 3750, 18750],
2951                    'out': [18.75, 93.75, 1371.38671875],
2952                },
2953            }.items():
2954                a, b, c = values['in']
2955                m, n, o = values['exchanged']
2956                x, y, z = values['out']
2957                if debug:
2958                    print('rate', rate, 'values', values)
2959                for case in [
2960                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2961                        {'safe': {0: {'below_nisab': x}}},
2962                    ], False, m),
2963                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2964                        {'safe': {0: {'count': 1, 'total': y}}},
2965                    ], True, n),
2966                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2967                        {'cave': {0: {'count': 3, 'total': z}}},
2968                    ], True, o),
2969                ]:
2970                    if debug:
2971                        print(f"############# check(rate: {rate}) #############")
2972                    self.reset()
2973                    self.exchange(account=case[1], created=case[2], rate=rate)
2974                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2975
2976                    # assert self.nolock()
2977                    # history_size = len(self._vault['history'])
2978                    # print('history_size', history_size)
2979                    # assert history_size == 2
2980                    assert self.lock()
2981                    assert not self.nolock()
2982                    report = self.check(2.17, None, debug)
2983                    (valid, brief, plan) = report
2984                    assert valid == case[4]
2985                    if debug:
2986                        print('brief', brief)
2987                    assert case[5] == brief[0]
2988                    assert case[5] == brief[1]
2989
2990                    if debug:
2991                        pp().pprint(plan)
2992
2993                    for x in plan:
2994                        assert case[1] == x
2995                        if 'total' in case[3][0][x][0].keys():
2996                            assert case[3][0][x][0]['total'] == brief[2]
2997                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2998                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2999                        else:
3000                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3001                    if debug:
3002                        pp().pprint(report)
3003                    result = self.zakat(report, debug=debug)
3004                    if debug:
3005                        print('zakat-result', result, case[4])
3006                    assert result == case[4]
3007                    report = self.check(2.17, None, debug)
3008                    (valid, brief, plan) = report
3009                    assert valid is False
3010
3011            history_size = len(self._vault['history'])
3012            if debug:
3013                print('history_size', history_size)
3014            assert history_size == 3
3015            assert not self.nolock()
3016            assert self.recall(False, debug) is False
3017            self.free(self.lock())
3018            assert self.nolock()
3019
3020            for i in range(3, 0, -1):
3021                history_size = len(self._vault['history'])
3022                if debug:
3023                    print('history_size', history_size)
3024                assert history_size == i
3025                assert self.recall(False, debug) is True
3026
3027            assert self.nolock()
3028            assert self.recall(False, debug) is False
3029
3030            history_size = len(self._vault['history'])
3031            if debug:
3032                print('history_size', history_size)
3033            assert history_size == 0
3034
3035            account_size = len(self._vault['account'])
3036            if debug:
3037                print('account_size', account_size)
3038            assert account_size == 0
3039
3040            report_size = len(self._vault['report'])
3041            if debug:
3042                print('report_size', report_size)
3043            assert report_size == 0
3044
3045            assert self.nolock()
3046            return True
3047        except:
3048            # pp().pprint(self._vault)
3049            assert self.export_json("test-snapshot.json")
3050            assert self.save("test-snapshot.pickle")
3051            raise
def test(debug: bool = False):
3054def test(debug: bool = False):
3055    ledger = ZakatTracker()
3056    start = ZakatTracker.time()
3057    assert ledger.test(debug=debug)
3058    if debug:
3059        print("#########################")
3060        print("######## TEST DONE ########")
3061        print("#########################")
3062        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
3063        print("#########################")
class Action(enum.Enum):
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()
CREATE = <Action.CREATE: 1>
TRACK = <Action.TRACK: 2>
LOG = <Action.LOG: 3>
SUB = <Action.SUB: 4>
ADD_FILE = <Action.ADD_FILE: 5>
REMOVE_FILE = <Action.REMOVE_FILE: 6>
BOX_TRANSFER = <Action.BOX_TRANSFER: 7>
EXCHANGE = <Action.EXCHANGE: 8>
REPORT = <Action.REPORT: 9>
ZAKAT = <Action.ZAKAT: 10>
class JSONEncoder(json.encoder.JSONEncoder):
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).

def default(self, obj):
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)
class MathOperation(enum.Enum):
 98class MathOperation(Enum):
 99    ADDITION = auto()
100    EQUAL = auto()
101    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 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:

  1. GET /{file_uuid}/get: Download the database file specified by database_path.
  2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
  3. 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()

def find_available_port() -> int:
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}")

class FileType(enum.Enum):
13class FileType(Enum):
14    Database = 'db'
15    CSV = 'csv'
Database = <FileType.Database: 'db'>
CSV = <FileType.CSV: 'csv'>